深入探究Node | (4)“內(nèi)存控制” 有十五問
本文轉(zhuǎn)載自微信公眾號「前端陽光」,作者事業(yè)有成的張啦啦 。轉(zhuǎn)載本文請聯(lián)系前端陽光公眾號。
1. V8是用什么給對象分配內(nèi)存的呢?
2. V8為何要限制堆的大小?
3. 原來如此,那你知道垃圾回收機(jī)制的策略是什么嗎?
4. 為什么要分代呢?
5. 哦,那你談?wù)勈窃趺捶执?
6. 那 新生代是怎么回收的?
7. 很好奇,一個新生代它是怎么晉升成老生代的。
8. 為什么要設(shè)置25%這個這么低的值呢?
9. 新生代的對象晉升后就成老生代了,那老生代為什么不能用Scavenge回收?
10. 那老生代的對象該怎么處理?
11. 那為什么還要標(biāo)記整理?
12. 咦!既然標(biāo)記整理是基于標(biāo)記清除上演變而來的,也就是它包括了標(biāo)記清除,這么棒,那就用標(biāo)記整理好了,干嘛還要說它結(jié)合標(biāo)記清除使用呢?
13. 原來是這樣啊,要是垃圾回收算法時間花費(fèi)很長,豈不是就要卡頓?
14. 你知道Buffer對象嗎?Buffer對象是通過V8分配內(nèi)存的嗎?
15. 可以利用fs.readFile()和fs.writeFile()方法 來 讀寫大文件嗎?
1. V8是用什么給對象分配內(nèi)存的呢?
在V8中,所有的JavaScript對象都是通過堆來進(jìn)行分配的。Node提供了V8中內(nèi)存使用量的查看方式,執(zhí)行下面的代碼,將得到輸出的內(nèi)存信息:
- $ node
- > process.memoryUsage();
- { rss: 14958592,
- heapTotal: 7195904,
- heapUsed: 2821496 }
在上述代碼中,在memoryUsage()方法返回的3個屬性中,heapTotal和heapUsed是V8的堆內(nèi)存使用情況,前者是已申請到的堆內(nèi)存,后者是當(dāng)前使用的量。至于rss為何,我們在后續(xù)的內(nèi)容中會介紹到。V8的堆示意圖:
當(dāng)我們在代碼中聲明變量并賦值時,所使用對象的內(nèi)存就分配在堆中。如果已申請的堆空閑內(nèi)存不夠分配新的對象,將繼續(xù)申請堆內(nèi)存,直到堆的大小超過V8的限制為止。
2. V8為何要限制堆的大小?
表層原因為V8最初為瀏覽器而設(shè)計,不太可能遇到用大量內(nèi)存的場景。對于網(wǎng)頁來說,V8的限制值已經(jīng)綽綽有余。
深層原因是V8的垃圾回收機(jī)制的限制。按官方的說法,以1.5 GB的垃圾回收堆內(nèi)存為例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。這是垃圾回收中引起JavaScript線程暫停執(zhí)行的時間,在這樣的時間花銷下,應(yīng)用的性能和響應(yīng)能力都會直線下降。這樣的情況不僅僅后端服務(wù)無法接受,前端瀏覽器也無法接受。因此,在當(dāng)時的考慮下直接限制堆內(nèi)存是一個好的選擇。
3. 原來如此,那你知道垃圾回收機(jī)制的策略是什么嗎?
V8的垃圾回收策略主要基于分代式垃圾回收機(jī)制。
4. 為什么要分代呢?
因為在實際的應(yīng)用中,對象的生存周期長短不一,不同的算法只能針對特定情況具有最好的效果。為此,現(xiàn)代的垃圾回收算法中按對象的存活時間將內(nèi)存的垃圾回收進(jìn)行不同的分代,然后分別對不同分代的內(nèi)存施以更高效的算法。
5. 哦,那你談?wù)勈窃趺捶执?
在V8中,主要將內(nèi)存分為新生代和老生代兩代。新生代中的對象為存活時間較短的對象,老生代中的對象為存活時間較長或常駐內(nèi)存的對象。圖為V8的分代示意圖。
6. 那新生代是怎么回收的?
在分代的基礎(chǔ)上,新生代中的對象主要通過Scavenge算法進(jìn)行垃圾回收。是一種采用復(fù)制的方式實現(xiàn)的垃圾回收算法。
它將堆內(nèi)存一分為二,每一部分空間稱為semispace。在這兩個semispace空間中,只有一個處于使用中,另一個處于閑置狀態(tài)。處于使用狀態(tài)的semispace空間稱為From空間,處于閑置狀態(tài)的空間稱為To空間。當(dāng)我們分配對象時,先是在From空間中進(jìn)行分配。當(dāng)開始進(jìn)行垃圾回收時,會檢查From空間中的存活對象,這些存活對象將被復(fù)制到To空間中,而非存活對象占用的空間將會被釋放。完成復(fù)制后,F(xiàn)rom空間和To空間的角色發(fā)生對換。
簡而言之,在垃圾回收的過程中,就是通過將存活對象在兩個semispace空間之間進(jìn)行復(fù)制。
Scavenge的缺點(diǎn)是只能使用堆內(nèi)存中的一半,這是由劃分空間和復(fù)制機(jī)制所決定的。但Scavenge由于只復(fù)制存活的對象,并且對于生命周期短的場景存活對象只占少部分,所以它在時間效率上有優(yōu)異的表現(xiàn)。
由于Scavenge是典型的犧牲空間換取時間的算法,所以無法大規(guī)模地應(yīng)用到所有的垃圾回收中。但可以發(fā)現(xiàn),Scavenge非常適合應(yīng)用在新生代中,因為新生代中對象的生命周期較短,恰恰適合這個算法。
是故,V8的堆內(nèi)存示意圖應(yīng)當(dāng)如圖所示。
當(dāng)一個對象經(jīng)過多次復(fù)制依然存活時,它將會被認(rèn)為是生命周期較長的對象。這種較長生命周期的對象隨后會被移動到老生代中,采用新的算法進(jìn)行管理。對象從新生代中移動到老生代中的過程稱為晉升。
7. 很好奇,一個新生代它是怎么晉升成老生代的。
對象晉升的條件主要有兩個,一個是對象是否經(jīng)歷過Scavenge回收,一個是To空間的內(nèi)存占用比超過限制。
在默認(rèn)情況下,V8的對象分配主要集中在From空間中。對象從From空間中復(fù)制到To空間時,會檢查它的內(nèi)存地址來判斷這個對象是否已經(jīng)經(jīng)歷過一次Scavenge回收。如果已經(jīng)經(jīng)歷過了,會將該對象從From空間復(fù)制到老生代空間中,如果沒有,則復(fù)制到To空間中。這個晉升流程如圖所示。
另一個判斷條件是To空間的內(nèi)存占用比。當(dāng)要從From空間復(fù)制一個對象到To空間時,如果To空間已經(jīng)使用了超過25%,則這個對象直接晉升到老生代空間中,這個晉升的判斷示意圖如圖所示。
8. 為什么要設(shè)置25%這個這么低的值呢?
設(shè)置25%這個限制值的原因是當(dāng)這次Scavenge回收完成后,這個To空間將變成From空間,接下來的內(nèi)存分配將在這個空間中進(jìn)行。如果占比過高,會影響后續(xù)的內(nèi)存分配。
9. 新生代的對象晉升后就成老生代了,那老生代為什么不能用Scavenge回收?
對于老生代中的對象,由于存活對象占較大比重,再采用Scavenge的方式會有兩個問題:一個是存活對象較多,復(fù)制存活對象的效率將會很低;另一個問題依然是浪費(fèi)一半空間的問題。這兩個問題導(dǎo)致應(yīng)對生命周期較長的對象時Scavenge會顯得捉襟見肘。
10. 那老生代的對象該怎么處理?
V8在老生代中主要采用了Mark-Sweep和Mark-Compact相結(jié)合的方式進(jìn)行垃圾回收。
Mark-Sweep是標(biāo)記清除的意思,它分為標(biāo)記和清除兩個階段。與Scavenge相比,Mark-Sweep并不將內(nèi)存空間劃分為兩半,所以不存在浪費(fèi)一半空間的行為。與Scavenge復(fù)制活著的對象不同,Mark-Sweep在標(biāo)記階段遍歷堆中的所有對象,并標(biāo)記活著的對象,在隨后的清除階段中,只清除沒有被標(biāo)記的對象。可以看出,Scavenge中只復(fù)制活著的對象,而Mark-Sweep只清理死亡對象。活對象在新生代中只占較小部分,死對象在老生代中只占較小部分,這是兩種回收方式能高效處理的原因。圖為Mark-Sweep在老生代空間中標(biāo)記后的示意圖,黑色部分標(biāo)記為死亡的對象。
11. 那為什么還要標(biāo)記整理?
Mark-Sweep最大的問題是在進(jìn)行一次標(biāo)記清除回收后,內(nèi)存空間會出現(xiàn)不連續(xù)的狀態(tài)。這種內(nèi)存碎片會對后續(xù)的內(nèi)存分配造成問題,因為很可能出現(xiàn)需要分配一個大對象的情況,這時所有的碎片空間都無法完成此次分配,就會提前觸發(fā)垃圾回收,而這次回收是不必要的。
為了解決Mark-Sweep的內(nèi)存碎片問題,Mark-Compact被提出來。Mark-Compact是標(biāo)記整理的意思,是在Mark-Sweep的基礎(chǔ)上演變而來的。它們的差別在于對象在標(biāo)記為死亡后,在整理的過程中,將活著的對象往一端移動,移動完成后,直接清理掉邊界外的內(nèi)存。圖為Mark-Compact完成標(biāo)記并移動存活對象后的示意圖,白色格子為存活對象,深色格子為死亡對象,淺色格子為存活對象移動后留下的空洞。
完成移動后,就可以直接清除最右邊的存活對象后面的內(nèi)存區(qū)域完成回收。
12. 咦!既然標(biāo)記整理是基于標(biāo)記清除上演變而來的,也就是它包括了標(biāo)記清除,這么棒,那就用標(biāo)記整理好了,干嘛還要說它結(jié)合標(biāo)記清除使用呢?
這里將Mark-Sweep和Mark-Compact結(jié)合著介紹不僅僅是因為兩種策略是遞進(jìn)關(guān)系,在V8的回收策略中兩者是結(jié)合使用的。表是目前介紹到的3種主要垃圾回收算法的簡單對比。
從表中可以看到,在Mark-Sweep和Mark-Compact之間,由于Mark-Compact需要移動對象,所以它的執(zhí)行速度不可能很快,所以在取舍上,V8主要使用Mark-Sweep,在空間不足以對從新生代中晉升過來的對象進(jìn)行分配時才使用Mark-Compact。
13. 原來是這樣啊,要是垃圾回收算法時間花費(fèi)很長,豈不是就要卡頓?
垃圾回收的3種基本算法都需要將應(yīng)用邏輯暫停下來,待執(zhí)行完垃圾回收后再恢復(fù)執(zhí)行應(yīng)用邏輯,這種行為被稱為“全停頓”(stop-the-world)。在V8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默認(rèn)配置得較小,且其中存活對象通常較少,所以即便它是全停頓的影響也不大。
但V8的老生代通常配置得較大,且存活對象較多,全堆垃圾回收(full垃圾回收)的標(biāo)記、清理、整理等動作造成的停頓就會比較可怕,需要設(shè)法改善。
為了降低全堆垃圾回收帶來的停頓時間,V8先從標(biāo)記階段入手,將原本要一口氣停頓完成的動作改為增量標(biāo)記(incremental marking),也就是拆分為許多小“步進(jìn)”,每做完一“步進(jìn)”就讓JavaScript應(yīng)用邏輯執(zhí)行一小會兒,垃圾回收與應(yīng)用邏輯交替執(zhí)行直到標(biāo)記階段完成。圖為增量標(biāo)記示意圖。
V8在經(jīng)過增量標(biāo)記的改進(jìn)后,垃圾回收的最大停頓時間可以減少到原本的1/6左右。V8后續(xù)還引入了延遲清理(lazy sweeping)與增量式整理(incrementalcompaction),讓清理與整理動作也變成增量式的。同時還計劃引入并行標(biāo)記與并行清理,進(jìn)一步利用多核性能降低每次停頓的時間。
14. 你知道Buffer對象嗎?Buffer對象是通過V8分配內(nèi)存的嗎?
知道。他不是。
為何Buffer對象并非通過V8分配?這在于Node并不同于瀏覽器的應(yīng)用場景。在瀏覽器中,JavaScript直接處理字符串即可滿足絕大多數(shù)的業(yè)務(wù)需求,而Node則需要處理網(wǎng)絡(luò)流和文件I/O流,操作字符串遠(yuǎn)遠(yuǎn)不能滿足傳輸?shù)男阅苄枨蟆?/p>
關(guān)于Buffer的細(xì)節(jié) 后面再仔細(xì)講解一下。
所以,從這里我們可以知道,Node的內(nèi)存構(gòu)成主要由通過V8進(jìn)行分配的部分和Node自行分配的部分。受V8的垃圾回收限制的主要是V8的堆內(nèi)存。
15. 可以利用fs.readFile()和fs.writeFile()方法 來 讀寫大文件嗎?
由于V8的內(nèi)存限制,我們無法通過fs.readFile()和fs.writeFile()直接進(jìn)行大文件的操作,而改用fs.createReadStream()和fs.createWriteStream()方法通過流的方式實現(xiàn)對大文件的操作。
下面的代碼展示了如何讀取一個文件,然后將數(shù)據(jù)寫入到另一個文件的過程:
由于讀寫模型固定,上述方法有更簡潔的方式,具體如下所示:
圖片可讀流提供了管道方法pipe(),封裝了data事件和寫入操作。通過流的方式,上述代碼不會受到V8內(nèi)存限制的影響,有效地提高了程序的健壯性。如果不需要進(jìn)行字符串層面的操作,則不需要借助V8來處理,可以嘗試進(jìn)行純粹的Buffer操作,這不會受到V8堆內(nèi)存的限制。但是這種大片使用內(nèi)存的情況依然要小心,即使V8不限制堆內(nèi)存的大小,物理內(nèi)存依然有限制。
14. 你知道Buffer對象嗎?Buffer對象是通過V8分配內(nèi)存的嗎?
知道。他不是。
為何Buffer對象并非通過V8分配?這在于Node并不同于瀏覽器的應(yīng)用場景。在瀏覽器中,JavaScript直接處理字符串即可滿足絕大多數(shù)的業(yè)務(wù)需求,而Node則需要處理網(wǎng)絡(luò)流和文件I/O流,操作字符串遠(yuǎn)遠(yuǎn)不能滿足傳輸?shù)男阅苄枨蟆?/p>
關(guān)于Buffer的細(xì)節(jié) 后面再仔細(xì)講解一下。
所以,從這里我們可以知道,Node的內(nèi)存構(gòu)成主要由通過V8進(jìn)行分配的部分和Node自行分配的部分。受V8的垃圾回收限制的主要是V8的堆內(nèi)存。
15. 可以利用fs.readFile()和fs.writeFile()方法 來 讀寫大文件嗎?
由于V8的內(nèi)存限制,我們無法通過fs.readFile()和fs.writeFile()直接進(jìn)行大文件的操作,而改用fs.createReadStream()和fs.createWriteStream()方法通過流的方式實現(xiàn)對大文件的操作。
下面的代碼展示了如何讀取一個文件,然后將數(shù)據(jù)寫入到另一個文件的過程:
由于讀寫模型固定,上述方法有更簡潔的方式,具體如下所示:
可讀流提供了管道方法pipe(),封裝了data事件和寫入操作。通過流的方式,上述代碼不會受到V8內(nèi)存限制的影響,有效地提高了程序的健壯性。如果不需要進(jìn)行字符串層面的操作,則不需要借助V8來處理,可以嘗試進(jìn)行純粹的Buffer操作,這不會受到V8堆內(nèi)存的限制。但是這種大片使用內(nèi)存的情況依然要小心,即使V8不限制堆內(nèi)存的大小,物理內(nèi)存依然有限制。