1. 为什么需要垃圾回收机制
在浏览器中,Chrome V8引擎实例的生命周期不会很长(谁没事一个页面开着几天几个月不关),而且运行在用户的机器上。如果不幸发生内存泄露等问题,仅仅会 影响到一个终端用户。且无论这个V8实例占用了多少内存,最终在关闭页面时内存都会被释放,几乎没有太多管理的必要(当然并不代表一些大型Web应用不需 要管理内存)。但如果使用Node作为服务器,就需要关注内存问题了,一旦内存发生泄漏,久而久之整个服务将会瘫痪(服务器不会频繁的重启)
2. V8内存限制
- 在64位操作系统可以使用1.4G内存
- 在32位操作系统中可以使用0.7G内存
2.1 为何限制内存大小
- 因为V8的垃圾收集工作原理导致的, 1.4G内存完全一次垃圾收集需要1秒以上
- 这个暂停时间成为
Stop The World
,在这个期间,应用的性能和响应能力都会下降
全停顿 Stop-The-World
为了避免JavaScript应用逻辑和垃圾回收器的内存资源竞争导致的不一致性问题,垃圾回收器会将JavaScript应用暂停,这个过程,被称为全停顿(stop-the-world)。
3. V8的垃圾回收机制
V8是基于分代的垃圾回收
,不同代垃圾回收机制也不一样,按存活的时间分为新生代
和老生代
3.1 新生代和老生代
年龄小的是新生代,
由From区域和To区域两个区域组成
在64位系统里,新生代内存是32M,From区域和To区域各占用16M
在32位系统里,新生代内存是16M,From区域和To区域各占用8M
年龄大的是老生代,默认情况下,
64为系统下老生代内存是1400M
32为系统下老生代内存是700M
如何判断回收内容
如何确定哪些内存需要回收,哪些内存不需要回收,这是垃圾回收期需要解决的最基本问题。我们可以这样假定,一个对象为活对象当且仅当它被一个根对象
或另一个活对象
指向。根对象永远是活对象,它是被浏览器或V8所引用的对象。被局部变量所指向的对象也属于根对象,因为它们所在的作用域对象被视为根对象。全局对象(Node中为global,浏览器中为window)自然是根对象。浏览器中的DOM元素也属于根对象
3.1.1 新生代的特点
大多数的对象被分配在这里,这个区域很小但是垃圾回特别频繁。在新生代分配内存非常容易,我们只需要保存一个指向内存区的指针,不断根据新对象的大小进行递增即可。当该指针到达了新生代内存区的末尾,就会有一次清理(仅仅是清理新生代)
3.1.2 新生代的垃圾回收算法-Scavenge算法
新生代使用Scavenge算法
进行回收。在Scavenge算法的实现中,主要采用了Cheney算法。
Cheney算法是一种采用复制的方式实现的垃圾回收算法。它将内存一分为二,每一部分空间称为semispace。在这两个semispace中,一个处于使用状态,另一个处于闲置状态。处于使用状态的semispace空间称为From空间
,处于闲置状态的空间称为To空间
,当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收算法时,会检查From空间中的存活对象
,这些存活对象将会被复制到To空间中(复制完成后会进行紧缩),而非活跃对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换
。也就是说,在垃圾回收的过程中,就是通过将存活对象在两个semispace之间进行复制。可以很容易看出来,使用Cheney算法时,总有一半的内存是空的
。但是由于新生代很小,所以浪费的内存空间并不大。而且由于新生代中的对象绝大部分都是非活跃对象,需要复制的活跃对象比例很小,所以其时间效率十分理想。复制的过程采用的是BFS(广度优先遍历)
的思想,从根对象出发,广度优先遍历所有能到达的对象
举个栗子(以及凑篇幅),如果有类似如下的引用情况:
+----- A对象
|
根对象----+----- B对象 ------ E对象
|
+----- C对象 ----+---- F对象
|
+---- G对象 ----- H对象 D对象
在执行Scavenge之前,From区长这幅模样
+---+---+---+---+---+---+---+---+--------+
| A | B | C | D | E | F | G | H | |
+---+---+---+---+---+---+---+---+--------+
那么首先将根对象能到达的ABC对象复制到To区,于是乎To区就变成了这个样子:
allocationPtr
↓
+---+---+---+----------------------------+
| A | B | C | |
+---+---+---+----------------------------+
↑
scanPtr
接下来进入循环,扫描scanPtr所指的A对象,发现其没有指针,于是乎scanPtr移动,变成如下这样
allocationPtr
↓
+---+---+---+----------------------------+
| A | B | C | |
+---+---+---+----------------------------+
↑
scanPtr
接下来扫描B对象,发现其有指向E对象的指针,且E对象在From区,那么我们需要将E对象复制到allocationPtr所指的地方并移动allocationPtr指针:
allocationPtr
↓
+---+---+---+---+------------------------+
| A | B | C | E | |
+---+---+---+---+------------------------+
↑
scanPtr
B对象里所有指针都已被复制完,所以移动scanPtr:
allocationPtr
↓
+---+---+---+---+------------------------+
| A | B | C | E | |
+---+---+---+---+------------------------+
↑
scanPtr
接下来扫描C对象,C对象中有两个指针,分别指向F对象和G对象,且都在From区,先复制F对象到To区:
allocationPtr
↓
+---+---+---+---+---+--------------------+
| A | B | C | E | F | |
+---+---+---+---+---+--------------------+
↑
scanPtr
然后复制G对象到To区
allocationPtr
↓
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G | |
+---+---+---+---+---+---+----------------+
↑
scanPtr
这样C对象内部的指针已经复制完成了,移动scanPtr:
allocationPtr
↓
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G | |
+---+---+---+---+---+---+----------------+
↑
scanPtr
逐个扫描E,F对象,发现其中都没有指针,移动scanPtr:
allocationPtr
↓
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G | |
+---+---+---+---+---+---+----------------+
↑
scanPtr
扫描G对象,发现其中有一个指向H对象的指针,且H对象在From区,复制H对象到To区,并移动allocationPtr:
allocationPtr
↓
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H | |
+---+---+---+---+---+---+---+------------+
↑
scanPtr
完成后由于G对象没有其他指针,且H对象没有指针移动scanPtr:
allocationPtr
↓
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H | |
+---+---+---+---+---+---+---+------------+
↑
scanPtr
此时scanPtr和allocationPtr重合,说明复制结束
可以对比一下From区和To区在复制完成后的结果:
//From区
+---+---+---+---+---+---+---+---+--------+
| A | B | C | D | E | F | G | H | |
+---+---+---+---+---+---+---+---+--------+
//To区
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H | |
+---+---+---+---+---+---+---+------------+
D对象没有被复制,它将被作为垃圾进行回收
3.2.1 老生代的特点
老生代所保存的对象大多数是生存周期很长
的甚至是常驻内存的对象,而且老生代占用的内存较多
V8在老生代中的垃圾回收策略采用Mark-Sweep和Mark-Compact相结合
3.2.2 mark-sweep(标记清除)
标记活着的对象,随后清除在标记阶段没有标记的对象,只清理死亡对象
- 标记阶段:对老生代进行第一次扫描,标记活动对象
- 清理阶段:对老生代进行第二次扫描,清除未被标记的对象,即清理非活动对象
问题 在于清除后会出现内存不连续的情况,这种内存碎片
会对后续的内存分配产生影响 如果要分配一个大对象,碎片空间无法分配
3.2.3 mark-compact(标记整理)
标记死亡后会对对象进行整理,活着的对象向左移动
,移动完成后直接清理掉边界外的内存
3.3 新生代到老生代的升级
当一个对象经历过多次的垃圾回收依然存活的时候,生存周期比较长的对象会被移动到老生代, 这个移动过程被成为晋升或者升级
- 经过5次以上的回收还存在
- TO的空间使用占比超过25%
- 或者超大对象
3.4 三种算法的对比
3.5 优化 Orinoco
orinoco为V8的垃圾回收器的项目代号,为了提升用户体验,解决全停顿问题,它利用了增量标记
、懒性清理
、并发
、并行
来降低主线程挂起的时间。
3.5.1 增量标记 - Incremental marking
为了降低全堆垃圾回收的停顿时间,增量标记将原本的标记全堆对象拆分为一个一个任务,让其穿插在JavaScript应用逻辑之间执行,它允许堆的标记时的5~10ms的停顿。增量标记在堆的大小达到一定的阈值时启用,启用之后每当一定量的内存分配后,脚本的执行就会停顿并进行一次增量标记。
3.5.2 懒性清理 - Lazy sweeping
增量标记只是对活动对象和非活动对象进行标记,惰性清理用来真正的清理释放内存。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理的过程延迟一下,让JavaScript逻辑代码先执行,也无需一次性清理完所有非活动对象内存,垃圾回收器会按需逐一进行清理,直到所有的页都清理完毕。
增量标记与惰性清理的出现,使得主线程的最大停顿时间减少了80%,让用户与浏览器交互过程变得流畅了许多,从实现机制上,由于每个小的增量标价之间执行了JavaScript代码,堆中的对象指针可能发生了变化,需要使用写屏障技术来记录这些引用关系的变化,所以也暴露出来增量标记的缺点:
并没有减少主线程的总暂停的时间,甚至会略微增加
由于写屏障(Write-barrier)机制的成本,增量标记可能会降低应用程序的吞吐量
3.5.3 并发 - Concurrent
并发式GC允许在在垃圾回收的同时不需要将主线程挂起,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作。但是这种方式也要面对增量回收的问题,就是在垃圾回收过程中,由于JavaScript代码在执行,堆中的对象的引用关系随时可能会变化,所以也要进行写屏障操作。
3.5.4 并行 - Parallel
并行式GC允许主线程和辅助线程同时执行同样的GC工作,这样可以让辅助线程来分担主线程的GC工作,使得垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。
V8当前垃圾回收机制
2011年,V8应用了增量标记机制。直至2018年,Chrome64和Node.js V10启动并发标记(Concurrent),同时在并发的基础上添加并行(Parallel)技术,使得垃圾回收时间大幅度缩短。
参考: