大多數時候,js開發者其實根本無須接觸垃圾回收機制或內存管理機制等問題,因為曾經的js僅僅應用于客戶端瀏覽器(現在的絕大多數前端開發場景同樣也是),瀏覽器端幾乎絕少出現垃圾回收對我們的網站性能構成較大影響的情況,即時發生內存泄漏的情況,比如早期的低版本IE與DOM交互可能發生的內存泄漏問題,頁面的卡頓估計也迫使用戶進行刷新操作了,且瀏覽器端的應用運行時間也比較短,進程一旦退出,內存也會自動釋放,幾乎沒有內存管理的必要了。
然而,2008年Chrome開源了V8引擎,這款性能極高的JavaScript引擎促使了Node.js的誕生并且得到了快速的普及(事實上,V8在接下來的性能跑分中一直處于領先地位,一改以往JavaScript的性能低下的形象),如今Node.js的使用覆蓋了各個端,各種應用,Node.js成為了前端開發的必學技術。現在撇開短時間運行的CLI工具或是桌面應用不談,考慮服務器上部署的Node.js應用,此時,內存資源“寸土寸金”,在海量的用戶請求和長時間的運行場景下,即時是很小的內存使用不恰當,也可能會造成非常嚴重的性能問題,這時候就需要Node.js開發者們熟悉V8的內存管理模式和垃圾回收機制了。
V8的內存限制
首先需要說明的是,V8限制了所能使用的內存極限(64位系統下約為1.4GB,32位系統下約為0.7GB),所以在使用nodejs進行服務端開發的時候不能直接進行大內存對象的操作。V8限制內存使用上限的原因,表面上看是因為V8最初是為瀏覽器的js引擎設計,如前言中所說,瀏覽器端對內存的使用需求很小,V8設計的內存使用大小在瀏覽器端上運行起來綽綽有余,但是其更深層次的原因還是受V8的垃圾回收機制的限制,在后文中會具體說明。
V8內存使用策略
V8的內存配置和JVM一樣,都是分配在堆內存中的,可以使用process.memoryUsage()方法查看V8的內存使用情況(單位:字節),下面將單位轉換后輸出查看:
function memUsage() {
const memory = process.memoryUsage();
console.log('=============================');
console.log(`heapTotal:${(memory.heapTotal / 1024 / 1024).toFixed(2)}MB`);
console.log(`heapUsed: ${(memory.heapUsed / 1024 / 1024).toFixed(2)}MB`);
console.log(`rss: ${(memory.rss / 1024 / 1024).toFixed(2)}MB`);
}
memUsage();
可以在你自己的電腦上輸出查看一下,我的如下:
如上圖所示,各個字段的含義為:
- heapTotal:V8已申請到的堆內存
- heapUsed:當前內存使用量
- rss:官網解釋:駐留集大小, 是給這個進程分配了多少物理內存(占總分配內存的一部分) 這些物理內存中包含堆,棧,和代碼段。簡單說:進程的常駐內存(node所占的內存)
可以看到當前V8申請到的堆內存很小,只有7MB不到,現在嘗試擴大內存花銷,看看擴大過程中堆內存使用情況的變化,嘗試執行以下代碼:
function memoryAnalyse() {
const setArray = () => {
// 設置一個超大長度的數組
let size = 30 * 1024 * 1024;
let array = new Array(size);
// 不停地往數組中塞值,這將引起內存花銷的迅速增大
for (let i = 0; i < size; i ++) {
array[i] = 0;
}
return array;
}
// 連續設置8個上面所示的超大數組
for (let j = 0; j < 8; j++) {
setArray();
memUsage(); // 輸出內存使用情況
}
}
ok,執行以上代碼得到以下結果
可以看到每次設置一個超大數組時,heapTotal都增加了大概240MB,并且heapUsed基本等于heapTotal,heapTotal最大時達到了1GB以上了,再往后heapTotal反而減小了。上述過程意味著:V8并不是一開始就申請到其內存上限的大小的,而是在當前堆內存使用已滿時再申請更多的堆內存,直至V8的堆內存使用上限,當達到上限之后內存溢出了。這里竟然出現了內存中數據被銷毀的問題!(在不同的V8和node版本中,內存溢出的處理情況可能不一致,某些情況下會出現內存溢出后,內存因無法繼續分配導致循環都無法繼續執行),總之:千萬注意內存使用情況,避免內存溢出!
當然,V8并沒有對內存的限制進行完全封死,它提供了擴大內存上限的選項,網上查看的資料所示的命令都是如下兩條:
node --max-old-space-size=2048 memory.js // 設置老生代內存最大限制,單位:MB
node --max-new-space-size=1024 memory.js // 設置新生代內存最大上限,單位:MB
也許是不同系統或node版本下命令不同,我的兩條命令如下所示:
--max_old_space_size // 老生代內存最大限制
--min_semi_space_size // 新生代中單個semi-space的內存最大上限(一共兩個semi-space)
具體可以使用以下命令查看自己電腦的版本下命令是什么:
node --v8-options
可以看到終端顯示了大量關于V8的選項及其含義:
上面提到了新生代內存、老生代內存以及semi-space的概念,接下來詳細解釋這些東西。
V8的垃圾回收機制
V8的堆其實并不只是由老生代和新生代兩部分構成,可以將堆分為以下幾個不同的區域:
- 新生代內存區:存儲存活時間較短的對象,這個區域很小但是垃圾回收特別頻繁
- 老生代指針區:屬于老生代,這里包含了大多數可能存在指向其他對象的指針的對象,大多數從新生代晉升的對象會被移動到這里
- 老生代數據區:屬于老生代,這里只保存原始數據對象,這些對象沒有指向其他對象的指針
- 大對象區:這里存放體積超越其他區大小的對象,每個對象有自己的內存,垃圾回收其不會移動大對象
- 代碼區:代碼對象,也就是包含JIT之后指令的對象,會被分配在這里。唯一擁有執行權限的內存區
- Cell區、屬性Cell區、Map區:存放Cell、屬性Cell和Map,每個區域都是存放相同大小的元素,結構簡單
垃圾回收器只會針對新生代內存區、老生代指針區以及老生代數據區進行垃圾回收。所以本文的討論中可以將V8的內存劃分簡化為以下所示:
上文提到的--max-old-space-size就是設置老生代內存區最大尺寸的命令,--max-new-space-size為設置新生代內存最大尺寸的命令,但是這兩個值只能在啟動進程時就設置好,并不能在應用運行過程中實時調控。
V8的垃圾回收器對新生代和老生代采取了兩種不同的回收算法:
新生代垃圾回收策略
新生代中的對象主要通過Scavenge算法進行回收,在Scavenge算法的實現中,主要采用了Cheney算法。
Cheney算法是一種采用復制的方式實現的垃圾回收算法。它將內存一分為二,每一部分空間稱為semi-space。在這兩個semi-space中,一個處于使用狀態,另一個處于閑置狀態。處于使用狀態的semi-space空間稱為From空間,處于閑置狀態的空間稱為To空間。當我們分配對象時,先是在From空間中進行分配;當開始進行垃圾回收算法時,會檢查From空間中的存活對象,這些存活對象將會被復制到To空間中(復制完成后會進行緊縮),而非存活對象占用的空間將會被釋放。完成復制后,From空間和To空間的角色發生對換(稱為翻轉)。簡而言之,在垃圾回收的過程中,就是通過將存活對象在兩個semi-space之間進行復制。
很明顯可以看出:Scavenge算法只能使用堆內存的一半,但是由于新生代中的對象的存活時間一般較短,所以存活對象占新生代所有對象的較小部分,這樣復制所需要的開銷就很小,這是一種典型的犧牲空間換取時間的算法。
現在V8的內存劃分大致是如下情況了:
如何判斷對象是否存活?
如何判斷一個對象是否存活是垃圾回收中最根本的問題。存活對象的條件為:當且僅當它被一個根對象或另一個活對象所指向。根對象永遠是活對象,它是被瀏覽器或V8所引用的對象。被局部變量所指向的對象也屬于根對象,因為它們所在的作用域對象被視為根對象。全局對象(Node中為global,瀏覽器中為window)自然是根對象。瀏覽器中的DOM元素也屬于根對象。
對象復制過程
首先將From區的根對象直接指向的對象復制進To區中,To區有兩個指針:scanPtr和allocationPtr。scanPtr指向即將掃描的存活對象 ,allocationPtr指向即將為新對象分配內存的地方。scanPtr循環掃描To中的對象,判斷對象是否有別的指向并且確定指向哪里,若其對象有指向并且指向的是From區對象,則從From區復制這個被指向的對象進To空間的allocationPtr位置,scanPtr移向下一個存活對象,allocationPtr移向下一個空閑位置。對象復制過程采用的廣度優先算法,從根對象出發,遍歷所有能達到的對象。我們先假設有一個程序的對象引用情況如下所示:
其復制過程大致如下所示:
-
From區的對象存儲為:
7.jpg -
From區中根對象直接指向的對象復制進To區,此時To區為:
8.jpg -
scanPtr掃描A對象,發現有指向對象D,且D在From區中,則復制D進To區的allocationPtr所在位置,如下所示:
9.jpg -
scanPtr掃描B對象,發現指向E和F,且都在From區,則依次復制進To區中:
10.jpg -
scanPtr掃描D對象,沒有發現指向,繼續掃描E,發現指向G,且G在From區,則復制G進To區中:
11.jpg -
scanPtr掃描F對象,沒有發現指向,繼續掃描G,也沒有發現指向,scanPtr繼續下移,于是此時和allocationPtr指向了同一塊位置,則此輪復制已結束:
12.jpg 此時From區和To區的情況如下所示,由于C對象沒有任何指向,也不被任何對象指向,所以垃圾回收器將視其為非存活對象進行回收:
寫屏障
如果新生代中的一個對象只有一個指向它的指針,而這個指針在老生代中,我們如何判斷這個新生代的對象是否存活?為了解決這個問題,需要建立一個列表用來記錄所有老生代對象指向新生代對象的情況。每當有老生代對象指向新生代對象的時候,我們就記錄下來
對象晉升
當一個對象經過多次Scavenge算法進行復制后,還處于存活狀態,則說明這個對象存活時間較長,應該被移至老生代內存中。對象從新生代內存中被移至老生代內存中的過程稱為晉升。對象晉升有兩種情況:
- From區復制某對象時,會檢查它的內存地址來判斷這個對象是否已經經歷過一次Scavenge的回收,若是,則進行晉升,否則復制到To區中;
- 對象在被復制到To時,如果To區的空間已經被使用了超過25%,那么這個對象直接進行晉升。
至此,新生代的垃圾回收過程已完成。老生代的垃圾回收策略在下一篇中進行討論。
PS. 若本文有任何錯誤之處,歡迎指出!