JS的垃圾回收机制
一. 垃圾回收机制
程序运行需要内存,无论是高级语言还是低级语言,内存管理都是:
- 内存分配
- 内存使用
- 内存回收
内存使用结束后需要及时回收,不在使用的内存,如果没有及时回收,就叫做内存泄漏。内存泄漏会导致内存使用率变高,轻则影响性能,重则导致系统崩溃
有些语言,例如C语言, 需要手动释放内存,程序员负责内存管理
这样很麻烦,所以很多语言都提供自动内存管理机制,称为垃圾回收机制
二. 回收机制原理
垃圾回收机制会定期(周期性)找出那些不再用到的内存(变量),然后释放其内存
对于JavaScript而言,最初的垃圾回收机制是采用引用计数法,后来升级到了标记清除法
1. 引用计数
var obj = { //引用次数为1
value: 1
}
var a = obj //引用计数为2
obj = null // 1
a = null // 0 可回收
当声明了一个变量并将一个引用类型值赋值该变量时,则这个值的引用次数就是1.如果同一个值又被赋给另外一个变量,则该值得引用次数加1。相反,如果包含对这个值引用的变量又取 得了另外一个值,则这个值的引用次数减 1。当这个值的引用次数变成 0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那 些引用次数为零的值所占用的内存。
问题: 循环引用
function func() {
var a = {}
var b = {}
a.key = b
b.key = a
}
在上面的代码中,a 和 b相互引用, 两个的引用计数都是2
在函数执行完毕后,a,b两个变量还将继续存在,因为他们的引用计数永远不可能是零,因此最终放弃了引用计数法,采用标记清除法。
2. 标记-清除
标记清除算法是当前主流的GC算法,V8里用的就是这个。
分为两个阶段:
-
标记阶段 : 从根节点出发,标记所有可以到达的对象
function mark(obj) { if(obj.marked) return //防止重复标记 obj.marked = true //标记活动对象 for(o in obj) { //标记子对象 mark(o) } } mark(global) //从根出发,遍历并标记活动对象
-
收集阶段 : 遍历堆,回收所有未被标记的对象
p = HEAD_START //HEAD_START HEADT_END分别是堆的开始位置和结束位置 while(p<HEAD_END){ //遍历堆 size = p.size //对象的大小 if(p.marked){ //对于标记对象,清除,为下一次标记作准备 p.marked = false }else { free(p) //清除标记 } p += size }
这里面的free函数也不是简单的回收,而是把空闲空间地址记录到一个链表之中,下一次申请空间的时候,就在链表之中查找大小合适的块分配。
问题 1. 内存碎片化
可以看到, 只对内存进行了清除, 但是没有整理. 而内存的申请又是动态的, 就会导致出现很多离散的小片空闲内存. 极端情况甚至可能内存中还有200mb的空闲内存, 但是申请个10kb的空间却找不到.
问题 2.暂停时间长
每次回收都要遍历一遍堆,遍历的时间与堆的大小成正比,堆越大,遍历的时间越长
三. v8的垃圾回收机制
v8的内存限制
在使用Node的时候,发现只能使用部分内存(64位系统中约为1.4GB),这导致Node无法直接操作大的内存对象。
为什么要做这样的限制?
- 以1.5GB的垃圾回收堆内存为例,v8做一次小的垃圾回收要50ms以上,做一次非增量式的垃圾回收要1s以上,垃圾回收过程会阻塞JS线程执行,以此直接限制堆内存是一个很好的选择。
- v8是为了浏览器的需求而设计的
- 可以手工调整内存大小
v8的内存分配
node
> process.memoryUsage()
{
rss: 23334912, // resident set size 进程常驻内存
heapTotal: 5042176, // 已经申请到的堆内存
heapUsed: 3167080, // 堆内存使用量
external: 1620480, // v8引擎内部C++对象占用的内存
arrayBuffers: 9404 // Buffer 使用量
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iqW4XhyZ-1603977247104)(http://www.ruanyifeng.com/blogimg/asset/2017/bg2017041702-1.png)]
内存分代算法
将对象按不同的存活时间进行分代,v8中主要把内存分为新生代和老生代,新生代存放存活时间较短的对象,老生代中存放存活时间较长或者常驻内存的对象
没有什么回收算法可以胜任所有的场景,根据对象的存活时间从而使用不同的回收算法,从而达到最好的效果。
node --max-old-space-size=1700 test.js //单位为MB。设置老生代
node --max-new-space-size=1024 test.js //单位为KB。设置新生代
通过上面的代码可以设置新生代和老生代的大小,因为大小是启动时就制定的,所以无法动态扩展新生代和老生代的大小
新生代-Scavenge算法
在新生代中,主要通过Scavenge算法进行垃圾回收
在Scanvage算法中,他将堆一分为二,两部分中只有一部分处于使用中,另一部分处于闲置状态,处于使用状态的称为From空间,处于闲置状态的称为To空间,当进行垃圾回收时,会检查From空间中的存活对象,把存活的对象复制到To空间中,释放掉非存活的对象,然后From,To空间角色互换。
那么新生代的对象怎么放到老生代中呢?
-
对象经历了Scavenge回收
对象从From空间复制到To空间时,会进行一次判断,如果对象已经经历过了一次Scavenge回收,就把该对象复制到老生代空间中
-
To空间的内存占比超过了25%
当对象从From空间复制到To空间时,如果To空间的占比已经使用了25%,就把该对象放到老生代当中。这么做是为了,之后该To空间会转换为From空间,内存分配会在这个空间进行,如果使用占比过大,会对对象分配产生影响
老生代
老生代中由于存活对象占比比较大,显然不可能使用Scavaenge算法,首先会导致效率问题,而且会浪费空间,v8中老生代的垃圾回收主要使用标记清除算法和标记整理算法
标记清除算法在上面已将说过,使用标记清除算法会导致内存碎片化,而标记整理算法就是用来解决这个问题的
标记整理算法在回收的过程中,将活动对象向一侧移动,移动完成后清除掉边界外的内存
增量标记
由于Node单线程的,V8每次回收垃圾的时候,就会把应用逻辑停下来,执行完垃圾回收后在恢复,被称作全停顿,当回收新生代时,由于存活对象相对较少,全停顿没有多大的影响,但是在老生代中进行回收暂停较长的时间。
所以增量标记被提了出来,从标记阶段入手,把之前一次标记全部的活动对象改为了增量标记,拆分成了多个小步进,每完成一次步进就让JavaScript应用逻辑运行一会,垃圾回收与应用逻辑交替进行直到回收完成
四. 内存泄漏
常见的内存泄漏场景
-
缓存
-
作用域未释放(闭包)
-
没必要的全局变量
-
无效的DOM引用
//button 的引用仍在内存中 function click() { var button = document.getElementById('button') button.click() }
-
未清除的定时器
-
事件监听未清除
内存泄漏检测方法
经验法则是,连续进行5次垃圾回收后,内存占用一次比一次大,就有内存泄漏
-
浏览器。在浏览器中查看内存占用,如果稳定,就不存在内存泄漏
-
通过process.memoryUsage()返回的heapUsed字段判断
-
Node-heapdump.
var heapdump = require('node-heapdump')
之后可以通过向服务器发送SIGUSR2信号,让node-heapdump抓拍一份堆内存快照
$ kill -USR2 <pid>
抓拍的快照是JSON格式的可以通过Chorme工具查看
-
node-profiler
与node-heapdump类似的抓取内存快照的工具
内存泄漏优化方法
- 解除引用
- 提供手动清除变量的方法
- 避免过多使用闭包
- 避免创建过多生命周期较长的对象,或者分解为多个字对象
- 清除定时器和事件监听器
- 使用stream和buffer操作大文件,不受nodejs内存限制
- 使用redis等缓存数据