评论

GC引发的小游戏内存“乌龙危机”

从一个GC引发的小游戏内存“乌龙危机”事件分析开发过程中的小游戏JS堆内存的占用情况

1. 背景

某天,开发同学突然接收到开发者反馈的一个异常问题:微信小游戏在iOS执行环境下,Javascript Heap增长远大于微信开发工具中看到的内存消耗。具体场景:从网络下载配置文件到本地并插入到JS的数组,微信开发者工具环境的内存快照(Heap snapshot)显示内存增长1.3M,但在iOS环境的总运行时内存增量6M。iOS环境下多出来的内存来自哪里? 对于这种诡异的现象,开发同学马不停蹄地分析起来。

2. 工具

微信开发者工具
PerfDog

3. Case 代码

代码片段 :https://developers.weixin.qq.com/s/QivSpmmT7Zhc

代码逻辑:
游戏加载 --> 进入主界面(主界面是黑屏状态) --> 点击屏幕 --> 从网络中下载配置文件 minigametest.manifest --> 将其中的每一组配置写入JS对象 window.BPmanifest --> 执行GC操作 wx.triggerGC();
备注:为了避免页面渲染等其他操作带来的影响,整个操作都是黑屏状态。

4. 场景再现

场景A - 微信开发者工具:

分别在“点击屏幕”操作的前后记录一次Heap snapshot。对比发现,两个时刻的堆内存差异为1.9M。对两个时刻的快照进行对比可以发现具体的对象差异。

场景B - iOS微信小游戏端:

iOS手机扫描微信开发者工具下该小游戏的二维码进行预览。使用PerfDog软件对微信内存占用情况进行监控,发现“点击屏幕”操作后内存增长了6M,而不是场景A的1.9M。

场景对比

根据代码逻辑,将配置信息写入JS对象window.BPmanifest后,执行了GC,最后,微信小游戏在iOS执行环境下和微信开发者工具环境此时所增长的内存应该是一致的。

5. 问题猜想

  1. 考虑到微信开发者工具(Android)采用V8引擎,而iOS中的小游戏运行环境采用的是javascriptcore引擎。这种异常情况会不会是由于引擎的差异导致的?
  2. GC是否真正执行了?
  3. 内存泄漏?

6. 问题定位

在通过多次测试验证测试数据可信之后,从微信开发者工具侧查看代码的运行是如何影响堆内存的。

通过微信开发者工具定位内存问题时,一般使用调试器下的PerformanceMemory,其操作同Chrome Devtools

开发者如果想详细了解如何使用Chrome Devtools,可参考Chrome Devtools 说明文档Chrome PerformanceChrome Memory
粗略了解可参考链接

6.1 测试和分析过程

在微信开发者工具的调试器下选中Performance选中ScreenShotsMemory

测试步骤:

  • Step 1. 点击⏺录制
  • Step 2. “点击屏幕”(触发下载配置文件事件)
  • Step 3. 点击“垃圾回收”按钮,如图:
  • Step 4. 点击⏺结束录制。

录制完成后,将Main调用栈定位到点击屏幕操作位置,查看此时JS Heap内存变化,如下图所示(JS heap变化曲线在不同的录制场景下存在一定差异,但总体趋势保持一致)。

Main中的函数调用栈可以帮助我们分析每个时刻的内存变化是受到哪个函数的影响。根据Main调用栈可以将代码执行过程分为三个阶段:加载配置文件阶段(红框区域),写入阶段(绿框区域),GC阶段(黄框区域)。

1、 加载配置文件阶段
加载配置文件的代码如下:

function loadRes(url, callback) {
	let xmlhttp = new XMLHttpRequest();
	xmlhttp.onreadystatechange = state_Change;
	xmlhttp.open("GET", url, true);
	xmlhttp.send(null);

	function state_Change() {
		if (xmlhttp.readyState == 4) { // 4 = "loaded"
			if (xmlhttp.status == 200) { // 200 = OK
				console.log("xmlhttp's sizeof:", sizeof(xmlhttp));
				console.log("responseText's sizeof:", sizeof(xmlhttp.responseText));
				callback && callback(xmlhttp.responseText);
			} else {
				alert("Problem retrieving XML data");
			}
		}
	}
}

在这个过程中,可以看到五处JS Heap的增长,根据调用栈信息知道这部分变化来自于state_Change函数。由上述state_Change函数代码可知,其作用是监控xmlhttp的readyState改变,而readyState恰好有五个状态值,分别是0~4,每个状态都需要申请一定的堆内存来处理对应的逻辑,这正对应着每次JS Heap的增长。

2. 写入阶段
写入阶段是指将加载的文件写入到JS对象,也即Window.BPmanifest中。写入阶段的代码如下:

let lines = hashMap.split('\n');
let stack = [];

lines.forEach((line) => {
if (line != null && line.length > 0) {
	if (line.substr(0, 1) == '/') {
		let dirname = line.substr(1);
		stack.push(dirname);
	} else if (line.substr(0, 1) == '\\') {
		stack.pop();
	} else {
		let cols = line.split(' ');
		let filename = cols[0];
		let hash = cols[1];
		let extname = filename.split('.').pop();
		let originPath = `${stack.join('/')}/${filename}`;
		if (stack.length == 0) originPath = filename;
		if (/uipic\//.test(originPath)) {
			originPath = originPath.replace('uipic/', '');
		}
		let str_temp = `client_res/${hash}.${extname}`;
		window.BPmanifest[originPath] = str_temp;
		for(let i=0; i<1; i++) {
			window.BPmanifest[originPath + i.toString()] = str_temp;
		}
	}
}

分析调用栈容易知道,该区域的sizeof下内存的增长是对应代码中的console.log("xxxx", sizeof(xxx));其余部分则是赋值windows.BPmanifest的过程。

3. GC阶段
手动触发强制垃圾回收,可以看到此时执行了Major GC

三个阶段执行完成后,可以发现JS Heap增长了(8504440 - 6676284)B ,约为1.9M,和第4节的场景A(微信开发者工具环境下)测试结果一致,但是和场景B(iOS环境下)不一致。重复测试多次,结果依旧如此。

6.2 定位问题,提出猜想

幸运的是,在偶然的一次不规范的测试步骤中发现了问题。
此次测试步骤如下:

测试步骤:

  • Step 1. 点击⏺录制
  • Step 2. “点击屏幕”(触发下载配置文件事件)
  • Step 4. 点击⏺结束录制。

在这次测试步骤中,漏掉了强制触发GC的步骤,即Step 3。测试结果如下图:

对比6.1节的结果发现,测试结果没有GC阶段,JS Heap增长了(12555396 - 6904224)B ,约为6M。内存变化的大小和iOS环境下的测试结果(6M)吻合了。因此,提出猜想,iOS环境下测试的结果会不会是因为堆内存没有经过垃圾回收导致的?
可是代码中实现了wx.triggerGC()触发垃圾回收,为什么没有起到效果?翻阅微信官方文档才发现:wx.triggerGC()加快触发 JavaScriptCore 垃圾回收(Garbage Collection)。GC 时机是由 JavaScriptCore 来控制的,并不能保证调用后马上触发 GC。

6.3 猜想验证

为了验证猜想:iOS环境下微信小游戏的内存变化和微信开发者工具环境下的不一致,是因为iOS环境下没有触发垃圾回收(GC),本文做了两个测试:
(1)延长iOS环境下的微信小游戏测试时长,观察内存是否有回落;
(2)增加不可被GC的内存大小,减少因为GC带来的影响。

测试1:延长iOS环境下的测试时长
使用PerfDog对该小游戏Case进行测试,等待一段时间,发现内存逐渐下降到了增长幅度为2M的区间。

测试2:增加不可被GC的内存大小
1.9M大小的资源对JS Heap的增长很容易受到其他因素(如本例子下的xmlhttp所占的堆内存,这部分内存最终会被GC)的影响。为了更清晰地解读和分析内存占用场景,一种很方便的方式是调整资源大小,增加不可被GC的内存大小。本文将配置文件赋值给JS对象的过程循环执行100次,代码修改如下:

// 代码调整
return new Promise((res, rej) => {
    ···
    loadRes(hashUrl, (hashMap) => {
        ···
        lines.forEach((line) => {
            ···
            window.BPmanifest[originPath] = str_temp;
            for(let i=0; i<100; i++) { // 此处 将 'i < 1' 改为 'i < 100' 
              window.BPmanifest[originPath + i.toString()] = str_temp;
            }
         		...
        }
     }
}

本次测试中发现,在微信开发者工具环境下,通过PerformanceMemory测试的结果如下图所示:
Performance中显示JS Heap增长(100592440-6043844)= 90M(未强制GC);
Memory中显示JS Heap增长(96-8)=88M。
两者虽有一定差距,但是可以看到差别很小,可以理解为GC带来的差别。


在iOS环境下,使用PerfDog测试微信小游戏的结果如下图所示:点击操作前后的内存差距恰好为90M,和微信开发者工具中测试的结果吻合。

6.4 结论

在iOS环境和微信开发者工具环境下,微信小游戏内存变化不一致的问题原来是由GC引发的“乌龙危机”。并最终得出结论:
此次iOS环境下微信小游戏的内存变化和微信开发者工具环境下的不一致,是因为观察时间较短,此期间iOS还未及时GC导致的。本质是GC 时机由 JavaScriptCore 来控制的,并不能保证调用后马上触发 GC

7. 总结

虽然这个case只是一场“乌龙事件”,但是在这个过程中,开发同学收获了定位一个内存问题的几个关键的小Tip:

  • 微信开发者工具中结合PerformanceMemory,对比PerfDog上真机的内存表现,发现差异,定位问题。
  • Performance中的Main堆栈对于分析代码对JS heap的影响很有帮助。
  • Memory中记录的Heap snapshot是页面对象及DOM内存,可以简单的理解为不包含垃圾内存。
  • 为了避免垃圾回收带来的测试影响,可以静止一段时间观察垃圾回收情况,或者触发强制垃圾回收。
  • 对于本身内存较小的资源可以适当放大其在JS Heap的占用,以更好的观察效果,避免其他变量带来的影响。
  • 多次测试观察平均效果。

8. 参考文献

PerfDog使用说明:移动全平台iOS/Android性能测试、分析工具平台。
Chrome Performance :Chrome Devtools 善用微信开发者工具。
Chrome Memory:善用微信开发者工具。
Visualizing memory management in V8 Engine :V8内存管理。

最后一次编辑于  2020-06-05  
点赞 6
收藏
评论
登录 后发表内容