引言:什么是内存泄漏?
想象一下你的手机是一个酒店,每个应用程序都是酒店的客人。当客人(应用程序)使用房间(内存)时,酒店经理(系统)会分配房间给他们使用。正常情况下,客人退房(应用关闭)后,房间应该被清理并重新可用。
内存泄漏就像是客人离开了酒店却忘了退房,房间一直被占用无法重新分配。随着时间推移,被占用的房间越来越多,最终酒店没有空房可供新客人使用——这就是应用程序变慢甚至崩溃的原因。
先复习一下前端开发中的内存泄漏
常见的前端内存泄漏场景
1. 意外的全局变量
// 错误示例:意外创建全局变量
function createLeak() {leakedData = '这是一个全局变量'; // 没有使用var/let/const
}// 正确做法
function noLeak() {const safeData = '局部变量'; // 使用const或let
}
2. 未清理的定时器和回调函数
// 可能引发内存泄漏
setInterval(() => {// 某些操作
}, 1000);// 正确做法:保存引用并适时清理
const intervalId = setInterval(() => {// 某些操作
}, 1000);// 在组件卸载时清理
clearInterval(intervalId);
3. DOM引用未释放
// 保存DOM引用
const elements = {button: document.getElementById('myButton'),container: document.getElementById('myContainer')
};// 即使从DOM移除,JavaScript仍保留引用
document.body.removeChild(document.getElementById('myContainer'));// 需要手动释放引用
elements.container = null;
4. 闭包使用不当
// 可能引起内存泄漏的闭包
function createClosure() {const largeData = new Array(1000000).fill('*');return function() {// 闭包持有largeData引用,即使外部函数已执行完毕console.log('闭包被调用');};
}// 正确使用:避免不必要的闭包引用
function safeClosure() {return function() {// 不引用外部变量console.log('安全的闭包');};
}
前端内存泄漏检测工具
-
Chrome DevTools
-
Memory面板:生成堆快照(Heap Snapshot)
-
Performance面板:记录内存分配情况
-
Allocation instrumentation on timeline:跟踪内存分配
-
使用示例
// 手动触发垃圾回收(仅在DevTools中有效)
if (window.gc) {window.gc();
}
鸿蒙应用中的内存泄漏
鸿蒙内存管理特点
鸿蒙系统使用方舟编译器和高性能的运行时环境,但仍然需要开发者注意内存管理。
常见的鸿蒙内存泄漏场景
1. 未取消注册的回调
javascript
// 注册回调 appContext.registerReceiver(receiver, intentFilter);// 必须适时取消注册 appContext.unregisterReceiver(receiver);
2. 资源未正确释放
javascript
// 使用资源 const pixelMap = await image.createPixelMap(byteBuffer);// 使用完毕后必须释放 pixelMap.release();
3. 组件引用未清理
javascript
// 自定义组件中 @Component struct MyComponent {private controller: VideoController = new VideoController();// 必须实现aboutToAppear和aboutToDisappearaboutToDisappear() {// 清理资源this.controller.release();} }
4. 异步任务未取消
javascript
// 启动异步任务 const task = new MyAsyncTask(); task.execute();// 在组件销毁时取消任务 aboutToDisappear() {if (task) {task.cancel();} }
鸿蒙应用中使用工具来检测
初步识别内存问题
- 使用实时监控功能(详细使用方法请参考性能问题定界:实时监控)对应用的内存资源进行监控。正常操作应用,观察运行过程中的应用内存变化情况。当在一段时间内应用内存没有明显增加或者在内存上涨后又逐渐回落至正常水平,则基本可以排除应用存在内存问题;反之,在一段时间内不断上涨且无回落或者内存占用明显增长超出预期,那么则可初步判断应用可能存在内存问题。
- 当从实时监控页面初步判断应用可能存在内存问题后,可以使用Memory泳道来抓取应用内存在问题场景下的详细数据以及变化趋势,初步定界问题出现的位置。Memory泳道存在Allocation或Snapshot模板中,使用Allocation或Snapshot模板录制均可。
- 创建模板后,将模板中的其余泳道去除勾选,仅录制Memory泳道的数据。
说明
其余泳道会开启对内存分配、内存对象等数据的抓取,这些功能会带来额外的开销,可能会对我们初步定界问题产生噪音,影响分析,故先排除录制。
- 点击三角按钮
即开始录制。
- 录制过程中,不断操作应用在问题场景的功能,将问题放大,便于快速定界问题点。
- 点击下图中方块按钮或者左侧停止按钮结束录制。
- 录制完成后,展开Memory泳道,其中ArkTS Heap表示方舟虚拟机内存,这部分内存受到方舟虚拟机的管控。Native Heap表示Native内存,主要是应用使用到的一些涉及Native的API所申请的内存以及开发者自己的Native代码所申请使用的堆内存(通常是C/C++),这部分内存需要开发者自行管理申请和释放。
当ArkTS Heap有明显的上涨,说明在方舟虚拟机内的堆内存上可能存在内存泄漏,可以使用Snapshot模板进行下一步分析;当Native Heap有明显的上涨,说明Native内存上可能存在内存泄漏,可以使用Allocation模板进行下一步分析。
使用Snapshot模板分析ArkTS内存问题
分析步骤
分析内存泄漏问题步骤如下:
- 使用Snapshot模板录制数据;
- 在问题场景前拍摄快照;
- 触发问题场景后,再次拍摄快照;
- 对比两次快照的数据,可快速找到泄漏对象并做进一步分析;
- 当有多个对象在比较视图都存在时,可以重复多次触发问题场景后拍摄快照,分别和问题场景前拍摄的快照进行对比,观察是否有对象出现明显的线性变化趋势,进一步缩小泄漏对象的范围。
录制Snapshot模板数据
- 连接设备后启动应用,点击应用选择框选择需要录制的应用,选择Snapshot模板,点击Create Session或双击Snapshot图标即可创建一个Snapshot的录制模板。
- 创建模板后,点击三角按钮即开始录制。
- 待右侧泳道全部显示recording后则表明正在录制中。
- 拍摄第一次堆快照作为基准(点击图中①处拍摄按钮,待②处显示出紫色条块表示快照拍摄完成)。
说明
方舟虚拟机提供了在获取快照前自动GC(Garbage Collection,对堆内存进行垃圾回收)的能力,因此拍摄快照之前不用主动触发GC。
- 多次触发内存泄漏操作。可以操作5,7,11等这种特殊的次数。比如操作了5次对比两个快照发现有很多创建了5次没释放的场景,则可能存在内存泄漏,再操作7次,如果创建了7次那就可以确认发生了泄漏。
- 拍摄第二次堆快照。
- 点击下图中方块按钮或者左侧停止按钮结束录制。
分析ArkTS Heap
- 在每次拍摄堆快照之前,虚拟机都会触发GC,所以理论上堆快照内存在的对象都是当前虚拟机已经无法GC掉的对象。我们可以将两个堆快照进行比较,来查看哪些对象是在触发问题场景时新增了且不能释放的。切换到窗口下方详情区域的“Comparison”页签,将两次快照进行对比。图中数据的含义是以Snapshot2作为基准,Snapshot2对比Snapshot1的数据变化量。
- 优先寻找与触发内存泄漏操作次数强相关、与业务代码强相关的Constructor,首先来分析这些对象是否正常。主要是按照Distance逐渐减小的方式找引用链,可以从references里面一层层去寻找,排查引用链上的可疑对象(一般指与业务代码关联的对象)。
说明
选择一个实例结点,底部搜索栏的Path to GC Root按钮成可点击状态。点击该按钮,系统会计算从GC Roots垃圾收集器根到选定实例对象的最短路径(最短路径是指Distance逐渐-1的路径,最终抵达Distance = 1的结点),并在右侧区域展示。
分析Snapshot数据
常见对象介绍
JSArray
目前所有JSArray展开后为数组里的各个元素:
其中__proto__:原型对象,所有数组的__proto__应该是一致的;length:内置属性访问器,可以访问数组长度。
TaggedDict
位于(array)标签中,一般为虚拟机内部创建的字典,ArkTS代码层面不可见。
TaggedArray
位于(array)标签中,一般为虚拟机内部创建的数组,ArkTS代码层面不可见。
COWArray
位于(array)标签中,一般为虚拟机内部创建的数组,ArkTS代码层面不可见。
JSObject
JSObject展开后为内部的各个属性如下:
以下通过具体代码来介绍下实例化对象、声明对象、构造函数间的关系:
// HelloWorldPage.ets
class People {
old: number
name: string
constructor(old: number, name: string) {
this.old = old;
this.name = name;
}
printOld() {
console.log("old = ", this.old);
}
printName() {
console.log("name = ", this.name);
}
}@Entry
@Component
struct HelloWorldPage {
@State message: string = 'Hello World';
private people: People = new People(20, "Tom");build() {
Row() {
Column() {
Text(this.message)
.fontSize(50)
.fontWeight(FontWeight.Bold)
}
.width('100%')
}
.height('100%')
}
}
采集到的snapshot数据如下:
202169对象对应的是People,其主要声明了对象的属性和方法。
实例化对象的__proto__属性指向声明时的对象,声明对象里则会有constructor构造函数。当实例化多个对象时,实例化对象会有多个,但是声明对象和构造函数只有一个。
JSFunction
目前所有JSFunction都在(closure)标签中,展开即可看到所有JSFunction:
每个函数展开后为函数内的各个属性:
其中HomeObject表示父类对象,即该方法属于哪个对象;_proto_表示原型对象;LexicalEnv表示该函数的闭包上下文;name是内置属性访问器,可获取函数名;FunctionExtraInfo表示额外信息,比如一些napi接口会在这里记录函数地址;ProtoOrHClass表示原型或者隐藏类。
如果函数显示为anonymous(),则表示为匿名函数;如果函数显示为JSFunction(),则表示该函数可能为框架层函数,创建函数的时候未设置函数名。对于这两种函数名不可见的情况,可以通过查看其引用来间接确认其名称:
ArkInternalConstantPool
虚拟机创建的常量池,ArkTS代码层面不可见,涉及到的字符串常量会在(array)标签中展示:
LexicalEnv
闭包变量上下文;闭包是一个链状结构,如下所示:
733这个节点本身是一个闭包数组,其中0号元素是调用者(或者再往上的调用者,以此类推)的闭包;1号元素存储的是调试信息;2号及以后的元素存储的就是闭包传递的变量,上例传递了一个变量。
InternalAccessor
内置属性访问器,会有getter和setter方法,通过getter、setter可以获取、设置该属性。
分析方法
查看对象名称
对于声明对象,可以通过constructor属性来确定对象名称。
对于实例化对象,一般没有constructor,则需要展开__proto__属性后查找constructor;
若对象里有一些标志性属性,可以通过在代码里搜索属性名称来找到具体是哪个对象。
如果对象间有继承关系,则可以继续展开__proto__:
总结
内存泄漏就像是软件中的"慢性病",初期不易察觉,但长期积累会导致严重性能问题。无论是前端开发还是鸿蒙应用开发,都应该:
-
提高意识:认识到内存管理的重要性
-
遵循最佳实践:使用正确的编程模式和API
-
定期检查:使用工具检测潜在的内存问题
-
及时修复:发现内存泄漏立即解决
通过良好的内存管理习惯,可以显著提升应用性能,提供更流畅的用户体验。记住,预防总是比治疗更有效,在编写代码时就考虑到内存管理,可以避免后期大量的调试和优化工作
华为开发者学堂