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'
}]