Javascript的垃圾回收机制知多少?

6,788 阅读12分钟

写在前面

本文主要围绕JS引擎相关知识,来深入了解底层运行逻辑,这对于日常开发维护高性能Javascript代码以及排查代码性能问题有着很好的帮助。关于JS引擎底层的垃圾回收机制,后面才能理解内存泄漏的问题以及手动预防和优化,实现对JS内存管理以及内存溢出的处理。

那么我们需要考虑几个问题:

  • 什么是垃圾回收机制(GC)?
  • 垃圾是怎样产生的?
  • 为什么要进行垃圾回收?
  • Javascript的内存是如何管理的?
  • Chrome浏览器又是如何进行垃圾回收的?

内存管理

在Javascript编程中,内存管理大概分成三个步骤,也是内存的生命周期:

  • 分配你所需系统内存的空间
  • 使用分配到的内存进行读写操作
  • 不需要使用内存时,将空间进行释放和归还

未命名文件 (4).png

与其它手动管理内存的语言不一样的是,在Javascript中,当我们创建变量时,系统会给对象进行自动分配对应的内存空间以及闲置资源回收,也就是不需要我们手动进行分配。但是,正是因为垃圾回收机制导致开发者有着错误的感觉,就是他们不用关心内存管理。

const name = "yichuan";//给字符串分配栈内存
const age = 18;//给数值分配栈内存

//给对象以及包含的值分配堆内存
const user = {
  name: "onechuan",
  age: 19
}
//给数组以及包含的值分配堆内存
const arr = ["yichuan","onechuan",18];
//给函数对象分配堆内存
function sum(x,y){
  return x + y;
}

在前面《Javascript的数据类型知多少》文中,我们知道了基础数据类型和引用数据类型的分配机制,即:

  • 简单数据类型内存保存在固定的栈空间中,可直接通过值进行访问
  • 引用数据类型的值大小不固定,其引用地址保存在栈空间、引用所指向的值保存在堆空间中,需要通过引用进行访问

栈内存中的基本数据类型,可以直接通过操作系统进行处理,而堆内存中的引用数据类型的值大小不确定,因此需要JS的引擎通过垃圾回收机制进行处理。

内存回收机制(GC)

Javascript的V8引擎被限制了内存的使用,因此根据不同操作系统的内存大小会不一样。

V8引擎最初设计是作为浏览器的引擎,并未考虑占据过多的内存空间,随着web技术工程化的发展,占据了越来越多的内存空间。又由于被v8的会回收机制所限制,这样就引起了js执行的线程被挂起,会影响当前执行的页面应用性能。

垃圾回收算法:就是垃圾收集器按照固定的时间间隔,周期性地寻找那些不再使用的变量,然后将其清除或释放内存。 但是垃圾回收算法是个不完美的方案,因为某块内存是否还可用,属于不可预判的问题,也就意味着单纯依靠算法是解决不了的。还有为什么不是实时的找出无用内存并释放呢?其实很简单,实时开销太大了。

我们知道了垃圾是如何产生的,那么我们应该如何清除呢?在浏览器的发展历史上有两种解决策略:

  • 标记清除
  • 引用计数

标记清除

标记清除分为:标记阶段和清除阶段。

首先它会遍历堆内存上所有的对象,分别给它们打上标记,然后在代码执行过程结束之后,对所使用过的变量取消标记。在清除阶段再把具有标记的内存对象进行整体清除,从而释放内存空间。 未命名文件 (5).png

整个标记清除算法大致过程就像下面这样

  • 垃圾收集器在运行时会给内存中的所有变量都加上一个标记
  • 然后从各个根对象开始遍历,把还在被上下文变量引用的变量标记去掉标记
  • 清理所有带有标牌机的变量,销毁并回收它们所占用的内存空间
  • 最后垃圾回收程序做一次内存清理

使用标记清除策略的最重要的优点在于简单,无非是标记和不标记的差异。通过标记清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,这就造成出现内存碎片的问题。内存碎片多了后,如果要存储一个新的需要占据较大内存空间的对象,就会造成影响。对于通过标记清除产生的内存碎片,还是需要通过标记整理策略进行解决。

简而言之:

  • 优点:简单
  • 缺点:内存碎片化、分配速度慢

标记整理

经过标记清除策略整理后,老生代内存中因此产生了许多内存碎片,如果不进行清理内存碎片,就会对存储造成影响。

标记整理(Mark-Compact)算法 就可以有效地解决标记清除的两个缺点。它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存。 未命名文件 (6).png

引用计数

引用计数是一种不常见的垃圾回收策略,其思路就是对每个值都记录其的引用次数。具体的:

  • 当变量进行声明并赋值后,值的引用数为1。
  • 当同一个值被赋值给另一个变量时,引用数+1
  • 当保存该值引用的变量被其它值覆盖时,引用数-1
  • 当该值的引用数为0时,表示无法再访问该值了,此时就可以放心地将其清除并回收内存。
let a = new Object() 	// 此对象的引用计数为 1(a引用)
let b = a 		// 此对象的引用计数是 2(a,b引用)
a = null  		// 此对象的引用计数为 1(b引用)
b = null 	 	// 此对象的引用计数为 0(无引用)
...			// GC 回收此对象

这种回收策略看起来很方便,但是当其进行循环引用时就会出现问题,会造成大量的内存不会被释放。当函数结束后,两个对象都不在作用域中,A 和 B 都会被当作非活动对象来清除掉,相比之下,引用计数则不会释放,也就会造成大量无用内存占用,这也是后来放弃引用计数,使用标记清除的原因之一。

V8对于垃圾回收机制的优化

大多数浏览器都是基于标记清除算法,不同的只是在运行垃圾回收的频率具有差异。V8 对其进行了一些优化加工处理,那接下来我们主要就来看 V8 中对垃圾回收机制的优化。

分代式垃圾回收

V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。

新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持 1~8M 的容量,而老生代的对象为存活事件较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大。

V8 整个堆内存的大小就等于新生代加上老生代的内存,对于新老两块内存区域的垃圾回收,V8 采用了两个垃圾回收器来管控。

未命名文件 (7).png

新生代内存回收

在64操作系统下分配为32MB,因为新生代中的变量存活时间短,不太容易产生太大的内存压力,因此不够大也是能够理解。

对于新生代内存的回收,通常是通过Scavenge 的算法进行垃圾回收,就是将新生代内存进行一分为二,正在被使用的内存空间称为使用区,而限制状态的内存空间称为空闲区。 未命名文件 (8).png 新生代内存回收的原理是:

  • 新加入的对象都会存放在使用区,当使用区快写满时就进行一次垃圾清理操作。
  • 在开始进行垃圾回收时,新生代回收器会对使用区内的对象进行标记
  • 标记完成后,需要对使用区内的活动对象拷贝到空闲区进行排序
  • 而后进入垃圾清理阶段,将非活动对象占用的内存空间进行清理
  • 最后对使用区和空闲区进行交换,使用区->空闲区,空闲区->使用区

新生代中的变量如果经过回收之后依然一直存在,那么会放入到老生代内存中,只要是已经经历过一次Scavenge算法回收的,就可以晋升为老生代内存的对象。

老生代内存回收

当然,Scavenge算法也有其适用场景范围,对于内存空间较大的就不适合使用Scavenge算法。此时应该使用Mark-Sweep(标记清除)和Mark-Compact(标记整理)的策略进行老生代内存中的垃圾回收。

首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象。清除阶段老生代垃圾回收器会直接将非活动对象,也就是数据清理掉。

同样的标记清除策略会产生内存碎片,因此还需要进行标记整理策略进行优化。

内存泄漏与优化

内存泄漏,指在JS中已经分配内存地址的对象由于长时间未进行内存释放或无法清除,造成了长期占用内存,使得内存资源浪费,最终导致运行的应用响应速度变慢以及最终崩溃的情况。

在代码中创建对象和变量时会占据内存,但是JS基于自己的内存回收机制是可以确定哪些变量不再需要,并将其进行清除。但是,当你的代码中存在逻辑缺陷时,你以为你已经不需要,但是程序中还存在这引用,这就导致程序运行完后并没有进行合适的回收所占有的内存空间。运行时间越长占用内存越多,随之出现的问题就是:性能不佳、高延迟、频繁崩溃。

造成内存泄漏的常见原因有:

  • 过多的缓存。及时清理过多的缓存。
  • 滥用闭包。尽量避免使用大量的闭包。
  • 定时器或回调太多。与节点或数据相关联的计时器不再需要时,DOM节点对象可以清除,整个回调函数也不再需要。可是,计时器回调函数仍然没有被回收(计时器停止才会被回收)。当不需要setTimeout或setInterval时,定时器没有被清除,定时器的糊掉函数以及其内部依赖的变量都不能被回收,会造成内存泄漏。解决方法:在定时器完成工作时,需要手动清除定时器。
  • 太多无效的DOM引用。DOM删除了,但是节点的引用还在,导致GC无法实现对其所占内存的回收。解决方法:给删除的DOM节点引用设置为null。
  • **滥用全局变量。**全局变量是根据定义无法被垃圾回收机制进行收集的,因此需要特别注意临时存储和处理大量信息的全局变量。如果必须使用全局变量来存储数据,请确保将其指定为null或在完成后重新分配它。解决方法:使用严格模式。
  • **从外到内执行appendChild。**此时即使调用removeChild也无法进行释放内存。解决方法:从内到外appendChild。
  • 反复重写同一个数据会造成内存大量占用,但是IE浏览器关闭后会被释放。
  • 注意程序逻辑,避免编写『死循环』之类的代码。
  • DOM对象和JS对象相互引用

关于内存泄漏,如果你想要更好地排查以及提前避免问题的发生,最好的解决方法是通过熟练使用Chrome的内存剖析工具,多分析多定位Chrome帮你分析保留的内存快照,来查看持续占用大量内存的对象。

参考文章

写在后面

本篇文章聊了JS的内存管理机制,以及v8垃圾回收机制,最后我们也分析了一些日常编码中经常遇到内存泄漏问题,根据不同的原因给出对应的解决方案。