JavaScript内功修炼:内存管理与分代垃圾回收机制详解
JavaScript内功修炼:内存管理与分代垃圾回收机制详解
JavaScript引擎的由来
JavaScript引擎的发展历史可以追溯到1995年,当时Netscape公司在其Netscape Navigator浏览器中引入了一种名为LiveScript的脚本语言。随后,这种语言被更名为JavaScript。随着互联网的快速发展,JavaScript迅速成为Web开发的核心语言,用于为网页添加交互性和动态内容。
JavaScript引擎的由来和发展是与Web的发展密不可分的。自1995年JavaScript诞生以来,JavaScript引擎经历了从简单的解释器到复杂的即时编译引擎的演变过程。这些引擎的不断进化,推动了Web技术的发展,使现代Web应用具备了丰富的交互性和高效的性能。今天,V8、JavaScriptCore、SpiderMonkey和Chakra等引擎,继续在不断优化和创新,为开发者提供强大的工具,以应对未来Web开发的挑战。
1. JavaScript的诞生与初期发展
- JavaScript的起源:
- 1995年,Netscape公司雇佣了Brendan Eich,他在短短10天内开发了JavaScript的第一个版本。最初,JavaScript的设计目的是为网页开发者提供一种简单的脚本语言,使他们能够轻松地为网页添加动态行为。
- 1996年,Microsoft公司在其Internet Explorer浏览器中推出了JScript,这是一个与JavaScript兼容的版本。为了避免专利纠纷,Microsoft将其命名为JScript。
- 早期的JavaScript引擎:
- SpiderMonkey(1995): Brendan Eich为Netscape Navigator编写的第一个JavaScript引擎,被称为SpiderMonkey。它是JavaScript的初代引擎,负责将JavaScript代码解析并执行在Web浏览器中。
- JScript引擎(1996): Microsoft开发的JScript引擎,首次集成在Internet Explorer 3.0中,使得JavaScript在浏览器领域得到更广泛的应用。
2. JavaScript引擎的进化
随着Web应用的复杂性不断增加,JavaScript引擎也在不断进化,以提高代码执行效率并支持更多的语言特性。
- Rhino(1997):
- 由Mozilla基金会开发,Rhino是第一个JavaScript引擎的Java实现版本。它主要用于服务器端应用程序中,特别是在Java应用中嵌入JavaScript脚本。
- JavaScriptCore(2003):
- Apple在开发Safari浏览器时,引入了JavaScriptCore引擎,这是WebKit浏览器引擎的一部分。JavaScriptCore的引入标志着JavaScript引擎从单纯的解释执行向更高效的字节码执行模式过渡。
- V8引擎(2008):
- 由Google开发的V8引擎是JavaScript引擎发展中的一个重要里程碑。V8引擎使用即时编译(JIT)技术,将JavaScript代码直接编译为机器码,大幅提升了代码的执行速度。V8引擎最初用于Google Chrome浏览器,后来被广泛应用于Node.js中。
- SquirrelFish/Nitro(2008):
- Apple为Safari浏览器开发了SquirrelFish引擎,并在随后的版本中进一步优化为Nitro引擎。这些引擎采用字节码解释器,显著提升了JavaScript代码的执行效率。
- Chakra(2010):
- Microsoft为Internet Explorer 9开发了Chakra引擎,使用了类似V8的JIT编译技术,优化了JavaScript的执行速度。后来,Chakra引擎也用于Microsoft Edge浏览器。
3. JavaScript引擎的现代化
随着JavaScript在Web开发中的地位越来越重要,现代JavaScript引擎在性能、内存管理和安全性等方面进行了大量优化。
- 多线程与并行处理:
- 现代JavaScript引擎如V8和SpiderMonkey,采用了多线程和并行处理技术,允许在后台进行垃圾回收和JIT编译,从而最大限度地减少对主线程的影响,提升页面响应速度。
- WebAssembly的支持:
- 为了应对对性能要求更高的Web应用,JavaScript引擎开始支持WebAssembly(Wasm),一种低级别的二进制格式,允许开发者在浏览器中运行接近本机速度的代码。
- 持续的性能优化:
- 现代JavaScript引擎不断优化其JIT编译器和垃圾回收机制,以应对日益复杂的Web应用,并提供更高的执行性能和更低的内存占用。
为什么需要引擎它与垃圾回收之间的关联 ?
在JavaScript代码执行的过程中,内存管理是一个非常重要的方面。JavaScript引擎不仅负责代码的执行,还必须管理程序的内存使用,确保不再使用的内存能够被及时回收,以防止内存泄漏和系统性能的下降。这个内存管理的关键机制就是垃圾回收(Garbage Collection)。
1. 内存管理的需求:
- 自动内存管理: 与C或C++等手动管理内存的语言不同,JavaScript的内存管理是自动化的。开发者无需手动分配或释放内存,JavaScript引擎会自动管理对象的内存分配和释放。这大大降低了编程复杂性,同时也避免了常见的内存管理错误,如内存泄漏或悬空指针。
2. 垃圾回收的工作原理:
- 跟踪与回收: JavaScript引擎中的垃圾回收器(如V8的Orinoco)会跟踪内存中的所有对象,并识别那些不再被使用的对象。在大多数情况下,垃圾回收器使用“标记-清除”(Mark-and-Sweep)或“标记-整理”(Mark-and-Compact)算法来执行这些任务。
- 内存回收的自动化: 垃圾回收器定期扫描内存中的对象,并回收那些不再被引用的对象所占用的内存。这一过程通常在引擎的后台进行,以尽量减少对代码执行的影响。
3. 引擎和垃圾回收的协同工作:
- 性能平衡: JavaScript引擎不仅要快速执行代码,还要在不影响性能的情况下进行垃圾回收。垃圾回收是一个计算密集型的任务,但如果不及时回收内存,可能会导致内存不足或应用程序性能下降。因此,现代引擎(如V8)通过增量垃圾回收、并行垃圾回收等技术,尽量减少垃圾回收对代码执行的中断。
- 优化与去优化: 当JavaScript引擎优化代码时,它假设一定的内存模型和引用模式。如果这些假设在运行时被打破,引擎可能需要去优化代码,同时重新考虑内存管理策略。这就要求垃圾回收器和引擎紧密协作,动态调整内存管理和代码执行策略。
Nodejs整体架构
1. Node.js标准库(Node standard lib)
Node.js的标准库提供了丰富的API,用于处理文件系统、网络请求、流、缓冲区等常见任务。开发者可以直接使用这些标准库来构建应用程序,而无需手动处理底层细节。它是构建Node.js应用的核心组件,简化了开发过程,提供了统一的接口来操作各种系统资源。
2. 绑定层(Binding)
绑定层是Node.js中一个关键部分,它负责把Node.js的JavaScript代码与底层的C/C++代码连接起来。简单来说,绑定层就像是一个“翻译官”,它让JavaScript代码可以直接调用底层系统功能,比如文件读写、网络通信等,这些功能通常是用C/C++编写的,因为这些语言在处理这些任务时非常高效。
通过绑定层,Node.js能够充分利用这些底层功能的高性能,并把它们包装成容易使用的JavaScript接口,供开发者调用。这种设计让Node.js既保持了JavaScript的简单易用性,又拥有了接近底层系统的强大性能。
3. V8引擎
V8是Google开发的JavaScript引擎,它将JavaScript代码编译成机器码,并高效执行。Node.js通过使用V8引擎来快速运行JavaScript代码,确保应用具备高性能。
4. libuv
libuv是一个跨平台的支持库,负责提供事件驱动的异步I/O操作、线程池管理、文件系统访问以及TCP/UDP网络通信等功能。它是Node.js异步I/O模型的核心,负责管理事件循环和线程池,从而使Node.js能够在不阻塞主线程的情况下,高效地处理大量并发连接。
5. C-ares
C-ares是一个异步DNS解析库,专门用于处理DNS查询操作。Node.js利用C-ares来异步处理DNS请求,确保这些操作不会阻塞主线程。C-ares的作用在于让Node.js能够高效地处理DNS查询,对于构建高性能网络应用至关重要,因为它避免了网络操作中的阻塞问题。
6. OpenSSL
OpenSSL是一个用于安全通信的加密库,负责实现SSL/TLS协议。Node.js使用OpenSSL来加密网络通信,确保数据传输的安全性。OpenSSL为Node.js提供了加密功能,支持HTTPS和其他安全通信协议,是保障Node.js应用安全性的核心组件。
V8 引擎的简单介绍
1. Heap Memory Allocation
- 堆内存分配:V8引擎负责管理和分配内存空间,主要通过堆(Heap)来分配对象和数据的内存。JavaScript中的所有对象和引用类型数据都在堆内存中存储。
2. Call Stack Execution Context
- 调用栈执行上下文:这是V8处理函数调用和执行顺序的地方。每当一个函数被调用时,一个新的执行上下文被创建并压入调用栈,执行完成后会弹出调用栈。这个过程确保了JavaScript代码的顺序执行。
3. Orinoco Garbage Collector
- Orinoco垃圾回收器:这是V8引擎中的垃圾回收系统,负责自动回收不再使用的内存。Orinoco使用了一系列优化策略,如增量标记和并行清理,以最小化垃圾回收对程序性能的影响。
4. TurboFan Optimization Compiler
- TurboFan优化编译器:这是V8中的JIT(即时)编译器,专门负责将热点代码编译为高效的机器码。TurboFan会根据运行时收集的数据进行优化,提高代码的执行效率。
5. Ignition JS Interpreter
- Ignition JavaScript解释器:Ignition是V8的解释器,负责将JavaScript代码解析为字节码并执行。它是V8中最先执行JavaScript代码的组件,负责快速启动代码的执行。
6. Liftoff WebAssembly
- Liftoff WebAssembly编译器:Liftoff是V8引擎中专门为WebAssembly设计的编译器,负责将WebAssembly字节码编译为机器码并执行。WebAssembly是一种与JavaScript并行的低级语言,主要用于性能要求较高的应用场景。
V8 的处理过程
1. 代码解析(Parsing)
始于从网络中获取 JavaScript 代码,代码进入解析器(Parser),被解析为抽象语法树(AST)。AST是代码的结构化表示,提供了代码的语法和逻辑结构。
2. 字节码生成与执行(Bytecode Generation and Execution)
AST生成后,V8引擎的Ignition解释器将其转换为字节码。字节码是中间形式,介于源代码和机器码之间,适合在虚拟机中执行。 一旦字节码生成,V8引擎开始执行这些字节码。执行过程中,V8会收集代码的运行数据,包括类型信息和执行路径,这些数据将用于后续的优化步骤。
3. 动态优化(Dynamic Optimization)
在字节码执行的过程中,V8引擎会识别出热点代码——那些被频繁执行的代码段。引擎的TurboFan优化编译器对热点代码进行优化,生成更高效的机器码,提升代码执行速度。如果运行时的某些假设被证明不成立,V8引擎会触发去优化(Deoptimization)过程,将执行流程退回到字节码执行阶段,确保代码正确运行。
为何需要垃圾回收?
在JavaScript代码执行的过程中,内存管理是一个非常重要的方面。JavaScript引擎不仅负责代码的执行,还必须管理程序的内存使用,确保不再使用的内存能够被及时回收,以防止内存泄漏和系统性能的下降。这个内存管理的关键机制就是垃圾回收(Garbage Collection)。
1. 内存管理的需求:
- 自动内存管理: 与C或C++等手动管理内存的语言不同,JavaScript的内存管理是自动化的。开发者无需手动分配或释放内存,JavaScript引擎会自动管理对象的内存分配和释放。这大大降低了编程复杂性,同时也避免了常见的内存管理错误,如内存泄漏或悬空指针。
2. 垃圾回收的工作原理:
- 跟踪与回收: JavaScript引擎中的垃圾回收器(如V8的Orinoco)会跟踪内存中的所有对象,并识别那些不再被使用的对象。在大多数情况下,垃圾回收器使用“标记-清除”(Mark-and-Sweep)或“标记-整理”(Mark-and-Compact)算法来执行这些任务。
- 内存回收的自动化: 垃圾回收器定期扫描内存中的对象,并回收那些不再被引用的对象所占用的内存。这一过程通常在引擎的后台进行,以尽量减少对代码执行的影响。
3. 引擎和垃圾回收的协同工作:
- 性能平衡: JavaScript引擎不仅要快速执行代码,还要在不影响性能的情况下进行垃圾回收。垃圾回收是一个计算密集型的任务,但如果不及时回收内存,可能会导致内存不足或应用程序性能下降。因此,现代引擎(如V8)通过增量垃圾回收、并行垃圾回收等技术,尽量减少垃圾回收对代码执行的中断。
- 优化与去优化: 当JavaScript引擎优化代码时,它假设一定的内存模型和引用模式。如果这些假设在运行时被打破,引擎可能需要去优化代码,同时重新考虑内存管理策略。这就要求垃圾回收器和引擎紧密协作,动态调整内存管理和代码执行策略。
垃圾回收
可达性
在垃圾回收的过程中,“可达性”是判断对象是否可以被回收的关键概念。可达性指的是对象是否能够通过某种路径(如直接引用或间接引用)从“根对象”访问到。根对象包括全局变量、当前函数的局部变量和参数等,这些对象是垃圾回收器判定为“可达”的起点。
只要一个对象能够通过引用链从根对象访问到,它就是“活”的,并且不会被回收。根对象的来源不仅限于栈上的变量,还包括全局变量、闭包中捕获的变量、浏览器中的DOM元素引用等。垃圾回收器会定期检查这些引用链,以识别那些没有被任何可达对象引用的“孤立”对象,并将其从内存中回收。这种机制有效防止内存泄漏,确保程序的高效运行。
全局变量的引用链
1 |
|
- 解释:
globalObj
是一个全局变量,它是根对象的一部分。由于它直接被代码引用,并且没有任何途径使它变得不可达,因此它不会被垃圾回收。
函数内部的局部变量
1 |
|
- 解释:
localObj
是函数内部的局部变量,但因为它被返回并赋值给了全局变量obj
,所以即使函数执行完毕,localObj
依然是可达的,因为它可以通过obj
引用链访问到。
通过对象引用保持可达性
1 |
|
- 解释:
localObj
是函数内部的局部变量,但因为它被返回并赋值给了全局变量obj
,所以即使函数执行完毕,localObj
依然是可达的,因为它可以通过obj
引用链访问到。
闭包中的变量引用
1 |
|
- 解释:在这个例子中,
closureObj
是outerFunction
内部的局部变量,但由于innerFunction
闭包引用了closureObj
,即使outerFunction
执行完毕,closureObj
仍然保持可达性,并不会被垃圾回收。
DOM元素的引用
1 |
|
解释:element
是一个DOM元素,它被obj
对象的elementRef
属性引用。因此,只要obj
是可达的,那么DOM元素element
也不会被垃圾回收。
可达示例
1 |
|
分析
- 初始状态:
- 当
exampleFunction()
被调用时,函数内部的obj1
、obj2
和obj3
都是局部变量,它们都可以通过栈帧访问,因此它们是可达的。
- 当
- 引用关系:
obj2
引用了obj1
,这意味着即使obj1
不再被局部变量直接引用,它仍然通过obj2
是可达的。obj3
虽然是局部变量,但它没有其他引用,仅仅在函数内部可达。
- 设置
obj1
为null:- 当
obj1
被设置为null
时,函数内部的局部变量不再直接引用最初的obj1
对象。然而,因为obj2
引用了obj1
,所以原来的obj1
对象仍然是可达的。 - 这意味着
obj1
的内容仍然存在于内存中,未被垃圾回收。
- 当
- 函数返回后:
exampleFunction()
返回了obj2
,并且globalObj
引用了obj2
。由于obj2
是全局变量,垃圾回收器认为obj2
是根对象,obj2
及其引用的obj1
对象都被保留在内存中。obj3
在函数结束时失去引用,成为不可达的对象,因此obj3
及其内容可能会被垃圾回收器回收。
结论
- 可达性:在这个示例中,通过函数返回值和变量之间的引用链,
obj1
和obj2
都保持了可达性,而obj3
失去了可达性,最终会被垃圾回收。 - 垃圾回收:垃圾回收器会回收那些不可达的对象,以释放内存。在这个例子中,
obj3
就是一个被垃圾回收器标记为不可达并可能最终回收的对象。
GC算法是什么?
- GC(Garbage Collection)是内存管理中的一种机制,负责自动回收不再使用的内存。具体的回收工作由 垃圾回收器(Garbage Collector)来完成。
- 垃圾回收器的工作主要包括两个方面:一是查找哪些内存空间可以被释放(即识别哪些对象是“垃圾”),二是释放这些空间,使其可供程序再次使用。
- GC算法则是垃圾回收器在执行这些工作时所遵循的规则和方法。不同的GC算法采用不同的策略来确定哪些对象应该被回收,以及如何有效地管理内存,以尽量减少对程序性能的影响。
垃圾回收中常见的GC算法
引用计数算法
引用计数算法是一种常见的内存管理方法,通过跟踪每个值被引用的次数来决定何时回收内存。当一个变量被赋予一个引用类型的值时,引用次数增加;当该值被赋给其他变量时,引用次数进一步增加;如果变量被赋予新值或不再引用该值,引用次数则减少。当引用次数降为零时,说明该值已不再被使用,垃圾回收器会释放其占用的内存空间。这种方法有效地管理了内存,但也可能导致一些复杂的内存泄漏问题,如循环引用。
引用计数算法的实现原理
引用计数算法是一种简单而直观的内存管理机制,其核心思想是通过跟踪每个对象的引用次数来决定该对象是否应该被回收。以下是引用计数算法的基本实现原理:
1. 引用计数的初始化
- 当一个对象被创建时,引用计数器会初始化为 1,表示该对象被至少一个变量引用。
2. 引用计数的增加
- 每当有一个新的引用指向该对象时(例如,将对象赋值给另一个变量),该对象的引用计数就会增加 1。
3. 引用计数的减少
- 当一个引用不再指向该对象时(例如,将变量赋值为
null
或将对象引用赋值为另一个对象),该对象的引用计数就会减少 1。
4. 引用计数为 0 时的回收
- 当某个对象的引用计数降为 0 时,说明没有任何变量或对象再引用它,因此该对象无法再被访问,垃圾回收器就会释放该对象占用的内存。
5. 循环引用问题
- 如果两个或多个对象之间存在循环引用,即它们互相引用但不再被其他对象引用,则它们的引用计数永远不会降为 0。这会导致这些对象无法被回收,从而引发内存泄漏。这是引用计数算法的一个主要缺陷。
1 |
|
优点
引用计数算法相较于标记清除算法有几个显著的优势:
- 即时回收:引用计数算法在对象的引用次数降为 0 时立即回收内存。这意味着垃圾一旦生成,便能立即释放内存,从而避免了内存堆积。
- 无停顿回收:标记清除算法需要定期暂停应用程序执行来进行垃圾回收,这会导致短暂的性能停顿。而引用计数算法则不需要全局的停顿来执行垃圾回收,它的内存管理是分散在各个操作中的,因此不会引入明显的性能中断。
- 简单高效:引用计数算法在引用和解除引用时更新计数,不需要像标记清除算法那样遍历整个堆内存的所有对象,这在某些情况下可以更加高效。
缺点
引用计数算法也有一些明显的缺陷:
- 额外的计数器开销:每个对象都需要维护一个引用计数器,这个计数器需要占用内存空间。尤其是在引用较多时,这些计数器可能会占用大量的内存。
- 无法处理循环引用:引用计数算法的最大问题是无法检测和处理循环引用。两个或多个对象互相引用时,即使它们不再被程序其他部分引用,它们的引用计数也不会降为 0,导致内存泄漏。
总之,虽然引用计数算法有其即时回收和无停顿回收的优点,但它在处理复杂引用关系和内存管理开销方面的不足也不可忽视。循环引用问题尤其严重,常常需要与其他垃圾回收算法结合使用以确保内存能被正确回收。
标记清除算法(mark-sweep)
标记-清除(Mark-Sweep)算法是现代垃圾回收器中广泛使用的一种算法。它通过两个主要阶段来管理内存:标记阶段和清除阶段。
1. 标记阶段(Mark Phase)
在标记阶段,垃圾回收器会遍历所有的根对象(通常是全局对象、栈中的局部变量和静态变量等),并递归地标记所有从根对象可达的对象为“活动”(reachable)。
- 根对象:根对象是程序运行中始终存在的对象,通常包括全局变量、栈帧中的变量以及一些静态变量。
- 可达对象:如果一个对象可以从根对象通过直接或间接的引用访问到,那么它被认为是可达的,且在标记阶段会被标记为“活动”。
2. 清除阶段(Sweep Phase)
在清除阶段,垃圾回收器会扫描堆内存中的所有对象,并回收那些没有在标记阶段被标记为“活动”的对象,即不可达的对象。
- 未标记的对象:这些对象在标记阶段没有被标记,意味着它们无法通过任何引用链从根对象访问到。垃圾回收器将它们视为“垃圾”,并回收它们占用的内存。
标记清除算法的实现原理
初始化:
- 垃圾收集器在启动时会假设内存中的所有对象都是垃圾,并为它们加上一个标记,将其全部标记为0。这相当于初始化,准备后续的遍历和清理工作。
标记阶段:
- 垃圾收集器从各个根对象(如全局变量、栈中的局部变量等)开始遍历内存,识别出那些仍然可达的对象。对于每一个可达的对象,垃圾收集器会将其标记改为1,表示它是“活动的”,即不会被回收。
清除阶段:
- 在标记阶段结束后,垃圾收集器会扫描内存中所有的对象,并清理那些仍然标记为0的对象。这些对象被视为垃圾,因此它们所占用的内存将被销毁并回收。
内存释放:
- 清除阶段结束后,被回收的内存可以再次分配给新创建的对象。
优点
- 解决循环引用问题:
- 标记清除算法通过遍历根对象来标记可达对象,不依赖于引用计数,因此可以有效解决引用计数算法无法处理的循环引用问题。即使对象之间存在循环引用,只要它们是不可达的,最终都会被回收。
- 无需维护引用计数:
- 不像引用计数算法需要在每次引用增加或减少时更新计数,标记清除算法只在垃圾回收时进行一次标记和清除操作,减少了在正常程序执行中的开销。
- 通用性强:
- 标记清除算法适用于各种编程语言和内存管理需求,能够处理复杂的对象图,并且在很多现代垃圾回收器中都有使用。
缺点
- 程序停顿(Stop-the-World):
- 在标记和清除阶段,程序执行需要暂停,这种“Stop-the-World”操作可能导致应用的响应速度下降,特别是在堆内存非常大时,这种停顿可能会较为明显。
- 内存碎片化:
- 清除阶段会释放不可达对象的内存,这可能导致内存空间变得不连续,形成内存碎片化。碎片化的内存空间虽然被回收了,但由于其分散性,可能难以用于分配新的大对象。
- 效率问题:
- 标记清除算法在每次垃圾回收时需要遍历整个对象图并扫描整个堆内存,这对大规模应用程序的性能可能会造成一定的影响,尤其是在堆内存很大或者对象很多的情况下。
总结
标记清除算法通过初始标记、遍历根对象标记可达对象、清理不可达对象以及重置标记四个步骤,实现了有效的垃圾回收。它能够识别并清除不再使用的内存空间,并且能够处理循环引用问题。然而,标记清除算法在运行过程中会暂停程序的执行,并可能导致内存碎片化,这些都是需要在实际应用中考虑的问题。
标记整理算法(Mark-Compact)
标记整理(Mark-Compact)算法是标记清除算法的一个改进版本,旨在解决标记清除算法中存在的内存碎片化问题。标记整理算法通过将存活对象整理到内存的一端,从而将碎片化内存整理成连续的空闲空间,方便后续的内存分配。
标记整理算法的工作原理
标记整理算法的执行过程可以分为以下几个阶段:
标记阶段(Mark Phase):
- 与标记清除算法类似,垃圾回收器从根对象开始,遍历对象图,标记所有可达的对象为“活动”对象。这些对象将不会被回收。
整理阶段(Compact Phase):
- 在标记阶段之后,垃圾回收器会将所有存活的对象(即标记为“活动”的对象)向内存的一端移动,使内存中的活动对象排列得更加紧凑,减少内存碎片。这一步骤还会更新引用地址以确保它们指向正确的位置。
释放未使用的内存:
- 在整理阶段完成后,所有非活动对象占用的内存被回收,形成一个连续的空闲区域,等待新的对象分配。这时内存中的布局显示出活动对象被集中到一端,未使用的内存空间则完全被释放。
GC算法总结
引用计数
- 可以即时回收垃圾对象
- 减少程序卡顿时间
- 无法回收循环引用的对象
- 资源消耗较大
标记清除
1.标记清除分两个阶段进行,首先标记活动对象,然后清除未标记的对象。
2.可以回收循环引用的对象空间,是引用计数算法的改进。
3.容易产生碎片化操作,无法最大化利用空间。
4.垃圾对象不会被立即回收,而是在最后清除,可能导致程序停止工作。
标记整理
1.标记整理通过整理地址空间来解决标记清除的空间碎片化问题。
2.同样无法立即回收垃圾对象,相对于引用计数和标记清除是缺点。
V8引擎的内存限制
1. 内存限制的背景
V8 引擎在不同环境下的内存限制有所不同,主要包括以下几个方面:
- 桌面环境(如 Chrome 浏览器、桌面版 Node.js):
- 在 64 位系统上,V8 引擎的堆内存限制通常在 1.5 GB 到 2 GB 之间。
- 在 32 位系统上,这一限制较低,通常为 512 MB 到 1 GB。
- 移动设备:
- 在移动设备上,由于内存资源更为有限,V8 引擎的内存限制通常在 256 MB 到 512 MB 之间。
2. 内存限制的原因
- 设备限制:
- 不同设备的硬件能力,特别是内存容量的差异,决定了 V8 必须对内存使用进行严格控制,以确保在各种设备上都能高效运行。
- 性能优化:
- V8 引擎通过设置内存限制,可以优化垃圾回收和内存管理,避免因内存过度占用导致的性能问题。例如,在内存紧张的情况下,垃圾回收频率可能会增加,以确保程序不会因内存不足而崩溃。
- 安全性考虑:
- 限制内存使用量还能够防止恶意脚本或程序占用过多的内存资源,从而影响系统的稳定性。这在浏览器环境和服务器端环境中都尤为重要,以防止内存泄漏或恶意攻击。
3. Node.js 中的内存限制
在 V8 引擎中,所有的 JavaScript 对象都存储在堆内存中。V8 对堆内存的大小设置了默认限制。在旧版本的 Node.js 中,64 位系统上的堆内存限制通常为 1.5 GB 左右,而在 32 位系统上为 0.7 GB 左右。随着 Node.js 的版本更新和硬件的提升,最新版本(如
v20.14.0
)的堆内存限制已提升至 4 GB。如果一个 Node.js 进程的堆内存使用量超过了这个限制,进程就会由于内存不足而退出。由于 Node.js 是基于 V8 引擎构建的,因此 V8 通过其内部机制来管理和分配这些堆内存。
1 |
|
V8 的垃圾回收策略
V8 引擎采用了分代式垃圾回收机制,根据对象的存活时间将内存分为不同的代,然后对每个代采用不同的垃圾回收算法。这种策略旨在提高垃圾回收的效率,同时最大限度地减少对程序性能的影响。
V8引擎内存空间划分
- Old Space(老生代空间):
- 黄色区域:表示老生代空间(Old Space),用于存储生命周期较长或永久存在的对象。这部分内存空间通常占用较大,因为随着程序运行,很多对象会从新生代晋升到老生代。
- New Space(新生代空间):
- 浅蓝色区域:表示新生代空间(New Space),这是存放生命周期较短的小对象的区域。新生代通常会被分为两个部分,一个是活动的新生代空间,另一个是非活动的新生代空间(Inactive New Space),用于 Scavenge 算法的复制过程。
- Large Object Space(大对象空间):
- 红色区域:表示大对象空间(Large Object Space),用于存储非常大的对象,这些对象由于太大而无法在新生代空间中管理,直接存放在这个专用空间中。
- Code Space(代码空间):
- 蓝色区域:表示代码空间(Code Space),存储 JIT 编译后的可执行代码。
- Map Space(映射空间):
- 深蓝色区域:表示映射空间(Map Space),用于存储对象的映射结构,即对象的形状信息(如对象的属性和方法布局)。
V8新生代与老生代分布图
在 V8 引擎的内存结构中,新生代(Young Generation)主要用于存放生命周期较短的对象。新生代内存由两个称为 Semispace(半空间)的区域组成,内存最大值在 64 位系统和 32 位系统上分别为 32 MB 和 16 MB。为了高效地管理和回收这些短生命周期的对象,新生代的垃圾回收主要采用 Scavenge 算法。
新生代
1. 对象分配
- 新生代内存(Young Generation)被分成两个相同大小的区域:
From Space
和To Space
。 - 新分配的对象会先被放置在
From Space
中。
2. 垃圾回收触发
- 当
From Space
空间快满时,V8的垃圾回收机制会触发一轮Minor GC(小型垃圾回收),也称为Scavenge。
3. 对象复制
在进行垃圾回收时,V8会扫描
From Space
中的存活对象,并将它们复制到To Space
。在复制过程中,会更新所有指向这些对象的引用,以确保它们指向新位置。
垃圾(即不再被引用的对象)不会被复制,因此自然会被回收。
4. 空间交换
当复制完成后,
From Space
和To Space
的角色会交换:From Space
(原来的To Space
)成为新的活跃空间,用来存放接下来分配的新对象。To Space
(原来的From Space
)则被清空,等待下一次垃圾回收的使用。
5. 对象晋升
- 如果一个对象在多次垃圾回收中都存活下来,V8会将该对象从新生代(Young Generation)移动到老生代(Old Generation),这称为晋升(Promotion)。老生代用于存储生命周期较长的对象,垃圾回收频率较低。
总结
From Space
和To Space
是新生代(Young Generation)中的两个区域,它们在每次垃圾回收后交换角色。- 新生代内存的垃圾回收采用复制收集算法,存活的对象被复制到新的空间,不再使用的对象则被丢弃。
- 多次垃圾回收后仍存活的对象将被晋升到老生代,以减少新生代内存的压力。
老生代
不同于新生代,老生代中存储的内容是相对使用频繁并且短时间无需清理回收的内容。这部分我们可以使用标记整理进行处理。
从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象
清除阶段老生代垃圾回收器会直接将非活动对象进行清除。
全停顿(Stop-The-World)与 V8 引擎的优化策略
由于 JavaScript 运行在主线程之上,一旦执行垃圾回收(Garbage Collection,GC),必须将正在执行的 JavaScript 脚本暂停,待垃圾回收完毕后再恢复脚本执行。这种行为称为 全停顿(Stop-The-World,STW)其实如果用前端开发的术语来解释,就是阻塞。
全停顿的影响
STW 会导致系统出现周期性的卡顿,这对实时性要求高或与时间相关的任务影响尤为显著。例如,当 JavaScript 脚本需要执行动画效果时,如果恰好遇到 GC 过程,动画可能会出现卡顿现象,导致用户体验极差。
V8 引擎的优化策略:Orinoco
为了降低 STW 导致的卡顿和性能不佳,V8 引擎引入了名为 Orinoco 的垃圾回收器。Orinoco 是 V8 团队经过多年不断优化和精细化调校后的成果,具备了多种优化手段,从而极大地提升了 GC 过程的性能和用户体验。
关键优化手段
- 增量标记(Incremental Marking):
- Orinoco 在垃圾回收过程中引入了增量标记技术,将标记阶段分解为多个小步骤,在 JavaScript 脚本执行的间隙进行。这种方式有效减少了 STW 导致的长时间卡顿现象。
- 并发标记(Concurrent Marking):
- 并发标记允许标记阶段和 JavaScript 脚本执行并行进行,这进一步降低了 STW 的影响,减少了用户感知到的卡顿时间。
- 并行清理(Parallel Sweeping):
- 在清理阶段,Orinoco 垃圾回收器可以利用多核处理器进行并行清理操作,从而加快垃圾回收过程,缩短 STW 时间。
- 延迟回收(Lazy Sweeping):
- 延迟回收技术允许垃圾回收器将部分非关键的清理工作延后进行,在不影响应用程序性能的情况下进一步减少卡顿。
并行回收
什么是并行回收?
在传统的垃圾回收过程中,JavaScript 代码执行的主线程需要暂停一段时间,等垃圾回收完成后才能继续执行。这段时间我们称为 全停顿(Stop-the-World,STW)。如果这个暂停时间太长,比如几秒钟,用户可能会感觉到应用程序卡顿,尤其是在需要持续响应的场景下,如动画播放或游戏中。
为了改善这种情况,V8 引擎引入了 并行回收 技术。简单来说,就是把垃圾回收的工作分成几部分,让多个处理器核心同时进行这些工作,而不是让主线程一个个地完成所有任务。
并行回收如何工作?
任务分解:
- 当垃圾回收器开始工作时,它不会一次性暂停所有的 JavaScript 代码执行并处理所有垃圾回收工作,而是把这个任务分解成几个较小的步骤。
多个处理器核心同时工作:
- 这些较小的步骤会被分配给多个处理器核心,让它们并行处理。这样,垃圾回收的工作速度就能大大加快。
主线程的暂停时间减少:
- 由于这些垃圾回收工作同时进行,主线程需要暂停的时间就被压缩到最小。比如,原本可能需要暂停 3 秒的垃圾回收工作,现在可以分成 3 个部分,每部分只暂停 1 秒。
举个简单的例子
想象一下,你和你的朋友要清理一个大房间。传统的垃圾回收方式就像一个人独自清理房间,这样会花很长时间。而并行回收就像是你和几个朋友一起分工合作,同时清理房间。这样清理房间的时间就会大大缩短,你们也可以更快地回到正常活动中。
并行回收的好处
- 减少卡顿:通过分工合作、并行处理,垃圾回收的暂停时间大大减少,应用程序的卡顿现象明显降低。
- 提升用户体验:尤其是在动画播放、游戏或其他需要持续响应的场景中,并行回收可以让用户感觉到应用程序运行更加流畅。
- 更高效的资源利用:充分利用多核处理器的优势,加快垃圾回收的速度,让整个系统的资源使用更为高效。
增量回收
在 V8 引擎中,虽然并行回收策略已经减少了部分 STW(Stop-The-World,全停顿)现象,但在处理老生代中的大对象时,仍然可能会出现较长的停顿时间。为了解决这一问题,V8 引擎引入了 增量回收(Incremental Collection) 策略。增量回收的核心思路是将一次 GC 标记过程分解为许多小步,每执行完一个小步后,应用逻辑得以继续执行一段时间。通过这种交替进行的方式,增量回收能够在完成一轮 GC 标记的同时,显著减少 STW 对程序性能的影响。
三色标记法
三色标记法是一种用于垃圾回收的算法,能够在垃圾回收器随时启动或暂停时,保持标记过程的完整性,并确保不丢失已经标记的结果。该算法使用三种颜色来表示对象的不同状态:白色、灰色和黑色。
规则和流程:
- 初始化:
- 白色:初始状态下,所有对象都被标记为白色。白色对象表示尚未访问或处理的对象。
- 标记开始:
- 从 GC Root(垃圾回收的根对象集合)开始遍历所有可到达的对象,将它们标记为 灰色,并放入待处理的队列中。灰色对象表示已经被访问,但其引用的对象尚未全部处理完毕。
- 处理灰色对象:
- 从待处理队列中取出一个灰色对象,将它所引用的所有对象标记为灰色,并放入待处理队列。同时,将当前灰色对象标记为 黑色。黑色对象表示已经完全处理完毕,它和它引用的对象都不再需要进一步检查。
- 重复标记:
- 重复以上步骤,直到灰色对象队列为空。此时,所有存活的对象要么是黑色(已完全处理),要么是灰色(处理中)。当标记完成后,所有未被标记的白色对象即为垃圾对象,可以进行回收。
算法的优势:
- 可暂停和恢复:垃圾回收器可以根据内存中是否还有灰色对象来判断标记过程是否完成。如果灰色对象队列为空,表示标记过程已完成,可以进行清理工作。如果仍有灰色对象,当垃圾回收器再次启动时,可以从这些灰色对象继续处理,确保标记过程的完整性。
总结
三色标记法通过颜色标记系统(白、灰、黑)管理对象的状态,确保在垃圾回收过程中,标记可以随时暂停和恢复,且不丢失已经处理的结果。该算法在确保标记准确性的同时,也提高了垃圾回收的灵活性和效率,特别适用于需要频繁暂停和恢复的场景。
写屏障
写屏障(Write Barrier)是一种用于垃圾回收器的机制,旨在解决在 JavaScript 代码执行过程中,因对象引用变化而导致标记不准确的问题。特别是在增量标记过程中,写屏障能够确保标记结果的准确性,避免在垃圾回收过程中出现遗漏或错误。
对象引用变化的两种情况:
- 已标记的黑色或灰色对象不再被其他对象引用:
- 这种情况通常不会引发严重问题,因为在下次垃圾回收(GC)过程中,这些对象会重新被标记为白色,并最终被清除掉。
- 新引入的对象可能是白色对象:
- 新对象最初被标记为白色,而白色对象意味着尚未被访问和标记。如果一个黑色对象(已经完全处理的对象)突然引用了这个白色对象,由于白色对象还没有被标记为存活,它在接下来的垃圾回收中可能会被错误地清除,导致程序异常。
写屏障的工作原理:
- 强制颜色转换:
- 当系统检测到一个黑色对象开始引用一个白色对象时,写屏障机制会立即将该白色对象标记为灰色。这样一来,白色对象就不会在本轮垃圾回收中被误清除,而是会在下一个标记阶段中被正确处理。
- 保障标记的准确性:
- 通过使用写屏障策略,垃圾回收器能够确保在对象引用发生变化时,所有引用关系都能被正确跟踪和更新。这种方式确保了增量标记过程中,引用变动不会影响垃圾回收的准确性。
强三色原则
这种写屏障机制通常被称为 强三色原则。它确保无论何时,只要一个黑色对象引用了一个白色对象,这个白色对象都会被立即标记为灰色,避免它在当前垃圾回收过程中被误清除。
总结
写屏障在增量标记的垃圾回收过程中起到了关键作用。通过实时更新引用对象的颜色状态,写屏障确保了垃圾回收的准确性和安全性,避免了因对象引用变动而导致的潜在错误。特别是在高性能和复杂应用场景中,写屏障是保障垃圾回收稳定性的重要机制。
惰性清理
在 V8 引擎中,增量标记用于区分活动对象和非活动对象,但真正的内存释放工作则依赖于 惰性清理(Lazy Sweeping) 策略。
惰性清理的工作原理:
- 延迟清理:
- 当增量标记完成后,V8 引擎会判断当前的可用内存是否足以支持 JavaScript 代码的继续执行。如果可用内存充足,那么没有必要立即进行全面的内存清理。相反,V8 会选择将清理过程稍微延迟,让 JavaScript 代码优先执行。
- 按需清理:
- 惰性清理允许内存的清理过程按需进行,而不是一次性清理所有非活动对象的内存。V8 会逐步清理这些非活动对象的内存,直到所有需要回收的内存都被释放完毕。
优势
这种策略能够有效平衡内存管理与程序执行的效率,在确保内存资源得到充分利用的同时,减少对 JavaScript 代码执行的中断和影响。通过惰性清理,V8 引擎能够在不影响应用程序性能的前提下,逐步释放内存,优化用户体验。
并发回收
并发回收(Concurrent Collection) 是一种垃圾回收技术,旨在减少垃圾回收对应用程序主线程执行的干扰,进一步提升应用程序的性能和响应速度。与传统的 STW(Stop-The-World,全停顿)回收方式不同,并发回收允许垃圾回收器在主线程继续执行应用逻辑的同时,利用独立的线程或处理器核心进行垃圾回收任务。
并发回收的工作原理
- 分离回收线程:
- 在并发回收策略中,垃圾回收任务被分配到单独的线程或处理器核心上执行。这意味着主线程可以继续处理用户交互、动画渲染或其他任务,而垃圾回收则在后台悄无声息地进行。
- 标记并发:
- 在并发标记阶段,垃圾回收器通过一个独立的线程对内存中的对象进行标记。这种并行处理确保了标记过程不会对主线程产生显著影响,从而减少全停顿时间。
- 并发清理:
- 在标记完成后,并发回收器会继续在独立的线程上进行内存的清理和整理工作。因为这些操作都是在后台进行的,主线程几乎不会感知到这些操作,应用程序的执行不会受到明显影响。
- 处理线程间的同步:
- 为了确保数据的一致性,并发回收器需要与主线程进行同步,特别是在处理一些对象引用更新或其他关键操作时。V8 引擎采用了高效的同步机制,确保并发回收过程中数据的一致性和正确性。
并发回收的优势
- 减少全停顿时间:
- 并发回收的主要优势在于大幅减少了垃圾回收导致的全停顿时间。应用程序可以在几乎不被打断的情况下继续执行,从而提高了用户体验。
- 更高的吞吐量:
- 通过充分利用多核处理器的能力,并发回收能够提高系统的整体吞吐量,让垃圾回收和应用逻辑处理同时进行。
- 平滑的用户体验:
- 由于并发回收降低了垃圾回收对应用程序执行的影响,用户感知到的卡顿现象大大减少,特别是在需要持续高响应的应用场景中,例如实时互动和游戏应用。
应用场景
并发回收特别适合于那些对响应时间要求较高的应用场景,例如游戏、实时数据处理、以及高交互性的用户界面。它能够有效避免长时间的卡顿现象,确保应用程序在高负载下依然能够保持流畅的用户体验。
总结
并发回收通过在独立线程中执行垃圾回收任务,有效减少了垃圾回收对主线程的影响。这种策略不仅提升了应用程序的性能,还显著改善了用户体验,特别是在对实时性要求较高的应用场景中,表现尤为出色。
分代式垃圾回收机制总结
分代式垃圾回收机制通过将内存中的对象划分为 新生代 和 老生代,大幅提升了垃圾回收的效率
新生代:存放新创建的、小型的、生命周期较短的对象。由于这些对象的存活时间较短,因此新生代采用了高频率、快速清理的回收策略,使用一小块内存进行频繁的垃圾回收,以快速回收内存。
老生代:存放大对象、生命周期较长或长期存活的对象。老生代中的对象由于已经存活较长时间,不太可能很快被回收,因此回收的频率较低,采用了更复杂和谨慎的回收机制。
V8 引擎通过分代式垃圾回收机制,结合针对新生代和老生代的不同回收算法,优化了内存管理的效率。这种划分使 V8 能够根据对象的生命周期特性,采用最适合的回收策略,从而提高了垃圾回收的效率,减少了内存碎片,并最大限度地降低了垃圾回收对程序性能的影响。
特殊的小知识点
- 浏览器垃圾回收与 JavaScript 脚本执行:
- 当浏览器进行垃圾回收时,JavaScript 脚本的执行会被暂时中断,直到垃圾回收完成后才会继续执行。因此,在高频繁的垃圾回收场景中,优化策略的使用显得尤为重要,以避免不必要的脚本执行延迟。
- WeakMap 和 WeakSet:
WeakMap
和WeakSet
是特殊的数据结构,它们的键(对于WeakMap
)或值(对于WeakSet
)不会被垃圾回收机制考虑。这意味着如果没有其他引用指向这些键或值,它们可以被垃圾回收,从而避免内存泄漏。
- 闭包与内存泄漏:
- 闭包中的变量是我们需要用到的,因此不会导致内存泄漏。尽管闭包可能会在函数执行完毕后保留对外部变量的引用,但只要这些引用仍然有用,垃圾回收机制就不会回收它们。
容易导致内存泄露的场景
- 内存泄露的概念:
- 内存泄露是指那些「用不到」(无法访问)的变量依然占据着内存空间,无法被垃圾回收机制回收,导致内存无法再利用。
- 常见场景:
- 意外的全局变量:
- 在非严格模式下,如果在函数中未定义的变量会自动创建为全局变量,这会意外地污染全局作用域并导致内存泄露。
- 解决方案:使用严格模式 (
'use strict'
) 来避免未定义变量变为全局变量。
- 被遗忘的计时器或回调函数:
- 如果存在被遗忘的计时器或回调函数,它们的引用不会被垃圾回收机制自动清除,从而导致内存泄露。
- 解决方案:现代浏览器可以检测到此类问题,并在必要时清除这些引用,开发者也应确保及时清理不再需要的计时器和回调。
- 脱离 DOM 的引用:
- 将 DOM 元素保存到对象或数组中,而不清除它们的引用,可能导致内存泄露,因为这些引用会持续存在,即使这些 DOM 元素已经从页面上移除。
- 解决方案:确保在不再需要这些 DOM 元素时,及时清除对它们的引用。
- 意外的全局变量:
理解与解决内存泄漏的优化策略
1. 怎么理解内存泄漏?
内存泄漏是指程序在运行过程中,分配的内存无法被系统回收利用的情况。具体来说,当程序不再需要某些数据或对象时,这些对象仍然占据着内存空间,导致内存得不到释放。随着时间的推移,内存泄漏会导致系统内存逐渐减少,最终可能导致程序崩溃或性能严重下降。
2. 怎么解决内存泄漏?代码层面如何优化?
减少全局变量的使用
全局变量不会被垃圾回收器轻易回收,建议尽量减少全局变量的使用,将变量声明在局部作用域内。
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15不推荐的写法:使用全局变量
var i, str = ""
function packageDomGlobal() {
for(i = 0; i < 1000; i++) {
str += i
}
}
// 推荐的写法:使用局部变量
function packageDomLocal() {
let str = ''
for(let i = 0; i < 1000; i++) {
str += i
}
}
减少不必要的变量声明
在循环或重复执行的代码中,尽量减少不必要的变量声明,特别是那些值不变的变量,应该提前抽离出来以减少内存消耗。
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 不推荐的写法:每次循环都计算数组长度
var printArray = () => {
let arr = ['apple', 10, 'banana'];
for(let i = 0; i < arr.length; i++){
console.log(arr[i]);
}
}
// 推荐的写法:提前计算数组长度
var printArray = () => {
let arr = ['apple', 10, 'banana'];
const length = arr.length;
for(let i = 0; i < length; i++){
console.log(arr[i]);
}
}
清除不再使用的引用
当不再需要某些对象或数据时,确保清除对它们的引用,特别是在 DOM 操作和事件绑定中,要及时解除绑定并移除不再使用的 DOM 元素。
示例:
1
2
3
4
5function removeElement() {
const element = document.getElementById('myElement');
element.parentNode.removeChild(element);
element = null; // 清除引用
}
使用性能分析工具
利用浏览器的 Performance 和 Memory 工具,可以帮助分析和检测内存泄漏问题。通过这些工具,可以查看页面内存使用情况,发现潜在的内存泄漏点,并优化代码以提高性能。
- 示例:
- 使用 Chrome 开发者工具的 Memory 面板进行内存快照分析。
- 使用 Performance 面板查看 JavaScript 执行时间和内存消耗。
总结
内存泄漏是一个需要关注的问题,通过优化代码、减少不必要的全局变量和重复计算,及时清理不再使用的引用,以及使用性能分析工具,可以有效预防和解决内存泄漏,提升应用的稳定性和性能。