
如朴靈說過,Node對內存泄露十分敏感,一旦線上應用有成千上萬的流量,那怕是一個字節的內存泄漏也會造成堆積,垃圾回收過程中將會耗費更多時間進行對象掃描,應用響應緩慢,直到進程內存溢出,應用崩潰。
雖然從很久以前就知道內存問題是不容忽視的,但是日常開發的時候並沒有碰到性能上的瓶頸,直到最近做了一個百萬PV級的營銷項目,由於訪問量,並發量都達到了一個量級。一些細小的、平時沒注意到的問題被放大,這才映入眼簾,開始注意到了內存問題。殊不知Node對內存的泄露是如此的敏感。
為此,趕緊去補習了一下V8中的內存處理機制。
那么,V8中的內存機制是怎么樣的?
V8的內存機制
內存的限制
Node中並不像其他后端語言中,對內存的使用沒有多少限制。在Node中使用內存,只能使用到系統的一部分內存,64位系統下約為1.4GB,32位系統下約為0.7GB。這歸咎於Node使用了本來運行在瀏覽器的V8引擎。
V8引擎的設計之初只是運行在瀏覽器中,而在瀏覽器的一般應用場景下使用起來綽綽有余,足以勝任前端頁面中的所有需求。
雖然服務端操作大內存也不是常見的需求,但是萬一有這樣的需求,還是可以解除限制的。
在啟動node程序的時候,可以傳遞兩個參數來調整內存限制的大小。
node --max-nex-space-size=1024 app.js // 單位為KB node --max-old-space-size=2000 app.js // 單位為MB
這兩條命令分別對應Node內存堆中的「新生代」和「老生代」
不受內存限制的特例
在Node中,使用Buffer可以讀取超過V8內存限制的大文件。原因是Buffer對象不同於其他對象,它不經過V8的內存分配機制。這在於Node並不同於瀏覽器的應用場景。在瀏覽器中,JavaScript直接處理字符串即可滿足絕大多數的業務需求,而Node則需要處理網絡流和文件I/O流,操作字符串遠遠不能滿足傳輸的性能需求。
內存的分配
一切JavaScript對象都用堆來存儲
當我們在代碼中聲明變量並賦值時,所使用對象的內存就分配在堆中。如果已申請的對空閑內存不夠分配新的對象,講繼續申請堆內存,直到堆的大小超過V8的限制為止。
V8的垃圾回收機制
分代式垃圾回收
V8的垃圾回收策略主要基於「分代式垃圾回收機制」,基於這個機制,V8把內存分為「新生代(New Space)」和 「老生代 (Old Space)」。
新生代中的對象為存活時間較短的對象,老生代中的對象為存活時間較長或常駐內存的對象。
前面提及到的--max-old-space-size
命令就是設置老生代內存空間的最大值,而--max-new-space-size
命令則可以設置新生代內存空間的大小。
為什么要分成新老兩代?
垃圾回收算法有很多種,但是並沒有一種是勝任所有的場景,在實際的應用中,需要根據對象的生存周期長短不一,而使用不同的算法,已達到最好的效果。在V8中,按對象的存活時間將內存的垃圾回收進行不同的分代,然后分別對不同的內存施以更高效的算法。
新生代中的垃圾回收
在新生代中,主要通過Scavenge算法進行垃圾回收。
Scavenge
在Scavenge算法中,它將堆內存一分為二,每一部分空間稱為semispace。在這兩個semispace空間中,只有一個處於使用中,另外一個處於閑置狀態。處於使用狀態的semispace稱為From空間,處於閑置狀態的semispace稱為To空間。當我們分配對象時,先是從From空間中分配。當開始進行垃圾回收時,會檢查From空間中存活的對象,這些存活的對象會被復制到To空間中,而非存活的對象占用的空間會被釋放。完成復制后,From空間和To空間角色互換。簡而言之,在垃圾回收的過程中,就是通過將存活對象在兩個semispace空間之間進行復制。
在新生代中的對象怎樣才能到老生代中?
在新生代存活周期長的對象會被移動到老生代中,主要符合兩個條件中的一個:
1. 對象是否經歷過Scavenge回收。
對象從From空間中復制到To空間時,會檢查它的內存地址來判斷這個對象是否已經經歷過一次Scavenge回收,如果已經經歷過了,則將該對象從From空間中復制到老生代空間中。
2. To空間的內存占比超過25%限制。
當對象從From空間復制到To空間時,如果To空間已經使用超過25%,則這個對象直接復制到老生代中。這么做的原因在於這次Scavenge回收完成后,這個To空間會變成From空間,接下來的內存分配將在這個空間中進行。如果占比過高,會影響后續的內存分配。
老生代中的垃圾回收
對於老生代的對象,由於存活對象占比較大比重,使用Scavenge算法顯然不科學。一來復制的對象太多會導致效率問題,二來需要浪費多一倍的空間。所以,V8在老生代中主要采用「Mark-Sweep」算法與「Mark-Compact」算法相結合的方式進行垃圾回收。
Mark-Sweep
Mark-Sweep是標記清除的意思,分為標記和清除兩個階段。在標記階段遍歷堆中的所有對象,並標記存活的對象,在隨后的清除階段中,只清除標記之外的對象。
但是Mark-Sweep有一個很嚴重的問題,就是進行一次標記清除回收之后,內存會變得碎片化。如果需要分配一個大對象,這時候就無法完成分配了。這時候就該Mark-Compact出場了。
Mark-Compact
Mark-Compact是標記整理的意思,是在Mark-Sweep基礎上演變而來。Mark-Compact在標記存活對象之后,在整理過程中,將活着的對象往一端移動,移動完成后,直接清理掉邊界外的內存。
Incremental Marking
鑒於Node單線程的特性,V8每次垃圾回收的時候,都需要將應用邏輯暫停下來,待執行完垃圾回收后再恢復應用邏輯,被稱為「全停頓」。在分代垃圾回收中,一次小垃圾回收只收集新生代,且存活對象也相對較少,即使全停頓也沒有多大的影響。但是在老生代中,存活對象較多,垃圾回收的標記、清理、整理都需要長時間的停頓,這樣會嚴重影響到系統的性能。
所以「增量標記 (Incrememtal Marking)」被提出來。它從標記階段入手,將原本要一口氣停頓完成的動作改為增量標記,拆分為許多小「步進」,每做完一「步進」就讓JavaScript應用邏輯執行一小會,垃圾回收與應用邏輯這樣交替執行直到標記階段完成。
內存泄露排查的工具
node-heapdump
它允許對V8堆內存抓取快照,用於事后分析。
在程序中引入
var heapdump = require("node-heapdump");
之后可以通過向服務器發送SIGUSR2信號,讓node-heapdump抓拍一份堆內存的快照:
$ kill -USR2 <pid>
這份抓拍的快照會默認存放在文件目錄下,這是一份大JSON文件,可以通過Chrome的開發者工具打開查看。
node-memwatch
需要注意,node-memwatch只是支持到node v0.12.x為止,當使用更高的版本的時候,就會安裝不上,這時候可以使用node-watch-next 替代,一摸一樣的API。
不同於node-heapdump,它提供了兩個事件監聽器,用來提供內存泄露的以及垃圾回收的信息:
-
stats事件:每次進行全堆回收時,會觸發改時間,傳遞內存的統計信息
-
leak事件:經過五次垃圾回收之后,內存仍沒有被釋放的對象,會觸發leak事件,傳遞相關的信息。
node-profiler
node-profiler 是 alinode團隊出品的一個與node-heapdump類似的抓取內存堆快照的工具,不同的是,node-profiler的實現不一樣,使用起來更便捷。附上他們的教程:如何使用Node Profiler
alinode
alinode官方如似說:
alinode 是阿里雲出品的 Node.js 應用服務解決方案,是一套基於社區 Node 改進的運行時環境和服務平台。在社區的基礎上我們內建了強大的支持功能,幫助開發者迅速洞見性能細節,快速定位疑難雜症,直探問題根源。