搞不定 NodeJS 內存泄漏?先從了解垃圾回收開始

  • 2021 年 9 月 24 日
  • 筆記

通常來說,內存管理有兩種方式,一種是手動管理,一種是自動管理。

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

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

垃圾回收

垃圾回收器(garbage collector,GC)通過判斷對象是否還在被其他對象引用來決定是否回收該對象的內存空間。

垃圾回收之前的內存

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

image

垃圾回收之後的內存

垃圾回收一旦開始運行,內存中的那些完全孤立(不可到達)的對象會被刪除,內存空間會被釋放。

image

垃圾回收是如何工作的

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

基本概念

  • 常駐集大小(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'})
    

    對象創建後,堆內存狀態如下:

    image

    現在我們添加更多的對象:

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

    堆內存狀態如下:

    image

    如果現在執行垃圾回收,沒有任何內存會被釋放,因為每個對象都在被使用(可到達)。

    現在我們修改代碼,如下:

    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})
    

    堆內存狀態變成:

    image

    如果我們不在使用 Mater 的話,通過 Mater = undefined 刪除了對內存中對象的引用,則內存狀態變化為:

    image

    此時內存中的 Mater 不再被其他對象使用了(不可達),當垃圾回收運行的時候,Mater 對象會被回收,其佔據的內存會被釋放。

    image

  • 對象的淺層大小(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 之後,內存的空間將會變得不連續,這樣會對後續內存分配造成問題。比如,當需要分配一個比較大的對象時,沒有任何一個碎片內支持分配,這將提前觸發一次垃圾回收,儘管這次垃圾回收是沒有必要的。

image

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

image

由於 Mark-Compact 需要移動對象,所以執行速度上,比 Mark-Sweep 要慢。所以,V8 主要使用 Mark-Sweep 算法,然後在當空間內存分配不足時,採用 Mark-Compact 算法。

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