淺談JS內(nèi)存機(jī)制
大廠技術(shù)??堅(jiān)持周更??精選好文
前言
隨著web的發(fā)展與普及,前端頁(yè)面不僅只加載在瀏覽器上,也慢慢流行于各種app的webview里。尤其在如今設(shè)備性能越來(lái)越好的條件下,前端頁(yè)面更是開(kāi)始在app中擔(dān)任重要的角色。如此一來(lái),前端頁(yè)面的停留時(shí)間變得更長(zhǎng),我們理應(yīng)越發(fā)重視前端的內(nèi)存管理,防止內(nèi)存泄露,提高頁(yè)面的性能。
想要打造高性能前端應(yīng)用,防止崩潰,就必須得搞清楚JS的內(nèi)存機(jī)制,其實(shí)就是弄清楚JS內(nèi)存的分配與回收。
JS數(shù)據(jù)存儲(chǔ)機(jī)制
內(nèi)存空間

從圖中可以看出, 在 JavaScript 的執(zhí)行過(guò)程中, 主要有三種類型內(nèi)存空間,分別是代碼空間、??臻g和堆空間。
代碼空間:用來(lái)存放可執(zhí)行代碼
棧空間:一塊連續(xù)的內(nèi)存區(qū)域,容量較小,讀取速度快,被設(shè)計(jì)成先進(jìn)后出結(jié)構(gòu)
堆空間:不連續(xù)的內(nèi)存區(qū)域,容量較大,用于儲(chǔ)存大數(shù)據(jù),讀取速度慢
數(shù)據(jù)類型

JavaScript 發(fā)展至今總共有八種數(shù)據(jù)類型,其中 Object 類型稱為引用類型,其余七種稱為基本類型,Object 是由其余七種基本類型組成的kv結(jié)構(gòu)數(shù)據(jù)。
棧空間和堆空間
??臻g其實(shí)就是 JavaScript 中的調(diào)用棧,是用來(lái)儲(chǔ)存執(zhí)行上下文,以及存儲(chǔ)執(zhí)行上下文中的一些基本類型中的小數(shù)據(jù),如下圖所示:

變量環(huán)境: 存放var聲明與函數(shù)聲明的變量空間,編譯時(shí)就能確定,不受塊級(jí)作用域影響
詞法環(huán)境: 存放let與const聲明的變量空間,編譯時(shí)不能完全確定,受塊級(jí)作用域影響
而堆空間,則是用來(lái)儲(chǔ)存大數(shù)據(jù)如引用類型,然后把他們的引用地址保存到??臻g的變量中,所以多了這一道中轉(zhuǎn),JavaScript 對(duì)堆空間數(shù)據(jù)的讀取自然會(huì)比??臻g數(shù)據(jù)的要慢,可以用下圖表示兩者關(guān)系:
通常情況下,??臻g都不會(huì)設(shè)置太大,這是因?yàn)?JavaScript 引擎需要用棧來(lái)維護(hù)程序執(zhí)行期間上下文的狀態(tài),如果??臻g大了的話,所有的數(shù)據(jù)都存放在??臻g里面,那么會(huì)影響到上下文切換的效率,進(jìn)而又影響到整個(gè)程序的執(zhí)行效率。
閉包
內(nèi)部函數(shù)總是可以訪問(wèn)其外部函數(shù)中聲明的變量,當(dāng)通過(guò)調(diào)用一個(gè)外部函數(shù)返回一個(gè)內(nèi)部函數(shù)后,即使該外部函數(shù)已經(jīng)執(zhí)行結(jié)束了,但是內(nèi)部函數(shù)引用外部函數(shù)的變量依然保存在內(nèi)存中,我們就把這些變量的集合稱為閉包
閉包中的數(shù)據(jù)會(huì)組成一個(gè)對(duì)象,然后保存在堆空間中,如:

可以利用開(kāi)發(fā)者工具查看閉包情況,其中括號(hào)中的名稱就是產(chǎn)生閉包的函數(shù)名。一般我們會(huì)認(rèn)為閉包是返回的內(nèi)部函數(shù)引用的變量集合,但閉包有一個(gè)較為迷惑的情況,如下:




可以理解為,如果函數(shù)存在閉包,其所有內(nèi)部函數(shù)都會(huì)擁有一個(gè)指向這個(gè)閉包的引用,即所有內(nèi)部函數(shù)會(huì)共享同一個(gè)閉包,只要任意內(nèi)部函數(shù)有引用外部函數(shù)中聲明的變量,這個(gè)變量都會(huì)被納入閉包內(nèi),而且最內(nèi)部的函數(shù)會(huì)持有所有外部的閉包。
堆棧存放的數(shù)據(jù)類型
原始類型的數(shù)據(jù)是存放在棧中,引用類型的數(shù)據(jù)是存放在堆中?
上面這句話是用來(lái)描述棧中數(shù)據(jù)的存儲(chǔ)情況,調(diào)用棧中的引用類型存放在堆中,相信大家都沒(méi)有問(wèn)題,但是原始類型真的都存放在棧中嗎?
數(shù)字
V8把數(shù)字分成兩種類型:smi 和 heapNumber
smi是范圍為 :-231 到 231-1的整數(shù),在棧中直接存值;除了smi,其余數(shù)字類型都是heapNumber,需要另外開(kāi)辟堆空間進(jìn)行儲(chǔ)存,變量保存其引用。
var?times?=?50000;
var?smi_in_stack?=?1;
var?heap_number?=?1.1;
//?about?1.5~1.6ms,?fast
console.time('smi_in_stack');
for?(let?i?=?0;?i?times;?i++)?{
??smi_in_stack++;
}
console.timeEnd('smi_in_stack');
//?about?2.1~2.5ms,?slow
console.time('heap_number');
for?(let?i?=?0;?i?times;?i++)?{
??heap_number++;
}
console.timeEnd('heap_number');
同時(shí)我們可以通過(guò)heap snapshots觀察到heap_number的存在,所以驗(yàn)證了棧中的heapNumber值是存在堆中,smi值是直接存在棧中。
更基本的基本類型
V8定義了一種 oddball[1] 類型,屬于 oddball 類型的有null、undefined、true和false
function?BasicType()?{
??this.oddBall1?=?true;
??this.oddBall2?=?false;
??this.oddBall3?=?undefined;
??this.oddBall4?=?null;
??this.oddBall5?=?'';
}
const?obj1?=?new?BasicType();
const?obj2?=?new?BasicType();

這里可以看到oddball類型以及空字符串的堆引用全部都是一個(gè)固定值,代表在V8跑起來(lái)的第一時(shí)間,不管我們有沒(méi)有聲明這些基本類型,他們都已經(jīng)在堆中被創(chuàng)建完畢了。由此猜想棧中這些類型使用的也是堆中的地址。
function?Obj()?{
??this.string?=?'str';
??this.num1?=?1;
??this.num2?=?1.1;
??this.bigInt?=?BigInt('1');
??this.symbol?=?Symbol('1');
}
const?obj?=?new?Obj();
debugger;
obj.string?=?'other?str';
obj.num1?=?2;
obj.num2?=?1;
obj.bigInt?=?BigInt('2');
obj.symbol?=?Symbol('2');

debugger后內(nèi)存快照

其中bigInt、string、symbol的內(nèi)存地址都進(jìn)行了更換,由此可以猜想是因?yàn)檫@三種類型占用的內(nèi)存大小不是一個(gè)固定值,需要根據(jù)其值進(jìn)行動(dòng)態(tài)分配,所以內(nèi)存地址會(huì)進(jìn)行更換;而heapNumber的內(nèi)存地址并沒(méi)有發(fā)生變化,這個(gè)更換值的操作還是在原來(lái)的內(nèi)存空間中進(jìn)行。因?yàn)闂J且粔K連續(xù)的內(nèi)存空間,不希望運(yùn)行中會(huì)產(chǎn)生內(nèi)存碎片,由此可以得出bigInt、string、symbol這些內(nèi)存大小不固定的類型在棧中也是保存其堆內(nèi)存的引用。同時(shí)我們?cè)跅V锌梢月暶骱艽蟮膕tring,如果string存放在棧中明顯也不合理
故??臻g中的基本類型儲(chǔ)存位置如下:
| 類型 | 儲(chǔ)存位置 |
|---|---|
| Number | smi儲(chǔ)存棧中,heapNumber儲(chǔ)存堆中 |
| String | 堆 |
| Boolean | 堆 |
| Null | 堆 |
| undefined | 堆 |
| BigInit | 堆 |
| Symbol | 堆 |
上述結(jié)論主要是從heap snapshots和棧的特性中得出,畢竟最正確的答案是在源碼中獲得,如有不當(dāng),請(qǐng)指正。
JS內(nèi)存回收
棧內(nèi)存回收
function?fn1()?{
??//....
??function?fn2()?{
????//...
??}
??fn2();
}
fn1();
調(diào)用棧中有一個(gè)記錄當(dāng)前執(zhí)行狀態(tài)的指針(稱為 ESP),隨著函數(shù)的執(zhí)行,函數(shù)執(zhí)行上下文被壓入調(diào)用棧中,執(zhí)行上下文中的數(shù)據(jù)會(huì)按照前面說(shuō)的JS數(shù)據(jù)存儲(chǔ)機(jī)制被分配到堆棧中,ESP會(huì)指向最后壓棧的執(zhí)行上下文,如左圖所示的fn2函數(shù)。當(dāng)fn2函數(shù)調(diào)用完畢,JS 會(huì)把ESP指針下移至fn1函數(shù),這個(gè)指針下移的操作就是銷毀fn1函數(shù)執(zhí)行上下文的過(guò)程。最后fn1函數(shù)執(zhí)行上下文所占用的區(qū)域會(huì)變成無(wú)效區(qū)域,下一個(gè)函數(shù)執(zhí)行上下文壓入調(diào)用棧的時(shí)候會(huì)直接覆蓋其內(nèi)存空間。簡(jiǎn)而言之,只要函數(shù)調(diào)用結(jié)束,該棧內(nèi)存就會(huì)自動(dòng)被回收,不需要我們操心。剛剛我們也聊到閉包,如果出現(xiàn)閉包的情況,閉包的數(shù)據(jù)就會(huì)組成一個(gè)對(duì)象保存在堆空間里。
堆內(nèi)存回收
內(nèi)存垃圾回收領(lǐng)域中有個(gè)重要術(shù)語(yǔ):代際假說(shuō),其有以下兩個(gè)特點(diǎn):
大部分對(duì)象在內(nèi)存中存在的時(shí)間很短,簡(jiǎn)單來(lái)說(shuō),就是很多對(duì)象一經(jīng)分配內(nèi)存,很快就變得不可訪問(wèn);
不死的對(duì)象,會(huì)活得更久。
基于代際假說(shuō),JS 把堆空間分成新生代和老生代兩個(gè)區(qū)域,新生代中存放的是生存時(shí)間短的對(duì)象,通常只支持 1~8M 的容量;老生代中存放的生存時(shí)間長(zhǎng)的對(duì)象,一些大的數(shù)據(jù)也會(huì)被直接分配到老生區(qū)中。而針對(duì)這兩個(gè)區(qū)域,JS 存在兩個(gè)垃圾回收器:主垃圾處理器和副垃圾處理器。這里先說(shuō)說(shuō)垃圾回收一般都有相同的執(zhí)行流程:
標(biāo)記空間中活動(dòng)對(duì)象和非活動(dòng)對(duì)象
回收非活動(dòng)對(duì)象所占據(jù)的內(nèi)存
內(nèi)存整理,這步是可選的,因?yàn)橛械睦厥掌鞴ぷ鬟^(guò)程會(huì)產(chǎn)生內(nèi)存碎片,這時(shí)就需要內(nèi)存整理防止不夠連續(xù)空間分配給大數(shù)據(jù)
副垃圾回收器
副垃圾回收器主要是采用 Scavenge 算法進(jìn)行新生區(qū)的垃圾回收,它把新生區(qū)劃分為兩個(gè)區(qū)域:對(duì)象區(qū)域和空閑區(qū)域,新加入的對(duì)象都會(huì)存放到對(duì)象區(qū)域,當(dāng)對(duì)象區(qū)域快被寫滿時(shí),會(huì)對(duì)對(duì)象區(qū)域進(jìn)行垃圾標(biāo)記,把存活對(duì)象復(fù)制并有序排列至空閑區(qū)域,完成后讓這兩個(gè)區(qū)域角色互轉(zhuǎn),由此便能無(wú)限循環(huán)進(jìn)行垃圾回收。同時(shí)存在對(duì)象晉升策略,也就是經(jīng)過(guò)兩次垃圾回收依然還存活的對(duì)象,會(huì)被移動(dòng)到老生區(qū)中。
主垃圾回收器
由于老生區(qū)空間大,數(shù)據(jù)大,所以不適用 Scavenge 算法,主要是采用標(biāo)記-整理算法,其工作流程是從一組根元素開(kāi)始,遞歸遍歷這組根元素,在這個(gè)遍歷過(guò)程中,能到達(dá)的元素稱為活動(dòng)對(duì)象,沒(méi)有到達(dá)的元素就可以判斷為垃圾數(shù)據(jù)。接著讓所有存活的對(duì)象都向一端移動(dòng),然后直接清理掉端邊界以外的內(nèi)存。垃圾回收工作是需要占用主線程的,必須暫停JS腳本執(zhí)行等待垃圾回收完成后恢復(fù),這種行為稱為全停頓。 由于老生代內(nèi)存大,全停頓對(duì)性能的影響非常大,所以出現(xiàn)了增量標(biāo)記的策略進(jìn)行老生區(qū)的垃圾回收。
JS內(nèi)存泄漏
由于棧內(nèi)存會(huì)隨著函數(shù)調(diào)用結(jié)束而被釋放(覆蓋),所以JS中的內(nèi)存泄漏一般發(fā)生在堆中。之前有同學(xué)分享過(guò)一篇關(guān)于內(nèi)存泄漏的文章 ,里面講到一些常見(jiàn)內(nèi)存泄漏的原因和監(jiān)測(cè)手段,這里我就不贅述,但是可以根據(jù)最近的IM工作講一些實(shí)踐:
確認(rèn)是否有內(nèi)存泄漏的情況
本地打包一個(gè)去掉壓縮、擁有sourcemap及沒(méi)有任何console的生產(chǎn)版本(console會(huì)保留對(duì)象引用,阻礙銷毀;去掉壓縮和保留sourcemap有利于定位源碼)
啟動(dòng)本地服務(wù)器,使cef訪問(wèn)本地項(xiàng)目
不斷操作和記錄heap snapshots,觀察snapshots和timeline情況
最終內(nèi)存從22.5m上升至34.6m,conversation實(shí)例從443上升至1117,message實(shí)例從443上升至1287,而該用戶實(shí)際只有221個(gè)會(huì)話
不斷在會(huì)話間切換,通過(guò)timeline看到有內(nèi)存沒(méi)被釋放,而且生成detached dom

通過(guò)上述觀測(cè),可以判斷為有內(nèi)存泄漏情況。
確定內(nèi)存泄漏排查方式
IM頁(yè)分為:會(huì)話列表,會(huì)話頂欄,消息列表,輸入框四部分。使用逐一排查法縮小排查范圍,排查各個(gè)部分內(nèi)存情況。如:先保留會(huì)話列表,注釋其余三個(gè)部分,操作會(huì)話列表并使用timeline和heap snapshots進(jìn)行內(nèi)存排查。按照這一方法逐步排查四個(gè)部分組件,并針對(duì)各個(gè)組件進(jìn)行優(yōu)化??梢院?jiǎn)單歸納成一個(gè)通用步驟:
使用timeline進(jìn)行錄制,觀察是否像上面那樣有不被釋放的內(nèi)存區(qū)域
選擇不被釋放的區(qū)域進(jìn)行查看,先找自己項(xiàng)目中的錨點(diǎn)物:像我們IM數(shù)據(jù)都是用conversation和messsage對(duì)象進(jìn)行儲(chǔ)存,所以可以先進(jìn)行這兩個(gè)對(duì)象的搜索查看
如果沒(méi)有好的錨點(diǎn)物也沒(méi)關(guān)系,接著查看detached dom(畢竟很多事件綁定在dom中,事件中引用著數(shù)據(jù),造成無(wú)法被釋放)和 string

有些detached dom可能是react虛擬dom的數(shù)據(jù),但像上面的Detached HTMLAudioElement會(huì)隨著操作一直增加,所以這個(gè)是不正常的。

像這里string的重復(fù),經(jīng)排查是有相同conversation和message對(duì)象引起

堆快照里包含太多運(yùn)行時(shí)、上下文等信息,實(shí)在太難從中找到有用的信息,所以會(huì)把目標(biāo)放在錨點(diǎn)物、detached dom和string上
利用heap snapshot 的comparison模式過(guò)濾出操作階段內(nèi)存變更情況,更有利于查找影響位置

上面是個(gè)人進(jìn)行內(nèi)存泄露排查整理的方法,如果你有更好的方法,歡迎交流∠(°ゝ°)
React中一個(gè)需要注意的內(nèi)存泄漏問(wèn)題

現(xiàn)象: 當(dāng)組件被銷毀后,仍有一些異步事件調(diào)用組件中setState方法
原理: 組件銷毀后,再調(diào)用setstate方法會(huì)保留相關(guān)引用,造成內(nèi)存泄漏
//?測(cè)試代碼
const?[test,?setTest]?=?useState(null);
useEffect(()?=>?{
??(async?()?=>?{
????//?這里表達(dá)一個(gè)異步操作如:xhr、fetch、promise等等
????await?sleep(3000);
????const?obj?=?new?TestObj();
????setTest(obj);
??})();
},?[]);

如果把代碼改成這樣,就不會(huì)造成內(nèi)存泄漏:
const?[test,?setTest]?=?useState(null);
useEffect(()?=>?{
??let?unMounted?=?false;
??(async?()?=>?{
????await?sleep(3000);
????if?(unMounted)?return;
????const?obj?=?new?TestObj();
????setTest(obj);
??})();
??return?()?=>?{
????unMounted?=?true;
??};
},?[]);
這是在開(kāi)發(fā)環(huán)境測(cè)試的,翻看源碼發(fā)現(xiàn)react只會(huì)在開(kāi)發(fā)模式保留這些引用,然后拋出warning來(lái)提醒開(kāi)發(fā)者這里可能有內(nèi)存泄漏的問(wèn)題(如這些setState是注冊(cè)在全局事件里或者setInterval里的調(diào)用),生產(chǎn)環(huán)境是不會(huì)對(duì)其進(jìn)行引用,所以不需要額外進(jìn)行處理也不會(huì)造成內(nèi)存泄漏



react18更是直接把這個(gè)報(bào)錯(cuò)給干掉,以免誤導(dǎo)開(kāi)發(fā)者使用剛剛說(shuō)的類似手段來(lái)進(jìn)行避免報(bào)錯(cuò),這里有做解釋:https://github.com/facebook/react/pull/22114

總結(jié)
本文先是講述js類型在內(nèi)存空間的儲(chǔ)存位置,接著探討堆棧中的內(nèi)存是如何進(jìn)行回收,最后描述內(nèi)存泄漏確定和排查的方法,也補(bǔ)充一個(gè)react中有關(guān)setState造成“內(nèi)存泄漏”的例子。內(nèi)存泄漏在復(fù)雜應(yīng)用中是難以避免的,個(gè)人排查也只能是解決一些比較明顯的內(nèi)存泄漏現(xiàn)象。所以為了更好地解決這個(gè)應(yīng)用內(nèi)內(nèi)存泄漏問(wèn)題,必須做好線上監(jiān)控,利用廣大用戶操作數(shù)據(jù),發(fā)現(xiàn)內(nèi)存泄漏問(wèn)題,進(jìn)而不斷改善應(yīng)用的性能。
參考資料
https://developer.chrome.com/docs/devtools/memory-problems/memory-101/
https://www.cnblogs.com/goloving/p/15352261.html
https://hashnode.com/post/does-javascript-use-stack-or-heap-for-memory-allocation-or-both-cj5jl90xl01nh1twuv8ug0bjk
https://www.ditdot.hr/en/causes-of-memory-leaks-in-javascript-and-how-to-avoid-them
參考資料
oddball: https://github.com/v8/v8/blob/c736a452575f406c9a05a8c202b0708cb60d43e5/src/objects.h#L9368
???謝謝支持
以上便是本次分享的全部內(nèi)容,希望對(duì)你有所幫助^_^
喜歡的話別忘了?分享、點(diǎn)贊、收藏?三連哦~。
歡迎關(guān)注公眾號(hào)?趣談前端?收獲大廠一手好文章~

