JavaScript內存分配及垃圾回收機制

JavaScript內存分配及垃圾回收機制

簡介

像C語言這樣的高級語言一般都有底層的內存管理接口,比如 malloc()和free()。另一方面,JavaScript創建變量(對象,字符串等)時分配內存,並且在不再使用它們時“自動”釋放。 後一個過程稱爲垃圾回收。這個“自動”是混亂的根源,並讓JavaScript(和其他高級語言)開發者感覺他們可以不關心內存管理。 這是錯誤的。

內存生命週期

不管什麼程序語言,內存生命週期基本是一致的:

分配你所需要的內存
使用分配到的內存(讀、寫)
不需要時將其釋放\歸還

所有語言第二部分都是明確的。第一和第三部分在底層語言中是明確的,但在像JavaScript這些高級語言中,大部分都是隱含的。

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);
通過函數調用分配內存

有些函數調用結果是分配對象內存:

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 的結果
使用值

使用值的過程實際上是對分配內存進行讀取與寫入的操作。讀取與寫入可能是寫入一個變量或者一個對象的屬性值,甚至傳遞函數的參數。

當內存不再需要使用時釋放

大多數內存管理的問題都在這個階段。在這裏最艱難的任務是找到“所分配的內存確實已經不再需要了”。它往往要求開發人員來確定在程序中哪一塊內存不再需要並且釋放它。高級語言解釋器嵌入了“垃圾回收器”,它的主要工作是跟蹤內存的分配和使用,以便當分配的內存不再使用時,自動釋放它。這隻能是一個近似的過程,因爲要知道是否仍然需要某塊內存是無法判定的(無法通過某種算法解決)。V8 實現了準確式 GC,GC 算法採用了分代式垃圾回收機制。因此,V8 將內存(堆)分爲新生代和老生代兩部分。

一.新生代算法

新生代中的對象一般存活時間較短,使用 Scavenge GC 算法(一種清理的執行機制)。
在新生代空間中,內存空間分爲兩部分,分別爲 From 空間和 To 空間。在這兩個空間中,必定有一個空間是使用的,另一個空間是空閒的。新分配的對象會被放入 From 空間中,當 From 空間被佔滿時,新生代 GC 就會啓動了。算法會檢查 From 空間中存活的對象並複製到 To 空間中,如果有失活的對象就會銷燬。當複製完成後將 From 空間和 To 空間互換,這樣 GC 就結束了。

二.老生代算法

老生代中的對象一般存活時間較長且數量也多,使用了兩個算法,分別是標記清除算法和標記壓縮算法。
在講算法前,先來說下什麼情況下對象會出現在老生代空間中:

新生代中的對象是否已經經歷過一次 Scavenge 算法,如果經歷過的話,會將對象從新生代空間移到老生代空間中。
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
};

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

某一個空間沒有分塊的時候
空間中被對象超過一定限制
空間不能保證新生代中的對象移動到老生代中

在這個階段中,會遍歷堆中所有的對象,然後標記活的對象,在標記完成後,銷燬所有沒有被標記的對象。在標記大型對內存時,可能需要幾百毫秒才能完成一次標記。這就會導致一些性能上的問題。爲了解決這個問題,2011 年,V8 從 stop-the-world 標記切換到增量標誌。在增量標記期間,GC 將標記工作分解爲更小的模塊,可以讓 JS 應用邏輯在模塊間隙執行一會,從而不至於讓應用出現停頓情況。但在 2018 年,GC 技術又有了一個重大突破,這項技術名爲併發標記。該技術可以讓 GC 掃描和標記對象時,同時允許 JS 運行。
清除對象後會造成堆內存出現碎片的情況,當碎片超過一定限制後會啓動壓縮算法。在壓縮過程中,將活的對象像一端移動,直到所有對象都移動完成然後清理掉不需要的內存。

三.GC的執行機制

由於對象進行了分代處理,因此垃圾回收區域、時間也不一樣。GC有兩種類型:Scavenge GC和Full GC。

1. Scavenge GC

一般情況下,當新對象生成,並且在Eden申請空間失敗時,就會觸發Scavenge GC,對Eden區域進行GC,清除非存活對象,並且把尚且存活的對象移動到Survivor區。然後整理Survivor的兩個區。這種方式的GC是對年輕代的Eden區進行,不會影響到年老代。因爲大部分對象都是從Eden區開始的,同時Eden區不會分配的很大,所以Eden區的GC會頻繁進行。因而,一般在這裏需要使用速度快、效率高的算法,使Eden去能儘快空閒出來。

2.Full GC

對整個堆進行整理,包括Young、Tenured和Perm。Full GC因爲需要對整個堆進行回收,所以比Scavenge GC要慢,因此應該儘可能減少Full GC的次數。在對JVM調優的過程中,很大一部分工作就是對於FullGC的調節。有如下原因可能導致Full GC:

1.年老代(Tenured)被寫滿
2.持久代(Perm)被寫滿
3.System.gc()被顯示調用
4.上一次GC之後Heap的各域分配策略動態變化

四.V8的垃圾回收機制

1. 如何判斷回收內容

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

2.如何識別指針和數據

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

目前主要有三種方法來識別指針:

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

自動垃圾回收算法的演變過程中出現了很多算法,但是由於不同對象的生存週期不同,沒有一種算法適用於所有的情況。所以V8採用了一種分代回收的策略,將內存分爲兩個生代:新生代和老生代。新生代的對象爲存活時間較短的對象,老生代中的對象爲存活時間較長或常駐內存的對象。分別對新生代和老生代使用不同的垃圾回收算法來提升垃圾回收的效率。對象起初都會被分配到新生代,當新生代中的對象滿足某些條件時(也就是上面所說的當 From 空間被佔滿時),會被移動到老生代(晉升)

4.V8的分代內存

默認情況下,64位環境下的V8引擎的新生代內存大小32MB、老生代內存大小爲1400MB,而32位則減半,分別爲16MB和700MB。V8內存的最大保留空間分別爲1464MB(64位)和732MB(32位)。具體的計算公式是4*reserved_semispace_space_ + max_old_generation_size_,新生代由兩塊reserved_semispace_space_組成,每塊16MB(64位)或8MB(32位)

五.參考

Concurrent marking in V8
淺談V8引擎中的垃圾回收機制
JavaScript內存管理


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章