Chrome V8系列--淺析Chrome V8引擎中的垃圾回收機制和內存泄露優化策略


V8 實現了准確式 GC,GC 算法采用了分代式垃圾回收機制。因此,V8 將內存(堆)分為新生代和老生代兩部分。

 

一、前言

V8的垃圾回收機制:JavaScript使用垃圾回收機制來自動管理內存。垃圾回收是一把雙刃劍,其好處是可以大幅簡化程序的內存管理代碼,降低程序員的負擔,減少因 長時間運轉而帶來的內存泄露問題。

但使用了垃圾回收即意味着程序員將無法掌控內存。ECMAScript沒有暴露任何垃圾回收器的接口。我們無法強迫其進 行垃圾回收,更無法干預內存管理

內存管理問題:在瀏覽器中,Chrome V8引擎實例的生命周期不會很長(誰沒事一個頁面開着幾天幾個月不關),而且運行在用戶的機器上。如果不幸發生內存泄露等問題,僅僅會 影響到一個終端用戶。且無論這個V8實例占用了多少內存,最終在關閉頁面時內存都會被釋放,幾乎沒有太多管理的必要(當然並不代表一些大型Web應用不需 要管理內存)。但如果使用Node作為服務器,就需要關注內存問題了,一旦內存發生泄漏,久而久之整個服務將會癱瘓(服務器不會頻繁的重啟)。

 

二、chrome內存限制

2.1存在限制

Chrome限制了所能使用的內存極限(64位為1.4GB,32位為1.0GB),這也就意味着將無法直接操作一些大內存對象。

2.2為何限制

Chrome之所以限制了內存的大小,表面上的原因是V8最初是作為瀏覽器的JavaScript引擎而設計,不太可能遇到大量內存的場景,而深層次的原因 則是由於V8的垃圾回收機制的限制。由於V8需要保證JavaScript應用邏輯與垃圾回收器所看到的不一樣,V8在執行垃圾回收時會阻塞 JavaScript應用邏輯,直到垃圾回收結束再重新執行JavaScript應用邏輯,這種行為被稱為“全停頓”(stop-the-world)。 若V8的堆內存為1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。這樣瀏覽器將在1s內失去對用戶的響 應,造成假死現象。如果有動畫效果的話,動畫的展現也將顯著受到影響

 

三、chrome V8的堆構成

V8的堆其實並不只是由老生代和新生代兩部分構成,可以將堆分為幾個不同的區域:

1、新生代內存區:大多數的對象被分配在這里,這個區域很小但是垃圾回特別頻繁;

2、老生代指針區:屬於老生代,這里包含了大多數可能存在指向其他對象的指針的對象,大多數從新生代晉升的對象會被移動到這里;

3、老生代數據區:屬於老生代,這里只保存原始數據對象,這些對象沒有指向其他對象的指針;

4、大對象區:這里存放體積超越其他區大小的對象,每個對象有自己的內存,垃圾回收其不會移動大對象;

5、代碼區:代碼對象,也就是包含JIT之后指令的對象,會被分配在這里。唯一擁有執行權限的內存區;

6、Cell區、屬性Cell區、Map區:存放Cell、屬性Cell和Map,每個區域都是存放相同大小的元素,結構簡單。

每個區域都是由一組內存頁構成,內存頁是V8申請內存的最小單位,除了大對象區的內存頁較大以外,其他區的內存頁都是1MB大小,而且按照1MB對 齊。內存頁除了存儲的對象,還有一個包含元數據和標識信息的頁頭,以及一個用於標記哪些對象是活躍對象的位圖區。另外每個內存頁還有一個單獨分配在另外內 存區的槽緩沖區,里面放着一組對象,這些對象可能指向其他存儲在該頁的對象。垃圾回收器只會針對新生代內存區、老生代指針區以及老生代數據區進行垃圾回收。

 

四、chrome V8的垃圾回收機制

4.1如何判斷回收內容

如何確定哪些內存需要回收,哪些內存不需要回收,這是垃圾回收期需要解決的最基本問題。我們可以這樣假定,一個對象為活對象當且僅當它被一個根對象 或另一個活對象指向。根對象永遠是活對象,它是被瀏覽器或V8所引用的對象。被局部變量所指向的對象也屬於根對象,因為它們所在的作用域對象被視為根對 象。全局對象(Node中為global,瀏覽器中為window)自然是根對象。瀏覽器中的DOM元素也屬於根對象。

 

4.2如何識別指針和數據

垃圾回收器需要面臨一個問題,它需要判斷哪些是數據,哪些是指針。由於很多垃圾回收算法會將對象在內存中移動(緊湊,減少內存碎片),所以經常需要進行指針的改寫:

目前主要有三種方法來識別指針:
1. 保守法:將所有堆上對齊的字都認為是指針,那么有些數據就會被誤認為是指針。於是某些實際是數字的假指針,會背誤認為指向活躍對象,導致內存泄露(假指針指向的對象可能是死對象,但依舊有指針指向——這個假指針指向它)同時我們不能移動任何內存區域。
2. 編譯器提示法:如果是靜態語言,編譯器能夠告訴我們每個類當中指針的具體位置,而一旦我們知道對象時哪個類實例化得到的,就能知道對象中所有指針。這是JVM實現垃圾回收的方式,但這種方式並不適合JS這樣的動態語言
3. 標記指針法:這種方法需要在每個字末位預留一位來標記這個字段是指針還是數據。這種方法需要編譯器支持,但實現簡單,而且性能不錯。V8采用的是這種方式。V8將所有數據以32bit字寬來存儲,其中最低一位保持為0,而指針的最低兩位為01

 

4.3 V8回收策略

自動垃圾回收算法的演變過程中出現了很多算法,但是由於不同對象的生存周期不同,沒有一種算法適用於所有的情況。所以V8采用了一種分代回收的策 略,將內存分為兩個生代:新生代和老生代

新生代的對象為存活時間較短的對象,老生代中的對象為存活時間較長或常駐內存的對象。分別對新生代和老生代使用 不同的垃圾回收算法來提升垃圾回收的效率。對象起初都會被分配到新生代,當新生代中的對象滿足某些條件(后面會有介紹)時,會被移動到老生代(晉升)。

 

五、新生代算法

新生代中的對象一般存活時間較短,使用 Scavenge GC 算法。在Scavenge的具體實現中,主要是采用一種復制的方式的方法--cheney算法。

在新生代空間中,內存空間分為兩部分,分別為 From 空間和 To 空間。在這兩個空間中,必定有一個空間是使用的,另一個空間是空閑的。新分配的對象會被放入 From 空間中,當 From 空間被占滿時,新生代 GC 就會啟動了。算法會檢查 From 空間中存活的對象並復制到 To 空間中,如果有失活的對象就會銷毀。當復制完成后將 From 空間和 To 空間互換,這樣 GC 就結束了。

 

六、老生代算法

老生代中的對象一般存活時間較長且數量也多,使用了兩個算法,分別是標記清除算法標記壓縮算法

在講算法前,先來說下什么情況下對象會出現在老生代空間中:

1、新生代中的對象是否已經經歷過一次 Scavenge 算法,如果經歷過的話,會將對象從新生代空間移到老生代空間中。

2、To 空間的對象占比大小超過 25 %。在這種情況下,為了不影響到內存分配,會將對象從新生代空間移到老生代空間中。

老生代中的空間很復雜,有如下幾個空間:

enum AllocationSpace {
  // TODO(v8:7464): Actually map this space's memory as read-only. RO_SPACE, // 不變的對象空間 NEW_SPACE, // 新生代用於 GC 復制算法的空間 OLD_SPACE, // 老生代常駐對象空間 CODE_SPACE, // 老生代代碼對象空間 MAP_SPACE, // 老生代 map 對象 LO_SPACE, // 老生代大空間對象 NEW_LO_SPACE, // 新生代大空間對象 FIRST_SPACE = RO_SPACE, LAST_SPACE = NEW_LO_SPACE, FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE, LAST_GROWABLE_PAGED_SPACE = MAP_SPACE };

在老生代中,以下情況會先啟動標記清除算法:

1、某一個空間沒有分塊的時候

2、空間中被對象超過一定限制

3、空間不能保證新生代中的對象移動到老生代中

Mark Sweep 是將需要被回收的對象進行標記,在垃圾回收運行時直接釋放相應的地址空間,如下圖所示(紅色的內存區域表示需要被回收的區域):

Mark Compact 的思想有點像新生代垃圾回收時采取的 Cheney 算法:將存活的對象移動到一邊,將需要被回收的對象移動到另一邊,然后對需要被回收的對象區域進行整體的垃圾回收。

在這個階段中,會遍歷堆中所有的對象,然后標記活的對象,在標記完成后,銷毀所有沒有被標記的對象。在標記大型對內存時,可能需要幾百毫秒才能完成一次標記。這就會導致一些性能上的問題。為了解決這個問題,2011 年,V8 從 stop-the-world 標記切換到增量標志。在增量標記期間,GC 將標記工作分解為更小的模塊,可以讓 JS 應用邏輯在模塊間隙執行一會,從而不至於讓應用出現停頓情況。但在 2018 年,GC 技術又有了一個重大突破,這項技術名為並發標記。該技術可以讓 GC 掃描和標記對象時,同時允許 JS 運行。

清除對象后會造成堆內存出現碎片的情況,當碎片超過一定限制后會啟動壓縮算法。在壓縮過程中,將活的對象像一端移動,直到所有對象都移動完成然后清理掉不需要的內存。

 

七、內存泄露和優化

7.1 什么是內存泄露?

存泄露是指程序中已分配的堆內存由於某種原因未釋放或者無法釋放,造成系統內存的浪費,導致程序運行速度減慢甚至系統奔潰等后果。。

7.2 常見的內存泄露的場景

7.2.1 緩存

js開發時候喜歡用對象的鍵值來緩存函數的計算結果,但是緩存中存儲的鍵越多,長期存活的對象就越多,導致垃圾回收在進行掃描和整理時,對這些對象做了很多無用功。

7.2.2 作用域未釋放(閉包)

var leakArray = []; exports.leak = function () { leakArray.push("leak" + Math.random()); }

模塊在編譯執行后形成的作用域因為模塊緩存的原因,不被釋放,每次調用 leak 方法,都會導致局部變量 leakArray 不停增加且不被釋放。

閉包可以維持函數內部變量駐留內存,使其得不到釋放。

 

7.2.3 沒有必要的全局變量

聲明過多的全局變量,會導致變量常駐內存,要直到進程結束才能夠釋放內存。

 

7.2.4 無效的DOM引用

//dom still exist function click(){ // 但是 button 變量的引用仍然在內存當中。 const button = document.getElementById('button'); button.click(); } // 移除 button 元素 function removeBtn(){ document.body.removeChild(document.getElementById('button')); }

 

7.2.5 定時器未清除

// vue 的 mounted 或 react 的 componentDidMount componentDidMount() { setInterval(function () { // ...do something }, 1000) }

vue 或 react 的頁面生命周期初始化時,定義了定時器,但是在離開頁面后,未清除定時器,就會導致內存泄漏。

 

7.2.6 事件監聽為空白

componentDidMount() {
    window.addEventListener("scroll", function () { // do something... }); }

在頁面生命周期初始化時,綁定了事件監聽器,但在離開頁面后,未清除事件監聽器,同樣也會導致內存泄漏。

 

7.3 內存泄露優化

7.3.1 解除引用

確保占用最少的內存可以讓頁面獲得更好的性能。而優化內存占用的最佳方式,就是為執行中的代碼只保存必要的數據。一旦數據不再有用,最好通過將其值設置為 null 來釋放其引用——這個做法叫做解除引用(dereferencing)

function createPerson(name){ var localPerson = new Object(); localPerson.name = name; return localPerson; } var globalPerson = createPerson("Nicholas"); // 手動解除 globalPerson 的引用 globalPerson = null;

解除一個值的引用並不意味着自動回收該值所占用的內存。解除引用的真正作用是讓值脫離執行環境,以便垃圾收集器下次運行時將其回收

 

7.3.2 提供手動清空變量的方法

var leakArray = []; exports.clear = function () { leakArray = []; }

 

7.3.3 其他方法

1、在業務不需要的用到的內部函數,可以重構到函數外,實現解除閉包。

2、避免創建過多的生命周期較長的對象,或者將對象分解成多個子對象。

3、避免過多使用閉包。

4、注意清除定時器和事件監聽器。

5、nodejs中使用stream或buffer來操作大文件,不會受nodejs內存限制。

6、使用redis等外部工具來緩存數據。

 

八、總結

js是一門具有自動回收垃圾收集的編程語言,在瀏覽器中主要是通過標記清除的方法回收垃圾,在nodejs中主要是通過分代回收,Scavenge,標記清除,增量標記等算法來回收垃圾。在日常開發中,有一些不引入注意的書寫方式可能會導致內存泄露,多注意自己代碼規范。

 

九、參考

1、V8的垃圾回收機制與內存限制

2、node 內存限制的問題

3、node內存控制

4、深入淺出Nodejs

5、javascript高級程序設計

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM