
一、前言
在相當(dāng)長(zhǎng)一段時(shí)間里,JS運(yùn)行時(shí)的內(nèi)存問題都不被前端開發(fā)人員所關(guān)注。
一方面,日常開發(fā)中基本不會(huì)遇上需要對(duì)內(nèi)存精準(zhǔn)控制的場(chǎng)景,另一方面,寫JS不需要像寫 C/C++ 那樣在開發(fā)過程中隨時(shí)關(guān)注內(nèi)存的分配和釋放問題。
隨著生態(tài)的逐漸完善,JS的執(zhí)行環(huán)境也不再局限于瀏覽器中。目前,JS主要的執(zhí)行場(chǎng)景包括服務(wù)端(NodeJS、Deno)、桌面端(Electron)、瀏覽器(Chrome、Microsoft Edge)。
其中,JS執(zhí)行引擎 V8 因其優(yōu)異的性能表現(xiàn),已經(jīng)成為主流。因此,本文對(duì)JS內(nèi)存管理模型的研究也將基于V8展開。
二、內(nèi)存結(jié)構(gòu)

上圖展示了V8引擎的內(nèi)存結(jié)構(gòu),整體上分成兩部分:
2.1 堆內(nèi)存
這里是存儲(chǔ)對(duì)象或動(dòng)態(tài)數(shù)據(jù)的地方,也是占比最大的內(nèi)存區(qū)域。堆內(nèi)可以細(xì)分以下區(qū)域:
-
新生代(Young generation)新生代是新對(duì)象存在的地方,這些對(duì)象中的大多數(shù)都是短暫存在的。
這部分空間很?。J(rèn)情況下16~32M),并且拆分成了兩個(gè)空間。
空間使用 Minor GC (Scavenger) 進(jìn)行垃圾回收。 -
老生代(Old generation)新生代中經(jīng)歷了兩個(gè)Minor GC 周期的對(duì)象會(huì)被轉(zhuǎn)移到老生代中存放。這里占據(jù)著大量的內(nèi)存空間(默認(rèn)情況下700~1400M)
空間使用 Major GC(Mark-Sweep & Mark-Compact) 進(jìn)行垃圾回收。 -
大對(duì)象區(qū)(LARGE OBJECT SPACE)超過一定大小的對(duì)象會(huì)直接在大對(duì)象區(qū)中被創(chuàng)建,并在不被使用時(shí)將其直接回收。 -
代碼區(qū)(CODE SPACE)這是 即時(shí)編譯器(JIT) 存儲(chǔ)編譯代碼塊的地方。 -
其他區(qū)(CELL, PROPERTY CELL,MAP SPACE)這些空間存放大小相同的對(duì)象,并且對(duì)它們指向的對(duì)象類型有一些限制。
比如MAP SPACE里存放的是hidden class信息,這能讓V8快速定位到對(duì)象值所在的內(nèi)存區(qū)。
2.2 棧內(nèi)存
棧是用來存儲(chǔ)靜態(tài)數(shù)據(jù)的地方,內(nèi)容主要包括:
-
基本類型(Number, Boolean, String, Null, Undefined, Symbol, BigInt)對(duì)于基本類型,系統(tǒng)會(huì)為新的變量在棧內(nèi)存中分配一個(gè)新值。 -
引用類型系統(tǒng)會(huì)為新的變量在棧內(nèi)存中分配一個(gè)值,這個(gè)值是一個(gè)對(duì)象的引用。 -
調(diào)用棧解釋器創(chuàng)建了調(diào)用棧來記錄函數(shù)的調(diào)用過程。
每調(diào)用一個(gè)函數(shù),解釋器就把該函數(shù)添加進(jìn)調(diào)用棧,解釋器會(huì)為被添加進(jìn)來的函數(shù)創(chuàng)建一個(gè)棧幀(用來保存函數(shù)的局部變量以及執(zhí)行語(yǔ)句)并立即執(zhí)行。
如果正在執(zhí)行的函數(shù)還調(diào)用了其他函數(shù),新函數(shù)會(huì)繼續(xù)被添加進(jìn)入調(diào)用棧。
三、內(nèi)存回收
棧內(nèi)存 其實(shí)是由操作系統(tǒng)進(jìn)行自動(dòng)管理的,本文不做討論。
堆內(nèi)存 由V8進(jìn)行管理,它占據(jù)最大的內(nèi)存空間,并且隨著程序運(yùn)行時(shí)間的增加可能會(huì)持續(xù)增長(zhǎng),最終耗盡內(nèi)存。它也會(huì)變得碎片化,影響程序運(yùn)行的速度。這時(shí)內(nèi)存回收的重要性就體現(xiàn)出來了。
要進(jìn)行內(nèi)存回收,需要先明確一個(gè)問題:什么樣的數(shù)據(jù)可以被回收。
V8通過回收 不可達(dá)對(duì)象 來釋放堆內(nèi)存,整個(gè)回收過程總體可以分為 標(biāo)記 和 回收 兩個(gè)階段,涉及到的原理是 三色標(biāo)記 和 分代回收 。
3.1 新生代
從 2.1堆內(nèi)存 小節(jié)中我們知道,新生代被拆分成兩個(gè)小空間,使用 Minor GC (Scavenger) 進(jìn)行垃圾回收。Minor GC (Scavenger) 我們可以簡(jiǎn)稱為次要GC。
兩個(gè)拆分出來的空間分別稱之為 to-space 和 from-space。新加入的對(duì)象都會(huì)存放到from-space,當(dāng)from-space被填滿時(shí),會(huì)觸發(fā)次要GC。
GC過程:
- 標(biāo)記 從堆棧指針開始遞歸遍歷 from-space 中的對(duì)象圖查找 活躍對(duì)象。
-
復(fù)制 將這些對(duì)象復(fù)制到 to-space 中(包括被這些對(duì)象引用的所有對(duì)象)。
重復(fù)此操作,直到掃描 from-space 中的所有對(duì)象。另外,to-space 會(huì)分配連續(xù)的內(nèi)存塊,以減少碎片。 - 清除 清空 from-space,因?yàn)榇藭r(shí)剩余的對(duì)象都是可回收的。
- 交換 將 to-space 和 from-space 互換,即 to-space 變成 from-space。

回收的最后一步是更新引用已移動(dòng)的原始對(duì)象的指針。每個(gè)被復(fù)制的對(duì)象都會(huì)留下一個(gè)新地址,用于更新原始指針以指向新位置。
此時(shí)還存在一個(gè)問題:隨著活躍對(duì)象的累積,from-space 很快會(huì)被填滿。
這時(shí)就輪到老生代出場(chǎng)了,在新生代中經(jīng)歷兩次GC并存活下來的對(duì)象,會(huì)被轉(zhuǎn)移到老生代,這個(gè)過程被稱為晉升。如下圖:

至此,新生代一次完整的垃圾回收就完成了。
3.2 老生代
在老生代中,垃圾回收為主要GC(Major GC),包含了 標(biāo)記清除(Mark-Sweep) 和 標(biāo)記整理(Mark-Compact)。
GC過程:
- 標(biāo)記 垃圾收集器識(shí)別哪些對(duì)象正在使用,哪些對(duì)象未使用。正在使用或可從GC根域遞歸訪問的對(duì)象被標(biāo)記為活躍對(duì)象。
- 清除 清除未被標(biāo)記為活躍對(duì)象的數(shù)據(jù)。
- 整理 如果碎片較多,會(huì)將存活的對(duì)象移動(dòng)到一起,減少碎片提高內(nèi)存使用率。

3.3 三色標(biāo)記
之前提到,垃圾回收時(shí)會(huì)先標(biāo)記 活躍對(duì)象 來區(qū)分對(duì)象是否應(yīng)該被回收,這里就涉及到了三色標(biāo)記算法。
標(biāo)記位有三種顏色:
- 白色 對(duì)象未被標(biāo)記。
- 灰色 對(duì)象已經(jīng)被標(biāo)記,但對(duì)象內(nèi)屬性還未遍歷完成。
- 黑色 對(duì)象已經(jīng)被標(biāo)記,且對(duì)象內(nèi)的屬性也已完成遍歷(活躍對(duì)象)。
標(biāo)記過程:
-
開始標(biāo)記
初始所有對(duì)象都是白色,當(dāng)收集器發(fā)現(xiàn)白色對(duì)象并將其推送到標(biāo)記工作列表時(shí),會(huì)將其標(biāo)記成灰色。
01.jpg
-
標(biāo)記完成對(duì)象
當(dāng)收集器訪問目標(biāo)對(duì)象的所有字段后,會(huì)將對(duì)象的顏色由灰色變?yōu)楹谏?br>02.jpg
-
標(biāo)記結(jié)束
當(dāng)沒有灰色對(duì)象時(shí),代表標(biāo)記結(jié)束。此時(shí)剩余的白色對(duì)象表示無法訪問,可以被回收。
03.jpg
經(jīng)歷過以上三步,一次完整的標(biāo)記過程就完成了。
3.4 回收優(yōu)化
現(xiàn)在我們知道,一次垃圾回收總需要經(jīng)歷 標(biāo)記,回收,整理 等階段。
事實(shí)上,整個(gè)垃圾回收的過程是非常耗時(shí)的,比如光是標(biāo)記整個(gè)堆內(nèi)存的活躍對(duì)象可能就要花費(fèi)數(shù)百毫秒,并且期間會(huì)阻塞程序的正常的執(zhí)行。
[圖片上傳失敗...(image-ad8178-1632313567745)]
對(duì)此,V8也在持續(xù)優(yōu)化,目前主要的優(yōu)化方式有:
增量標(biāo)記
通過將標(biāo)記任務(wù)拆分成一系列小任務(wù),確保每次標(biāo)記任務(wù)的持續(xù)時(shí)間在5~10毫秒。
當(dāng)堆的占用空間達(dá)到某個(gè)閾值大小時(shí),開始激活標(biāo)記任務(wù),此后每分配一定量的內(nèi)存,就會(huì)執(zhí)行增量標(biāo)記。
增量標(biāo)記與常規(guī)標(biāo)記一樣,本質(zhì)上都是深度優(yōu)先搜索,同樣使用的是三色標(biāo)記算法。-
并行 & 并發(fā)
-
并行V8會(huì)創(chuàng)建輔助線程,與主線程同時(shí)處理GC任務(wù)。這樣GC時(shí)間就約等于總時(shí)間除以協(xié)同的線程數(shù)了。 -
并發(fā)這里的并發(fā)是指主線程不再處理GC任務(wù),而是由輔助線程來執(zhí)行。這樣的好處是垃圾回收不再阻塞正常任務(wù)的執(zhí)行。
-
惰性清除
當(dāng)所有對(duì)象都被標(biāo)記完成,此時(shí)已經(jīng)可以進(jìn)行垃圾清除的工作。但實(shí)際上這部分工作可以延遲執(zhí)行,尤其是當(dāng)內(nèi)存足夠的時(shí)候。
V8會(huì)在合適的時(shí)間點(diǎn)執(zhí)行清除工作,比如工作線程空閑,或者內(nèi)存不足的時(shí)候。
總結(jié)
內(nèi)存管理是一件非常復(fù)雜的事情,本文主要從 內(nèi)存結(jié)構(gòu) 和 內(nèi)存回收 兩個(gè)方面進(jìn)行了介紹,并且隱藏了其中的一些細(xì)節(jié)。
在V8的內(nèi)存管理模型中,其實(shí)能學(xué)習(xí)到一些通用的內(nèi)存管理思想和性能優(yōu)化方法。
比如,垃圾回收總是會(huì)圍繞標(biāo)記,清除,整理展開。
在標(biāo)記算法上,V8 和 JAVA,Golang,PHP 等編程語(yǔ)言一樣,使用了三色標(biāo)記。
在性能優(yōu)化上,多進(jìn)程/多線程, 分步, 異步, 延遲 等方式也總能發(fā)揮作用。
參考資料
- 《深入淺出nodeJS》
- https://v8.dev/
- https://deepu.tech/memory-management-in-programming
- https://en.wikipedia.org/wiki/Tracing_garbage_collection#Implementation_strategies
- http://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection
原文已在玩物得志技術(shù)公眾號(hào)上發(fā)布,鏈接:https://mp.weixin.qq.com/s/7mvP5jv5sBGNfnThap6JSQ


