JavaScript中的垃圾收集
- 程序的运行需要内存,只要程序要求,操作系统就必须提供内存
- JavaScript使用自动内存管理,这被称为”垃圾回收机制”(garbage collector)
- 优点是可以简化开发、节省代码,缺点是无法完整的掌握内存的分配与回收的具体过程
NodeJS中的内存管理
如果不再用到的内存没有及时释放,就叫做内存泄漏。
- 浏览器端的内存泄漏(其实没有那么严重,用户直接是独立进程)
- 对于持续运行的服务进程Node服务器端程序,必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。
V8内存管理
V8使用内存大小限制的,在64位操作系统最大可以使用1.4G,在32位操作系统中最大可以使用0.7G内存。
查看node中的内存使用情况
- JS对象都是通过V8进行分配管理内存的
- process.memoryUsage 返回一个对象,包含了 Node 进程的内存占用信息
// node 环境下
console.log(process.memoryUsage());
// {
// rss: 17637376, => 1.7m 所有内存占用,包括指令区和堆栈
// heapTotal: 4210688, => 4.2m 堆占用的内存,包括用到的和没用到的
// heapUsed: 2054992, => 2m 用到的堆的部分。判断内存泄漏,以 heapUsed 字段为准
// external: 651324 => 6k V8 引擎内部的 C++ 对象占用的内存
// }
V8为什么要限制内存大小
- 因为V8的垃圾收集工作原理导致的, 1.4G内存完全一次垃圾收集需要1秒以上
- 这个暂停时间成为 Stop The World ,在这个期间,应用的性能和响应能力都会下降(扫描过程中,做不成其他事情~)
如何突破内存限制
一旦初始化成功,生效后不能再修改
- –max-new-space-size,最大 new space 大小,执行 scavenge 回收,默认 16M,单位 KB
- –max-old-space-size,最大 old space 大小,执行 MarkSweep 回收,默认 1G,单位 MB
node --max-old-space-size=2000 app.js 单位是M
node --max-new-space-size=1024 app.js 单位是kb
V8的垃圾回收机制♻️
- V8是基于分代的垃圾回收
- 不同代垃圾回收机制也不一样
- 按存活的时间分为新生代和老生代
V8分代概念
- 年龄小的是新生代,由From区域和To区域两个区域组成
- 在64位系统里,新生代内存是32M,From区域和To区域各占一半
- 在32位系统里,新生代内存是16M,From区域和To区域各占一半
- 年龄大的是老生代,默认情况下,
- 64为系统下老生代内存是1400M
- 32为系统下老生代内存是700M
引用计数
- 语言引擎有一张引用表,保存了内存里面所有的资源的引用次数(从GC ROOT查找)。
- 如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放。
此图中,F,D,E对象在扫描时,均应该被回收掉。为了确切的理解引用计数,我们来写一段代码:
<body>
<script>
function Person(name){
this.name = name;
}
let p1 = new Person('ys1');
let p2 = new Person('ys2');
// 3s 之后干掉 p1
setTimeout(() => p1 = null, 3000);
// 10s 之后干掉 p2
setTimeout(() => p2 = null, 10000);
</script>
</body>
打开控制台,我们分别间隔 1s,3s,10s 拍下三个快照。
可以看到,解除引用的实例的内存被回收了。
再来看一个例子:
function Person(name){
this.name = name;
}
let p1 = new Person('ys1');
let set = new Set();
set.add(p1); // set 堆中保存着 0 -> ys1 实例 的引用
p1 = null;
这种情况下,ys1 的实例所在的堆内存是不会被回收的,因为虽然 p1 不再引用了,但是 set 中依旧保持着对实例对象的引用。如果想让该实例对象被回收,直接 set = null 即可,这也会导致 set 所在的堆内存回收哦。
新生代的垃圾回收
- 新生代区域一分为二,每个16M,一个使用,一个空闲
- 开始垃圾回收的时候,会检查FROM区域中的存活对象,如果还活着,拷贝到TO空间,完成后释放空间
- 完成后 FROM 和 TO 互换
- 新生代扫描的时候是一种广度优先的扫描策略
- 新生代的空间小,存活对象少
- 当一个对象经历过多次的垃圾回收依然存活的时候,生存周期比较长的对象会被移动到老生代,这个移动过程被成为晋升或者升级
- 经过5次以上的回收还存在
- TO的空间使用占比超过25%,或者超大对象
scanPointer(扫描指针)、allocatePointer(分配指针)
- 如图,首先回收前新生代的 from 区域存在 A、B、C 三个变量,某个时间点,V8 引擎开始扫描新生代 from 区域,发现存在根对象引用的 A「广度优先,如果还有其他直接引用的,也会拷贝到老生代」,将 A 拷贝到老生代,老生代中扫描指针指向 A(开始扫描 A 的引用),然后分配指针往后移一位(接下来复制过来的对象要放在这个内存地址)。
- 扫描 A 的引用对象,发现 A 引用了 B,将 B 拷贝到老生代,分配指针继续后移。
- 继续扫描 A,发现没有其他引用了,扫描指针后移,开始扫描 B。
- B 也没其他引用了,扫描指针继续后移,此时扫描指针与分配指针重合,新生代扫描结束,释放 from 所有内存,并将 to 作为下一个新生代,from 则转变为老生代。
那什么时候使用到老生代呢,毕竟新生代那么小,老生代可大着呢,有两种情况会把变量丢给老生代处理:
- 如果一个对象存活了五轮的新生代垃圾回收,那么它有资格放到老生代。
- 如果新生代中 to 的空间使用超过 25%,或者直接丢给个放不下的对象,这时候会把变量放到老生代。
老生代的垃圾回收
- 两种策略,mark-sweep(标记清除,比较快,但是会形成内存碎片),mark-compact(标记整理,略慢,内存连续)
- 老生代空间大,大部分都是活着的对象,GC 耗时比较长,而且在此期间无法响应,STOP-THE-WORLD(全停顿),V8 有一个优化方案,增量处理,把一个大暂停换成多个小暂停,INCREMENT-GC(增量标记)。
- mark-sweep(标记清除)
- 标记活着的对象,随后清除在标记阶段没有标记的对象,只清理死亡对象
- 问题在于清除后会出现内存不连续的情况,这种内存碎片会对后续的内存分配产生影响
- 如果要分配一个大对象,碎片空间无法分配 (比如 window 电脑,有一个功能是可以对磁盘碎片整理)
- mark-compact(标记整理)
- 标记死亡后会对对象进行整理,活着的对象向左移动,移动完成后直接清理掉边界外的内存(haha,特别像我写的移动0)
两种垃圾回收的策略都会使用,只是 mark-sweep(标记清除) 使用的较多,因为它很快,偶尔使用 mark-compact(标记整理),去整理内存空间,我猜是 10 比 1,你也可以猜一下。
因为老生代比较大,一次 GC 可能需要 1s 多,这对服务端是无法接受的,所以 V8 采用了一个 INCREMENT-GC(增量标记) 的优化策略~
- 以上三种回收时都需要暂停程序执行,收集完成后才能恢复, STOP-THE-WORLD(全停顿) 在新生代影响不大,但是老生代影响就非常大了,增量标记就是把标记改为了增量标记,把一口气的停顿拆分成了多个小步骤,做完一步程序运行一会儿,垃圾回收和应用程序运行交替进行,停顿时间可以减少到1/6左右。
新老生代三种 GC 回收算法对比
题外话,快照查看闭包
function Person(name){
this.name = name;
}
let PersonFactory = function(name) {
let p = new Person(name);
return function() {
console.log(p);
}
}
let p1 = PersonFactory('ys1');
这样也可以观察到闭包行为哦,注意,上述代码,因为 p1 引用了匿名函数,匿名函数引用了 ys1 实例,才导致实例没有被销毁。所以,我们使 p1 = null 可以释放掉匿名函数和 ys1 实例的堆内存哦。