💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
[TOC] # 内存生命周期 不管什么程序语言,内存生命周期基本是一致的: * 分配你所需要的内存 * 使用分配到的内存(读、写) * 不需要时将其释放\归还 所有语言第二部分都是明确的。第一和第三部分在底层语言中是明确的,但在像JavaScript这些高级语言中,大部分都是隐含的。 <br> <br> # JavaScript 的内存分配 ## 值的初始化 为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。 ~~~ var n = 123; // 给数值变量分配内存 var s = "azerty"; // 给字符串分配内存 var o = { a: 1, b: null }; // 给对象及其包含的值分配内存 // 给数组及其包含的值分配内存(就像对象一样) var a = [1, null, "abra"]; function f(a){ return a + 2; } // 给函数(可调用的对象)分配内存 // 函数表达式也能分配一个对象 someElement.addEventListener('click', function(){ someElement.style.backgroundColor = 'blue'; }, false); ~~~ <br> ## 通过函数调用分配内存 有些函数调用结果是分配对象内存: ~~~ var d = new Date(); // 分配一个 Date 对象 var e = document.createElement('div'); // 分配一个 DOM 元素 有些方法分配新变量或者新对象: var s = "azerty"; var s2 = s.substr(0, 3); // s2 是一个新的字符串 // 因为字符串是不变量, // JavaScript 可能决定不分配内存, // 只是存储了 [0-3] 的范围。 var a = ["ouais ouais", "nan nan"]; var a2 = ["generation", "nan nan"]; var a3 = a.concat(a2); // 新数组有四个元素,是 a 连接 a2 的结果 ~~~ <br> ## 使用值 使用值的过程实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。 <br> ## 当内存不再需要使用时释放 大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“所分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。 <br> V8 实现了准确式 GC,GC 算法采用了分代式垃圾回收机制。因此,V8 将内存(堆)分为新生代和老生代两部分。 <br> <br> # 垃圾回收机制 ## 标记清除算法 JavaScript 中最常用的垃圾收集方式是标记清除(mark-and-sweep)。 <br> 这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。 <br> 该算法假定设置一个叫做根(root)的对象(在Javascript里,根是全局对象)。垃圾回收器将**定期从根开始**(在JS中就是全局对象)扫描内存中的对象。凡是能从根部到达的对象,都是还需要使用的。那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。 <br> 此算法可以分为两个阶段,一个是标记阶段(mark),一个是清除阶段(sweep)。 <br> ### 标记阶段 垃圾回收器会从根对象开始遍历。每一个可以从根对象访问到的对象都会被添加一个标识,于是这个对象就被标识为可到达对象。 <br> ### 清除阶段 垃圾回收器会对堆内存从头到尾进行线性遍历,如果发现有对象没有被标识为可到达对象,那么就将此对象占用的内存回收,并且将原来标记为可到达对象的标识清除,以便进行下一次垃圾回收操作。 <br> ![](https://box.kancloud.cn/0b7cad3bb2d81ef81d840b07f0ab7fff_434x502.png) <br> 在标记阶段,从根对象1可以访问到B,从B又可以访问到E,那么B和E都是可到达对象,同样的道理,F、G、J和K都是可到达对象。 <br> 在回收阶段,所有未标记为可到达的对象都会被垃圾回收器回收。 <br> ### 何时开始垃圾回收 通常来说,在使用标记清除算法时,未引用对象并不会被立即回收。取而代之的做法是,垃圾对象将一直累计到内存耗尽为止。**当内存耗尽时,程序将会被挂起,垃圾回收开始执行。** <br> > 从2012年起,所有现代浏览器都使用了标记-清除垃圾回收算法。所有对JavaScript垃圾回收算法的改进都是基于标记-清除算法的改进,并没有改进标记-清除算法本身和它对“对象是否不再需要”的简化定义。 <br> ### 标记清除算法缺陷 * 那些无法从根对象查询到的对象都将被清除 * 垃圾收集后有可能会造成大**量的内存碎片**,像上面的图片所示,垃圾收集后内存中存在三个内存碎片,假设一个方格代表1个单位的内存,如果有一个对象需要占用3个内存单位的话,那么就会导致Mutator一直处于暂停状态,而Collector一直在尝试进行垃圾收集,直到Out of Memory。 <br> ## 引用计数算法 这是最初级的垃圾收集算法.现在已经没有浏览器会用这种算法. <br> 此算法把“对象是否不再需要”简化定义为“**对象有没有其他对象引用到它**”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。 <br> 引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数减1。当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为零的值所占用的内存。 <br> ### 引用计数缺陷 无法处理循环引用。如果两个对象被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收。 <br> <br> # Chrome V8 垃圾回收算法 Chrome 浏览器所使用的 V8 引擎就是采用的分代回收策略。这个和 Java 回收策略思想是一致的。目的是通过区分「临时」与「持久」对象;多回收「临时对象区」(新生代younggeneration),少回收「持久对象区」(老生代 tenured generation),减少每次需遍历的对象,从而减少每次GC的耗时。 <br> ## 内存限制 **在 Node 中** Javascript能使用的内存是有限制的. > 1. 64位系统下约为1.4GB。 > 2. 32位系统下约为0.7GB。 <br> 对应到分代内存中,默认情况下。 > 1. 32位系统新生代内存大小为16MB,老生代内存大小为700MB。 > 2. 64位系统下,新生代内存大小为32MB,老生代内存大小为1.4GB。 新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)或16MB(64位)。 <br> 这个限制在node启动的时候可以通过传递--max-old-space-size 和 --max-new-space-size来调整,如: <br> ~~~ node --max-old-space-size=1700 app.js //单位为MB node --max-new-space-size=1024 app.js //单位为kb ~~~ <br> **述参数在V8初始化时生效,一旦生效就不能再动态改变。** <br> ## 内存限制的原因 * 表层原因:V8最初为浏览器而设计,不太可能遇到用大量内存的场景。 * 深层原因:V8的垃圾回收机制的限制。 * 官方说法,以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起JS线程暂停执行的时间,在这样时间花销下,应用的性能和响应能力都会直线下降。 <br> ## V8的分代回收(Generation GC) V8垃圾回收策略主要基于**分代式垃圾回收机制**。现代的垃圾回收算法中按**对象的存活时间**将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存施以更高效的算法。 <br> 在V8中,主要将内存分为新生代和老生代,**新生代内存 存储的为存活时间较短的对象**,**老生代内存 存储的为存活时间较长或常驻内存的对象**,如下图: ![](https://box.kancloud.cn/409567af2d30a17bfa8586beeb02161b_725x158.png) <br> **V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间。** <br> ### V8新生代算法(Scavenge) 在分代基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收。在Scavenge的具体实现中,主要采用了Cheney算法。 <br> #### Cheney算法 Cheney算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。**处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。** <br> 当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而(From空间内的)非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换(**即以前的From空间释放后变为To;To空间在复制存活的对象后,变为From空间**)。简而言之,在垃圾回收过程中,就是通过将存活对象在两个semispace空间之间进行复制。 <br> #### 优点 Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它**在时间效率上有优异的表现。** **Scavenge是典型的牺牲空间换取时间的算法,** 所以无法大规模地应用到所有的垃圾回收中。但可以发现,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。 <br> #### 缺点 **只能使用堆内存中的一半**,这是由划分空间和复制机制所决定的。 <br> #### 晋升 实际使用的堆内存是新生代的两个semispace空间大小和老生代所用内存大小之和。当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。**对象从新生代中移动到老生代中的过程称为晋升。** <br> 在单纯的Scavenge过程中,From空间中的存活对象会被复制到To空间中去,然后对From空间和To空间进行角色对换(又称翻转)。但在分代式垃圾回收前提下,**From空间中的存活对象在复制到To空间之前需要进行检查。在一定条件下,需要将存活周期长的对象移动到老生代中,也就是完成对象晋升。** <br> **晋升条件:** 对象晋升的条件主要有两个,一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过25%限制。 <br> **设置25%这个限制值的原因:** 当这次Scavenge回收完成后,这个To空间将变成From空间,接下来的内存分配将在这个空间中进行。**如果占比过高,会影响后续的内存分配。** 对象晋升后,将会在老生代空间中作为存活周期较长的对象来对待,接受新的回收算法处理。 <br> ![](https://box.kancloud.cn/bfdc0bf253a0df570acf005155757e2f_535x413.png) <br> ![](https://box.kancloud.cn/b0ef6913192d1d1b6882467b641f90d0_513x415.png) <br> ### V8老生代算法(Mark-Sweep && Mark-Compact) 对于老生代中的对象,由于存活对象占较大比重,再采用Scavenge的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。为此,V8在老生代中主要采用Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。 <br> #### Mark-Sweep Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。与Scavenge相比,Mark-Sweep并不将内存空间划分为两半,所以**不存在浪费一半空间的行为**。 <br> 与Scavenge复制活着的对象不同,**Mark-Sweep在标记阶段遍历堆中所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。** 可以看出,**Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象。** 活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。 <br> **Mark-Sweep最大的问题** 在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现**需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。** <br> #### Mark-Compact 为了解决Mark-Sweep的内存碎片问题,Mark-Compact被提出来。Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的。它们的差别在于对象在标记为死亡后,**在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。** 下图为Mark-Compact完成标记并移动存活对象后的示意图,白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞。 <br> ![](https://box.kancloud.cn/0af576dcdcf5089e077e0aa931b9f88c_763x356.png) <br> **完成移动后,就可以直接清除最右边的存活对象后面的内存区域完成回收。** <br> **Mark-Sweep、Mark-Compact、Scavenge三种主要垃圾回收算法的简单对比** | 回收算法 | Mark-Sweep | Mark-Compact | Scavenge | | --- | --- | --- | --- | | 速度 | 中等 | 最慢 | 最快 | | 空间开销 | 少(有碎片) | 少(无碎片) | 双倍空间(无碎片) | | 是否移动对象 | 否 | 是 | 是 | 从表格上看,Mark-Sweep和Mark-Compact之间,由于Mark-Compact需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8主要使用Mark-Sweep,**在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact。** <br> ## 增量式标记回收(Incremental Marking) * 为了避免出现js应用逻辑与垃圾回收器看到的不一致的情况,**垃圾回收的3种基本算法都需要将应用逻辑暂停下来**,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。在V8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但V8的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(full垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善(PS: 若V8的堆内存为1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。)。 * 为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是**拆分为许多小“步进”,每做完一“步进”就让js应用逻辑执行一小会,垃圾回收与应用逻辑交替执行直到标记阶段完成。** * V8在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的1/6左右。 * V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。 <br> <br> # 参考资料 [JavaScript内存分配及垃圾回收机制](https://blog.csdn.net/suwu150/article/details/86508878) [前端面试查漏补缺--(二) 垃圾回收机制](https://juejin.im/post/5c6bba32f265da2db07382cf)