JS垃圾回收,这次可以看懂了(带图警告)

3,823 阅读12分钟

开篇劝退

先告诉阅读本篇文章的同学本人水平有限,菜鸟一只。

  • 分享内容是没人不知道的javascript垃圾回收机制
  • 如果面试官问你垃圾回收是什么,你只能用10句话以内就表述完,那么这篇文章很适合你。
  • 将会具体解释Node使用的V8引擎是如何高效的使用内存、如何执行垃圾回收以及对各种变量内存的具体操作。

提示:本篇文章不适合熟读各种源码的大佬阅读,会浪费时间

V8的内存限制

我们先抑后扬,Node不同于其他后端语言,Node在对系统的内存使用中,只能使用到系统的部分内存,比如64位系统只能使用1.4GB,32位系统只能使用0.7GB。随之到来的问题是Node采用单线程,就导致每个线程无法对大的内存对象进行处理,比如将一个2GB的文件读入内存进行字符串分析处理,即使你有16G的物理内存。

V8的对象分配

在javascript中我们的基本类型存储在栈中,所有对象都分配给了堆处理。 我们每赋值一个对象,该对象的内存就会分配在堆中。如果已申请堆所剩内存不足以分配新的对象,将会继续申请新内存,直到堆的大小超过V8的内存大小限制为止。

在这里插入图片描述
至于V8的内存限制,起源于V8本身是chrome为浏览器设计而生,而浏览器中对于网页来说,V8控制的内存绰绰有余。还源于V8设计者对于V8的垃圾回收机制的限制,官方以1.5GB的垃圾回收堆内存为例,V8执行一个小的垃圾回收要使用50毫秒以上,做一次常规非增量式垃圾回收要在1秒以上。

最关键的,javascript的垃圾回收会对javascript执行线程形成阻塞,作为一个开发人员你应该能够清楚时长1秒的进程阻塞,对你的项目性能的影响,故此V8的设计者采用了对堆内存进行限制的策略。

V8的内存分代

V8的垃圾回收策略主要基于分代,那么怎么分代呢?

在V8中,主要将内存分为新生代老生代两类。新生代指的是那些存活时间较短的对象,老生代指的是存活时间较长的或者常驻内存的对象。而新生代加老生代的对象所占空间大小就是V8的堆的整体大小。

补充知识点:V8提供了设置新生代和老生代最大内存值的方式,从而可以调整V8的整体内存限制,使用更多的内存空间。

使用--max-old-space-size来调整老生代最大空间和--max-new-space-size来调整新生代最大空间,但是该操 作需要在Node进程启动时就设置才有效。

V8的主要垃圾回收算法

Scanvenge算法

Scanvenge是一种复制形式的垃圾回收算法,是应用于新生代对象中的一种垃圾回收算法,算法首先将堆内存一分为二,两部分空间一半用来分配赋值的对象,叫做From空间,另一半处于空闲的叫做To空间。

为什么要有一半空间用来闲置呢?这不是让我们的可用内存更小了吗?

当我们为堆分配对象时,会将分配对象放到From空间中存储,在V8的垃圾和回收过程中,会首先检查From中存活的对象(什么是存活的对象,就是指那些还被继续引用没有完全释放的对象),V8会将From中存活的对象夫妇复制到To空间中,同时清理掉已经被释放的对象空间。完成该过程From空间和To空间即完成了角色对换,也就是在下一次回收中,之前的From空间变成了To空间,之前的To空间变成了From空间。

这样我们来重新定义一下:

用来存放对象的一半是From空间,处于闲置状态的一半是To空间。

在这里插入图片描述
Scanvenge算法明显的缺点就是只能使用堆内存的一半,但是随之带来的好处就是它在时间效率上的优异的表现,属于典型的牺牲空间换取时间的算法。 需要强调的是,开头提到的Scanvenge算法是应用于新生代对象中的一种垃圾回收算法,因为新生代对象中的生命周期较短的特性,也契合于该算法优先时间考虑的特性。

怎样算生命周期较长的对象?

当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种生命周期较长的对象随后会被移动到老生代对象中,采用新的算法(Mark-Sweep&Mark-Compact)进行管理,这个过程称为晋升。

通过上图可以了解到,对象进行垃圾回收是怎样从From到To之间转换的,那么这个晋升的过程在哪儿体现呢?

在默认情况下,V8对新生代对象进行从From到To空间进行复制时,会先检查它的内存地址来判断这个对象是否已经经历过一次Scanvenge回收。如果已经经历过,那么会将该对象从From空间直接复制到老生代空间,如果没有,才会将其复制到To空间。

对象晋升的条件主要有两个,一个是对象是否经历过Scanvenge回收一个是To空间的内存占用超过限制

以上,我们讲述的就是一个新生代对象如何晋升为老生代对象的第一个条件“对象是否经历过Scanvenge回收”,那么第二个条件也许你会更困惑,超出限制?多少算在限制?怎么超出?

假设一个对象像刚才说的没有经历过Scanvenge回收,要将它复制到To空间之前,还要再进行一次检查。检查To空间是否已经使用了超过25%,如果To空间超过25%,该对象将直接被晋升到老生代空间进行管理。

完整看一下这个流程:

对象晋升后,该对象即成为老生代中的存活周期较长的对象,所以我们可以重新对老生代进行定义:老生代对象为存活周期较长或常驻内存的对象,或为新生代对象回收中溢出的对象

至于为什么设置25%的原因是,当一次Scanvenge回收完成时,To空间变为From空间,如果新的From空间使用占比过高,将对接下来的内存分配到这个新的From空间过程存在很大的影响。

Mark-Sweep&Mark-Compact算法

接下来,讲一下老生代中的对象使用的回收算法,这种算法(Mark-Sweep)也是我们常说的垃圾回收中的标记清除算法。

首先,老生代空间不会一分为二,老生代空间进行垃圾回收时,首先是标记阶段。V8会在标记阶段遍历老生代空间中的所有对象,并标记存活的对象(即还没有被完全释放的对象),在随后的清除阶段,会将所有未标记的老生代对象全部回收。

再来张图:

如果你稍微有点强迫症,你就发现这张图有点问题。Mark-Sweep在执行完清除之后,导致内存空间出现不连续的情况,就像你的磁盘分析图一样。

这样会带来的一个问题就是,当你需要分配一个较大的对象时,剩余的内存因为碎片化的原因,没有任何一个内存碎片足以分配给这个大的对象内存空间,就会导致提前触发垃圾回收,而这次回收是不必要的。

所以Mark-Compact算法随之而生,Mark-Compact比Mark-Sweep增加了一个整理的概念,它的回收执行顺序是标记—整理—清除。Mark-Compact所谓的整理概念是指在对象同样被标记为存活后,会将活着的对象往一端移动,移动完成后在直接清理掉死亡的对象内存。

不要晕,来张图,你就可以的:

在这里插入图片描述

两种差别显而易见,Mark-Compact算法执行后的内存空间更合理。但是因为Mark-Compact算法需要移动对象,随之导致的就是它的执行速度没有Mark-Sweep快。

所以在V8中主要使用Mark-Sweep算法,只有在空间不足以对新生代中晋升过来的对象进行分配时,才会使用Mark-Compact算法进行回收。

回收算法 Scanvenge Mark-Sweep Mark-Compact
速度 最快 中等 最慢
空间开销 双倍空间(无碎片) 少(有碎片) 少(无碎片)
是否移动对象

Incremental Marking算法

因为垃圾回收会阻塞javascript的运行,故此老生代对象又因为其占用空间大,存活对象多的特点,对其进行标记,整理,回收的过程引起的阻塞要远远比新生代对象回收过程一起的阻塞要严重的多,Incremental Marking算法成为了优化老生代对象耗时的算法选择。

为了降低老生代空间垃圾回收带来的停顿影响,V8 采用了增量标记(incremental marking)的算法。将原本一口气停顿完成的来及回收过程拆分为许多小“步进”,每做完一“步进”就让JavaScript应用逻辑继续执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。取得的效果就是,将老生代空间垃圾回收的最大停顿时间可以减少到原本的1/6左右。

有点晕,不要怕,咱有图:

在这里插入图片描述

V8 后续还引入了延迟清理(lazy sweeping)、增量式整理(incremental compaction)、并发标记 等技术,其实看名字你也能理解大概,可以自行查阅。

扩展知识

相信上面的东西已经让你们明白,V8的垃圾回收机制是如何运行的。但是你还需要知道我们的代码中是如何触发、影响垃圾回收的,这就不得不掏出老生常谈的作用域和闭包。

作用域

在javascript中作用域有全局作用域和局部作用域,在这里,我们着重关注作用域对垃圾回收的影响。

假设一个函数调用产生的作用域:

var foo = function(){
	var local = {}
}

这是一个函数表达式,foo()函数在每次调用时会创建一个作用域,同时也会在该作用域创建一个局部变量local。函数执行结束,该作用域也会随之销毁,同时该作用域中声明的局部变量也会随作用域销毁而销毁。在这个实例中,由于局部变量引用的对象存活周期较短,将会分配在新生代空间的From中。作用域销毁后,其中的变量也随之被释放,该对象所占用的空间在下次垃圾回收时将会被清理。

作用域链

var foo = function(){
	var local = 100
	var bar = function(){
		console.log(local)
	}
}

在这个实例中,bar()中执行console,在当前函数作用于查找不到local变量,将会继续向上查找,查找上级最近的作用域,如果找到变量local,就会停止查找。如果找不到会一直查找到全局作用域,如果该变量在所有作用域都不存在,将会抛出未定义错误。

变量的主动释放

var a = { sex : 10 };
b = 200;
window.c = 300; 

如果变量是全局变量,需要注意的是全局作用域中的变量不会执行垃圾回收过程,此类对象将会常驻内存(在老生代空间)。如果需要释放该类对象空间,只能通过delete或重新赋值变量为undefind或者null来释放对象的引用。

闭包

什么叫闭包,历史争议问题啊。我们暂可以将能使外部作用域访问内部作用于中的变量的方法叫做闭包。

function foo(){
    var bar = function(){
        var local = "局部变量";
        return function(){
            return local;
        }
    }
    var baz = bar();
    console.log(baz())
}

闭包对垃圾回收带来的影响也随之出现,一旦有变量引用中间函数,这个中间函数将无法被释放,同时也会是该作用域无法释放,自然作用域中的变量也不会被释放并回收,除非不在被引用,该函数才会被逐渐释放。

不得不说全局变量和闭包是项目中不可缺少的角色,但是需对该类变量谨慎使用,防止在你的项目中这种无法轻易被释放的变量所占内存越来越多,结果就是你不想看到的内存泄露。

结束了

这篇文章是重新回顾了一遍《深入浅出Node.js》后,结合第5章的内存控制里的内容和概念分享给大家的一篇基础小文。这是一本13年的书,只能说书不怕老,内容清晰,很有助于大家对一下常用的概念进行很细致的了解。

有补充的评论加个知识点,没补充捧个赞也好。

谢谢。