Google V8 引擎淺析 - 內存管理
繼續探索 V8 引擎技術的主旨,接着來我們再看下 V8 引擎底層,對內存管理方面還有哪些值得學習的地方。如果大家對上兩次分享感興趣的話,可以移步到:
衆所周知,Javascript 語言是沒有能力管理內存和自動垃圾回收的,最直觀的判斷就是並沒有這些方面的 api 及主動處理機制,這些能力完全依賴了底層引擎的處理,想要弄清楚 V8 引擎的性能爲何出衆,更加需要了解其重要的內存管理及垃圾回收的策略是如何運行的。
內存管理
內存作爲計算機的最重要部分之一,它是與 CPU 進行溝通的橋樑,程序運行時 CPU 需要調用的指令和數據只能通過內存獲取。計算機中所有程序的運行都是在內存中進行的,因此內存的性能對計算機的影響非常大。內存一般是半導體存儲單元,包括了 ROM + RAM + Cache,其中最重要的就是 RAM 部分。
內存的生命週期一般包括:分配內存大小 > 使用內存(讀 or 寫)> 不需要時進行釋放。
運行 js 代碼時,內存空間使用包括了堆內存和棧內存。
棧
小而連續,數組結構,由系統自動分配相對固定大小的內存空間,並由系統自動釋放,遵循 LIFO 後進先出的規則,主要職責是 javascript 中存儲局部變量及管理函數調用。
基礎數據類型的變量都是直接存儲在棧中,複雜類型數據會將對象的引用(實際存儲的指針地址)存儲在棧中,數據本身存儲在堆中。
每個函數的調用時,解釋器都會現在棧中創建一個調用棧(call stack)來存儲函數的調用流程順序。然後把該函數添加進調用棧,解釋器會爲被添加進的函數再創建一個棧幀(Stack Frame)並立即執行。如果正在執行的函數還調用了其它函數,那麼新函數也將會被添加進調用棧並執行。直到這個函數執行結束,對應的棧幀也會被立即銷燬。棧幀中一般會存放信息包括:
-
函數的返回地址和參數
-
臨時變量:函數局部變量 + 編譯器自動生成的其他臨時變量
-
函數調用的上下文
(函數的調用棧順序)
思考:爲什麼大部分高級語言都用棧來管理函數調用?
我們可以從函數自身的特性來分析這個問題:
-
函數具有可被調用的特性,代碼執行控制權從最開始父函數調用子函數開始,移交給子函數,再由子函數執行完成後又移交給父函數,這個控制權的轉移證明了:函數調用者的生命週期總是長於被調用者(後進),而且被調用者的生命週期總是先於調用者結束(先出)
-
函數還有作用域的限制,在執行的時候,定義在函數內部的臨時變量與外部環境隔離,只能在函數內訪問,外部函數無權訪問,當函數執行介紹後,臨時變量也會隨之被銷燬。關於臨時變量的資源佔用情況證明了:被調用者的資源分配總是晚於調用者(後進),同時被調用者的資源釋放卻又總先於調用者(先出)
從上面的函數的生命週期及資源分配情況來看,我們可以發現使用棧結構來管理函數調用,是最優解
堆
思考:有了棧爲什麼還需要堆?
棧空間是連續的,在棧上分配資源和銷燬資源的速度非常快,分配空間和銷燬空間只需要移動下指針就可以了。但是如果想在內存中分配一塊連續的大空間是非常難的,棧空間是有上限的,一旦函數循環嵌套次數過多,或者分配的數據過大,就會造成棧溢出問題,所以我們需要另外一種數據結構來存儲大數據。
引用數據類型存儲在堆內存中,因爲引用數據類型佔據空間大、大小不固定。如果存儲在棧中,將會影響程序運行的性能;引用數據類型在棧中存儲了指針,該指針指向堆中該實體的起始地址。當解釋器尋找引用值時,會首先檢索其在棧中的地址,取得地址後從堆中獲得實體。
相對棧內存結構來說,堆內存內部結構比較複雜,V8 引擎內存分配和垃圾回收機制複雜的設計也重點體現在堆內存管理上,下圖中是 V8 引擎內存結構總覽,我們來重點剖析下堆內存的結構。
主要分爲以下幾個區域:
- New space(新生代)
新生代主要是由兩個半空間(semi space)組成,一個是 from space,一個是 to space,空間的大小由 --min_semi_space_size(初始值) 和 --max_semi_space_size(最大值) 兩個標誌來控制,感興趣可以看下 V8 源碼 [1] 對於變量的定義,在 64 位和 32 位操作系統中最大值分別爲 64MB 和 32MB,新生代空間主要是用於新對象的存儲,後面配合垃圾回收再深入講下 gc 的過程。
- Old space (老生代)
這部分存儲的是經過多次 gc 後仍在新生代中存在的對象,空間的大小由 --initial_old_space_size(初始值) 和 --max_old_space_size(最大值) 兩個標誌來控制,代碼見此處 [2]。
-
這個區域包括了兩個部分:
-
Old pointer space: 存放存活下來包含指向其他對象指針的對象
-
Old data space:存放僅保存數據的對象,不含指向其他對象指針的對象,字符串等數據
-
Large object space (大對象區)
這是大於其他空間大小限制的對象存儲的地方,避免大對象的頻繁拷貝導致性能變差。大對象是不會被垃圾回收的。
-
Code space(代碼區): 即時(JIT)編譯器存儲編譯代碼塊的地方。唯一可執行代碼的空間
-
Cell space (單元區)
-
Property cell space(屬性單元區)
-
Map space(map 區域):用來存放對象的 map 信息,可以迴歸下之前講過的每個對象的隱藏類,爲了快速定位,單獨開闢了一個區域來用來存放這部分信息
-
Stack(棧內存)
垃圾回收
什麼是垃圾回收 (GC)?
GC = Garbage Collection,是指在內存空間進行垃圾回收的過程。如果不做 GC,容易造成內存空間大小超過上限而導致程序的崩潰,對比 C/C++ 等語言中,開發者需要手動處理內存的分配和釋放,人工控制優勢是在於可以細粒度控制,不足在於人工會導致失誤率的提高,分配或釋放太晚或太早會造成引用錯誤和內存泄漏,同時也增加了開發者的心智負擔。一些語言例如 js、java 等,會選擇在語言運行時中內置垃圾回收機制,雖然失去細顆粒度的控制,但得到了更高的開發效率,也解耦了對底層 api 的依賴,提高了內存的安全性。
Javascript 的標準 ECMAScript 並沒有對 GC 做相關的要求,GC 完全依賴底層引擎的能力。
堆內存中存儲着動態數據,隨着代碼的運行,這些數據隨時都可能會發生變化,而且這部分數據可能會相互引用,引擎需要不斷地遍歷找到這些數據相互之間的關係,從而發現哪些數據是非活動對象並對其進行 gc 操作,所以 gc 的算法及策略的好壞,直接影響着整個引擎執行代碼的性能,這部分是非常關鍵的。
如何判斷非活躍對象?
-
判斷對象是否是活躍的一般有兩種方法,引用計數法和可訪問性分析法。
-
引用計數法
-
V8 中並沒有使用這種方法,因爲每當有引用對象的地方,就加 1,去掉引用的地方就減 1,這種方式無法解決 A 與 B 循環引用的情況,引用計數都無法爲 0,導致無法完成 gc
-
可訪問性分析法
-
V8 中採用了這種方法,將一個稱爲 GC Roots 的對象(在瀏覽器環境中,GC Roots 可以包括:全局的 window 對象、所有原生 dom 節點集合等等)作爲所有初始存活的對象集合,從這個對象出發,進行遍歷,遍歷到的就認爲是可訪問的,爲活動對象,需要保留;如果沒有遍歷到的對象,就是不可訪問的,這些就是非活動對象,可能就會被垃圾回收。
代際假說
代際假說(The Generational Hypothesis)垃圾回收領域中的一個重要術語,它有兩個特點
-
大部分對象在內存中存活時間很短,比如函數內部聲明變量,塊級作用域中的變量等,這些代碼塊執行完分配的內存就會被清掉
-
不死的對象會活的更久,比如全局的 window、Dom、全局 api 等對象。
基於代際假說的理論,在 V8 引擎中,垃圾回收算法被分爲兩種,一個是 Major GC,主要使用了 Mark-Sweep & Mark-Compact 算法,針對的是堆內存中的老生代進行垃圾回收;另外一個是 Minor GC,主要使用了 Scavenger 算法,針對於堆內存中的新生代進行垃圾回收。
Scavenger 算法
是在新生代內存中使用的算法,速度更快,空間佔用更多的算法。New space 區域分爲了兩個半區,分別爲 from-space 和 to-space。不斷經過下圖中的過程,在兩個空間的角色互換中,完成垃圾回收的過程。每次都會有對象複製的操作,爲了控制這裏產生的時間成本和執行效率,往往新生代的空間並不大。同時爲了避免長時間之後,某些對象會一直積壓在新生代區域,V8 制定了晉升機制,滿足任一條件就會被分配到老生代的內存區中。
-
經歷一次 Scavenger 算法後,仍未被標記清除的對象
-
進行復制的對象大於 to space 空間大小的 25%
Mark-Sweep & Mark-Compact 算法
是老生代內存中的垃圾回收算法,標記 - 清除 & 標記 - 整理,老生代裏面的對象一般佔用空間大,而且存活時間長,如果也用 Scavenger 算法,複製會花費大量時間,而且還需要浪費一半的空間。
- 標記 - 清除過程:與之前講過的可訪問性分析一致,從 GC Root 開始遍歷,標記完成後,就直接進行垃圾數據的清理工作。
- 標記 - 整理過程:清除算法後會產生大量不連續的內存碎片,碎片過多會導致後面大對象無法分配到足夠的空間,所以需要進行整理,第一步的標記是一樣的,但標記完成活躍對象後,並不是進行清理,而是將所有存活的對象向一端移動,然後清理掉這端之外的內存。
優化策略
由於 JavaScript 是運行在主線程之上的,因此,一旦執行垃圾回收算法,都需要將正在執行的 JavaScript 腳本暫停下來,待垃圾回收完畢後再恢復腳本執行。這種行爲叫做全停頓(Stop-The-World)。
STW 會造成系統週期性的卡頓,對實時性高的和與時間相關的任務執行成功率會有非常大的影響。例如:js 邏輯需要執行動畫,剛好碰到 gc 的過程,會導致整個動畫卡頓,用戶體驗極差。
爲了降低這種 STW 導致的卡頓和性能不佳,V8 引擎中目前的垃圾回收器名爲 Orinoco,經過多年的不斷精細化打磨和優化,已經具備了多種優化手段,極大地提升了 GC 整個過程的性能及體驗。
並行回收
簡單來講,就是主線程執行一次完整的垃圾回收時間比較長,開啓多個輔助線程來並行處理,整體的耗時會變少,所有線程執行 gc 的時間點是一致的,js 代碼也不會有影響,不同線程只需要一點同步的時間,在新生代裏面執行的就是並行策略。
增量回收
-
並行策略說到底還是 STW 的機制,如果老生代裏面存放一些大對象,處理這些依然很耗時,Orinoco 又增加了增量回收的策略。將標記工作分解成小塊,插在主線程不同的任務之間執行,類似於 React fiber 的分片機制,等待空閒時間分配。這裏需要滿足兩個實現條件:
-
隨時可以暫停和啓動,暫停要保存當前的結果,等下一次空閒時機來才能啓動
-
暫停時間內,如果已經標記好的數據被 js 代碼修改了,回收器要能正確地處理
下面要講到的就是 Orinoco 引入了 3 色標記法來解決隨時啓動或者暫停且不丟之前標記結果的問題
三色標記法
-
三色標記法的規則如下:
-
最開始所有對象都是白色狀態
-
從 GC Root 遍歷所有可到達的對象,標記爲灰色,放入待處理隊列
-
從待處理隊列中取出灰色對象,將其引用的對象標記爲灰色放入待處理隊列,自身標記爲黑色
-
重複 3 中動作,直到灰色對象隊列爲空,此時白色對象就是垃圾,進行回收。
垃圾回收器可以依據當前內存中有沒有灰色節點,來判斷整個標記是否完成,如果沒有灰色節點了,就可以進行清理工作了。如果還有灰色標記,當下次恢復垃圾回收器時,便從灰色的節點開始繼續執行。
下面將要解決由於 js 代碼導致對象引用發生變化的情況,Orinoco 借鑑了寫屏障的處理辦法。
寫屏障(write-barrier)
-
一旦對象發生變化時,如何精確地更新標記的結果,我們可以分析下一般 js 執行過程中帶來的對象的變化有哪些,其實主要有 2 種:
-
標記過的黑色或者灰色的對象不再被其他對象所引用
-
引入新的對象,新的對象可能是白色的,面臨隨時被清除的危險,導致代碼異常
第一種問題不大,在下次執行 gc 的過程中會被再次標記爲白色,最後會被清空掉;第二種就使用到了寫屏障策略,一旦有黑色對象引用到了白色對象,系統會強制將白色對象標記成爲灰色對象,從而保證了下次 gc 執行時狀態的正確,這種模式也稱爲強三色原則。
併發回收
雖說三色標記法和寫屏障保證了增量回收的機制可以實現,但依然改變不了需要佔用主線程的情況,一旦主線程繁忙,垃圾回收依然會影響性能。所以增加了併發回收的機制。V8 裏面的併發機制相對複雜,簡化來看,當主線程運行代碼時,輔助線程併發進行標記,當標記完成後,主線程執行清理的過程時,輔助線程也並行執行。
總結
摘自 V8 官網的 blog[3]: V8 中的垃圾收集器自誕生以來已經走過了漫長的道路。向現有 GC 添加並行、增量和併發技術是一項多年的努力,但已經取得了回報,將大量工作轉移到後臺任務。它極大地改善了暫停時間、延遲和頁面加載,使動畫、滾動和用戶交互更加流暢。並行 Scavenger 算法將主線程年輕代垃圾收集的總時間減少了大約 20%–50%,具體取決於工作負載。空閒時間 gc 策略可以在 Gmail 空閒時將其 JavaScript 堆內存減少 45%。併發標記和清除策略已將重型 WebGL 遊戲的暫停時間減少了多達 50%。
代碼建議
如何避免內存泄漏
- 儘量減少創建全局變量,儘量使用局部變量
function foo() {
a = 1; // 等價於window.a = 1
}
- 定時器隱患
const a = []; //手動不清掉定時器,a將無法被回收
const foo = () => {
for(let i = 0; i < 1000; i++) {
a.push(i);
}
}
window.setInterval(foo, 1000);
- 閉包的錯誤使用
function foo() {
let a = 123;
return function() {
return a;
}
}
const bar = foo();
console.log(bar()); // 存在變量引用其返回的匿名函數,導致作用域無法得到釋放
- 推薦弱引用
es6 中新增了:WeakMap 和 WeakSet,它的鍵名所引用的對象均是弱引用,弱引用是指垃圾回收的過程中不會將鍵名對該對象的引用考慮進去,只要所引用的對象沒有其他的引用了,垃圾回收機制就會釋放該對象所佔用的內存。
- DOM 引用
const elements = {
button: document.getElementById('button')
};
function removeButton() {
document.body.removeChild(document.getElementById('button'));
}
// removeChild 清除了元素,但對象引用中還存在,要手動清除引用
參考資料
[1]
V8 源碼: https://source.chromium.org/chromium/chromium/src/+/main:v8/src/heap/heap.cc;l=5236?q=FLAG_min_semi_space_size&ss=chromium%2Fchromium%2Fsrc:v8%2F
[2]
見此處: https://source.chromium.org/chromium/chromium/src/+/main:v8/src/heap/heap.cc;l=5177?q=max_old_space_size&ss=chromium%2Fchromium%2Fsrc:v8%2F
[3]
blog: https://v8.dev/blog/trash-talk
[4]
Trash talk: the Orinoco garbage collector: https://v8.dev/blog/trash-talk
[5]
Memory Management in V8, garbage collection and improvements: https://dev.to/jennieji/memory-management-in-v8-garbage-collection-and-improvements-18e6
[6]
WIKI:Tracing garbage collection: https://en.wikipedia.org/wiki/Tracing_garbage_collection
[7]
Google I/O 2013 - Accelerating Oz with V8: Follow the Yellow Brick Road to JavaScript Performance: https://www.youtube.com/watch?v=VhpdsjBUS3g
[8]
Garbage-First Garbage Collection: http://citeseerx.ist.psu.edu/viewdoc/download?spm=a2c6h.12873639.article-detail.15.c8451ddb05xXlv&doi=10.1.1.63.6386&rep=rep1&type=pdf
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/dQYS7M9m_ylNaUV4no_LaA