前言
JavaScript提供了高效的内存管理机制,它的垃圾回收功能是自动的。在我们创建新对象、函数、原始类型和变量时,所有这些编程元素都会占用内存。那么JavaScript是如何管理这些元素并在它们不再使用时清理它们的呢?
在本节中,我们将讨论JavaScript中的内存管理的所有内部过程。我们将关注以下主题:
-
JavaScript中的内存生命周期
-
JavaScript中的内存分配
-
当内存不再使用时的释放
-
JavaScript的垃圾回收
-
JavaScript垃圾回收中的引用概念
-
标记-清除算法
其他编程语言(例如C语言)支持手动内存管理方法,比如malloc()和free()。相比之下,JavaScript会自动为新对象分配内存,并在不再使用时释放它们。因此,JavaScript是web上速度最快的编程语言之一。
让我们了解JavaScript中的内存管理,深入探讨JavaScript垃圾回收的主题。
内存生命周期
每种编程语言的内存生命周期都是相似的。它按照以下方式工作:
-
为新实例分配所需的内存
-
在调用实例时使用已分配的内存(读取、写入)
-
当实例不再使用时,释放分配的内存
但是,第二部分可能在所有编程语言中有所不同,具体取决于它们的架构和用法。在低级别的语言(例如C语言)中,第一步和第三步是明确的,但在高级语言(例如JavaScript)中,它们通常是隐式的。
JavaScript中的内存分配
值的初始化
在JavaScript中,当值被声明时,它们会自动分配内存。我们不需要手动为创建的变量和对象分配内存。
让我们看下面的例子,解释不同类型的变量和对象的内存分配:
const a = 200; // 为一个数字类型的变量分配内存
const a = 'anystring'; // 为一个字符串类型的变量分配内存
const new_obj = {
a: 5,
b: null,
}; // 为一个对象分配内存并包含值
// 就像对象一样,它也会为数组和包含的值分配内存
const my_array = [5, null, 'anystring'];
// JavaScript也会为函数分配内存
function my_function(a) {
return a + 5;
} // 它会分配一个函数(可调用对象)
// JavaScript也会为函数表达式对象分配内存
anyHtmlElement.addEventListener('click', function() {
htmlElement.style.backgroundColor = 'green';
}, true);
从上面的例子中,我们可以看到JavaScript为每个对象、变量和方法分配了值。
读取和写入值
当在JavaScript中使用值时,可以从分配的内存中读取和写入这些值。我们可以从变量或对象属性的分配内存中读取和写入值,甚至可以将参数传递给函数。
在Javascript中,基本数据类型存储在栈内存中,引用数据类型存储在堆内存中,但是引用数据类型会在栈内存中存储一个实际对象的引用。
示例如下:
当内存不再使用时的释放
当需要释放分配的内存时,大多数内存管理问题都发生在这个阶段。在这个阶段,最复杂的任务是确定特定变量、对象或函数何时不再需要。
低级别的编程语言要求开发人员手动指定他们希望释放的分配实例的内存的时间点。
但是,像JavaScript这样的高级语言使用自动内存管理,也就是垃圾回收。
垃圾回收的主要用途是自动监视并找到不再使用的特定块,并将其回收。
然而,内存管理的自动处理完全基于近似值。因此,确定特定内存块是否仍然需要是无法确定的通用问题。这就是JavaScript垃圾回收器登场并高效地管理内存的原因。
现在我们已经讨论了JavaScript中的内存管理,让我们继续我们的主题:垃圾回收。
JavaScript垃圾回收
如前所述,垃圾回收面临的主要问题是自动查找特定内存是否将被使用。在这种情况下,垃圾回收器为解决这个问题实施了一种限制。为了解决这个问题,JavaScript使用了引用的概念。让我们了解引用的概念:
JavaScript垃圾回收中的引用概念
对于自动内存管理,JavaScript依赖于引用的概念。在这个概念中,在内存管理上下文中,如果另一个对象在应用程序中的后续位置被访问(无论是隐式还是显式访问),则将一个对象视为对另一个对象的引用。例如,对象将具有对其原型的引用,这是一个隐式引用,以及对其属性值的引用,这是一个显式引用。
在这种情况下,对象的概念将扩展到比JavaScript中的常规对象更广泛的范围。它还包含了对象的作用域。
引用计数垃圾回收
JavaScript的垃圾回收器遵循计算引用的算法。该算法确定一个对象是否还需要,以确定一个对象是否仍然有其他对象引用它。如果一个对象没有引用指向它,它将被视为垃圾。
让我们通过下面的例子来理解它:
let a = {
x: {
y: 5
}
};
// 这里创建了两个对象:x和y,其中x被另一个y作为其属性之一引用。
// 另一个对象是通过分配给'a'变量来引用的。
// 在这里,给定的对象没有一个可以垃圾回收的理由。
let b = a; // 'b'变量是引用对象的第二个引用。
a = 5; // 现在,原来在'a'中的对象将有一个唯一的引用,即'b'变量。
let z = b.x; // 对象的'x'属性的引用。
// 现在,'z'对象将有两个引用:一个作为属性,另一个作为'z'变量。
b = 'chrome'; // 原来在'b'中的对象现在没有引用指向它。
// 然而它的'a'属性仍然被'z'变量引用,所以它不能被释放。
z = null; // 原来在'a'中的对象的'a'属性现在没有引用指向它。它可以被垃圾回收。
限制(循环引用)
JavaScript的垃圾回收器在发现循环引用时存在限制。例如,考虑以下例子:
function myFunction() {
const a = {};
const b = {};
a.x = b; // a引用了b
b.y = a; // b引用了a
return 'someString';
}
myFunction();
从上面的例子中,我们创建了两个相互引用的对象,形成了一个循环引用的情况。当函数myFunction()完成执行时,它将超出作用域。它们在那时不再需要,但是它们分配的内存将被回收。因此,垃圾收集器不会清理它们的分配。
然而,垃圾收集器的引用计数算法将不会将它们识别为可回收对象,但它们中的每一个都有一个引用,这就是为什么它们都不会被标记为垃圾回收的原因。循环引用问题是应用程序中内存泄漏的原因之一。
IE 6和IE 7维护引用计数垃圾回收器,但没有其他现代引擎使用引用计数垃圾回收器来防止由于循环引用而引发的内存泄漏。
垃圾回收的两种常用方法
标记-清除算法
标记-清除算法减少了“一个对象不再使用”算法的使用,而是专注于“一个对象不可达”方法。
这种方法专注于根(一组对象)。在JavaScript中,根是全局对象。在这种方法中,垃圾收集器从根(全局对象)开始,找到从这些根指向的对象。它引用了从这些根对象指向的所有对象。因此,它找到了所有可达和不可达的对象。
这个算法是引用计数算法的改进。它将一个对象视为不可达,如果它没有引用指向它。因此,在这个算法中,如果对象具有零引用,那么它就不可达。所以,在这个算法中,如果对象具有循环引用,该对象不再持有真实的零引用。
截至2012年,所有现代浏览器都遵循标记-清除算法进行垃圾回收。过去几年中,JavaScript的所有改进都采用了这种方法,而不是其本地方法“一个对象不再需要”。
在第一个例子中,在函数调用后没有两个对象引用任何资源。因此,垃圾收集器将它们视为不可达的,并且回收它们的内存。
这是目前浏览器大多基于标记清除法。我们可以分为两个阶段:
- 标记:从根节点遍历为每个可以访问到的对象都打上一个标记,表示该对象可达。
- 清除:在没有可用分块时,对堆内存遍历,若没有被标记为可达对象就将其回收。
优点:实现简单。
缺点:a 内存过于碎片化。
b 内存分配速度慢。
解决方法:标记-整理法
标记整理法和标记清除法标记阶段一致,只是整理阶段是先将被引用的对象移动到一端,然后清理掉标记的内存。
视图如下:
2、引用计数法
引用计数法就是追踪每个变量被引用的次数,当引用数为0将可以被回收掉。
优点:当引用数为0时会被立即回收
缺点:a 计数器的增减处理频繁,会导致空间的使用效率降低。
b 循环引用无法收回,导致内存泄漏。
若有一函数Person中a引用了b,b引用了a。每次调用函数Person,它们的引用计数都不为0,则永远不能被回收。
示例如下:
function Person(){
let a={};
let b={};
a.prop = b;
b.prop = a;
}
限制:
在应用程序中可能会有这样的时刻,手动指定哪些内存将被释放会很方便。要释放对象的内存,我们需要手动将其显式设置为不可达。
我们不能手动触发JavaScript垃圾收集器。
分代式垃圾回收机制
把分代式垃圾回收机制单独拎出来是因为涉及内容较多,容易混淆。v8中将内存分成了两个区,新生代和老生代。新生代对象存活时间较短,内存通常支持1~8MB。而老生代存储存活时间较长或常驻内存的对象。对于新老两块内存区域的垃圾回收频率不同,所以V8 采用了两个垃圾回收器来管控。
视图如下:
1、 新生代垃圾回收
新生代垃圾回收通过Scavenge策略进行垃圾回收,在具体实现中,主要采用了一种复制式的方法Cheney算法。Cheney算法将堆内存也分为两个区,一个使用状态的空间我们称为使用区。一个处于闲置状态的空间称为空闲区。新加入的对象都会被存放到使用区,当使用区快被写满时,就执行一次垃圾回收操作。
(1)垃圾回收流程
先对使用区中的活动做标记
标记完成后,将使用区的活动对象复制进空闲区并进行排序
将原先使用区对象占用的空间释放
最后进行角色互换,把空闲区变为使用区,使用区变为空闲区
(2)新生代对象何时会到老生代?
第一种情况:经过多次复制后依然存活的对象会被认为是生命周期较强的对象,会被移到老生代管理。
第二种情况:如果复制一个对象到空闲区时,空闲区空间占用超过25%,那么这个对象将被移到老生代区域。原因是,当完成Scavenge回收后,空闲区转变成使用区,需继续进行内存分配,若占比过大,将会影响后续内存的分配。
(3)并行回收
Javascript是一门单线程语言,它是运行在主线程上的,而在进行垃圾回收的时候就会阻塞Javascript脚本的执行,需等待垃圾回收完毕后再恢复脚本执行,这种行为叫全停顿。那当GC时间过长就会造成页面卡顿问题。那一个人干活慢,n个人一起速度便会是一个人的n倍。程序也一样,我们可以通过引入多个辅助线程来同时处理。因此V8引入了并行回收机制。
新生代对象空间就采用并行策略。在垃圾回收过程中,启动多个线程来负责新生代中的垃圾清理,这些线程同时将对象空间中的数据移到空闲区。由于这个过程中数据地址会发生改变,所以还需要同步更新引用这些对象的指针。
2、老生代垃圾回收
老生代数据大多是存活的对象,不需要时常清除更新,所以采用上面提到的标记清除法来进行垃圾回收。因为上面也提到标记清除后会产生大量内存碎片,所以V8就采用了上文所说的标记整理法来解决这个问题。
(1)增量标记
并行策略虽然可以增加垃圾回收的效率,对于新生代这样存放较小对象的回收器能有很好的优化,但其实还是全停顿式的。对于存放较大对象的老生代来说,这些较大对象GC时哪怕使用并行策略依旧会消耗大量时间。所以V8对老生代进行了优化,从全停顿标记切换到了增量标记。
增量标记:就是将一次GC分成多步小GC标记,让JS和小GC标记交替执行,直到标记完成。
问题来了,小GC标记执行完后是如何暂停执行JS任务,而后又是如何进行下一次小GC 标记?如果执行JS任务时刚被标记好的对象引用又被修改了该当如何?V8解决这两个问题的方法分别是三色标记法和写屏障。
1)解决问题一:三色标记法
标记清理法区分是通过非黑即白的策略,但这样便会出现在增量标记时,内存中黑白都有,我们无法区分下一步是什么?所以采用了三色标记法,使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:黑(11),白(00),灰(10)。
黑色表示对象自身及对象的引用都被标记(已检查状态)
白色表示未被标记的对象(初始状态)
灰色表示自身被标记,自身的引用未被标记(待检查状态)
执行流程如下:
初始将所有对象都是白色
从root对象开始,先将root对象标记为灰色并推入标记工作表中
当收集器从标记工作表中弹出对象并访问他的所有引用对象时,自身灰色就会变成黑色。
将自身的下一个引用对象标记为灰色
一直这样执行下去,直到没有可以被标记为灰色的对象时,剩下的白色对象都是不可达的,进入清理阶段。恢复时从灰色标记对象开始执行。
2)解决问题二:写屏障
为了解决黑色对象在程序执行中被新添加引用或已经标记黑色的被引用对象不再被引用了。写屏障就有了以下两个变化:
不对已标记的黑色对象做处理,因为在之后的GC中也会被清理。
Write-barrier 机制强制不变黑的对象指向白色对象。这个也被称作强三色不变性。所以一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记。
(2)懒性清理
增量标记完后,如果当前内存足以支持代码的快速运行,也没必要立即清理,可让程序先运行,也无需一次性清理完所有垃圾对象,可以按需清理直到所有垃圾对象清理完后再继续增量标记。
并发回收
并发主要发生在工作线程上。当在工作线程(辅助线程)执行GC是,应用程序可以继续在主线程运行并不会被挂起。
这也是有问题的,因为GC也在进行,应用程序也在执行,此时对象的引用关系随时都有可能变化,所以之前做的一些标记就需要改变,所以需要读写锁机制来控制这一点。
总结:
JavaScript提供了高效的内存管理机制。JavaScript自动支持内存管理,对我们来说是透明的。我们不能手动触发JavaScript垃圾收集器。