简介
像C语言这样的底层语言,一般是由应用程序手动分配内存和释放内存的
。而JavaScript是由引擎自动完成内存管理
的。虽然JavaScript会自动释放内存,但如果编码不当,也会造成内存泄漏
问题。文章分析了JavaScript的内存管理机制
,同时,总结了几种典型的内存泄漏的场景,最后,介绍了排查内存泄漏的相关实践。希望能够帮助我们在编码中尽量避免内存泄漏的问题,即使有问题了也可以快速定位。
程序执行
内存是用来存储程序运行过程中的数据
。
程序的执行是一系列函数调用
的过程。函数可以嵌套调用,当子函数执行完毕,其占用的内存资源也应该得到释放,所以用栈结构
来存储上下文信息(包括基本类型变量)。
如果把所有数据都放入栈中,将会影响上下文切换的效率。所有还需要另外的空间来存储比较大的数据(引用类型变量),即堆空间
。
内存空间
根据程序运行的需要,JavaScript引擎把内存空间划分如下:
代码空间栈空间: 程序执行上下文信息(包括基本类型数据)。堆空间: 引用类型数据。
栈空间
栈空间是一种先进后出
的结构。当调用函数时,把执行上下文压入栈
,函数内定义的基本类型变量也会被存在栈中。函数执行完毕后,再把数据弹出栈
。
采用静态分配方式,即在编译时就能确定变量占用内存大小。
堆空间
内存分配
当创建一个引用类型变量时,引擎会在堆空间分配内存,并通过一个栈变量索引到该堆地址
。
采用动态分配方式,即在运行时确定变量占用内存大小。
垃圾回收
回收对象
垃圾回收策略有一个重要的环节,如何确定哪些内存是可回收的
?
通常有两种方法:引用计数法
和访问可达法
。
引用计数法:当一个对象被引用次数为零时,就认为该对象是可被回收的。但存在循环引用
的问题。访问可达法:从根对象(window/global)出发,递归遍历子对象,把不可访问到的对象认为是可被回收的。
循环引用:
访问可达法:
代际假说
JavaScript引擎的垃圾回收机制是基于"代际假说"
的:即大部分对象的生命周期比较短,在一个回收周期内可被回收。
基于"代际假说",把堆内存分为"新生代"
和"老生代"
。新对象被放入"新生代",经过两轮垃圾回收依然存活的对象被移动到"老生代"。
"新生代"和"老生代"的垃圾回收策略有所不同:"新生代"使用Minor GC
,"老生代"使用Major GC
。
Major GC
主回收器的工作过程包括: marking、sweeping、defragmenting三个阶段。
marking: 完成标记。把不可访问到的对象,标记为非活动对象(垃圾)。sweeping: 回收垃圾。记录非活动对象的地址信息,下次有新对象需要分配内存时可以直接使用。defragment: 整理内存碎片。经过多次的内存回收后,原本连续的内存块会被分割得比较零散。这时候把所有对象复制到连续的内存片段,可以提高内存利用效率。
Minor GC
“新生代"又分为"对象区"
和"空闲区"
:新对象放入"对象区”;当对象区快写满了,进行垃圾回收,把活动对象复制到"空闲区";然后交换"对象区"和"空闲区"的角色。
副回收器的工作过程包括: marking、copying、 exchange role。
marking:标记阶段。同Major GC。copying:回收垃圾。把活动对象从"对象区"复制到"空闲区",此时的"对象区"空间被当做垃圾回收。exchange role:交换"对象区"和"空闲区"的角色。即"空闲区"变成"对象区",可以继续分配给新对象。
Major GC VS Minor GC
相同点:
marking阶段是相同的。当复制对象到新的内存时,需要更新引用地址,保证该对象能够被正常访问。
不同点:
sweeping阶段是不同的:
Minor GC是复制活动对象到空闲区,留下来的非活动对象会被当作垃圾回收。由于新生代的对象一般占内存小且存活周期短,所以复制并不会带来太大的开销。
Major GC不是每次都复制,而是把非活动对象的地址信息存起来,下次可以直接分配给新对象。当内存碎片过多时再进行复制。
GC执行时机
GC是运行在主线程的,会阻塞JS脚本的执行,被称为全停顿
(stop-the-world)。
针对全停顿问题,常见的有以下两种方案:
增量(increment)执行
把GC任务拆分成多个小任务
,碎片化执行。虽然没有提高GC工作效率,但可以给JS脚本更高优先级的响应,避免页面卡顿。
并发(concurrent)执行
GC运行在另外的辅助线程,不再占用主线程。理论上避免了全停顿问题。
内存泄漏
少量的内存泄露是不容易被察觉的,用户一刷新页面,问题也就被隐藏了。当页面交互越多,用户停留时间越长,特别是SPA程序,就更容易暴露内存泄露问题: 导致可用内存减少,GC工作频率和时长增加,主线程被占用,最终可能导致页面卡顿甚至程序崩溃。
典型场景
在了解内存管理机制之后,我们知道当一个变量是可访问的/被引用的,那么GC将不会回收内存。所以开发中应该做到:变量不用之后立刻解除引用
。下面介绍几种典型的内存泄漏场景,大家在开发中可以规避。
不必要的全局变量
未使用关键字声明的变量// 非严格模式下,memoryLeak会被挂在全局上function testGlobalValue() {memoryLeak = new MemoryLeak();}
解决方法:
开启严格模式或者lint工具检查 未声明的变量。尽量避免使用全局变量存储大量的数据。
不恰当的闭包(closure)
闭包是JavaScript的一大特性:当一个对象被内部函数引用了,就会形成闭包。即使外部函数已经执行完毕,内部函数依然可以访问到该对象
。由于闭包对象是存储在堆空间的,如果内部函数被引用了,闭包对象将不会被释放。
举个栗子:
let globalArray = [];function testClosure() {function wrap() {let dateArray = new Array(5 * 1000).map(() => {return new Date();});return function inner() {return dateArray;};}globalArray.push(wrap());}
由于内部函数inner()引用了dateArray,形成闭包。并且inner()最终被全局对象globalArray引用,导致dateArray无法被释放。如果这不是预期行为,那就是内存泄漏了。
解决方法:
尽量避免在闭包中使用大量的数据。
detached DOM/EventListener
<body><div id="node"></div><script>function onclick() {}let node = document.getElementById("detachedNode");node.addEventListener("click", onclick);//node.removeEventListener("click", onclick); // 删除节点后,未移除的事件监听也会造成内存泄漏。node.parentNode.removeChild(node); // 虽然从dom树移除了节点,但该节点还被JavaScript变量(node)引用着,所以导致dom节点无法被回收。</script></body>
解决方法:
在操作完DOM之后,及时清除对DOM节点的引用,即赋值null。在释放DOM节点之前,记得移除节点上的事件监听。
未移除的事件监听
添加事件监听会占用内存,所以在事件处理完成后立刻移除事件监听。
function testUnremoveEventListener() {document.addEventListener("mousedown", onMousedown);}function onMousedown() {document.addEventListener("mousemove", onMousemove);document.addEventListener("mouseup", onMouseup);}function onMousemove() {console.log("mousemove");}function onMouseup() {console.log("mouseup");// 事件处理完后应该立刻释放 事件监听// document.removeEventListener("mousedown", onMousedown);// document.removeEventListener("mousemove", onMousemove);// document.removeEventListener("mouseup", onMouseup);}
类似的,还有setTimeout和setInterval函数。
注意:setInterval运行期间,入参对象始终被定时器引用。setTimeout在触发回调之后,入参会被解除引用。
function testTimer() {let couter = new MemoryLeak();// 在定时器被clearInterval之前,couter对象不会被释放。window.timeoutID = setInterval(onTimeout, 1 * 1000, couter);}function onTimeout(couter) {console.log("timeout");couter.value++;}
排查方法
思路
目前是没有工具可以自动检查出内存泄漏的,但是,内存泄漏有个明显的特征:当执行某个操作之后,占用内存会变大。所以排查问题的思路就是逐一排查可能存在内存泄漏的页面组件,观察内存使用情况
。
在浏览器开发环境中,有两个辅助工具:timeline
和heap snapshot
。
timeline直观地展示了内存分配随时间的变化情况。heap snapshot记录了当前内存中所有对象占用内存的大小。
过程
具体排查过程如下:
通过timeline排查出页面中可能存在内存泄漏的组件。审查对应模块的代码,结合内存泄漏的典型场景,找出可能存在问题的代码(业务数据/listener/detached dom)。记录操作前后的heap snapshot,通过对比可疑对象的内存大小变化,进一步锁定可疑对象。找出可能存在问题的代码进行修复,重复上述操作进行验证。
这里以上面的detached DOM/EventListener
为例做分析:
第一步,操作页面组件,观察timeline。我们观察到执行动作后,timeline出现了蓝线且一直未消失。
第二步,选中蓝线附近区域,查看下面的内存对象列表。观察Shallow Size大的对象是否为应用程序数据对象。由于例子中的数据量不大,所以这里很难看出问题。
第三步,审查对应模块的代码。发现是DOM操作和事件监听相关的,可能是detached DOM和Listener造成。所以尝试搜索detached对象,果然出现了detached DOM。
第四步,记录操作前后的heap snapshot,进行对比。观察到存在未释放的DOM节点和事件监听。
第五步,找出可能存在问题的代码进行修复,然后再通过timeline和heap snapshot进行验证。
参考资料
MDN内存管理
图文并茂讲清楚 JavaScript 内存管理
浅谈V8垃圾回收机制
浅谈JS内存机制