这是我参与8月更文挑战的第11天,活动详情查看:8月更文挑战
垃圾回收
Trash talk: the Orinoco garbage collector · V8
原始数据是存储在栈空间的,引用类型的数据是存储在堆空间中的,通过这种方式解决了数据的内存分配问题。
但是有一些数据被使用后就不再需要了,这种数据就叫做垃圾数据,如果这些数据一直保存在内存中,那内存占用就会越来越大,因此我们需要对垃圾数据做回收,用以释放有限的内存空间。
C和C++可以通过malloc和free来主动进行分配内存和销毁内存,如果一段数据已经不需要了但是忘记释放内存,那就会导致内存泄漏。
而JavaScript,Java,Python等采用的是自动垃圾回收策略。
调用栈中的数据是如何回收的?
通过代码来分析回收机制
function foo(){
var a = 1
var b = {name:"极客邦"}
function showName(){
var c = 2
var d = {name:"极客时间"}
}
showName()
}
foo()
复制代码
当执行showName的时候此时调用栈和堆的情况是以下这样
可以看到原始类型数据就在执行上下文中存储,而引用类型实际是存储在堆中的,只是从栈中指向堆中对应的对象。
同时还有一个概念,就是有一个记录当前执行状态的指针,叫做ESP,会指向当前执行函数的对应执行上下文,比如说当你执行showName结束的时候,ESP指针就会下移到foo函数执行上下文
此时虽然showName的执行上下文依然保存在栈内存中,但是已经是无效内存了。比如若foo函数调用内部另一个函数的时候,这块无效内存就会被直接覆盖掉,用来存放另外一个函数的执行上下文。
结论:当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。
堆中数据是如何回收的?
当foo函数执行完毕之后,此时ESP就应当指向全局执行上下文了,虽然此时的showName函数和foo函数的执行上下文已经是无效状态了,但是保存在堆中的那两个对象还依然占用着内存空间。
此时很明显,我们需要回收堆中这两块被占用的地方,这时候就要用到JavaScript中的垃圾回收器了。
代际假说
首先你需要明白什么是代际假说(The Generational Hypothesis),是垃圾回收中的一个重要术语,垃圾回收的策略都是建立在这个假说的基础上的。
代际假说有以下两个特点:
- 大部分对象在内存中存在的事件很短,简单说,就是很多对象一被分配内存,很快就变得不可访问了
- 不死的对象会活的更久
这两个特点不仅适用于JavaScript,同样适用于大多数动态语言,比如Java,Python等
V8是如何实现垃圾回收的
在V8中,会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代存放生存时间久的对象。
新生代区通常只支持1~8M的容量,而老生代区支持的容量要大的多。
对于新生代区和老生代区,V8引擎采用两个不同的垃圾回收期,为了实现更高效的垃圾回收。
- 副垃圾回收器,主要负责新生代的垃圾回收
- 主垃圾回收器,主要负责老生代的垃圾回收
垃圾回收器的工作流程
不论什么类型的垃圾回收器,都是一套相同的执行流程。
- 标记空间中的活动对象和非活动对象。(活动对象就是还在用的对象,非活动对象就是那种没人引用的对象)
- 回收非活动对象占用的内存,也就是在标记完成后统一清理所有被标记为非活动对象所占的内存。
- 做内存整理。为什么要做内存整理?因为频繁回收对象后,内存中就会产生大量不连续空间,这些不连续的空间就叫做内存碎片。当内存中有很多内存碎片的时候,如果要分配较大内存的时候,就有可能会内存不足。所以为了避免出现这种内存不足的情况,需要去整理内存碎片。不过这步是可选的,因为有的垃圾回收器不产生内存碎片,例如副垃圾回收器。
副垃圾回收器
主要负责新生代区的垃圾回收。通常大多数小的对象会被分配到这里,虽然区域不大,但是垃圾回收比较频繁。
采用的是Scavenge算法。
原理:
1、把新生代空间对半划分为两个区域,一半是对象区域(From-Space),一半是空闲区域(To-Space),代表永远都会有一半空间是空的。
2、新加入的对象都会存放到From-Space,当From-Space快被写满时,就需要执行一次垃圾清理操作。
第一次垃圾回收,对From-Space做垃圾标记,此时将活动的对象排序放入To-Space。
3、清空From-Space,然后翻转两块空间,将原本清空后的From-Space变为To-Space,将存放了活动对象的To-Space转换为From-Space。当进行第二次垃圾回收(也就是From-Space快满时)的时候发现刚才那4个对象依然存活,先将这四个和一个新加入的活跃对象放入To区,然后存活了两次的那四块因为对象晋升策略就移交给了老生区,此时To-Space就只剩下新加入的活动对象,然后再次对换To和From两块空间,下一次的时候刚开始From-Space就仅只有一个活跃对象了,然后等From-Space快满了就这样循环接着执行垃圾回收,依次循环…
对象晋升策略:经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
主垃圾回收器
主垃圾回收器主要负责老生区的垃圾回收,除了从新生晋升到老生区的对象,一些大的对象会直接被分配到老生区,因此老生区的对象特点就是 对象占用空间大||对象存活时间长===true
主垃圾回收器采用的是标记-清除(Mark-Sweep)的算法进行垃圾回收的。
-
标记阶段
从一组根元素(调用栈和全局对象)开始,递归遍历这组根元素,在这个遍历过程中,有变量指向的元素称为活动对象,没有变量指向的对象就判断为垃圾数据
比如刚执行完foo函数中的showName,ESP向下移动,此时ESP指向了foo的执行上下文,此时遍历调用栈,会发现找不到引用1003这块数据的变量,因此1003被标记为垃圾数据,而1050这块数据被b指向,因此是活动对象。
-
清除垃圾阶段
参考下图
清除数据部分和副垃圾回收器是不同的,因为是在一块空间上进行操作,因此就是先清除掉垃圾数据,但是如果对一块内存多次执行标记-清除算法,会产生大量不连续的内存碎片,这样可能会导致对象无法分配到足够的连续内存,因此产生了另一个算法标记-整理(Mark-Compact)算法,实际上就是将存活的块复制到未被占用的块,这样就能充分利用不同内存块之间的间隙
潜在问题是,当我们分配了很多长期存活的对象时,我们会为拷贝这些对象付出了高昂的代价。这就是为什么我们选择只标记-整理一些高度碎片化的页面,而只对其他页面进行标记-清除,而不是所有的都标记-整理。
全停顿
因为JavaScript是运行在主线程上的,一旦执行垃圾回收算法,就需要将正在执行的JavaScript脚本暂停下来,待垃圾回收完毕后恢复脚本的执行,这种行为叫全停顿。
几种处理全停顿的方法
- Parallel 平行
- Incremental 增量
- Concurrent 辅助线程和主线程同时进行
V8目前在新生代也就是次要垃圾回收期回收采用的是跨辅助线程分配工作
在主要垃圾回收器,也就是老生代采用的是并发标记
空闲时间处理垃圾回收任务
GC 可以发布“空闲任务”,像Chrome会有空闲时间的概念,也就是说Chrome每秒60帧,浏览器有16.6毫秒来渲染每一帧动画,如果每一帧的动画被提前渲染完毕,那Chrome可以选择在这一帧和下一帧的空闲时间里运行GC创建的空闲任务。