javaScript-了解V8引擎的垃圾回收策略

2021-12-22·7min
type
Post
summary
status
Published
category
tags
slug
date
Dec 22, 2021
password
icon
📌
作为目前最流行的javaScript引擎,V8引擎在底层为我们处理了很多麻烦事,包括自动的垃圾回收管理。
所以在日常开发中,很少会碰到由于内存泄漏导致的程序崩溃问题,尤其是浏览器端。
但了解V8引擎的垃圾回收策略能够帮助我们规避导致内存泄漏的代码,以及写出对V8引擎友好的代码。

什么是垃圾回收?

简单来说,将失去作用的对象所占用的内存空间进行清空,就是垃圾回收。

为什么需要垃圾回收?

当我们创建一个对象时,JS引擎都要在内存空间中分配相应的内存空间来储存这个对象的实体。
试想一下,如果每个对象分配的内存空间都不再被回收,那么整体占用的内存就会越来越大,直到超过V8引擎所持有的内存,从而引起程序崩溃。
所以为了内存的合理分配,就要找到那些不再被使用的对象并将其清除,从而释放内存空间,以供程序的流畅运行。

V8引擎的垃圾回收

由于JS是单线程的,当垃圾回收工作正在进行时,其他任务都要等待。
为了避免因为垃圾回收而阻塞主线程,V8引擎有一套回收机制。

内存限制

V8引擎可使用的内存不是无限制的,具体点来说,在64位操作系统中可使用内存大概有1.4GB,32位操作系统则为0.7GB。
至于为什么要做内存限制,主要有两个原因:
  1. 没必要
JS在设计之初是作为浏览器脚本语言的,作用就是让用户与浏览器交互、操作DOM、表单验证。
在这种场景下,就很少有占用大内存的情况。
当然后来出现了node.JS,涉及到的复杂I/O操作导致很可能出现内存溢出的情况。
因此V8也为我们提供了调整内存大小的可配置项,不过需要在node初始化时配置,这个就不在本文深究了。
  1. 避免垃圾回收耗时过长
虽然V8会自动进行垃圾回收,但进行一次垃圾回收是很耗时的,并且还会堵塞主线程。
如果V8不对内存进行限制,很可能一次垃圾回收就要处理大量的垃圾,从而导致应用的堵塞。
基于以上两点,V8引擎可使用内存是有限制的。

分代式垃圾回收

V8引擎的垃圾回收机制基于分代式垃圾回收机制,简单来说就是将不同存活时长的对象分区管理,并使用不同的回收机制。
基于分代式的理念,V8引擎将堆内存分为新生代和老生代两个区,并有相应的回收算法。

新生代区

新生代区主要用来存放存活时间较短的对象,大多数的对象刚创建时都会被分配到这里,这个区域较小但是回收频率特别频繁。
新生代区由两部分组成:激活空间(From)与未激活空间(To)。
这两个空间至始至终都只会有一个处于激活状态。
当新生代区开始进行垃圾处理工作时,主要会经历这么几个流程:
  1. 标记所有存活的对象(从根节点出发,标记所有可以访问到的变量)。
  1. 将存活的对象从激活空间(From)复制到未激活空间(To)。
  1. 清空激活空间(From)。
  1. 两个空间角色反转, 未激活空间(To)变为新的激活空间(From),原先的激活空间(From)变为未激活空间(To)。
新生代区的垃圾回收过程就是将存活的对象从激活空间(From)复制到未激活空间(To),并完成空间角色的互换。所以缺点也很明显,浪费了一半的空间用于复制。

对象晋升

当一个对象在新生代区中经历多次复制仍然存活,V8引擎就会判定它是一个生命周期较长的对象。
就会在下一次新生代区垃圾回收时,将其移入老生代区进行储存。
这个过程,称之为对象晋升。
对象晋升有两个条件:
  1. 该对象至少被复制过一次
新生代区进行垃圾回收时,会判断该对象是否已被复制过一次,如果符合条件,就会将其移入老生代区进行管理。
  1. 未激活空间(To)的内存占比超过25%
如果对象没有经过一次复制,但是未激活空间(To)的内存占比已经超过了25%,则该对象依旧会被转移到老生代区。
这是由于未激活空间(To)随后就会变成激活空间(From),为了保证后续对象的内存分配,就必须限制内存占比。

老生代

老生代区存储着大量的存活对象,所以不可能再像新生区一样浪费一半空间提供复制操作。
老生代区使用两种算法来进行垃圾处理工作:
  1. 标记-清除(Mark-Sweep)
在标记阶段,V8引擎会从根节点出发,标记所有可以访问到的变量。
在清除阶段,将所有未标记的对象进行清除。
但这个标记-清除过程会导致内存中出现大量不连续的内存空间,也就是出现所谓的内存碎片。从而导致没有足够的内存储存大内存对象。
为了避免出现内存碎片。标记-整理(Mark-Compact)被提了出来。
  1. 标记-整理(Mark-Compact)
同样的标记阶段,会标记所有可以访问到的对象。
在整理阶段,会将所有标记的对象往内存的一端移动,移动完成后清理边界外的全部内存。
简单来说,老生代区整体的垃圾回收流程可以总结为:标记-整理-清除三个阶段。

V8的垃圾回收优化

在前面提到过,由于JS是单线程的,所以垃圾回收会阻塞主线程的执行。
如果垃圾回收的标记阶段(遍历堆内存)耗时过长,就会导致页面的卡顿、甚至程序的无响应。
所以为了避免卡顿,V8引擎又引入了增量标记(Incremental Marking)的概念。
简单理解就是,V8引擎会先标记一部分内存对象,然后暂停标记,让出主线程的执行权。等待主线程清空再继续标记工作,直到标记完所有可访问的对象。
后来V8引擎又继续引入了延迟清理(lazy sweeping)和增量式整理(incremental compaction),让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也引入了并行标记和并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能。

如何避免内存泄漏

  1. 尽可能少创建全局变量
由于全局变量挂载在全局对象上,所以当把全局对象作为根节点进行标记工作时,一定是能访问到全局变量的。
所以全局变量会一直存活,直到整个程序退出,全局执行上下文出栈。
如果不是有必要的,尽可能少创建全局变量。
  1. 手动清除定时器
定时器本身是一个非常有用的功能,但是如果我们稍不注意,忘记在适当的时间手动清除定时器,那么很有可能就会导致内存泄漏。
示例如下:
在这个示例中,由于我们没有手动清除定时器,导致回调任务会不断地执行下去,回调中所引用的numbers变量也不会被垃圾回收,最终导致numbers数组长度无限递增,从而引发内存泄漏。
  1. 少用闭包
闭包是一个能访问已销毁作用域中的变量的函数,其实本质上就是在其内部属性scope中保存了对上级作用域的引用。
所以使用闭包会导致已销毁的作用域占用的内存无法被回收。
  1. 使用weakMap和weakSet
ES6中为我们新增了两个有效的数据结构WeakMap和WeakSet,就是为了解决内存泄漏的问题而诞生的。
WeakMap和WeakSet的键名所引用的对象均是弱引用。
也就是说垃圾回收的过程中不会将该键名对该对象的引用考虑进去,只要所引用的对象没有其他的引用了,垃圾回收机制就会释放该对象所占用的内存。

参考

> cd ..