JavaScript的記憶體管理

MomentYY發表於2022-02-08

為什麼要關注記憶體

如果我們有記憶體溢位,程式佔用的記憶體會越來越大,最終引起客戶端卡頓,甚至無響應。如果我們使用Node.js做後端應用,因為後端程式會長時間執行,如果有記憶體溢位,造成的後果會更嚴重,伺服器記憶體可能會很快就消耗光,應用不能正常執行。

JS資料型別與JS記憶體機制

JS有如下資料型別

  • 原始資料型別:String, Number, Boolean, Null, Undefined, Symbol

  • 引用資料型別:Object

而存放這些資料的記憶體又可以分為兩部分:棧記憶體(Stack)和堆記憶體(Heap)。原始資料型別存在棧中,引用型別存在堆中。

棧記憶體

棧是一種只能一端進出的資料結構,先進後出,後進先出。假如我們有如下三個變數:

var a = 10;
var b = 'hello';
var c = true;
複製程式碼

根據我們的定義順序,a會首先入棧,然後是b,最後是c。最終結構圖如下所示:

image-20200109154828868

我們定義一個變數是按照如下順序進行的,以var a = 10; 為例,我們先將10放入記憶體,然後申明一個變數a,這時候a的值是undefined,最後進行賦值,就是將a與10關聯起來。

image-20200109155211356image-20200109155824299image-20200109160447255

從一個棧刪除元素就是出棧,從棧頂刪除,他相鄰的元素成為新的棧頂元素。

image-20200109160831605

堆記憶體

JS中原始資料型別的記憶體大小是固定的,由系統自動分配記憶體。但是引用資料型別,比如Object, Array,他們的大小不是固定的,所以是存在堆記憶體的。JS不允許直接操作堆記憶體,我們在操作物件時,操作的實際是物件的引用,而不是實際的物件。可以理解為物件在棧裡面存了一個記憶體地址,這個地址指向了堆裡面實際的物件。所以引用型別的值是一個指向堆記憶體的引用地址。

image-20200109161516222

函式也是引用型別,當我們定義一個函式時,會在堆記憶體中開闢一塊記憶體空間,將函式體程式碼以字串的形式存進去。然後將這塊記憶體的地址賦值給函式名,函式名和引用地址會存在棧上。

image-20200109162509063

可以在Chrome除錯工具中嘗試一下,定義一個方法,然後不加括號呼叫,直接輸出函式,可以看到,列印出來的是函式體字串:

image-20200109162715573

垃圾回收

垃圾回收就是找出那些不再繼續使用的變數,然後釋放其佔用的記憶體,垃圾回收器會按照固定的時間間隔週期性執行這一操作。JS使用垃圾回收機制來自動管理記憶體,但是他是一把雙刃劍:

  • 優勢: 可以大幅簡化程式的記憶體管理程式碼,降低程式設計師負擔,減少因為長時間執行而帶來的記憶體洩漏問題。
  • 劣勢:程式設計師無法掌控記憶體,JS沒有暴露任何關於記憶體的API,我們無法進行強制垃圾回收,更無法干預記憶體管理。

引用計數(reference counting)

引用計數是一種回收策略,它跟蹤記錄每個值被引用的次數,每次引用的時候加一,被釋放時減一,如果一個值的引用次數變成0了,就可以將其記憶體空間回收。

const obj = {a: 10};  // 引用 +1
const obj1 = {a: 10};  // 引用 +1
const obj = {};  // 引用 -1
const obj1 = null;  // 引用為 0
複製程式碼

當宣告瞭一個變數並將一個引用型別值賦值該變數時,則這個值的引用次數就是1.如果同一個值又被賦給另外一個變數,則該值得引用次數加1。相反,如果包含對這個值引用的變數又取 得了另外一個值,則這個值的引用次數減 1。當這個值的引用次數變成 0時,則說明沒有辦法再訪問這個值了,因而就可以將其佔用的記憶體空間回收回來。這樣,當垃圾收集器下次再執行時,它就會釋放那 些引用次數為零的值所佔用的記憶體。

使用引用計數會有一個很嚴重的問題:迴圈引用。迴圈引用指的是物件A中包含一個指向物件B的指標,而物件B中也包含一個指向物件A的引用。

function problem(){ 
    var objectA = {};
    var objectB = {}; 

    objectA.a = objectB;
    objectB.b = objectA; 
}
複製程式碼

在這個例子中,objectA 和 objectB 通過各自的屬性相互引用;也就是說,這兩個物件的引用次數都是 2。當函式執行完畢後,objectA 和 objectB 還將繼續存在,因為它們的引用次數永遠不會是 0。

因為引用計數有這樣的問題,現在瀏覽器已經不再使用這個演算法了,這個演算法主要存在於IE 8及以前的版本,現代瀏覽器更多的採用標記-清除演算法。在老版的IE中一部分物件並不是原生 JavaScript 物件。例如,其 BOM 和 DOM 中的物件就是使用 C++以 COM(Component Object Model,元件物件模型)物件的形式實現的,而 COM物件的垃圾 收集機制採用的就是引用計數策略。

  因此,即使 IE的 JavaScript引擎是使用標記清除策略來實現的,但 JavaScript訪問的 COM物件依然是基於引用計數策略的。換句話說,只要在IE中涉及 COM物件,就會存在迴圈引用的問題。

  下面這個簡單的例子,展示了使用 COM物件導致的迴圈引用問題:

var element = document.getElementById("some_element"); 
var myObject = new Object();
myObject.element = element; 
element.someObject = myObject;
複製程式碼

這個例子在一個 DOM元素(element)與一個原生 JavaScript物件(myObject)之間建立了迴圈引用。

其中,變數 myObject 有一個名為 element 的屬性指向 element 物件;而變數 element 也有 一個屬性名叫 someObject 回指 myObject。

由於存在這個迴圈引用,即使將例子中的 DOM從頁面中移除,它也永遠不會被回收。

為了避免類似這樣的迴圈引用問題,最好是在不使用它們的時候手工斷開原生 JavaScript 物件與 DOM元素之間的連線。例如,可以使用下面的程式碼消除前面例子建立的迴圈引用:

myObject.element = null; 
element.someObject = null;
複製程式碼

將變數設定為 null 意味著切斷變數與它此前引用的值之間的連線。當垃圾收集器下次執行時,就會刪除這些值並回收它們佔用的記憶體。

為了解決上述問題,IE9把 BOM和 DOM物件都轉換成了真正的 JavaScript物件。這樣,就避免了兩種垃圾收集演算法並存導致的問題,也消除了常見的記憶體洩漏現象。

標記-清除演算法

標記-清除演算法就是當變數進入環境是,這個變數標記位“進入環境”;而當變數離開環境時,標記為“離開環境”,當垃圾回收時銷燬那些帶標記的值並回收他們的記憶體空間。這裡說的環境就是執行環境,執行環境定義了變數或函式有權訪問的資料。每個執行環境都有一個與之關聯的變數物件(variable object),環境中所定義的所有變數和函式都儲存在這個物件中。某個執行環境中所有程式碼執行完畢後,改環境被銷燬,儲存在其中的所有變數和函式也隨之銷燬。

全域性執行環境

全域性執行環境是最外圍的一個執行環境,在瀏覽器中,全域性環境是window,Node.js中是global物件。全域性變數和函式都是作為window或者global的屬性和方法建立的。全域性環境只有當程式退出或者關閉網頁或者瀏覽器的時候才會銷燬。

區域性執行環境(環境棧)

每個函式都有自己的執行環境。當執行流進入一個函式時,函式的環境會被推入一個環境棧中。當這個函式執行之後,棧將其環境彈出,把控制權返回給之前的環境。ECMAScript程式中的執行流就是這個機制控制的。

image-20200109172651697

在一個環境中宣告變數的時候,垃圾回收器將其標記為“進入環境”,當函式執行完畢時,將其標記為“離開環境”,記憶體被回收。

可能造成記憶體洩露的情況

上面我們提到了兩種可能造成記憶體洩露的情況:

1. 物件之間的迴圈引用
2. 老版IE(IE8及以前)裡面DOM與物件之間的迴圈引用
複製程式碼

其他也可能造成迴圈引用的情況:

1. 全域性變數會存在於整個應用生命週期,應用不退出不會回收,使用嚴格模式可以避免這種情況
2. 閉包因為自身特性,將函式內部變數暴露到了外部作用域,當其自身執行結束時,所暴露的變數並不會回收
3. 沒有clear的定時器
複製程式碼

V8的記憶體管理

V8是有記憶體限制的,因為它最開始是為瀏覽器設計的,不太可能遇到大量記憶體的使用場景。關鍵原因還是垃圾回收所導致的執行緒暫停執行的時間過長。根據官方說法,以1.5G記憶體為例,V8一次小的垃圾回收需要50ms,而一次非增量的,即全量的垃圾回收更需要一秒。這顯然是不可接受的。因此V8限制了記憶體使用的大小,但是Node.js是可以通過配置修改的,更好的做法是使用Buffer物件,因為Buffer的記憶體是底層C++分配的,不佔用JS記憶體,所以他也就不受V8限制。

V8採用了分代回收的策略,將記憶體分為兩個生代:新生代和老生代

新生代

新生代記憶體中的垃圾回收主要通過 Scavenge 演算法進行,具體實現時主要採用了 Cheney 演算法。新生代的堆記憶體被分為多個Semispace,每個Semispace分為兩部分fromto,只有from的空間是使用中的,分配物件空間時,只在from中進行分配,to是閒置的。進行垃圾回收時按照如下步驟進行:

1. 找出from中還在使用的物件,即存活的物件
2. 將這些活著的物件全部複製到to
3. 反轉from和to,這時候from中全部是存活物件,to全部是死亡物件
4. 對to進行全部回收
複製程式碼

image-20200109174644426

可以看到在新生代中我們複製的是存活的物件,死亡物件都留在原地,最後被全部回收。這是因為對於大多數新增變數來說,可能只是用一下,很快就需要釋放,那在新生代中每次回收會發現存活的是少數,死亡的是多數。那我們複製的就是少數物件,這樣效率更高。如果一個變數在新生代中經過幾次複製還活著,那他生命週期可能就比較長,會晉升到老生代。有兩種情況會對物件進行晉升:

1. 新生代垃圾回收過程中,當一個物件經過多次複製後還存活,移動到老生代;
2. 在from和to進行反轉的過程中,如果to空間的使用量超過了25%,那麼from的物件全部晉升到老生代
複製程式碼

老生代

老生代存放的是生命週期較長的物件,他的結構是一個連續的結構,不像新生代分為fromto兩部分。老生代垃圾回收有兩種方式,標記清除和標記合併。

image-20200109180318322

標記清除

標記清除是標記死亡的物件,直接其空間釋放掉。在標記清除方法清除掉死亡物件後,記憶體空間就變成不連續的了,所以出現了另一個方案:標記合併。

image-20200109180634028

標記合併

這個方案有點像新生代的Cheney演算法,將存活的物件移動到一邊,將需要被回收的物件移動到另一邊,然後對需要被回收的物件區域進行整體的垃圾回收。

image-20200109181243348

與新生代演算法相比,老生代主要操作死亡物件,因為老生代都是生命週期較長的物件,每次回收死亡的比較少;而新生代主要操作的存活物件,因為新生代都是生命週期較短的物件,每次回收存活的較少。這樣無論新生代還是老生代,每次回收時都儘可能操作更少的物件,效率就提高了。


原創不易,每篇文章都耗費了作者大量的時間和心血,如果本文對你有幫助,請點贊支援作者,也讓更多人看到本文~~

更多文章請看我的掘金文章彙總


相關文章