JavaScript垃圾回收机制

JavaScript垃圾回收机制

JavaScript使用垃圾自动回收机制进行内存管理,无需程序员手动分配和释放内存。垃圾回收的基本思路是确定哪些变量不会再次被使用,然后回收这些变量占用的内存。垃圾回收过程是周期性的,垃圾回收程序每隔一段时间会运行一次,垃圾回收也会影响到应用程序的性能。常用的垃圾回收机制主要包括标记清除和引用计数。

1.标记清除

标记清除是最常用的垃圾回收机制。当程序的执行流进入到一个函数中时,系统会为这个函数创建执行上下文和与其执行上下文关联的活动对象。执行上下文决定了这个函数可以访问的变量及其行为,而能被这个函数访问和执行的变量与函数都位于活动对象上。位于活动对象上的变量应该被添加“存在于执行上下文”的标记,这是因为它们可以被使用。而当函数执行完毕,活动对象上的变量应该被添加“离开执行上下文”的标记,这代表它们不会被再次使用,应该被垃圾回收程序回收。当然,标记变量的方式不止这一种,我们也可以维护两个列表,分别表示存在于执行上下文和离开执行上下文的变量,当变量不会被使用时就将其移动到另一个列表。垃圾回收程序在每次运行时将销毁所有离开执行上下文的变量并回收其占用的空间。

“垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做次内存清理,销毁带标记的所有值并收回它们的内存。”  (引自《JavaScript高级程序设计》)

2.引用计数

引用计数的核心思想是记录每个值被引用的次数。一个引用值被赋值给一个变量时,其引用数加一,如果再赋给该变量另一个引用值,则前一个值的引用数减一。引用数为0的值没有办法被再次访问,因此可以被垃圾回收程序安全回收。引用计数可能带来循环引用问题,即不同对象的相互引用导致它们都没有办法被回收。

1 function foo() {
2     let obj1 = new Object(), obj2 = new Object();
3     obj1.myObject = obj2;
4     obj2.myObject = obj1;
5 }

在上面的代码中,obj1作为obj2的属性被引用,而obj2又作为obj1的属性被引用,在采用的引用计数策略的环境下,它们的引用数永远不可能为0,因此在函数执行结束之后,这两个对象占用的内存依然无法被回收。解决循环引用的办法是,在确定不会再次使用存在循环引用的变量的情况下,将它们的值设置为null,切断变量与引用值之间的关系,将引用数强制清零。

3.垃圾回收程序对性能的影响

垃圾回收程序会周期性运行。如果应用程序中存在很多变量,可能导致垃圾回收程序的执行时间很长,从而降低应用程序的性能。我们无法得知垃圾回收程序运行的时间点,因此需要在编程时确保垃圾回收过程每次都能尽快结束,尽可能降低其对性能的影响。

“现代垃圾回收程序会基于对 JavaScript 运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的。”(引自《JavaScript高级程序设计》)

4.内存管理

尽管在编写JavaScript程序时我们无需关心内存的分配与回收,但让内存的占用量保持在较低水平可以提高程序的性能。优化内存占用的一个常用方法是在一个变量不会再次被使用时将其设置为null,从而切断其与之前赋给它的引用值的联系,让垃圾回收程序可以及时回收内存。这种方法尤其适合全局变量和全局对象的属性,因为局部变量在程序执行流离开其所在上下文时会自动被解除引用。需要注意的是,解除对一个值的引用并不会导致其所占用的内存空间立刻被回收,这样做的目的是确保这个值不存在于上下文中,以便在下一次垃圾回收过程中回收其所占用的内存空间。

此外,在保证程序逻辑正确的前提下尽可能使用let和const声明变量和常量也可以提升程序性能。使用let和const声明的变量和常量拥有块级作用域,而块级作用域通常比函数作用域更早结束,从而使不会被再次使用的值尽早被回收。

另一个需要注意的问题是V8引擎的隐藏类。在程序运行期间,V8会将每个对象与一个隐藏类关联起来,如果多个对象拥有相同的属性(但属性的值不一定要相同),那么它们将共享同一个隐藏类。

1 function Person(name, age) {
2     this.name = name;
3     this.age = age;
4 }
5 
6 let person1 = new Person("Alex", 18);
7 let person2 = new Person("Apple", 20);

在上面的代码中,person1和person2拥有相同的属性(相同的构造函数和原型),因此它们将共享同一个隐藏类。但是,如果之后为person1添加或者删除一个属性都将导致它们不再共享隐藏类,V8将创建一个新的与person1关联的隐藏类。如果程序中存在多个共享隐藏类的对象,频繁的增删它们的属性将导致V8频繁地创建新的隐藏类,这将对程序性能造成影响。解决这个问题的方法是,在一开始就尽可能确定一个对象的全部属性,避免在运行时对其属性进行频繁修改。此外,还可以通过将不再需要的属性设置为null而不是删除该属性来避免创建新的隐藏类。

内存泄漏问题同样值得注意。在函数中意外地声明全局变量会导致内存泄漏。

1 function foo() {
2     name = "Alex";
3     console.log("Hello " + name);
4 }
5 
6 foo();
7 console.log(global.name); // Alex

在上面的代码中,name被声明为全局变量,因此在函数执行完毕之后依然无法回收其所占用的内存,这种问题的解决方法是使用var,let或者const声明局部变量或常量。另外,在使用闭包时也会导致内存泄漏。

1 function foo() {
2     name = "Alex";
3     return function () {
4         console.log("Hello " + name);
5     }
6 }
7 
8 const func = foo();
9 func(); // Hello Alex

在上面的代码中,尽管foo函数已经执行完毕,但其返回的函数依然可以访问其作用域内部的name变量,这是因为foo函数返回的函数依然引用着foo函数的活动对象中的name变量,即foo函数的活动对象依然位于其返回的函数的作用域链上。为了解决这个问题,我们应该在func引用的函数执行完毕之后将其设置为null,解除其对foo的活动对象的引用,从而使得垃圾可以被及时回收。

5.V8引擎的垃圾回收机制

V8引擎的垃圾回收策略主要基于分代式垃圾回收机制。现代垃圾回收算法按照对象的存活时间将内存划分为不同的分代区域,然后分别对不同分代的内存施以更高效的算法。在V8中,内存被划分为新生代和老生代,新生代内存存储存活时间较短的对象,老生代内存存储存活时间较长或常驻内存的对象。

在V8中,堆的大小约等于新生代与老生代的内存大小之和。在分代的基础上,V8主要通过Scavenge算法对新生代内存中的对象进行垃圾回收,Scavenge又主要采用了Cheney算法。Cheney算法是一种通过复制实现的垃圾回收算法,它将堆内存划分为两个被称为semispace的区域。在这两个semispace中只有一个处于使用中的状态,而另一个处于闲置状态。处于使用中状态的semispace称为From空间,处于闲置状态的semispace称为To空间。当我们为对象分配内存空间时,会先在From空间中进行分配。当开始进行垃圾回收时,垃圾回收器会先检查From空间中的存活对象,这些存活对象将被复制到To空间中,而From空间内的非存活对象占用的内存将被回收。完成复制后,From空间和To空间的角色会发生交换,即当前的To空间转换为(下一次垃圾回收时的)From空间,当前的From空间转换为(下一次垃圾回收时的)To空间。简而言之,在垃圾回收过程中,存活对象会在两个semispace中传递。Scavenge的缺点在于系统只能使用一半堆内存,其优势在于高效率,因为Scavenge只复制存活的对象,而新生代内存中存活的对象相对较少。

如果一个新生代内存中的对象经过多次复制依然存活,它将被认为是生命周期较长的对象。这种生命周期较长的对象会被移动到老生代内存中,采用新的垃圾回收算法进行内存管理。对象从新生代内存移动到老生代内存的过程称为晋升。对象晋升的条件主要包括:1.对象经历过新生代内存中的垃圾回收并且依然存活。2.To空间的内存被占用超过25%。

老生代内存中存在较多存活对象,此时再采用Scavenge算法不仅会浪费一半空间,而且复制对象的效率也很低。为此,V8在老生代内存中主要采用Mark-Sweep算法和Mark-Compact算法相结合的方式进行垃圾回收。Mark-Sweep(标记清除)算法分为标记和清除两个阶段,与Scavenge相比,Mark-Sweep并不会将内存空间划分为两半。另外,与Scavenge算法只复制存活对象不同,Mark-Sweep算法在标记阶段遍历堆中的所有对象,并标记存活对象,在随后的清除阶段中,只清除没有被标记的死亡对象。由于老生代内存中死亡对象占比较低,而Mark-Sweep只清除死亡对象,因此其效率很高。
Mark-Sweep的问题在于内存碎片。在进行一次垃圾回收后,老生代内存中的死亡对象所占用内存被释放,此时老生代内存中出现了许多内存碎片。如果内存碎片数量太多,可能导致无法找到足够大的空闲内存块来存放新对象,尽管此时空闲内存总大小大于新对象所需空间大小。在这种情况下,垃圾回收可能会被提前触发以获得更多可用空间存放新对象,而这本是不必要的。
为了解决Mark-Sweep算法存在的内存碎片问题,人们提出了Mark-Compact算法。Mark-Compact是标记整理的意思,它是在Mark-Sweep的基础上演变而来的。这两个算法的区别在于对象被标记为死亡后,Mark-Compact算法会将存活对象往一端移动,在移动完成后,直接释放所有存活对象之外的内存空间。下图为Mark-Compact算法完成标记并移动存活对象后的示意图,白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞。
由于Mark-Compact算法需要移动对象,所以它的效率较低,因此V8主要使用Mark-Sweep算法,而只在老生代不足以为从新生代内存中晋升过来的对象分配内存时才使用Mark-Compact算法。
 
为了避免出现实际的应用逻辑与垃圾回收器看到的应用逻辑不一致的情况,上述三种垃圾回收算法都需要将应用暂停,待执行完垃圾回收后再恢复执行,这种行为被称为“全停顿”(stop-the-world)。在V8的分代式垃圾回收中,如果一次垃圾回收过程只在新生代进行,由于新生代空间较小,且其中存活对象通常较少,所以全停顿带来的影响较小。但V8的老生代空间通常较大且存活对象较多,此时全停顿将会对应用性能造成很大影响。
为了减少全停顿带来的影响,V8先从标记阶段入手,将原本通过暂停应用而一次性完成的标记动作改为增量标记(incremental marking),也就是将标记阶段拆分为许多小阶段,每完成一个小阶段就让应用执行一段时间,使垃圾回收与应用交替执行直到标记阶段结束。V8在通过增量标记进行改进后,垃圾回收的最大停顿时间可以减少到原来的1/6左右。V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时V8还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。
 
posted @ 2021-05-03 19:43  曹冲字仓舒  阅读(213)  评论(0)    收藏  举报