2019-05-30

1. V8內存管理和相關問題

Node.js基于V8引擎,其內存管理就是V8的內存管理。

V8內置了自動垃圾回收(GC)。

V8由Google開發,使用C++編寫,最早在Chrome中使用。相對于其他JavaScript引擎將代碼裝換成字節碼或解釋執行,V8將代碼變異成原生機器碼,并且使用了如內聯緩存等方法來提高性能。JavaScript程序在V8引擎下運行速度媲美二進制程序。

autoauto- 1. V8內存管理和相關問題 (原)auto - 1.1. V8內存設計auto - 1.1.1. 內存分區auto - 1.1.2. 內存生命周期auto - 1.2. V8垃圾回收auto - 1.2.1. 標記清除法auto - 1.2.2. 垃圾回收算法auto - 1.3. Node.js如何檢視內存和GCauto - 1.3.1. 測試auto - 1.3.1.1. external內存和GC測試auto - 1.3.1.2. heap內存和GC測試auto - 1.3.2. 更多auto - 1.3.2.1. 總結auto - 1.4. 常見的內存泄漏案例auto - 1.4.1. 全局變量auto - 1.4.2. 閉包auto - 1.4.3. 消費者速度小于生產者auto - 1.5. 如何發現和定位內存問題auto - 1.5.1. memwatch-nextauto - 1.5.2. heapdumpauto - 1.5.3. 使用PM2做 Memory Threshold Auto Reload 處理autoauto

1.1. V8內存設計

1.1.1. 內存分區

V8中,內存分為幾個部分:

  • 新生代區 new space
    大多數的對象都會被分配在這里,這個區域很小但是垃圾回收比較頻繁。

  • 老生代區 old space
    屬于老生代,這里只保存原始數據對象,這些對象沒有指向其他對象的指針。

  • 大對象區 large object space
    這里存放體積超越其他區大小的對象,每個對象有自己的內存,垃圾回收其不會移動大對象區。

  • 代碼區 code space
    代碼對象,會被分配在這里。唯一擁有執行權限的內存。

  • map區 map space
    存放 Cell 和 Map,每個區域都是存放相同大小的元素,結構簡單。

1.1.2. 內存生命周期

一個對象A創建后,被分配到新生代區。

新生代區滿后,V8進行Scavenge操作,清除需要回收的。如果對象A還有效,則保留。

如果對象A再次被清理(或者滿足其他條件),則晉升到老生代區。

老生代區滿后,V8進行Mark Sweep操作,將這時需要回收的對象A清除。

1.2. V8垃圾回收

1.2.1. 標記清除法

當變量進入環境(例如,在函數中聲明一個變量)時,就將這個變量標記為“進入環境”。從邏輯上講,永遠不能釋放進入環境的變量所占用的內存,因為只要執行流進入相應的環境,就可能會用到它們。而當變量離開環境時,則將其標記為“離開環境”。

與之對應的還有引用計數法,但會因循環引用導致內存泄漏,所以很少見到。

1.2.2. 垃圾回收算法

由于新生代和老生代存放了不同性質的內存對象,其清除方式也不同。

簡單來說,新生代使用Scavenge算法。分成From和To兩個區,將需要回收的對象留在From,其他移到To,然后交換From和To。垃圾回收將To空間內存全部釋放。

老生代使用Mark Sweep算法,直接標記需要被回收的對象,在垃圾回收時釋放相應地址空間。

此外,還有Mark Compact算法,將存活和需要回收的對象放在地址區域的兩邊,以避免回收后內存不連續的問題。

1.3. Node.js如何檢視內存和GC

Node.js提供了一些API來幫助開發者檢視程序的內存使用狀況和GC情況。

process.memoryUsage()

會返回一個內存使用信息對象,單位為字節Byte。類似:

Object {rss: 25358336, heapTotal: 8232960, heapUsed: 5488248, external: 8608}
  • rss 駐留集大小, 即程序分配的物理內存大小,包括堆、棧、代碼段
  • heapTotal V8堆總大小
  • heapTotal V8堆使用量大小
  • external V8綁定到Javascript的C++對象的內存大小

對象,字符串,閉包等存于堆內存。 變量存于棧內存。 實際的JavaScript源代碼存于代碼段內存。

1.3.1. 測試

下面的測試,執行時都給node添加啟動參數--trace-gc--expose-gc
前者可以打印出GC操作log,后者允許在代碼中控制GC。

1.3.1.1. external內存和GC測試

嘗試用fs.readFileSync('/path/')讀取一個100M左右的文件。發現rss和external增加了100M左右。
heapUsed則只增加了一點。看來直接讀取文件返回的是一個C++對象的引用。

即使沒有保存fs.readFileSync()返回的對象,rss和external還是增大了。且即使等待,這部分內存也不會被回收。

在代碼中調用global.gc()主動進行GC回收。
回收后,增加的100M左右rss被釋放。

1.3.1.2. heap內存和GC測試

如果使用fs.readFileSync('/path/', 'utf-8'),返回的將是一個字符串對象。會占用heap內存。
反復執行10次并保留每次的引用,發現程序錯誤:

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

這體現了V8的堆內存大小是有限制的。這個限制可以修改。
老生代用node --max-old-space-size=xxxx(單位MB)修改。
新生代用node --max-new-space-size=xxxx(單位MB)修改。

1.3.2. 更多

  • node --v8-options print v8 command line options
  • node --v8-pool-size=num set v8's thread pool size
  • node --prof-process process v8 profiler output generated using --prof
  • node --track-heap-objects track heap object allocations for heap snapshots
  • os.totalmem() 系統總內存
  • os.freemem() 系統空閑內存

1.3.2.1. 總結

通過測試,可以發現GC的一些表面規則:

  • 部分函數會創建C++對象并返回其引用,而不是JS對象。因此占用external而非heap。
  • global.x 不會被回收。const x,如果后面沒有使用x,則會很快被回收。

1.4. 常見的內存泄漏案例

1.4.1. 全局變量

全局變量global.xxx不會被GC回收。

未聲明變量會隱式產生全局變量:

function foo() {
  // 即 global.a = 1;
  a = 1;
}

使用tslint等工具規范代碼可以避免此種問題。

1.4.2. 閉包

閉包就是能夠讀取其他函數內部變量的函數。

閉包作用域會保留其中涉及的引用,會導致對象無法被回收。

要注意的一個知識點是:每當在同一個父作用域下創建閉包作用域的時候,這個作用域是被共享的。

看一個經典問題(曾經是web框架meteor的著名bug):

let theThing = null;
const replaceThing = function () {
  const originalThing = theThing;
  function unused() {
    if (originalThing) {}
  }
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: () => {}
  };
};
setInterval(() => {
  replaceThing();
  console.log(process.memoryUsage().heapTotal)
}, 1000);

運行這段代碼,會發現rss、heapTotal、heapUsed不斷增長。

這是因為在replaceThing()的詞法作用域中,聲明了originalThing,而閉包函數unused()使用了originalThing;theThing.someMethod()雖然是空函數,但由于上面提及的知識點,其閉包作用域也包含了originalThing,而theThing定義在文件作用域,無法回收。

這些就導致了每次重聲明的originalThing都無法回收,就會有大量longStr積累在堆中。

如果需要解決這個問題,可以在replaceThing()的最后加originalThing = null;

這個問題出現的關鍵在于,變量間產生了循環使用,且一個在閉包作用域中,導致其每次定義后,都無法釋放。

也可以改成下面這樣,效果一樣:

let theThing = null;
const replaceThing = function () {
  const originalThing = {
    theThing,
    longStr: new Array(1000000).join('*'),
  };
  function unused() {
    if (originalThing) {}
  }
  theThing = ()=> {}
};
setInterval(() => {
  replaceThing();
  console.log(process.memoryUsage().heapTotal)
}, 1000);

1.4.3. 消費者速度小于生產者

常見于使用消息隊列或大量IO操作時。由于作為生產者時,消費者一方不能及時處理任務,導致任務數據在生產者內存緩存中大量積存,最終導致內存溢出。

1.5. 如何發現和定位內存問題

1.5.1. memwatch-next

memwatch-next是一個能發現內存泄漏問題,并給出簡單問題分析的工具。

使用如下:

// 使用方式1:監聽內存泄漏
// 5個連續GC周期下,
memwatch.on('leak', function (info) { 
  console.warn("MEMLEAK", info);
});

// 使用方式2:生成一段時間的內存和對象變化報告
const hd = new memwatch.HeapDiff();
setTimeout(() => {
  const diff = hd.end();
  console.log(JSON.stringify(diff, null, "  "));
}, 1000 * 10);

在上面那個閉包引起內存泄漏的代碼中使用,可以發現部分報告輸出如下:

{
  "change": {
    "details": [
      {
        "what": "Closure",
        "size_bytes": 6624,
        "size": "6.47 kb",
        "+": 96,
        "-": 4
      },
      {
        "what": "String",
        "size_bytes": 93003520,
        "size": "88.7 mb",
        "+": 205,
        "-": 22
      }
    ]
  }
}

由此,可以推測是大量String對象造成內存占用,可能和閉包有關。

1.5.2. heapdump

heapdump是一個用于導出V8 Heap Snapshot的工具。導出數據可以導入到Chrome瀏覽器查看。

和memwatch結合使用:

memwatch.on('leak', function (info) { 
  console.warn("MEMLEAK", info);
  heapdump.writeSnapshot('' + Date.now() + '.heapsnapshot');
});

等到leak事件觸發后,便會導出一個.heapsnapshot文件。從 [Chrome開發者工具]-[memory]-[Profiles]-[Heap snapshot] 中,Load這個文件。

然后就可以看到報告內容。
可以按Shallow Size排序,查看是何種對象占用了大量內存。(如果內存泄漏時緩慢增長的,則可以等待足夠長時間后再導出報告)

對上面的閉包例子做報告,可以發現占用最多的是string,有多個大體積的“***...*”字符串。

1.5.3. 使用PM2做 Memory Threshold Auto Reload 處理

有時內存泄漏的問題隱藏地很深,短時間內難以定位和解決。這時要優先保證服務正常運行不收內存
問題的影響,就可以利用pm2管理工具的內存限制重啟特性。

具體方式是在配置文件中增加max_memory_restart屬性:

apps: [{
  max_memory_restart: '300M'
}]
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容