[JS] 內存管理與V8垃圾回收機制
內存管理簡介
內存管理是控制和協調軟件應用程序訪問計算機內存的方式的過程。
當一個程序運行在某個操作系統上時,進程需要擁有對RAM的訪問權限,以實現:
- 載入程序需要執行的字節碼;
- 存儲正在執行的程序所使用的數據值和數據結構;
- 載入程序執行所需的任何運行時系統。
一個進程在啟動時,會向操作系統申請內存空間。一個進程的內存空間被分為多個區域,其中最主要的兩個區分別是棧區和堆區。
棧區
棧區的內存分配符合棧先進后出這一特性,并且棧區的大小通常是固定的。
- 與堆區不同,棧區的變量查詢比較簡單,且通常只在棧頂進行數據的存儲和讀取,只需要維護一個棧指針即可,讀寫操作非常快;
- 存儲在棧中的數據必須在編譯時確定其大小,并且在運行時不會動態變化;
- 在進程中每調用一個函數,就會推入一個棧幀(stack frame)。每一個棧幀都記錄著函數執行所需要的數據。例如,當一個函數聲明了一個新變量時,變量會被添加到棧頂的棧幀中,當函數執行完畢返回時,棧幀會被清除,內部所有變量也都會被清除;
- 多線程進程的每一個線程擁有各自的調用棧;
- 棧區的內存管理由操作系統負責;
- 常見的存儲于棧區的數據有:局部變量、指針、函數幀;
- 棧區常見的異常是“棧溢出異常”(stack overflow error),這是因為棧區相比于堆區要小很多。
堆區
堆區用于動態地分配內存,程序需要通過指針在堆區中查找數據。
- 堆區與棧區相比可以存儲更多數據,但是查詢數據比較慢;
- 堆區用于存儲動態大小的數據;
- 多線程進程的多個線程共享一個堆區;
- 常見的存儲于堆區的數據有:全局變量,對象、字符串等引用類型;
為什么需要關注內存管理
內存的容量有限,如果程序不加節制地使用內存而不釋放,最終會導致內存耗盡,可能導致程序或操作系統崩潰。因此,編程語言通常提供自動內存管理的機制,以避免這種情況發生。
在討論內存管理時,我們通常指的是如何管理堆內存。
-
這是因為棧內存的管理由操作系統自動完成,通常只要避免遞歸調用導致棧溢出,就不會出錯;
-
而堆內存需要程序員手動管理分配與釋放,雖然自由度更好,但也帶來了更大的風險與復雜度。
內存管理方法
-
手動內存管理
開發者需要自行分配和釋放對象的內存。例如,C 和 C++ 提供了
malloc、realloc、calloc和free函數來管理內存,開發者必須在程序中分配和釋放堆內存,并有效地使用指針來管理內存。 -
垃圾回收(GC)
垃圾回收是現代語言中最常見的內存管理方式之一,通常在某些時間間隔運行,因此可能會產生稱為“暫停時間”的輕微開銷。JVM(Java/Scala/Groovy/Kotlin)、JavaScript、C#、Golang、OCaml 和 Ruby 默認使用垃圾回收進行內存管理。
-
標記 - 清除算法
這通常是一個兩階段的算法,首先標記仍然被引用的對象為“存活”,然后在下一階段釋放未存活對象的內存。
-
引用計數算法
每個對象都會有一個引用計數,當對它的引用發生變化時,該計數會增加或減少,當計數變為零時,就會進行垃圾回收。由于無法處理循環引用,這種方法很少被使用。
-
V8 引擎中的內存管理
V8 引擎被 NodeJS、Deno、Electron 等運行時以及 Chrome、Chromium、Brave、Opera 和 Microsoft Edge 等瀏覽器使用。
由于 JavaScript 是一種解釋性語言,它需要一個引擎來解釋和執行代碼。V8 引擎負責解釋 JavaScript 并將其編譯為原生機器碼。
V8 是用 C++ 編寫的,可以嵌入到任何 C++ 應用程序中。
V8內存結構
JavaScript 是單線程的,V8 引擎為每個 JavaScript 上下文生成一個進程;如果使用了工作線程(service workers),則 V8 引擎會為每個工作線程生成一個新的進程。

上圖的內容是 V8 引擎運行時在物理內存中常駐的部分,即常駐集(Resident Set),主要包含了棧區和堆區,其中堆區又細分為多個區域。
堆區
V8 將對象或者動態數據存儲于堆區。
垃圾回收(Garbage Collection)作用于這里的新生區(New space)和老生區(Old space)。
堆區被細分為以下幾個區域:
- 新生代區(New Space):存放新對象的地方,這些對象大多數壽命較短。這個空間較小,并且包含兩個半空間(Semi-space)。這部分空間由“Scavenger(小型垃圾回收,Minor GC)”管理。
- 老生代區(Old Space):存放那些在新生代中經過了兩次小型垃圾回收后仍然存活的對象。這部分空間由“主要垃圾回收(Major GC,標記-清除和標記-壓縮)”管理。
- 指針區(Old Pointer Space):存放那些含有指向其他對象的指針的存活對象。
- 數據區(Old Data Space):存放僅包含數據而不包含指向其他對象的指針的對象。字符串、封裝的數字以及未封裝雙精度數組在經過兩次小型垃圾回收后,會被移動到此處。
- 大對象區(Large Object Space):存放比其他空間大小限制更大的對象。每個對象都擁有自己的
mmap內存區域。大對象不會被垃圾回收器移動。 - 代碼區(Code Space):即時編譯器(Just In Time, JIT)存儲已編譯代碼塊的地方。此空間是唯一具有可執行內存的空間(雖然代碼也可能被分配到大對象空間,并且那些代碼也是可執行的)。
- Cell Space:用于存儲固定大小的
Cell對象,這些對象通常保存與 JavaScript 運行時相關的簡單數據或元數據,如對象的內部元數據、優化狀態、屬性訪問信息等。 - Property Cell Space:專門存儲與 JavaScript 對象屬性相關的
PropertyCell對象,優化屬性訪問性能。 - Map Space:存儲 JavaScript 對象的內部結構
Map對象,用于描述對象的布局和屬性,支持對象的高效訪問和繼承機制。
每個區都由若干個頁組成,頁是操作系統分配的一塊連續內存。除了大對象區,其它區的頁大小都是 1MB。
棧區
每一個 V8 進程都有一個棧內存區,存儲:函數調用棧幀、基本數據、指向對象的指針。
V8內存使用示例
示例代碼:
class Employee {
constructor(name, salary, sales) {
this.name = name;
this.salary = salary;
this.sales = sales;
}
}
const BONUS_PERCENTAGE = 10;
function getBonusPercentage(salary) {
const percentage = (salary * BONUS_PERCENTAGE) / 100;
return percentage;
}
function findEmployeeBonus(salary, noOfSales) {
const bonusPercentage = getBonusPercentage(salary);
const bonus = bonusPercentage * noOfSales;
return bonus;
}
let john = new Employee("John", 5000, 5);
john.bonus = findEmployeeBonus(john.salary, john.sales);
console.log(john.bonus);
堆區與棧區的變化:

從圖中可以觀察到:
- 全局作用域保存在棧上的“全局幀”中。
- 每次函數調用都會作為幀添加到棧內存中。
- 所有局部變量,包括參數和返回值,都保存在棧上的函數幀中。
- 所有原始類型直接存儲在棧上。
- 所有對象類型(如
Employee和Function)在堆上創建,并通過棧上的指針進行引用。在 JavaScript 中,函數本質上也是對象,這也適用于全局作用域。 - 從當前函數調用的函數會被推入棧的頂部。
- 當函數返回時,其對應的幀會從棧中移除。
- 一旦主進程完成,堆上的對象將不再有棧中的指針引用,并成為孤立對象。
V8 垃圾回收
V8 通過垃圾回收來自動管理堆內存,它通過釋放孤立對象(Orphan Objects)所占用的內存來騰出空間。孤立對象是指哪些不再被棧直接或間接引用的對象。
V8 的垃圾回收是基于分代結構的。這里的分代就是指上文提到的新生區和老生區。
為什么要分代?
在垃圾回收中,有一個重要的概念叫The Generational Hypothesis(不知道中文社區是否有相關的翻譯,大致譯為“代際假說”)。這個假說指出,大多數對象的生命周期很短,它們在短時間內失活。這一假說適用于大多數動態語言。
V8的分代結構就是基于這種假說設計的。
V8的垃圾回收器是一種壓縮/移動式的垃圾回收器,這意味著它會在垃圾回收時復制那些存活下來的對象。
在垃圾回收時復制對象是開銷很大的操作。但根據上面的假說,我們知道只有非常小比例的對象實際上能在垃圾回收中存活下來。
回收過程僅移動那些存活的對象,其他所有的分配都自動成為“隱式”垃圾。
這意味著我們只需支付與存活對象數量成比例的復制成本,而不是與所有分配的對象數量成比例。
V8 有兩個垃圾回收器:
- Major GC(Mark-Compact):在整個堆區回收垃圾;
- Minor GC(Scavenger):在新生區回收。
Minor GC(Scavenger)
對象會先被分配在新生代空間中,該空間相對較小。
minor GC 的過程:
新生代空間被分為兩個大小相等的半空間(semi-space):to-space 和 from-space。大多數分配是在 from-space 中進行的(除了某些類型的對象,如總是分配在老生代空間的可執行代碼)。當 from-space 被填滿時,就會觸發 minor GC。
案例分析1:
from-space 已存在4個對象 A、B、C、D,現在要給E分配空間,from-space 空間不夠,觸發 minor GC。
觸發minor GC,假設A、D仍存活,而B、C已失活,那么A、D會被復制到 to-space。
B、C已失活,會被清除,將 to-space 和 from-space 反轉,在 from-space 中為 E 分配空間。
至此,完成依次minor GC。
在Minor GC中存活兩次的對象,將不再復制到 to-space,而是轉移到老生區。
示例圖:
Major GC(Full Mark-Compact)

Major GC主要分為三個步驟:標記、清除、壓縮。
-
標記:識別出哪些對象仍然在運行時中存活,并需要保留。
垃圾回收器從根集合(root set)開始,這些根集合包含了已知的對象指針,如執行棧和全局對象。然后,回收器遍歷每個指針,查找并標記所有可達的對象。這是一個遞歸過程,垃圾回收器會繼續跟蹤這些對象中的每個指針,直到所有可達的對象都被標記為止。
-
清除:清理內存中不再使用的對象,并將空閑空間整理成可供將來使用的內存塊。
將失活對象留下的內存空隙添加到一個稱為空閑列表(free-list)的數據結構中的過程。當標記完成后,垃圾回收器找到由不可達對象留下的連續空隙,并將它們添加到適當的空閑列表中。空閑列表按內存塊的大小進行分離,以便快速查找。將來當我們需要分配內存時,只需查看空閑列表并找到一個合適大小的內存塊。
-
壓縮:通過壓縮內存來減少碎片化,從而優化內存利用效率。
垃圾回收器根據碎片化程度,選擇一些內存頁進行壓縮。它將存活的對象復制到其他未被壓縮的頁中,使用空閑列表來管理這些頁。這一過程中,分散的小空隙被整合,從而有效減少碎片。
性能優化
性能問題
上述的垃圾回收器采用的是一種“全停頓式”的做法。在執行垃圾回收操作時,會暫停應用程序的主線程,直到垃圾回收完成。這種方法的主要問題在于,它會在應用程序的執行過程中造成不可預見的延遲,從而影響用戶體驗。
- 頁面卡頓:由于主線程被暫停,頁面無法響應用戶的輸入,從而導致卡頓現象。
- 渲染不流暢:當頁面渲染過程中遇到垃圾回收操作時,頁面的渲染速度會明顯減慢,導致動畫或交互的流暢性下降。
- 延遲增大:執行垃圾回收期間,所有其他任務都會被推遲,增加了整體的響應時間。
優化技術
為了減少全停頓式垃圾回收對性能的影響,V8引擎開發了Orinoco項目,采用了先進的并行、增量和并發技術,以優化垃圾回收性能,最大限度地減少對主線程的影響。
-
并行(Parallel)
并行垃圾回收技術通過讓主線程和多個輔助線程同時進行垃圾回收工作,減少每個線程的工作負擔。雖然仍然屬于全停頓式,但多個線程分擔任務,使得總的暫停時間被顯著縮短。這種方法實現較為簡單,只需確保各線程間的同步即可。
-
增量(Incremental)
增量垃圾回收將垃圾回收任務分割成多個小片段,間歇性地執行。這種方法允許主線程在兩次垃圾回收任務之間繼續執行JavaScript代碼,減少了對主線程的長期停頓影響。
-
并發(Concurrent)
并發垃圾回收允許主線程在執行JavaScript代碼的同時,輔助線程在后臺完成垃圾回收任務。通過這種方法,主線程完全不需要暫停,從而避免了用戶體驗的下降。但是實現起來很復雜,需要處理多線程環境下的讀/寫競爭問題。(主線程 JS 在讀堆區的數據,helper 線程的GC在修改堆區的數據)
實際應用
-
新生代垃圾回收
V8在新生代垃圾回收過程中采用并行清除(Scavenging)技術,將工作分配給多個輔助線程。每個線程會接收一部分指針,沿著這些指針查找并將所有存活對象迅速移動到目標空間(To-Space)。在對象遷移過程中,清除任務通過原子讀取、寫入和比較并交換(compare-and-swap)操作來進行同步,因為其他清除任務可能通過不同路徑找到了相同的對象,并試圖進行遷移。
成功遷移對象的線程隨后會返回并更新指針,同時留下一個轉發指針,以便其他線程在訪問到該對象時能更新其對應的指針。
為了更快地分配存活對象,清除任務會使用線程本地分配緩沖區(Thread-Local Allocation Buffers, TLABs)。
-
老生代垃圾回收
V8的老年代垃圾回收從并發標記(Concurrent Marking)開始。當堆內存接近動態計算的閾值時,V8會啟動并發標記任務。每個輔助線程被分配了一些指針來追蹤,并標記它們找到的每一個對象。這些標記操作完全在后臺進行,而主線程仍然繼續執行JavaScript代碼。標記期間,使用寫屏障(Write Barriers)來跟蹤JavaScript在標記期間創建的新引用。
當并發標記完成或達到動態分配的限制時,主線程會執行一個快速標記終結(Marking Finalization)步驟,這個階段標志著老年代垃圾回收的暫停時間開始。在這期間,主線程會再次掃描根對象,確保所有存活對象都被正確標記,然后和一些輔助線程一起,啟動并行壓縮(Parallel Compaction)和指針更新(Pointer Updating)。老年代中的所有頁(Pages)并不都適合壓縮——不適合壓縮的頁會通過之前提到的空閑列表(Free Lists)進行清掃。在主線程暫停期間,V8會啟動并發清掃(Concurrent Sweeping)任務。這些任務會與并行壓縮任務和主線程自身同時進行,即使JavaScript代碼在主線程上繼續運行,清掃任務也可以同時執行。
-
空閑時間垃圾回收(Idle-time GC)
V8提供了一種機制,允許嵌入者觸發垃圾回收,即使JavaScript程序本身無法觸發。在空閑時間,GC可以發布“空閑任務”,這些任務會在將來被觸發。像Chrome這樣的嵌入者可能會有一些關于空閑時間的概念。例如,在Chrome中,每秒60幀動畫的情況下,瀏覽器大約有16.6毫秒來渲染每一幀。如果動畫工作提前完成,Chrome可以選擇在下一個幀之前利用這些空閑時間運行一些GC創建的空閑任務。
要點總結
V8的垃圾回收器經歷了多年的改進,逐步引入了并行、增量和并發技術,這些改進使得大量工作得以轉移至后臺任務,顯著減少了暫停時間、延遲和頁面加載時間,優化了動畫、滾動和用戶交互的流暢性。
盡管對于大多數開發者來說,在編寫JavaScript時無需關注垃圾回收的細節,但理解其工作原理有助于優化內存管理和編寫更高效的代碼。例如,由于V8的堆結構為分代設計,短生命周期的對象對于垃圾回收器來說成本較低,因為只有存活下來的對象才會產生開銷。這種編程模式不僅適用于JavaScript,還適用于許多使用垃圾回收機制的編程語言。
引用文章
[2] ?? Demystifying memory management in modern programming languages | Technorage (deepu.tech)





浙公網安備 33010602011771號