垃圾回收
1. GC是什么?
垃圾回收机制 GC 即 Garbage Collection ,程序工作过程中会产生很多 垃圾,这些垃圾是程序不用的内存或者是之前用过了以后不会再用的内存空间,而 GC 就是负责回收垃圾的。
当然也不是所有语言都有 GC,一般的高级语言里面会自带 GC,比如 Java、Python、JavaScript 等,也有无 GC 的语言,比如 C、C++ 等,那这种就需要我们程序员手动管理内存了,相对比较麻烦。
2. 垃圾产生&为何回收
我们知道写代码时创建一个基本类型、对象、函数……都是需要占用内存的,但是我们并不关注这些,因为这是引擎为我们分配的,我们不需要显式手动的去分配内存。
但是,你有没有想过,当我们不再需要某个东西时会发生什么?
JavaScript 引擎又是如何发现并清理它的呢?
我们举个简单的例子
let test = {
name: "isboyjc"
};
test = [1,2,3,4,5]
我们知道 JavaScript 的引用数据类型是保存在堆内存中的,然后在栈内存中保存一个对堆内存中实际对象的引用。
所以,JavaScript 中对引用数据类型的操作都是操作对象的引用而不是实际的对象。可以简单理解为,栈内存中保存了一个地址,这个地址和堆内存中的实际值是相关的。
那上面代码首先我们声明了一个变量 test,它引用了对象 {name: ‘isboyjc’},接着我们把这个变量重新赋值了一个数组对象,也就变成了该变量引用了一个数组,那么之前的对象引用关系就没有了,如下图:
没有了引用关系,也就是无用的对象,这个时候假如任由它搁置,一个两个还好,多了的话内存也会受不了,所以就需要被清理(回收)。
程序的运行需要内存,只要程序提出要求,操作系统或者运行时就必须提供内存,那么对于持续运行的服务进程,必须要及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则就会导致进程崩溃。
3. 垃圾回收策略
- JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。
- JavaScript通过自动内存管理实现内存分配和闲置资源回收。
- 基本思路:
确定哪个变量不会再使用然后释放它占用的内存
这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。
以函数中局部变量的正常生命周期为例。
函数中的局部变量会在函数执行时存在。此时,栈(或堆)内存会分配空间以保存相应的值。函数在内部使用了变量,然后退出。此时,就不再需要那个局部变量了,它占用的内存可以释放,供后面使用。
这种情况下显然不再需要局部变量了,但并不是所有时候都会这么明显。
垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收内存。
在浏览器的发展史上,用到过两种主要的标记策略:标记清理、引用计数。
- 标记清理
- 引用计数
- 分代回收
3.1 标记清理
- JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。
当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。
当变量离开上下文时,也会被加上离开上下文的标记
给变量加标记的方式有很多种,例如:
- 当变量进入上下文时,反转某一位;
- 或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。
- 标记的方法不重要,关键是策略
垃圾回收程序如何回收:
- 垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。
- 然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。
- 在此之后再被加上标记 的变量就是待删除的了。原因是任何在上下文中的变量都访问不到它们了。
- 随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。
优点:
- 标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单。
缺点:
标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片
(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题。
假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配(如下图)
那如何找到合适的块呢?我们可以采取下面三种分配策略:
-
First-fit
,找到大于等于 size 的块立即返回 -
Best-fit
,遍历整个空闲列表,返回大于等于 size 的最小分块 -
Worst-fit
,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回。
这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fit 和 Best-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择。
综上所述,标记清除有两个很明显的缺点:
- 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
- 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢
标记整理
标记整理(Mark-Compact)算法 就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存(如下图)。
3.2 引用计数
- 没那么常用的垃圾回收策略是引用计数。
- 思路是对每个值都记录它被引用的次数。
- 声明变量并给它赋一个引用值时,这个值的引用数为 1。
- 如果同一个值又被赋给另一个变
量,那么引用数加 1。 - 类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。
- 当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。
- 垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存
引用计数遇到的问题:
- 循环引用
function problem() {
let objectA = new Object();
let objectB = new Object();
objectA.someOtherObject = objectB;
objectB.anotherObject = objectA;
}
在这个例子中:
objectA 和 objectB 通过各自的属性相互引用,
意味着它们的引用数都是 2
objectA 和 objectB 在函数结束后还会存在
因为它们的引用数永远不会变成 0。
如果函数被多次调用
则会导致大量内存永远不会被释放。
4. V8对GC的优化
4.1 分代式垃圾回收
试想一下,我们上面所说的垃圾清理算法在每次垃圾回收时都要检查内存中所有的对象,这样的话对于一些大、老、存活时间长的对象来说同新、小、存活时间短的对象一个频率的检查很不好,因为前者需要时间长并且不需要频繁进行清理,后者恰好相反,怎么优化这点呢???分代式就来了。
4.1.1 新老生代
V8的垃圾回收策略主要是基于分代式垃圾回收机制,V8中将堆内存分为新生代
和老生代
两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。
新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持1~8M的容量。
老生代的对象为存货时间较长或者常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,通常容量比较大。
V8整个堆内存的大小=新生代内存+老生代内存
对于新老两块内存的垃圾回收,V8才用了两个垃圾回收器来管控,我们暂且将管理新生代的垃圾回收器叫做新生代垃圾回收器,同样的,我们称管理老生代的垃圾回收器叫做老生代垃圾回收器。
4.1.2 新生代垃圾回收
新生代对象是通过一个名为 Scavenge算法
进行垃圾回收,在 Scavenge算法 的具体实现中,主要采用了一种复制式的方法即 Cheney算法
。
Cheney算法
中将堆内存一分为二:
- 使用区: 处于使用状态的空间
- 空闲区: 处于空闲状态的空间
新加入的对象都会存放到使用区,当使用区快被写满时,就需要执行一次垃圾清理操作。
当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区。
当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理。
另外还有一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过了 25%,那么这个对象会被直接晋升到老生代空间中,设置为 25% 的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配。
4.1.3 老生代垃圾回收
相比于新生代,老生代的垃圾回收就比较容易理解了,上面我们说过,对于大多数占用空间大、存活时间长的对象会被分配到老生代里,因为老生代中的对象通常比较大,如果再如新生代一般分区然后复制来复制去就会非常耗时,从而导致回收执行效率不高,所以老生代垃圾回收器来管理其垃圾回收执行,它的整个流程就采用的就是上文所说的标记清除算法了。
首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象。
清除阶段老生代垃圾回收器会直接将非活动对象,也就是数据清理掉。
前面我们也提过,标记清除算法在清除后会产生大量不连续的内存碎片,过多的碎片会导致大对象无法分配到足够的连续内存,而 V8 中就采用了我们上文中说的标记整理算法来解决这一问题来优化空间。
5. 性能
- 垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。
- 在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。
调度垃圾回收程序方面IE7之前的版本:它的策略是根据分配数。
- 比如分配了 256 个变量、4096 个对象/数组字面量和数组槽位(slot),或者 64KB字符串。只要满足其中某个条件,垃圾回收程序就会运行。
- 分配那么多变量的脚本,很可能在其整个生命周期内始终需要那么多变量,结果就会导致垃圾回收程序过于频繁地运行。严重影响性能。
IE7 发布后,JavaScript 引擎的垃圾回收程序被调优为动态改变分配变量、字面量或数组槽位等会触发垃圾回收的阈值。
- 如果垃圾回收程序回收的内存不到已分配的 15%, 这些变量、字面量或数组槽位的阈值就会翻倍。
- 如果有一次回收的内存达到已分配的 85%,则阈值重置为默认值。
6. 内存管理
- 在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。
- 但分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动浏览器的就更少了。
- 为了避免运行大量 JavaScript 的网页耗尽系
统内存而导致操作系统崩溃。 - 将内存占用量保持在一个较小的值可以让页面性能更好。
优化内存占用的手段:
保证在执行代码时只保存必要的数据。
- 如果数据不再必要,那么把它设置为 null,从而释放其引用(或者称为解除引用)。
function createPerson(name){
let localPerson = new Object();
localPerson.name = name;
return localPerson;
}
let globalPerson = createPerson("Nicholas");
// 解除 globalPerson 对值的引用
globalPerson = null;
在上面的代码中:
变量 globalPerson 保存着 createPerson()函数调用返回的值。
在 createPerson()内部,localPerson 创建了一个对象
并给它添加了一个 name 属性。
然后,localPerson 作为函数值被返回,
并被赋值给 globalPerson。
localPerson 在 createPerson()执行完成超出上下文后会自
动被解除引用,不需要显式处理。
但 globalPerson 是一个全局变量,应该在不再需要时手动解除其引用,最后一行就是这么做的。
使用const和let声明提升性能:
- const和let都以块为作用域,相比于使用var,可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。
7. 那些情况引起内存泄露?
- 意外声明全局变量是最常见但也最容易修复的内存泄漏问题。
function setName() {
name = 'Jake';
}
解释器会把变量 name 当作 window 的属性来创建
(相当于 window.name = 'Jake')。
在 window 对象上创建的属性,只要 window 本身不被清理就不会消失。
这个问题很容易解决:
只要在变量声明前头加上 var、let 或 const 关键字即可
这样变量就会在函数执行完毕后离开作用域.
- 定时器也可能会悄悄地导致内存泄漏。
// 只要定时器一直运行
// 回调函数中引用的 name 就会一直占用内存。
let name = 'Jake';
setInterval(() => {
console.log(name);
}, 100);
- 使用 JavaScript 闭包很容易在不知不觉间造成内存泄漏
let outer = function() {
let name = 'Jake';
return function() {
return name;
};
};
- 没有清理的DOM元素引用
var elements = {
button: document.getElementById('button'),
image: document.getElementById('image'),
text: document.getElementById('text')
};
function doStuff() {
image.src = 'http://some.url/image';
button.click();
console.log(text.innerHTML);
}
function removeButton() {
document.body.removeChild(document.getElementById('button'));
// 此时,仍旧存在一个全局的 #button 的引用
// elements 字典。button 元素仍旧在内存中,不能被 GC 回收。
}
8. 内存泄露的识别方法
新版本的chrome在performance 中查看:
步骤:
- 打开开发者工具 Performance
- 勾选 Screenshots 和 memory
- 左上角小圆点开始录制(record)
- 停止录制
图中 Heap 对应的部分就可以看到内存在周期性的回落也可以看到垃圾回收的周期,如果垃圾回收之后的最低值(我们称为min),min在不断上涨,那么肯定是有较为严重的内存泄漏问题。