理解v8的垃圾回收机制

1,402 阅读13分钟

先来一张思维导图

简介

v8的内存管理和垃圾回收机制,在面试里经常会被问道,在一步步的追分下,会引申到 闭包、weakMap、内存泄露、等相关知识点,每当自己回答这类问题时,发现自己对这类的知识只有模糊的认知,只能说出比较表面的知识,在查阅的各种资料后,总结出这篇文章,梳理了相关的知识点,希望可以帮到大家理解这类的问题。

像一些 C、C++ 一些底层语言一般都有管理内存的接口,开发者可以手动的去进行分配内存;在 JavaScript 这样的高级语言中,当开发者创建一个变量时就会自动进行了分配内存,并且在不使用它们时候会”自动释放清除“,这个过程称为自动垃圾回收机制(GC:Garbage Collection)。JavaScript 开发者虽然不用可以不用关心内存管理的问题,但是我们也要清楚 v8 引擎的内存管理和回收机制,防止写出内存泄露的代码。

无论什么语言,内存的生命周期都由三部分组分:

  1. 内存分配:当申明变量、函数、对象的时候,会自动分配内存
  2. 内存使用:使用分配到的内存
  3. 内存回收:不需要时将其释放\归还

当我们使用 JavaScript 时,第一点和第三点都是隐含的,我们开发者不需要去关注,但第二点都是明确规定的,在我们定义变量时就完成了内存分配的操作。

var name = 20 // 内存分配

console.log(name) // 内存使用

name = null // 内存回收

JavaScript 的数据类型分为两种:基础类型引用类型,其中基础类型是存放在栈内存里,引用类型存放在堆内存。这方面的知识就不在这里描述了,可以参考笔者之前写的 Js基础知识梳理 这篇文章,里面有介绍相关的内容。

垃圾回收策略

垃圾回收算法有好几种,下面分析一些常见的算法。

引用计数

引用计数的含义是跟踪记录每个值被引用的次数,当声明一个变量并将一个引用类型的值赋给该变量时,这个时候的引用类型的值就会是引用次数+1了。如果同一个值又被赋给另外一个变量,则该值的引用次数又+1,当引用失效时,引用次数就会-1,如果引用次数变为0,那么这个对象就可以被释放。

缺点:会有循环引用的问题,比如对象 A 中包含一个指向对象 B 的指针,而对象 B 中也包含一个指向对象 A 的引用。,但是这两个对象没有被其他任何对象引用,属于垃圾对象,却不能回收。使用标记清除法就不会有这个问题,所以这个算法基本不会再使用。

循环引用的例子:

function test (){
  var a = {}
  var b = {}
  a.property = b
  b.property = a
}

上面的例子中,对象 A 和对象 B 通过 property 属性相互引用,导致了内存无法释放。如果这个函数被调用多次的话,就会不断有内存被占用。造成了内存泄露。

标记-清除算法(Mark-Sweep)

意思就是当变量进入环境时,例如,在函数中声明一个变量,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。

标记为”离开环境“的变量是可以进行垃圾回收的。

function test (){
    var a = 10 // 被标记为进入环境
    console.log(a)
}
test() // 执行完毕之后a又被标记为离开环境

缺点:在一次标记清除之后,内存空间会出现不连续的状态,会产生大量内存碎片,不利于大对象的分配。

IE9+、Firefox、Opera、Chrome、Safari 内核都是使用标记清除的垃圾回收策略或类似的策略,只是具体的优化策略算法有点不同。

标记-整理算法(Mark-Compact)

标记整理算法标记清除算法的核心一样:都是通过标记的形式实现,不同的是:标记整理算法在标记之后,把所有标记的对象都移到内存空间的一端,然后直接把边界之外的内存清零。

在标记-清除(Mark-Sweep)算法这种非移动式回收算法中最大的问题就是会产生碎片化的空间,而标记-整理(Mark-Compact)算法正是为了降低内存碎片化提出来的解决策略。

复制算法(Copying)

这种算法原理是将可用内存按容量划分为大小相等的两个空间,来源空间目标空间,在这两个空间中,有一个是使用的,一个是空闲的。新分配的内存会放在来源空间,当来源空间被占满时,就会触发 GC,把来源空间存活的对象复制到目标空间,然后清除来源空间,再将两个空间对换。

缺点:1.将内存缩小为原来的一半,减少了内存的可用率;2.在对象存活率较高的时就要进行较多的复制操作,效率降低。

分代回收算法

把对象存活周期的不同把内存划分为两个块,新生代和老生代,新生代和老生代的内存空间在一开始就是指定了大小;**新生代空间储存存活周期较短的对象,老生代中的对象为存活时间较长或者常驻内存的对象。**通过划分区域采取不同的回收策略,提升性能。

V8的垃圾回收机制(GC)

V8的垃圾回收策略主要基于分代式垃圾回收机制,将内存分为新生代和老生代两个区域。新生代中的对象位存活时间较短的对象,老生代中的对象为存活时间较长或者常驻内存的对象。

新生代和老生代的内存空间在一开始就已经指定了,新生区通常只支持 1~8M 的容量,而老生区支持的容量很大。但是 V8 的内存大小是有限制的,JavaScript 所能使用的内存(64位为1.4GB,32位为0.7GB),这也就意味着将无法直接操作一些大内存对象。

新生代垃圾回收

由于新生代区域储存的是存活时间较短的对象,导致触发GC会很频繁,执行的速度要求非常快速,所以新生代中是通过Scavenge算法进行垃圾回收。

Cheney算法是一种采用复制的方式实现的垃圾回收算法,会把新生代空间划分成两个相等大小的 from-spaceto-space;在这两个空间中,有一个处于使用中,另一个处于闲置状态。当我们去定义变量时(分配内存),会先从 from-space 进行分配,当 from-space 内存要占满的时,触发 GC ,会检查 from-space 中的存活对象,再把这些存活对象复制到 to-space 中,接着清空 from-space 内存,最后from-spaceto-space 角色发生变换,这样就完成了垃圾对象的回收操作。

从运行原理来看,Scavenge算法是牺牲了一定的内存空间,所以无法在内存较大的场景上去使用,但是非常适合在对象生命周期比较短的场景上使用,这也为什么说明新生代通常只支持 1~8M 的容量。

对象普升

为了提升执行效率,v8 采用了对象晋升策略,既经过两次垃圾回收依然还存活的对象,它就会被认为是生命周期比较长的对象,这种生命周期比较长的对象会被移动到老生代中。

对象普升的条件:

  1. 对象是否经历过Scavenge算法回收
    • 对象从 from-space 中复制到 to-space 中时,先判断这个对象是否经历过一次Scavenge算法回收,如果是,会从 from-space 复制到老生代空间,否则复制到 to-space
  2. 当一个对象从 from-space 复制到 to-space 时候,如果 to-space 使用率超过25%时,在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。

老生代垃圾回收

老生代采用**标记-清除算法(Mark-Sweep)标记-整理算法(Mark-Compact)**相结合的方式来实现。根据上面介绍两种算法的特点,可有有效的解决内存碎片的问题。

触发GC的时机

在垃圾回收的过程中,会阻塞JavaScript应用逻辑,直到垃圾回收结束再重新执行JavaScript应用逻辑,这种行为被称为“全停顿”,造成假死,如果有动画效果的话,动画的展现也将受到影响。

在新生代的算法中,因为空间比较小,执行速度开,对”全停顿“没有太大的影响;但是对于老生代区域,回收的适合和策略可以有很大的优化。

老生代GC的过程分为三步:

  1. 标记:这是两种算法共有的第一步,其中垃圾回收器标识正在使用的对象和未使用的对象。从GC根目录(堆栈指针)递归使用或使用的对象被标记为活动对象。从技术上讲,这是对堆的深度优先搜索,可以视为有向图
  2. 清除:垃圾收集器遍历堆并记下任何未标记为活动对象的内存地址。现在,该空间在空闲列表中被标记为空闲,可用于存储其他对象。
  3. 压缩:清扫后,如果需要,所有残留的物体将被移动到一起。这将减少碎片并提高为新对象分配内存的性能。

在 v8 引擎中,为了避免”全停顿“这种情况,使用了下面几种优化策略方案。

  • 增量GC:就是“每次处理一点,下次再处理一点,如此类推”。类似于 React fiber架构。
  • 并发标记:标记是使用多个帮助程序线程并发完成的,而不会影响主JavaScript线程。
  • 并发清除/压缩清除和压缩在帮助程序线程中同时进行,而不影响主JavaScript线程。
  • 懒散清除。延迟清除涉及延迟页面中垃圾的删除,直到需要内存为止。

这里有一遍文章介绍了v8并发标记的相关内容,有兴趣的可以去看看。Concurrent marking in V8

如何理解闭包?

正常情况下,当一个函数在执行开始的时候,会给其中定义的变量划分内存空间用以访问,等到函数执行完毕返回了,这些变量就被认为是无用的了,对应的内存空间也就被回收了,当下次再执行此函数的时候,定义的变量又会回到初始状态,重新执行使用。

当一个函数 A 执行时内部又返回了另外一个函数 B ,也就是说这个函数 B 有可能会再次被调用,如果这个函数 B 又使用了函数 A 中定义的变量时,为了防止 函数B 无法使用 函数A 中定义的变量,所以 JavaScript引擎在解析函数时,会把函数自身以及函数有可能使用到的变量一起保存起来(作用域链),构建成一个闭包,这些变量将不会被垃圾回收机制回收,只有当 函数B 无法访问时(指针为null),才会触发垃圾回收机制释放由闭包引起的变量。

闭包本质不会引起内存泄露,只是把内部作用域的对象延伸到了外部。

想要释放闭包,我们只需把闭包内的函数指针设置为null,在下一次 GC 启动时就会被回收。

常见的内存泄露

正常情况下,程序运行时会为程序所需要的变量、函数、对象分配内存,使用完成后会被释放。而内存泄露的本质就是没有处理好这一个过程,导致运行时生成的内存没有被回收,导致内存不断的占用和增加。

网上关于内存泄露的文章很多截这里,常见的内存泄露就不在这里阐述了,感兴趣的可以点开文章看看。

Vue 中的内存泄漏问题

下面介绍几种在vue里要注意的事项:

  1. 如果在 mounted/created 钩子中使用 JS 绑定了 DOM/BOM 对象中的事件,需要在 beforeDestroy 中做对应移除监听处理;
  2. 如果在 mounted/created 钩子中使用了第三方库初始化,需要在 beforeDestroy 中做对应销毁处理。
  3. 如果组件中使用了 setInterval,需要在 beforeDestroy 中做对应销毁处理;
  4. 如果在组件中注册了全局对象,要在组件销毁前把对象移除。

Set/WeakSet,Weak/WeakMap的应用

Set/Weak

  • Set:是值得集合,没有重复值。
  • Map:是键值对的集合,不同于对象的键,Map的键可以是任意类型。

下面看个例子:

let obj = {name: 'ding'}
const arr = [obj]
const user = {name: obj}
const set = new Set([obj])
const map = new Map([[obj, 'name']])

obj = null // 重写obj,垃圾回收机制无法回收
console.log(arr[0]) // { name: 'ding' }
console.log(user.name) // { name: 'ding' }
console.log(set.keys()) // SetIterator { { name: 'ding' } }
console.log(map.keys()) // MapIterator { { name: 'ding' } }

重写 obj 以后,即便对象已经没有指向它的引用,但是对象依然会存在于内存中。因为Set/Map、对象、数组对对象是强引用,所以任然可以获取到。

WeakSet/WeakMap

如果想要向对象添加对象属性时又不想干扰垃圾回收机制,就可以用 WeakSet/WeakMap,可以有助于防止内存泄露。

  • WeakSet 类似于 Set ,仅存储对象。
  • WeakMap 类似于 Map ,键必须是对象。

WeakSet/WeakMap 中对对象是弱引用,垃圾回收机制不会考虑WeakMap对该对象的引用,也就是JavaScript不会阻止将对象从内存移除。

let obj = {name: 'ding'}
const weakMap = new WeakMap([[obj, 'name']])

obj = null  // 重写obj,不影响垃圾回收机制回收

WeakMap 实例仅有 has()、set()、get()、delete() 操作方法,没有 size 属性、keys()、values()、entries()方法 ,所以不能获取其所有键值,也就不能迭代。

典型应用

1.给 DOM 元素添加数据时

const wm = new WeakMap()
const ele = document.getElementById('example')
wm.set(el, 'some information')
wm.get(el) //"some information"

用 WeakMap 好处是当该 DOM 元素被清除,对应的 WeakMap记录就会自动被移除。

2.注册监听事件的listener对象用WeakMap来实现

const listener = new WeakMap()
 
listener.set(ele1, handler1)
listener.set(ele2, handler2)
 
ele1.addEventListener('click', listener.get(ele1), false)
ele2.addEventListener('click', listener.get(ele2), false)

监听函数放在WeakMap中,一旦DOM移除,监听函数也随之从内存移除,不会造成内存泄漏。

其他

对于内存泄露的分析,可以查看这篇文章:developers.google.com/web/tools/c…

参考文献

developer.mozilla.org/zh-CN/docs/…

deepu.tech/memory-mana…

深入浅出Node.js