通常來說,記憶體管理有兩種方式,一種是手動管理,一種是自動管理。

手動管理需要開發者自己管理記憶體,什麼時候申請記憶體空間,什麼時候釋放都需要小心處理,否則容易形成記憶體洩漏和指標亂飛的局面。C 語言開發是典型的需要手動管理記憶體的例子。

自動管理通常通過垃圾回收的機制來實現記憶體管理。NodeJS 中的記憶體管理是自動管理的。

垃圾回收

垃圾回收器(garbage collector,GC)通過判斷物件是否還在被其他物件引用來決定是否回收該物件的記憶體空間。

垃圾回收之前的記憶體

在下面的圖中,有一些物件還在被其他物件使用,而有一些物件已經是完全孤立狀態,沒有其他物件使用它了。這些已經完全孤立狀態的物件是可以被垃圾回收器回收的。

垃圾回收之後的記憶體

垃圾回收一旦開始執行,記憶體中的那些完全孤立(不可到達)的物件會被刪除,記憶體空間會被釋放。

垃圾回收是如何工作的

要搞清楚垃圾回收是如何工作的,需要先了解一些基本概念。

基本概念

  • 常駐集大小(resident set size):NodeJS 程序執行時佔據的記憶體大小,通常包含:程式碼、棧和堆。

  • 棧(stack):包含原始型別資料和指向物件的引用資料。

    棧中儲存著區域性變數和指向堆上物件的指標或定義應用程式控制流的指標(比如函式呼叫等)。

    下面程式碼中,ab 都儲存在棧中。

    function add (a, b) {
    return a + b
    }
    add(4, 5)
  • 堆(heap):存放引用型別資料,比如物件、字串、閉包等。

    下面程式碼中,建立的 Car 物件會被儲存在堆中。

    function Car (opts) {
    this.name = opts.name
    } const LightningMcQueen = new Car({name: 'Lightning McQueen'})

    物件建立後,堆記憶體狀態如下:

    現在我們新增更多的物件:

    const SallyCarrera = new Car({name: 'Sally Carrera'})
    const Mater = new Car({name: 'Mater'})

    堆記憶體狀態如下:

    如果現在執行垃圾回收,沒有任何記憶體會被釋放,因為每個物件都在被使用(可到達)。

    現在我們修改程式碼,如下:

    function Engine (power) {
    this.power = power
    } function Car (opts) {
    this.name = opts.name
    this.engine = new Engine(opts.power)
    } let LightningMcQueen = new Car({name: 'Lightning McQueen', power: 900})
    let SallyCarrera = new Car({name: 'Sally Carrera', power: 500})
    let Mater = new Car({name: 'Mater', power: 100})

    堆記憶體狀態變成:

    如果我們不在使用 Mater 的話,通過 Mater = undefined 刪除了對記憶體中物件的引用,則記憶體狀態變化為:

    此時記憶體中的 Mater 不再被其他物件使用了(不可達),當垃圾回收執行的時候,Mater 物件會被回收,其佔據的記憶體會被釋放。

  • 物件的淺層大小(shallow size of an object):物件本身佔據的記憶體大小。

  • 物件的保留大小(retained size of an object):刪除物件及其依賴物件後釋放的記憶體大小

垃圾回收器是如何工作的

NodeJS 的垃圾回收通過 V8 實現。大多數物件的生命週期都很短,而少數物件的壽命往往更長。為了利用這種行為,V8 將堆分成兩個部分,年輕代(Young Generation)老年代(Old Generation)

年輕代

新的記憶體需求都在年輕代中分配。年輕代的大小很小,在 1 到 8 MB 之間。在年輕代中記憶體分配非常便宜,V8 在記憶體中會逐個為物件分配空間,當到達年輕代的邊界時,會觸發一次垃圾回收。

V8 在年輕代會採用 Scavenge 回收策略。Scavenge 採用複製的方式進行垃圾回收。它將記憶體一分為二,每一部分空間稱為 semispace。這兩個空間,只有一個空間處於使用中,另一個則處於閒置。使用中的 semispace 稱為 「From 空間」,閒置的 semispace 稱為 「To 空間」。

年輕代的記憶體分配過程如下:

  1. 從 From 空間分配物件,若 semispace 被分配滿,則執行 Scavenge 演算法進行垃圾回收。
  2. 檢查 From 空間中的物件,若物件可到達,則檢查物件是否符合提升條件,若符合條件則提升到老生代,否則將物件從 From 空間複製到 To 空間。
  3. 若物件不可到達,則釋放不可到達物件的空間。
  4. 完成複製後,將 From 空間與 To 空間進行角色翻轉(flip)。

在年輕代中倖存的物件會被提升到老年代。

老年代

老年代中的物件有兩個特點,第一是存活物件多,第二個存活時間長。若在老年代中使用 Scavenge 演算法進行垃圾回收,將會導致複製存活物件的效率不高,且還會浪費一半的空間。因此在老年代中,V8 通常採用 Mark-Sweep 和 Mark-Compact 策略回收。

Mark-Sweep 就是標記清除,它主要分為標記和清除兩個階段。

  • 標記階段,將遍歷堆中所有物件,並對存活的物件進行標記;
  • 清除階段,對未標記物件的空間進行回收。

與 Scavenge 策略不同,Mark-Sweep 不會對記憶體一分為二,因此不會浪費空間。但是,經歷過一次 Mark-Sweep 之後,記憶體的空間將會變得不連續,這樣會對後續記憶體分配造成問題。比如,當需要分配一個比較大的物件時,沒有任何一個碎片內支援分配,這將提前觸發一次垃圾回收,儘管這次垃圾回收是沒有必要的。

為了解決記憶體碎片的問題,提高對記憶體的利用,引入了 Mark-Compact (標記整理)策略。Mark-Compact 是在 Mark-Sweep 演算法上進行了改進,標記階段與 Mark-Sweep 相同,但是對未標記的物件處理方式不同。與Mark-Sweep是對未標記的物件立即進行回收,Mark-Compact則是將存活的物件移動到一邊,然後再清理端邊界外的記憶體。

由於 Mark-Compact 需要移動物件,所以執行速度上,比 Mark-Sweep 要慢。所以,V8 主要使用 Mark-Sweep 演算法,然後在當空間記憶體分配不足時,採用 Mark-Compact 演算法。

常見面試知識點、技術解決方案、教程,都可以掃碼關注公眾號“眾裡千尋”獲取,或者來這裡 https://everfind.github.io