JavaScript 內(nèi)存詳解 & 分析指南

前言
JavaScript 誕生于 1995 年,最初被設(shè)計(jì)用于網(wǎng)頁(yè)內(nèi)的表單驗(yàn)證。
這些年來(lái) JavaScript 成長(zhǎng)飛速,生態(tài)圈日益壯大,成為了最受程序員歡迎的開(kāi)發(fā)語(yǔ)言之一。并且現(xiàn)在的 JavaScript 不再局限于網(wǎng)頁(yè)端,已經(jīng)擴(kuò)展到了桌面端、移動(dòng)端以及服務(wù)端。
隨著大前端時(shí)代的到來(lái),使用 JavaScript 的開(kāi)發(fā)者越來(lái)越多,但是許多開(kāi)發(fā)者都只停留在“會(huì)用”這個(gè)層面,而對(duì)于這門(mén)語(yǔ)言并沒(méi)有更多的了解。
如果想要成為一名更好的 JavaScript 開(kāi)發(fā)者,理解內(nèi)存是一個(gè)不可忽略的關(guān)鍵點(diǎn)。
?? 本文主要包含兩大部分:
JavaScript 內(nèi)存詳解 JavaScript 內(nèi)存分析指南
看完這篇文章后,相信你會(huì)對(duì) JavaScript 的內(nèi)存有比較全面的了解,并且能夠擁有獨(dú)自進(jìn)行內(nèi)存分析的能力。
?? 話(huà)不多說(shuō),我們開(kāi)始吧!
文章篇幅較長(zhǎng),除去代碼也有 12000 字左右,需要一定的時(shí)間來(lái)閱讀,但是我保證你所花費(fèi)的時(shí)間都是值得的。
正文
內(nèi)存(memory)
什么是內(nèi)存(What is memory)
相信大家都對(duì)內(nèi)存有一定的了解,我就不從盤(pán)古開(kāi)天辟地開(kāi)始講了,稍微提一下。
首先,任何應(yīng)用程序想要運(yùn)行都離不開(kāi)內(nèi)存。
另外,我們提到的內(nèi)存在不同的層面上有著不同的含義。
?? 硬件層面(Hardware)
在硬件層面上,內(nèi)存指的是隨機(jī)存取存儲(chǔ)器。
內(nèi)存是計(jì)算機(jī)重要組成部分,用來(lái)儲(chǔ)存應(yīng)用運(yùn)行所需要的各種數(shù)據(jù),CPU 能夠直接與內(nèi)存交換數(shù)據(jù),保證應(yīng)用能夠流暢運(yùn)行。
一般來(lái)說(shuō),在計(jì)算機(jī)的組成中主要有兩種隨機(jī)存取存儲(chǔ)器:高速緩存(Cache)和主存儲(chǔ)器(Main memory)。
高速緩存通常直接集成在 CPU 內(nèi)部,離我們比較遠(yuǎn),所以更多時(shí)候我們提到的(硬件)內(nèi)存都是主存儲(chǔ)器。
?? 隨機(jī)存取存儲(chǔ)器(Random Access Memory,RAM)
隨機(jī)存取存儲(chǔ)器分為靜態(tài)隨機(jī)存取存儲(chǔ)器(Static Random Access Memory,SRAM)和動(dòng)態(tài)隨機(jī)存取存儲(chǔ)器(Dynamic Random Access Memory,DRAM)兩大類(lèi)。
在速度上 SRAM 要遠(yuǎn)快于 DRAM,而 SRAM 的速度僅次于 CPU 內(nèi)部的寄存器。
在現(xiàn)代計(jì)算機(jī)中,高速緩存使用的是 SRAM,而主存儲(chǔ)器使用的是 DRAM。
?? 主存儲(chǔ)器(Main memory,主存)
雖然高速緩存的速度很快,但是其存儲(chǔ)容量很小,小到幾 KB 最大也才幾十 MB,根本不足以?xún)?chǔ)存應(yīng)用運(yùn)行的數(shù)據(jù)。
我們需要一種存儲(chǔ)容量與速度適中的存儲(chǔ)部件,讓我們?cè)诒WC性能的情況下,能夠同時(shí)運(yùn)行幾十甚至上百個(gè)應(yīng)用,這也就是主存的作用。
計(jì)算機(jī)中的主存其實(shí)就是我們平時(shí)說(shuō)的內(nèi)存條(硬件)。
硬件內(nèi)存不是我們今天的主題,所以就說(shuō)這么多,想要深入了解的話(huà)可以根據(jù)上面提到關(guān)鍵詞進(jìn)行搜索。
?? 軟件層面(Software)
在軟件層面上,內(nèi)存通常指的是操作系統(tǒng)從主存中劃分(抽象)出來(lái)的內(nèi)存空間。
此時(shí)內(nèi)存又可以分為兩類(lèi):棧內(nèi)存和堆內(nèi)存。
接下來(lái)我將圍繞 JavaScript 這門(mén)語(yǔ)言來(lái)對(duì)內(nèi)存進(jìn)行講解。
在后面的文章中所提到的內(nèi)存均指軟件層面上的內(nèi)存。
棧與堆(Stack & Heap)
棧內(nèi)存(Stack memory)
?? 棧(Stack)
棧是一種常見(jiàn)的數(shù)據(jù)結(jié)構(gòu),棧只允許在結(jié)構(gòu)的一端操作數(shù)據(jù),所有數(shù)據(jù)都遵循后進(jìn)先出(Last-In First-Out,LIFO)的原則。
現(xiàn)實(shí)生活中最貼切的的例子就是羽毛球桶,通常我們只通過(guò)球桶的一側(cè)來(lái)進(jìn)行存取,最先放進(jìn)去的羽毛球只能最后被取出,而最后放進(jìn)去的則會(huì)最先被取出。
棧內(nèi)存之所以叫做棧內(nèi)存,是因?yàn)闂?nèi)存使用了棧的結(jié)構(gòu)。
棧內(nèi)存是一段連續(xù)的內(nèi)存空間,得益于棧結(jié)構(gòu)的簡(jiǎn)單直接,棧內(nèi)存的訪(fǎng)問(wèn)和操作速度都非常快。
棧內(nèi)存的容量較小,主要用于存放函數(shù)調(diào)用信息和變量等數(shù)據(jù),大量的內(nèi)存分配操作會(huì)導(dǎo)致棧溢出(Stack overflow)。
棧內(nèi)存的數(shù)據(jù)儲(chǔ)存基本都是臨時(shí)性的,數(shù)據(jù)會(huì)在使用完之后立即被回收(如函數(shù)內(nèi)創(chuàng)建的局部變量在函數(shù)返回后就會(huì)被回收)。
簡(jiǎn)單來(lái)說(shuō):棧內(nèi)存適合存放生命周期短、占用空間小且固定的數(shù)據(jù)。

?? 棧內(nèi)存的大小
棧內(nèi)存由操作系統(tǒng)直接管理,所以棧內(nèi)存的大小也由操作系統(tǒng)決定。
通常來(lái)說(shuō),每一條線(xiàn)程(Thread)都會(huì)有獨(dú)立的棧內(nèi)存空間,Windows 給每條線(xiàn)程分配的棧內(nèi)存默認(rèn)大小為 1MB。
堆內(nèi)存(Heap memory)
?? 堆(Heap)
堆也是一種常見(jiàn)的數(shù)據(jù)結(jié)構(gòu),但是不在本文討論范圍內(nèi),就不多說(shuō)了。
堆內(nèi)存雖然名字里有個(gè)“堆”字,但是它和數(shù)據(jù)結(jié)構(gòu)中的堆沒(méi)半毛錢(qián)關(guān)系,就只是撞了名罷了。
堆內(nèi)存是一大片內(nèi)存空間,堆內(nèi)存的分配是動(dòng)態(tài)且不連續(xù)的,程序可以按需申請(qǐng)堆內(nèi)存空間,但是訪(fǎng)問(wèn)速度要比棧內(nèi)存慢不少。
堆內(nèi)存里的數(shù)據(jù)可以長(zhǎng)時(shí)間存在,無(wú)用的數(shù)據(jù)需要程序主動(dòng)去回收,如果大量無(wú)用數(shù)據(jù)占用內(nèi)存就會(huì)造成內(nèi)存泄露(Memory leak)。
簡(jiǎn)單來(lái)說(shuō):堆內(nèi)存適合存放生命周期長(zhǎng),占用空間較大或占用空間不固定的數(shù)據(jù)。

?? 堆內(nèi)存的上限
在 Node.js 中,堆內(nèi)存默認(rèn)上限在 64 位系統(tǒng)中約為 1.4 GB,在 32 位系統(tǒng)中約為 0.7 GB。
而在 Chrome 瀏覽器中,每個(gè)標(biāo)簽頁(yè)的內(nèi)存上限約為 4 GB(64 位系統(tǒng))和 1 GB(32 位系統(tǒng))。
?? 進(jìn)程、線(xiàn)程與堆內(nèi)存
通常來(lái)說(shuō),一個(gè)進(jìn)程(Process)只會(huì)有一個(gè)堆內(nèi)存,同一進(jìn)程下的多個(gè)線(xiàn)程會(huì)共享同一個(gè)堆內(nèi)存。
在 Chrome 瀏覽器中,一般情況下每個(gè)標(biāo)簽頁(yè)都有單獨(dú)的進(jìn)程,不過(guò)在某些情況下也會(huì)出現(xiàn)多個(gè)標(biāo)簽頁(yè)共享一個(gè)進(jìn)程的情況。
函數(shù)調(diào)用(Function calling)
明白了棧內(nèi)存與堆內(nèi)存是什么后,現(xiàn)在讓我們看看當(dāng)一個(gè)函數(shù)被調(diào)用時(shí),棧內(nèi)存和堆內(nèi)存會(huì)發(fā)生什么變化。
當(dāng)函數(shù)被調(diào)用時(shí),會(huì)將函數(shù)推入棧內(nèi)存中,生成一個(gè)棧幀(Stack frame),棧幀可以理解為由函數(shù)的返回地址、參數(shù)和局部變量組成的一個(gè)塊;當(dāng)函數(shù)調(diào)用另一個(gè)函數(shù)時(shí),又會(huì)將另一個(gè)函數(shù)也推入棧內(nèi)存中,周而復(fù)始;直到最后一個(gè)函數(shù)返回,便從棧頂開(kāi)始將棧內(nèi)存中的元素逐個(gè)彈出,直到棧內(nèi)存中不再有元素時(shí)則此次調(diào)用結(jié)束。

上圖中的內(nèi)容經(jīng)過(guò)了簡(jiǎn)化,剝離了棧幀和各種指針的概念,主要展示函數(shù)調(diào)用以及內(nèi)存分配的大概過(guò)程。
在同一線(xiàn)程下(JavaScript 是單線(xiàn)程的),所有被執(zhí)行的函數(shù)以及函數(shù)的參數(shù)和局部變量都會(huì)被推入到同一個(gè)棧內(nèi)存中,這也就是大量遞歸會(huì)導(dǎo)致棧溢出(Stack overflow)的原因。
關(guān)于圖中涉及到的函數(shù)內(nèi)部變量?jī)?nèi)存分配的詳情請(qǐng)接著往下看。
儲(chǔ)存變量(Store variables)
當(dāng) JavaScript 程序運(yùn)行時(shí),在非全局作用域中產(chǎn)生的局部變量均儲(chǔ)存在棧內(nèi)存中。
但是,只有原始類(lèi)型的變量是真正地把值儲(chǔ)存在棧內(nèi)存中。
而引用類(lèi)型的變量只在棧內(nèi)存中儲(chǔ)存一個(gè)引用(reference),這個(gè)引用指向堆內(nèi)存里的真正的值。
?? 原始類(lèi)型(Primitive type)
原始類(lèi)型又稱(chēng)基本類(lèi)型,包括
string、number、bigint、boolean、undefined、null和symbol(ES6 新增)。原始類(lèi)型的值被稱(chēng)為原始值(Primitive value)。
補(bǔ)充:雖然
typeof null返回的是'object',但是null真的不是對(duì)象,會(huì)出現(xiàn)這樣的結(jié)果其實(shí)是 JavaScript 的一個(gè) Bug~
?? 引用類(lèi)型(Reference type)
除了原始類(lèi)型外,其余類(lèi)型都屬于引用類(lèi)型,包括
Object、Array、Function、Date、RegExp、String、Number、Boolean等等...實(shí)際上
Object是最基本的引用類(lèi)型,其他引用類(lèi)型均繼承自Object。也就是說(shuō),所有引用類(lèi)型的值實(shí)際上都是對(duì)象。引用類(lèi)型的值被稱(chēng)為引用值(Reference value)。
?? 簡(jiǎn)單來(lái)說(shuō)
在多數(shù)情況下,原始類(lèi)型的數(shù)據(jù)儲(chǔ)存在棧內(nèi)存,而引用類(lèi)型的數(shù)據(jù)(對(duì)象)則儲(chǔ)存在堆內(nèi)存。

特別注意(Attention)
全局變量以及被閉包引用的變量(即使是原始類(lèi)型)均儲(chǔ)存在堆內(nèi)存中。
?? 全局變量(Global variables)
在全局作用域下創(chuàng)建的所有變量都會(huì)成為全局對(duì)象(如 window 對(duì)象)的屬性,也就是全局變量。
而全局對(duì)象儲(chǔ)存在堆內(nèi)存中,所以全局變量必然也會(huì)儲(chǔ)存在堆內(nèi)存中。
不要問(wèn)我為什么全局對(duì)象儲(chǔ)存在堆內(nèi)存中,一會(huì)我翻臉了啊!
?? 閉包(Closures)
在函數(shù)(局部作用域)內(nèi)創(chuàng)建的變量均為局部變量。
當(dāng)一個(gè)局部變量被當(dāng)前函數(shù)之外的其他函數(shù)所引用(也就是發(fā)生了逃逸),此時(shí)這個(gè)局部變量就不能隨著當(dāng)前函數(shù)的返回而被回收,那么這個(gè)變量就必須儲(chǔ)存在堆內(nèi)存中。
而這里的“其他函數(shù)”就是我們說(shuō)的閉包,就如下面這個(gè)例子:
function?getCounter()?{
??let?count?=?0;
??function?counter()?{
????return?++count;
??}
??return?counter;
}
//?closure?是一個(gè)閉包函數(shù)
//?變量?count?發(fā)生了逃逸
let?closure?=?getCounter();
closure();?//?1
closure();?//?2
closure();?//?3
閉包是一個(gè)非常重要且常用的概念,許多編程語(yǔ)言里都有閉包這個(gè)概念。這里就不詳細(xì)介紹了,貼一篇阮一峰大佬的文章。
學(xué)習(xí) JavaScript 閉包:http://www.ruanyifeng.com/blog/2009/08/learning_javascript_closures.html
?? 逃逸分析(Escape Analysis)
實(shí)際上,JavaScript 引擎會(huì)通過(guò)逃逸分析來(lái)決定變量是要儲(chǔ)存在棧內(nèi)存還是堆內(nèi)存中。
簡(jiǎn)單來(lái)說(shuō),逃逸分析是一種用來(lái)分析變量的作用域的機(jī)制。
不可變與可變(Immutable and Mutable)
棧內(nèi)存中會(huì)儲(chǔ)存兩種變量數(shù)據(jù):原始值和對(duì)象引用。
不僅類(lèi)型不同,它們?cè)跅?nèi)存中的具體表現(xiàn)也不太一樣。
原始值(Primitive values)
?? Primitive values are immutable!
前面有說(shuō)到:原始類(lèi)型的數(shù)據(jù)(原始值)直接儲(chǔ)存在棧內(nèi)存中。
⑴ 當(dāng)我們定義一個(gè)原始類(lèi)型變量的時(shí)候,JavaScript 會(huì)在棧內(nèi)存中激活一塊內(nèi)存來(lái)儲(chǔ)存變量的值(原始值)。
⑵ 當(dāng)我們更改原始類(lèi)型變量的值時(shí),實(shí)際上會(huì)再激活一塊新的內(nèi)存來(lái)儲(chǔ)存新的值,并將變量指向新的內(nèi)存空間,而不是改變?cè)瓉?lái)那塊內(nèi)存里的值。
⑶ 當(dāng)我們將一個(gè)原始類(lèi)型變量賦值給另一個(gè)新的變量(也就是復(fù)制變量)時(shí),也是會(huì)再激活一塊新的內(nèi)存,并將源變量?jī)?nèi)存里的值復(fù)制一份到新的內(nèi)存里。

?? 總之就是:棧內(nèi)存中的原始值一旦確定就不能被更改(不可變的)。
原始值的比較(Comparison)
當(dāng)我們比較原始類(lèi)型的變量時(shí),會(huì)直接比較棧內(nèi)存中的值,只要值相等那么它們就相等。
let?a?=?'123';
let?b?=?'123';
let?c?=?'110';
let?d?=?123;
console.log(a?===?b);?//?true
console.log(a?===?c);?//?false
console.log(a?===?d);?//?false
對(duì)象引用(Object references)
?? Object references are mutable!
前面也有說(shuō)到:引用類(lèi)型的變量在棧內(nèi)存中儲(chǔ)存的只是一個(gè)指向堆內(nèi)存的引用。
⑴ 當(dāng)我們定義一個(gè)引用類(lèi)型的變量時(shí),JavaScript 會(huì)先在堆內(nèi)存中找到一塊合適的地方來(lái)儲(chǔ)存對(duì)象,并激活一塊棧內(nèi)存來(lái)儲(chǔ)存對(duì)象的引用(堆內(nèi)存地址),最后將變量指向這塊棧內(nèi)存。
?? 所以當(dāng)我們通過(guò)變量訪(fǎng)問(wèn)對(duì)象時(shí),實(shí)際的訪(fǎng)問(wèn)過(guò)程應(yīng)該是:
變量 -> 棧內(nèi)存中的引用 -> 堆內(nèi)存中的值
⑵ 當(dāng)我們把引用類(lèi)型變量賦值給另一個(gè)變量時(shí),會(huì)將源變量指向的棧內(nèi)存中的對(duì)象引用復(fù)制到新變量的棧內(nèi)存中,所以實(shí)際上只是復(fù)制了個(gè)對(duì)象引用,并沒(méi)有在堆內(nèi)存中生成一份新的對(duì)象。
⑶ 而當(dāng)我們給引用類(lèi)型變量分配為一個(gè)新的對(duì)象時(shí),則會(huì)直接修改變量指向的棧內(nèi)存中的引用,新的引用指向堆內(nèi)存中新的對(duì)象。

?? 總之就是:棧內(nèi)存中的對(duì)象引用是可以被更改的(可變的)。
對(duì)象的比較(Comparison)
所有引用類(lèi)型的值實(shí)際上都是對(duì)象。
當(dāng)我們比較引用類(lèi)型的變量時(shí),實(shí)際上是在比較棧內(nèi)存中的引用,只有引用相同時(shí)變量才相等。
即使是看起來(lái)完全一樣的兩個(gè)引用類(lèi)型變量,只要他們的引用的不是同一個(gè)值,那么他們就是不一樣。
//?兩個(gè)變量指向的是兩個(gè)不同的引用
//?雖然這兩個(gè)對(duì)象看起來(lái)完全一樣
//?但它們確確實(shí)實(shí)是不同的對(duì)象實(shí)例
let?a?=?{?name:?'pp'?}
let?b?=?{?name:?'pp'?}
console.log(a?===?b);?//?false
//?直接賦值的方式復(fù)制的是對(duì)象的引用
let?c?=?a;
console.log(a?===?c);?//?true
對(duì)象的深拷貝(Deep copy)
當(dāng)我們搞明白引用類(lèi)型變量在內(nèi)存中的表現(xiàn)時(shí),就能清楚地理解為什么淺拷貝對(duì)象是不可靠的。
在淺拷貝中,簡(jiǎn)單的賦值只會(huì)復(fù)制對(duì)象的引用,實(shí)際上新變量和源變量引用的都是同一個(gè)對(duì)象,修改時(shí)也是修改的同一個(gè)對(duì)象,這顯然不是我們想要的。
想要真正的復(fù)制一個(gè)對(duì)象,就必須新建一個(gè)對(duì)象,將源對(duì)象的屬性復(fù)制過(guò)去;如果遇到引用類(lèi)型的屬性,那就再新建一個(gè)對(duì)象,繼續(xù)復(fù)制...
此時(shí)我們就需要借助遞歸來(lái)實(shí)現(xiàn)多層次對(duì)象的復(fù)制,這也就是我們說(shuō)的深拷貝。
對(duì)于任何引用類(lèi)型的變量,都應(yīng)該使用深拷貝來(lái)復(fù)制,除非你很確定你的目的就是復(fù)制一個(gè)引用。
內(nèi)存生命周期(Memory life cycle)
通常來(lái)說(shuō),所有應(yīng)用程序的內(nèi)存生命周期都是基本一致的:
分配 -> 使用 -> 釋放
當(dāng)我們使用高級(jí)語(yǔ)言編寫(xiě)程序時(shí),往往不會(huì)涉及到內(nèi)存的分配與釋放操作,因?yàn)榉峙渑c釋放均已經(jīng)在底層語(yǔ)言中實(shí)現(xiàn)了。
對(duì)于 JavaScript 程序來(lái)說(shuō),內(nèi)存的分配與釋放是由 JavaScript 引擎自動(dòng)完成的(目前的 JavaScript 引擎基本都是使用 C++ 或 C 編寫(xiě)的)。
但是這不意味著我們就不需要在乎內(nèi)存管理,了解內(nèi)存的更多細(xì)節(jié)可以幫助我們寫(xiě)出性能更好,穩(wěn)定性更高的代碼。
垃圾回收(Garbage collection)
垃圾回收即我們常說(shuō)的 GC(Garbage collection),也就是清除內(nèi)存中不再需要的數(shù)據(jù),釋放內(nèi)存空間。
由于棧內(nèi)存由操作系統(tǒng)直接管理,所以當(dāng)我們提到 GC 時(shí)指的都是堆內(nèi)存的垃圾回收。
基本上現(xiàn)在的瀏覽器的 JavaScript 引擎(如 V8 和 SpiderMonkey)都實(shí)現(xiàn)了垃圾回收機(jī)制,引擎中的垃圾回收器(Garbage collector)會(huì)定期進(jìn)行垃圾回收。
?? 緊急補(bǔ)課
在我們繼續(xù)之前,必須先了解“可達(dá)性”和“內(nèi)存泄露”這兩個(gè)概念:
?? 可達(dá)性(Reachability)
在 JavaScript 中,可達(dá)性指的是一個(gè)變量是否能夠直接或間接通過(guò)全局對(duì)象訪(fǎng)問(wèn)到,如果可以那么該變量就是可達(dá)的(Reachable),否則就是不可達(dá)的(Unreachable)。
可達(dá)與不可達(dá) 上圖中的節(jié)點(diǎn) 9 和節(jié)點(diǎn) 10 均無(wú)法通過(guò)節(jié)點(diǎn) 1(根節(jié)點(diǎn))直接或間接訪(fǎng)問(wèn),所以它們都是不可達(dá)的,可以被安全地回收。
?? 內(nèi)存泄漏(Memory leak)
內(nèi)存泄露指的是程序運(yùn)行時(shí)由于某種原因未能釋放那些不再使用的內(nèi)存,造成內(nèi)存空間的浪費(fèi)。
輕微的內(nèi)存泄漏或許不太會(huì)對(duì)程序造成什么影響,但是一旦泄露變嚴(yán)重,就會(huì)開(kāi)始影響程序的性能,甚至導(dǎo)致程序的崩潰。
垃圾回收算法(Algorithms)
垃圾回收的基本思路很簡(jiǎn)單:確定哪個(gè)變量不會(huì)再使用,然后釋放它占用的內(nèi)存。
實(shí)際上,在回收過(guò)程中想要確定一個(gè)變量是否還有用并不簡(jiǎn)單。
直到現(xiàn)在也還沒(méi)有一個(gè)真正完美的垃圾回收算法,接下來(lái)介紹 3 種最廣為人知的垃圾回收算法。
標(biāo)記-清除(Mark-and-Sweep)
標(biāo)記清除算法是目前最常用的垃圾收集算法之一。
從該算法的名字上就可以看出,算法的關(guān)鍵就是標(biāo)記與清除。
標(biāo)記指的是標(biāo)記變量的狀態(tài)的過(guò)程,標(biāo)記變量的具體方法有很多種,但是基本理念是相似的。
對(duì)于標(biāo)記算法我們不需要知道所有細(xì)節(jié),只需明白標(biāo)記的基本原理即可。
需要注意的是,這個(gè)算法的效率不算高,同時(shí)會(huì)引起內(nèi)存碎片化的問(wèn)題。
?? 舉個(gè)栗子
當(dāng)一個(gè)變量進(jìn)入執(zhí)行上下文時(shí),它就會(huì)被標(biāo)記為“處于上下文中”;而當(dāng)變量離開(kāi)執(zhí)行上下文時(shí),則會(huì)被標(biāo)記為“已離開(kāi)上下文”。
?? 執(zhí)行上下文(Execution context)
執(zhí)行上下文是 JavaScript 中非常重要的概念,簡(jiǎn)單來(lái)說(shuō)的是代碼執(zhí)行的環(huán)境。
如果你現(xiàn)在對(duì)于執(zhí)行上下文還不是很了解,我強(qiáng)烈建議你抽空專(zhuān)門(mén)去學(xué)習(xí)下!!!
垃圾回收器將定期掃描內(nèi)存中的所有變量,將處于上下文中以及被處于上下文中的變量引用的變量的標(biāo)記去除,將其余變量標(biāo)記為“待刪除”。
隨后,垃圾回收器會(huì)清除所有帶有“待刪除”標(biāo)記的變量,并釋放它們所占用的內(nèi)存。
標(biāo)記-整理(Mark-Compact)
準(zhǔn)確來(lái)說(shuō),Compact 應(yīng)譯為緊湊、壓縮,但是在這里我覺(jué)得用“整理”更為貼切。
標(biāo)記整理算法也是常用的垃圾收集算法之一。
使用標(biāo)記整理算法可以解決內(nèi)存碎片化的問(wèn)題(通過(guò)整理),提高內(nèi)存空間的可用性。
但是,該算法的標(biāo)記階段比較耗時(shí),可能會(huì)堵塞主線(xiàn)程,導(dǎo)致程序長(zhǎng)時(shí)間處于無(wú)響應(yīng)狀態(tài)。
雖然算法的名字上只有標(biāo)記和整理,但這個(gè)算法通常有 3 個(gè)階段,即標(biāo)記、整理與清除。
?? 以 V8 的標(biāo)記整理算法為例
① 首先,在標(biāo)記階段,垃圾回收器會(huì)從全局對(duì)象(根)開(kāi)始,一層一層往下查詢(xún),直到標(biāo)記完所有活躍的對(duì)象,那么剩下的未被標(biāo)記的對(duì)象就是不可達(dá)的了。

② 然后是整理階段(碎片整理),垃圾回收器會(huì)將活躍的(被標(biāo)記了的)對(duì)象往內(nèi)存空間的一端移動(dòng),這個(gè)過(guò)程可能會(huì)改變內(nèi)存中的對(duì)象的內(nèi)存地址。
③ 最后來(lái)到清除階段,垃圾回收器會(huì)將邊界后面(也就是最后一個(gè)活躍的對(duì)象后面)的對(duì)象清除,并釋放它們占用的內(nèi)存空間。

引用計(jì)數(shù)(Reference counting)
引用計(jì)數(shù)算法是基于“引用計(jì)數(shù)”實(shí)現(xiàn)的垃圾回收算法,這是最初級(jí)但已經(jīng)被棄用的垃圾回收算法。
引用計(jì)數(shù)算法需要 JavaScript 引擎在程序運(yùn)行時(shí)記錄每個(gè)變量被引用的次數(shù),隨后根據(jù)引用的次數(shù)來(lái)判斷變量是否能夠被回收。
雖然垃圾回收已不再使用引用計(jì)數(shù)算法,但是引用計(jì)數(shù)技術(shù)仍非常有用!
?? 舉個(gè)栗子
注意:垃圾回收不是即使生效的!但是在下面的例子中我們將假設(shè)回收是立即生效的,這樣會(huì)更好理解~
//?下面我將?name?屬性為?ππ?的對(duì)象簡(jiǎn)稱(chēng)為?ππ
//?而?name?屬性為?pp?的對(duì)象則簡(jiǎn)稱(chēng)為?pp
//?ππ?的引用:1,pp 的引用:1
let?a?=?{
??name:?'ππ',
??z:?{
????name:?'pp'
??}
}
//?b?和?a?都指向?ππ
//?ππ?的引用:2,pp 的引用:1
let?b?=?a;
//?x?和?a.z?都指向?pp
//?ππ?的引用:2,pp 的引用:2
let?x?=?a.z;
//?現(xiàn)在只有?b?還指向?ππ
//?ππ?的引用:1,pp 的引用:2
a?=?null;
//?現(xiàn)在?ππ?沒(méi)有任何引用了,可以被回收了
//?在?ππ?被回收后,pp?的引用也會(huì)相應(yīng)減少
//?ππ?的引用:0,pp 的引用:1
b?=?null;
//?現(xiàn)在?pp?也可以被回收了
//?ππ?的引用:0,pp 的引用:0
x?=?null;
//?哦豁,這下全完了!
?? 循環(huán)引用(Circular references)
引用計(jì)數(shù)算法看似很美好,但是它有一個(gè)致命的缺點(diǎn),就是無(wú)法處理循環(huán)引用的情況。
在下方的例子中,當(dāng) foo() 函數(shù)執(zhí)行完畢之后,對(duì)象 a 與 b 都已經(jīng)離開(kāi)了作用域,理論上它們都應(yīng)該能夠被回收才對(duì)。
但是由于它們互相引用了對(duì)方,所以垃圾回收器就認(rèn)為他們都還在被引用著,導(dǎo)致它們哥倆永遠(yuǎn)都不會(huì)被回收,這就造成了內(nèi)存泄露。
function?foo()?{
??let?a?=?{?o:?null?};
??let?b?=?{?o:?null?};
??a.o?=?b;
??b.o?=?a;
}
foo();
//?即使?foo?函數(shù)已經(jīng)執(zhí)行完畢
//?對(duì)象?a?和?b?均已離開(kāi)函數(shù)作用域
//?但是?a?和?b?還在互相引用
//?那么它們這輩子都不會(huì)被回收了
// Oops!內(nèi)存泄露了!
V8 中的垃圾回收(GC in V8)
8?? V8
V8 是一個(gè)由 Google 開(kāi)源的用 C++ 編寫(xiě)的高性能 JavaScript 引擎。
V8 是目前最流行的 JavaScript 引擎之一,我們熟知的 Chrome 瀏覽器和 Node.js 等軟件都在使用 V8。
在 V8 的內(nèi)存管理機(jī)制中,把堆內(nèi)存(Heap memory)劃分成了多個(gè)區(qū)域。

這里我們只關(guān)注這兩個(gè)區(qū)域:
New Space(新空間):又稱(chēng) Young generation(新世代),用于儲(chǔ)存新生成的對(duì)象,由 Minor GC 進(jìn)行管理。 Old Space(舊空間):又稱(chēng) Old generation(舊世代),用于儲(chǔ)存那些在兩次 GC 后仍然存活的對(duì)象,由 Major GC 進(jìn)行管理。
也就是說(shuō),只要 New Space 里的對(duì)象熬過(guò)了兩次 GC,就會(huì)被轉(zhuǎn)移到 Old Space,變成老油條。
?? 雙管齊下
V8 內(nèi)部實(shí)現(xiàn)了兩個(gè)垃圾回收器:
Minor GC(副 GC):它還有個(gè)名字叫做 Scavenger(清道夫),具體使用的是 Cheney's Algorithm(Cheney 算法)。 Major GC(主 GC):使用的是文章前面提到的 Mark-Compact Algorithm(標(biāo)記-整理算法)。
儲(chǔ)存在 New Space 里的新生對(duì)象大多都只是臨時(shí)使用的,而且 New Space 的容量比較小,為了保持內(nèi)存的可用率,Minor GC 會(huì)頻繁地運(yùn)行。
而 Old Space 里的對(duì)象存活時(shí)間都比較長(zhǎng),所以 Major GC 沒(méi)那么勤快,這一定程度地降低了頻繁 GC 帶來(lái)的性能損耗。
?? 加點(diǎn)魔法
我們?cè)谏戏降摹皹?biāo)記整理算法”中有提到這個(gè)算法的標(biāo)記過(guò)程非常耗時(shí),所以很容易導(dǎo)致應(yīng)用長(zhǎng)時(shí)間無(wú)響應(yīng)。
為了提升用戶(hù)體驗(yàn),V8 還實(shí)現(xiàn)了一個(gè)名為增量標(biāo)記(Incremental marking)的特性。
增量標(biāo)記的要點(diǎn)就是把標(biāo)記工作分成多個(gè)小段,夾雜在主線(xiàn)程(Main thread)的 JavaScript 邏輯中,這樣就不會(huì)長(zhǎng)時(shí)間阻塞主線(xiàn)程了。

當(dāng)然增量標(biāo)記也有代價(jià)的,在增量標(biāo)記過(guò)程中所有對(duì)象的變化都需要通知垃圾回收器,好讓垃圾回收器能夠正確地標(biāo)記那些對(duì)象,這里的“通知”也是需要成本的。
另外 V8 中還有使用工作線(xiàn)程(Worker thread)實(shí)現(xiàn)的平行標(biāo)記(Parallel marking)和并行標(biāo)記(Concurrent marking),這里我就不再細(xì)說(shuō)了~
?? 總結(jié)一下
為了提升性能和用戶(hù)體驗(yàn),V8 內(nèi)部做了非常非常多的“騷操作”,本文提到的都只是冰山一角,但足以讓我五體投地佩服連連!
總之就是非常 Amazing 啊~
內(nèi)存管理(Memory management)
或者說(shuō)是:內(nèi)存優(yōu)化(Memory optimization)?
雖然我們寫(xiě)代碼的時(shí)候一般不會(huì)直接接觸內(nèi)存管理,但是有一些注意事項(xiàng)可以讓我們避免引起內(nèi)存問(wèn)題,甚至提升代碼的性能。
全局變量(Global variable)
全局變量的訪(fǎng)問(wèn)速度遠(yuǎn)不及局部變量,應(yīng)盡量避免定義非必要的全局變量。
在我們實(shí)際的項(xiàng)目開(kāi)發(fā)中,難免會(huì)需要去定義一些全局變量,但是我們必須謹(jǐn)慎使用全局變量。
因?yàn)槿肿兞坑肋h(yuǎn)都是可達(dá)的,所以全局變量永遠(yuǎn)不會(huì)被回收。
?? 還記得“可達(dá)性”這個(gè)概念嗎?
因?yàn)槿肿兞恐苯訏燧d在全局對(duì)象上,也就是說(shuō)全局變量永遠(yuǎn)都可以通過(guò)全局對(duì)象直接訪(fǎng)問(wèn)。
所以全局變量永遠(yuǎn)都是可達(dá)的,而可達(dá)的變量永遠(yuǎn)都不會(huì)被回收。
?? 應(yīng)該怎么做?
當(dāng)一個(gè)全局變量不再需要用到時(shí),記得解除其引用(置空),好讓垃圾回收器可以釋放這部分內(nèi)存。
//?全局變量不會(huì)被回收
window.me?=?{
??name:?'吳彥祖',
??speak:?function()?{
????console.log(`我是${this.name}`);
??}
};
window.me.speak();
//?解除引用后才可以被回收
window.me?=?null;
隱藏類(lèi)(HiddenClass)
實(shí)際上的隱藏類(lèi)遠(yuǎn)比本文所提到的復(fù)雜,但是今天的主角不是它,所以我們點(diǎn)到為止。
在 V8 內(nèi)部有一個(gè)叫做“隱藏類(lèi)”的機(jī)制,主要用于提升對(duì)象(Object)的性能。
V8 里的每一個(gè) JS 對(duì)象(JS Objects)都會(huì)關(guān)聯(lián)一個(gè)隱藏類(lèi),隱藏類(lèi)里面儲(chǔ)存了對(duì)象的形狀(特征)和屬性名稱(chēng)到屬性的映射等信息。
隱藏類(lèi)內(nèi)記錄了每個(gè)屬性的內(nèi)存偏移(Memory offset),后續(xù)訪(fǎng)問(wèn)屬性的時(shí)候就可以快速定位到對(duì)應(yīng)屬性的內(nèi)存位置,從而提升對(duì)象屬性的訪(fǎng)問(wèn)速度。
在我們創(chuàng)建對(duì)象時(shí),擁有完全相同的特征(相同屬性且相同順序)的對(duì)象可以共享同一個(gè)隱藏類(lèi)。
?? 再想象一下
我們可以把隱藏類(lèi)想象成工業(yè)生產(chǎn)中使用的模具,有了模具之后,產(chǎn)品的生產(chǎn)效率得到了很大的提升。
但是如果我們更改了產(chǎn)品的形狀,那么原來(lái)的模具就不能用了,又需要制作新的模具才行。
?? 舉個(gè)栗子
在 Chrome 瀏覽器 Devtools 的 Console 面板中執(zhí)行以下代碼:
//?對(duì)象?A
let?objectA?=?{
??id:?'A',
??name:?'吳彥祖'
};
//?對(duì)象?B
let?objectB?=?{
??id:?'B',
??name:?'彭于晏'
};
//?對(duì)象?C
let?objectC?=?{
??id:?'C',
??name:?'劉德華',
??gender:?'男'
};
//?對(duì)象?A?和?B?擁有完全相同的特征
//?所以它們可以使用同一個(gè)隱藏類(lèi)
//?good!
隨后在 Memory 面板打一個(gè)堆快照,通過(guò)堆快照中的 Comparison 視圖可以快速找到上面創(chuàng)建的 3 個(gè)對(duì)象:
注:關(guān)于如何查看內(nèi)存中的對(duì)象將會(huì)在文章的第二大部分中進(jìn)行講解,現(xiàn)在讓我們專(zhuān)注于隱藏類(lèi)。

在上圖中可以很清楚地看到對(duì)象 A 和 B 確實(shí)使用了同一個(gè)隱藏類(lèi)。
而對(duì)象 C 因?yàn)槎嗔艘粋€(gè) gender 屬性,所以不能和前面兩個(gè)對(duì)象共享隱藏類(lèi)。
?? 動(dòng)態(tài)增刪對(duì)象屬性
一般情況下,當(dāng)我們動(dòng)態(tài)修改對(duì)象的特征(增刪屬性)時(shí),V8 會(huì)為該對(duì)象分配一個(gè)能用的隱藏類(lèi)或者創(chuàng)建一個(gè)新的隱藏類(lèi)(新的分支)。
例如動(dòng)態(tài)地給對(duì)象增加一個(gè)新的屬性:
注:這種操作被稱(chēng)為“先創(chuàng)建再補(bǔ)充(ready-fire-aim)”。
//?增加?gender?屬性
objectB.gender?=?'男';
//?對(duì)象?B?的特征發(fā)生了變化
//?多了一個(gè)原本沒(méi)有的?gender?屬性
//?導(dǎo)致對(duì)象?B?不能再與?A?共享隱藏類(lèi)
//?bad!
動(dòng)態(tài)刪除(delete)對(duì)象的屬性也會(huì)導(dǎo)致同樣的結(jié)果:
//?刪除?name?屬性
delete?objectB.name;
// A:我們不一樣!
//?bad!
不過(guò),添加數(shù)組索引屬性(Array-indexed properties)并不會(huì)有影響:
其實(shí)就是用整數(shù)作為屬性名,此時(shí) V8 會(huì)另外處理。
//?增加?1?屬性
objectB[1]?=?'數(shù)字組引屬性';
//?不影響共享隱藏類(lèi)
//?so?far?so?good!
?? 那問(wèn)題來(lái)了
說(shuō)了這么多,隱藏類(lèi)看起來(lái)確實(shí)可以提升性能,那它和內(nèi)存又有什么關(guān)系呢?
實(shí)際上,隱藏類(lèi)也需要占用內(nèi)存空間,這其實(shí)就是一種用空間換時(shí)間的機(jī)制。
如果由于動(dòng)態(tài)增刪對(duì)象屬性而創(chuàng)建了大量隱藏類(lèi)和分支,結(jié)果就是會(huì)浪費(fèi)不少內(nèi)存空間。
?? 舉個(gè)栗子
創(chuàng)建 1000 個(gè)擁有相同屬性的對(duì)象,內(nèi)存中只會(huì)多出 1 個(gè)隱藏類(lèi)。
而創(chuàng)建 1000 個(gè)屬性信息完全不同的對(duì)象,內(nèi)存中就會(huì)多出 1000 個(gè)隱藏類(lèi)。
?? 應(yīng)該怎么做?
所以,我們要盡量避免動(dòng)態(tài)增刪對(duì)象屬性操作,應(yīng)該在構(gòu)造函數(shù)內(nèi)就一次性聲明所有需要用到的屬性。
如果確實(shí)不再需要某個(gè)屬性,我們可以將屬性的值設(shè)為 null,如下:
//?將?age?屬性置空
objectB.age?=?null;
//?still?good!
另外,相同名稱(chēng)的屬性盡量按照相同的順序來(lái)聲明,可以盡可能地讓更多對(duì)象共享相同的隱藏類(lèi)。
即使遇到不能共享隱藏類(lèi)的情況,也至少可以減少隱藏類(lèi)分支的產(chǎn)生。
其實(shí)動(dòng)態(tài)增刪對(duì)象屬性所引起的性能問(wèn)題更為關(guān)鍵,但因本文篇幅有限,就不再展開(kāi)了。
閉包(Closure)
前面有提到:被閉包引用的變量?jī)?chǔ)存在堆內(nèi)存中。
這里我們?cè)僦攸c(diǎn)關(guān)注一下閉包中的內(nèi)存問(wèn)題,還是前面的例子:
function?getCounter()?{
??let?count?=?0;
??function?counter()?{
????return?++count;
??}
??return?counter;
}
//?closure?是一個(gè)閉包函數(shù)
let?closure?=?getCounter();
closure();?//?1
closure();?//?2
closure();?//?3
現(xiàn)在只要我們一直持有變量(函數(shù)) closure,那么變量 count 就不會(huì)被釋放。
或許你還沒(méi)有發(fā)現(xiàn)風(fēng)險(xiǎn)所在,不如讓我們?cè)囅胱兞?count 不是一個(gè)數(shù)字,而是一個(gè)巨大的數(shù)組,一但這樣的閉包多了,那對(duì)于內(nèi)存來(lái)說(shuō)就是災(zāi)難。
//?我將這個(gè)作品稱(chēng)為:閉包炸彈
function?closureBomb()?{
??const?handsomeBoys?=?[];
??setInterval(()?=>?{
????for?(let?i?=?0;?i?100;?i++)?{
??????handsomeBoys.push(
????????{?name:?'陳皮皮',?rank:?0?},
????????{?name:?'?你?',?rank:?1?},
????????{?name:?'吳彥祖',?rank:?2?},
????????{?name:?'彭于晏',?rank:?3?},
????????{?name:?'劉德華',?rank:?4?},
????????{?name:?'郭富城',?rank:?5?}
??????);
????}
??},?100);
}
closureBomb();
//?即將毀滅世界
//????????????
?? 應(yīng)該怎么做?
所以,我們必須避免濫用閉包,并且謹(jǐn)慎使用閉包!
當(dāng)不再需要時(shí)記得解除閉包函數(shù)的引用,讓閉包函數(shù)以及引用的變量能夠被回收。
closure?=?null;
//?變量?count?終于得救了
如何分析內(nèi)存(Analyze)
說(shuō)了這么多,那我們應(yīng)該如何查看并分析程序運(yùn)行時(shí)的內(nèi)存情況呢?
“工欲善其事,必先利其器。”
對(duì)于 Web 前端項(xiàng)目來(lái)說(shuō),分析內(nèi)存的最佳工具非 Memory 莫屬!
這里的 Memory 指的是 DevTools 中的一個(gè)工具,為了避免混淆,下面我會(huì)用“Memory 面板”或”內(nèi)存面板“代稱(chēng)。
?? DevTools(開(kāi)發(fā)者工具)
DevTools 是瀏覽器里內(nèi)置的一套用于 Web 開(kāi)發(fā)和調(diào)試的工具。
使用 Chromuim 內(nèi)核的瀏覽器都帶有 DevTools,個(gè)人推薦使用 Chrome 或者 Edge(新)。
Memory in Devtools(內(nèi)存面板)
在我們切換到 Memory 面板后,會(huì)看到以下界面(注意標(biāo)注):

在這個(gè)面板中,我們可以通過(guò) 3 種方式來(lái)記錄內(nèi)存情況:
Heap snapshot:堆快照 Allocation instrumentation on timeline:內(nèi)存分配時(shí)間軸 Allocation sampling:內(nèi)存分配采樣
小貼士:點(diǎn)擊面板左上角的 Collect garbage 按鈕(垃圾桶圖標(biāo))可以主動(dòng)觸發(fā)垃圾回收。
?? 在正式開(kāi)始分析內(nèi)存之前,讓我們先學(xué)習(xí)幾個(gè)重要的概念:
?? Shallow Size(淺層大小)
淺層大小指的是當(dāng)前對(duì)象自身占用的內(nèi)存大小。
淺層大小不包含自身引用的對(duì)象。
?? Retained Size(保留大小)
保留大小指的是當(dāng)前對(duì)象被 GC 回收后總共能夠釋放的內(nèi)存大小。
換句話(huà)說(shuō),也就是當(dāng)前對(duì)象自身大小加上對(duì)象直接或間接引用的其他對(duì)象的大小總和。
需要注意的是,保留大小不包含那些除了被當(dāng)前對(duì)象引用之外還被全局對(duì)象直接或間接引用的對(duì)象。
Heap snapshot(堆快照)

堆快照可以記錄頁(yè)面當(dāng)前時(shí)刻的 JS 對(duì)象以及 DOM 節(jié)點(diǎn)的內(nèi)存分配情況。
?? 如何開(kāi)始
點(diǎn)擊頁(yè)面底部的 Take snapshot 按鈕或者左上角的 ? 按鈕即可打一個(gè)堆快照,片刻之后就會(huì)自動(dòng)展示結(jié)果。

在堆快照結(jié)果頁(yè)面中,我們可以使用 4 種不同的視圖來(lái)觀察內(nèi)存情況:
Summary:摘要視圖 Comparison:比較視圖 Containment:包含視圖 Statistics:統(tǒng)計(jì)視圖
默認(rèn)顯示 Summary 視圖。
Summary(摘要視圖)
摘要視圖根據(jù) Constructor(構(gòu)造函數(shù))來(lái)將對(duì)象進(jìn)行分組,我們可以在 Class filter(類(lèi)過(guò)濾器)中輸入構(gòu)造函數(shù)名稱(chēng)來(lái)快速篩選對(duì)象。

頁(yè)面中的幾個(gè)關(guān)鍵詞:
Constructor:構(gòu)造函數(shù)。 Distance:(根)距離,對(duì)象與 GC 根之間的最短距離。 Shallow Size:淺層大小,單位:Bytes(字節(jié))。 Retained Size:保留大小,單位:Bytes(字節(jié))。 Retainers:持有者,也就是直接引用目標(biāo)對(duì)象的變量。
?? Retainers(持有者)
Retainers 欄在舊版的 Devtools 里叫做 Object's retaining tree(對(duì)象保留樹(shù))。
Retainers 下的對(duì)象也展開(kāi)為樹(shù)形結(jié)構(gòu),方便我們進(jìn)行引用溯源。
在視圖中的構(gòu)造函數(shù)列表中,有一些用“()”包裹的條目:
(compiled code):已編譯的代碼。 (closure):閉包函數(shù)。 (array, string, number, symbol, regexp):對(duì)應(yīng)類(lèi)型( Array、String、Number、Symbol、RegExp)的數(shù)據(jù)。(concatenated string):使用 concat()函數(shù)拼接而成的字符串。(sliced string):使用 slice()、substring()等函數(shù)進(jìn)行邊緣切割的字符串。(system):系統(tǒng)(引擎)產(chǎn)生的對(duì)象,如 V8 創(chuàng)建的 HiddenClasses(隱藏類(lèi))和 DescriptorArrays(描述符數(shù)組)等數(shù)據(jù)。
?? DescriptorArrays(描述符數(shù)組)
描述符數(shù)組主要包含對(duì)象的屬性名信息,是隱藏類(lèi)的重要組成部分。
不過(guò)描述符數(shù)組內(nèi)不會(huì)包含整數(shù)索引屬性。
而其余沒(méi)有用“()”包裹的則為全局屬性和 GC 根。
另外,每個(gè)對(duì)象后面都會(huì)有一串“@”開(kāi)頭的數(shù)字,這是對(duì)象在內(nèi)存中的唯一 ID。
小貼士:按下快捷鍵 Ctrl/Command + F 展示搜索欄,輸入名稱(chēng)或 ID 即可快速查找目標(biāo)對(duì)象。
?? 實(shí)踐一下:實(shí)例化一個(gè)對(duì)象
① 切換到 Console 面板,執(zhí)行以下代碼來(lái)實(shí)例化一個(gè)對(duì)象:
function?TestClass()?{
??this.number?=?123;
??this.string?=?'abc';
??this.boolean?=?true;
??this.symbol?=?Symbol('test');
??this.undefined?=?undefined;
??this.null?=?null;
??this.object?=?{?name:?'pp'?};
??this.array?=?[1,?2,?3];
??this.getSet?=?{
????_value:?0,
????get?value()?{
??????return?this._value;
????},
????set?value(v)?{
??????this._value?=?v;
????}
??};
}
let?testObject?=?new?TestClass();

② 回到 Memory 面板,打一個(gè)堆快照,在 Class filter 中輸入“TestClass”:
可以看到內(nèi)存中有一個(gè) TestClass 的實(shí)例,該實(shí)例的淺層大小為 80 字節(jié),保留大小為 876 字節(jié)。

?? 注意到了嗎?
堆快照中的
TestClass實(shí)例的屬性中少了一個(gè)名為number屬性,這是因?yàn)槎芽煺詹粫?huì)捕捉數(shù)字屬性。
?? 實(shí)踐一下:創(chuàng)建一個(gè)字符串
① 切換到 Console 面板,執(zhí)行以下代碼來(lái)創(chuàng)建一個(gè)字符串:
//?這是一個(gè)全局變量
let?testString?=?'我是吳彥祖';
② 回到 Memory 面板,打一個(gè)堆快照,打開(kāi)搜索欄(Ctrl/Command + F)并輸入“我是吳彥祖”:

Comparison(比較視圖)
只有同時(shí)存在 2 個(gè)或以上的堆快照時(shí)才會(huì)出現(xiàn) Comparison 選項(xiàng)。
比較視圖用于展示兩個(gè)堆快照之間的差異。
使用比較視圖可以讓我們快速得知在執(zhí)行某個(gè)操作后的內(nèi)存變化情況(如新增或減少對(duì)象)。
通過(guò)多個(gè)快照的對(duì)比還可以讓我們快速判斷并定位內(nèi)存泄漏。
文章前面提到隱藏類(lèi)的時(shí)候,就是使用了比較視圖來(lái)快速查找新創(chuàng)建的對(duì)象。
?? 實(shí)踐一下
① 新建一個(gè)無(wú)痕(匿名)標(biāo)簽頁(yè)并切換到 Memory 面板,打一個(gè)堆快照 Snapshot 1。
?? 為什么是無(wú)痕標(biāo)簽頁(yè)?
普通標(biāo)簽頁(yè)會(huì)受到瀏覽器擴(kuò)展或者其他腳本影響,內(nèi)存占用不穩(wěn)定。
使用無(wú)痕窗口的標(biāo)簽頁(yè)可以保證頁(yè)面的內(nèi)存相對(duì)純凈且穩(wěn)定,有利于我們進(jìn)行對(duì)比。
另外,建議打開(kāi)窗口一段之間之后再開(kāi)始測(cè)試,這樣內(nèi)存會(huì)比較穩(wěn)定(控制變量)。
② 切換到 Console 面板,執(zhí)行以下代碼來(lái)實(shí)例化一個(gè) Foo 對(duì)象:
function?Foo()?{
??this.name?=?'pp';
??this.age?=?18;
}
let?foo?=?new?Foo();
③ 回到 Memory 面板,再打一個(gè)堆快照 Snapshot 2,切換到 Comparison 視圖,選擇 Snapshot 1 作為 Base snapshot(基本快照),在 Class filter 中輸入“Foo”:
可以看到內(nèi)存中新增了一個(gè) Foo 對(duì)象實(shí)例,分配了 52 字節(jié)內(nèi)存空間,該實(shí)例的引用持有者為變量 foo。

④ 再次切換到 Console 面板,執(zhí)行以下代碼來(lái)解除變量 foo 的引用:
//?解除對(duì)象的引用
foo?=?null;
⑤ 再回到 Memory 面板,打一個(gè)堆快照 Snapshot 3,選擇 Snapshot 2 作為 Base snapshot,在 Class filter 中輸入“Foo”:
內(nèi)存中的 Foo 對(duì)象實(shí)例已經(jīng)被刪除,釋放了 52 字節(jié)的內(nèi)存空間。

Containment(包含視圖)
包含視圖就是程序?qū)ο蠼Y(jié)構(gòu)的“鳥(niǎo)瞰圖(Bird's eye view)”,允許我們通過(guò)全局對(duì)象出發(fā),一層一層往下探索,從而了解內(nèi)存的詳細(xì)情況。

包含視圖中有以下幾種全局對(duì)象:
GC roots(GC 根)
GC roots 就是 JavaScript 虛擬機(jī)的垃圾回收中實(shí)際使用的根節(jié)點(diǎn)。
GC 根可以由 Built-in object maps(內(nèi)置對(duì)象映射)、Symbol tables(符號(hào)表)、VM thread stacks(VM 線(xiàn)程堆棧)、Compilation caches(編譯緩存)、Handle scopes(句柄作用域)和 Global handles(全局句柄)等組成。
DOMWindow objects(DOMWindow 對(duì)象)
DOMWindow objects 指的是由宿主環(huán)境(瀏覽器)提供的頂級(jí)對(duì)象,也就是 JavaScript 代碼中的全局對(duì)象 window,每個(gè)標(biāo)簽頁(yè)都有自己的 window 對(duì)象(即使是同一窗口)。
Native objects(原生對(duì)象)
Native objects 指的是那些基于 ECMAScript 標(biāo)準(zhǔn)實(shí)現(xiàn)的內(nèi)置對(duì)象,包括 Object、Function、Array、String、Boolean、Number、Date、RegExp、Math 等對(duì)象。
?? 實(shí)踐一下
① 切換到 Console 面板,執(zhí)行以下代碼來(lái)創(chuàng)建一個(gè)構(gòu)造函數(shù) $ABC:
構(gòu)造函數(shù)命名前面加個(gè) $ 是因?yàn)檫@樣排序的時(shí)候可以排在前面,方便找。
function?$ABC()?{
??this.name?=?'pp';
}
② 切換到 Memory 面板,打一個(gè)堆快照,切換為 Containment 視圖:
在當(dāng)前標(biāo)簽頁(yè)的全局對(duì)象下就可以找到我們剛剛創(chuàng)建的構(gòu)造函數(shù) $ABC。

Statistics(統(tǒng)計(jì)視圖)
統(tǒng)計(jì)視圖可以很直觀地展示內(nèi)存整體分配情況。

在該視圖里的空心餅圖中共有 6 種顏色,各含義分別為:
紅色:Code(代碼) 綠色:Strings(字符串) 藍(lán)色:JS arrays(數(shù)組) 橙色:Typed arrays(類(lèi)型化數(shù)組) 紫色:System objects(系統(tǒng)對(duì)象) 白色:空閑內(nèi)存
Allocation instrumentation on timeline(分配時(shí)間軸)

在一段時(shí)間內(nèi)持續(xù)地記錄內(nèi)存分配(約每 50 毫秒打一張堆快照),記錄完成后可以選擇查看任意時(shí)間段的內(nèi)存分配詳情。
另外還可以勾選同時(shí)記錄分配堆棧(Allocation stacks),也就是記錄調(diào)用堆棧,不過(guò)這會(huì)產(chǎn)生額外的性能消耗。
?? 如何開(kāi)始
點(diǎn)擊頁(yè)面底部的 Start 按鈕或者左上角的 ? 按鈕即可開(kāi)始記錄,記錄過(guò)程中點(diǎn)擊左上角的 ?? 按鈕來(lái)結(jié)束記錄,片刻之后就會(huì)自動(dòng)展示結(jié)果。
?? 操作一下
① 打開(kāi) Memory 面板,開(kāi)始記錄分配時(shí)間軸。
② 切換到 Console 面板,執(zhí)行以下代碼:
代碼效果:每隔 1 秒鐘創(chuàng)建 100 個(gè)對(duì)象,共創(chuàng)建 1000 個(gè)對(duì)象。
console.log('測(cè)試開(kāi)始');
let?objects?=?[];
let?handler?=?setInterval(()?=>?{
??//?每秒創(chuàng)建?100?個(gè)對(duì)象
??for?(let?i?=?0;?i?100;?i++)?{
????const?name?=?`n${objects.length}`;
????const?value?=?`v${objects.length}`;
????objects.push({?[name]:?value});
??}
??console.log(`對(duì)象數(shù)量:${objects.length}`);
??//?達(dá)到?1000?個(gè)后停止
??if?(objects.length?>=?1000)?{
????clearInterval(handler);
????console.log('測(cè)試結(jié)束');
??}
},?1000);
?? 又是一個(gè)細(xì)節(jié)
不知道你有沒(méi)有發(fā)現(xiàn),在上面的代碼中,我干了一件壞事。
在 for 循環(huán)創(chuàng)建對(duì)象時(shí),會(huì)根據(jù)對(duì)象數(shù)組當(dāng)前長(zhǎng)度生成一個(gè)唯一的屬性名和屬性值。
這樣一來(lái) V8 就無(wú)法對(duì)這些對(duì)象進(jìn)行優(yōu)化,方便我們進(jìn)行測(cè)試。
另外,如果直接使用對(duì)象數(shù)組的長(zhǎng)度作為屬性名會(huì)有驚喜~
③ 靜靜等待 10 秒鐘,控制臺(tái)會(huì)打印出“測(cè)試結(jié)束”。
④ 切換回 Memory 面板,停止記錄,片刻之后會(huì)自動(dòng)進(jìn)入結(jié)果頁(yè)面。

分配時(shí)間軸結(jié)果頁(yè)有 4 種視圖:
Summary:摘要視圖 Containment:包含視圖 Allocation:分配視圖 Statistics:統(tǒng)計(jì)視圖
默認(rèn)顯示 Summary 視圖。
Summary(摘要視圖)
看起來(lái)和堆快照的摘要視圖很相似,主要是頁(yè)面上方多了一條橫向的時(shí)間軸(Timeline)。

?? 時(shí)間軸
時(shí)間軸中主要的 3 種線(xiàn):
細(xì)橫線(xiàn):內(nèi)存分配大小刻度線(xiàn) 藍(lán)色豎線(xiàn):表示內(nèi)存在對(duì)應(yīng)時(shí)刻被分配,最后仍然活躍 灰色豎線(xiàn):表示內(nèi)存在對(duì)應(yīng)時(shí)刻被分配,但最后被回收
時(shí)間軸的幾個(gè)操作:
鼠標(biāo)移動(dòng)到時(shí)間軸內(nèi)任意位置,點(diǎn)擊左鍵或長(zhǎng)按左鍵并拖動(dòng)即可選擇一段時(shí)間 鼠標(biāo)拖動(dòng)時(shí)間段框上方的方塊可以對(duì)已選擇的時(shí)間段進(jìn)行調(diào)整 鼠標(biāo)移到已選擇的時(shí)間段框內(nèi)部,滑動(dòng)滾輪可以調(diào)整時(shí)間范圍 鼠標(biāo)移到已選擇的時(shí)間段框兩旁,滑動(dòng)滾輪即可調(diào)整時(shí)間段 雙擊鼠標(biāo)左鍵即可取消選擇

在時(shí)間軸中選擇要查看的時(shí)間段,即可得到該段時(shí)間的內(nèi)存分配詳情。

Containment(包含視圖)
分配時(shí)間軸的包含視圖與堆快照的包含視圖是一樣的,這里就不再重復(fù)介紹了。

Allocation(分配視圖)
對(duì)不起各位,這玩意兒我也不知道有啥用...
打開(kāi)就直接報(bào)錯(cuò),我:喵喵喵?

是不是因?yàn)闆](méi)人用這玩意兒,所以沒(méi)人發(fā)現(xiàn)有問(wèn)題...
Statistics(統(tǒng)計(jì)視圖)
分配時(shí)間軸的統(tǒng)計(jì)視圖與堆快照的統(tǒng)計(jì)視圖也是一樣的,不再贅述。

Allocation sampling(分配采樣)

Memory 面板上的簡(jiǎn)介:使用采樣方法記錄內(nèi)存分配。這種分析方式的性能開(kāi)銷(xiāo)最小,可以用于長(zhǎng)時(shí)間的記錄。
好家伙,這個(gè)簡(jiǎn)介有夠模糊,說(shuō)了跟沒(méi)說(shuō)似的,很有精神!
我在官方文檔里沒(méi)有找到任何關(guān)于分配采樣的介紹,Google 上也幾乎沒(méi)有與之有關(guān)的信息。所以以下內(nèi)容僅為個(gè)人實(shí)踐得出的結(jié)果,如有不對(duì)的地方歡迎各位指出!
簡(jiǎn)單來(lái)說(shuō),通過(guò)分配采樣我們可以很直觀地看到代碼中的每個(gè)函數(shù)(API)所分配的內(nèi)存大小。
由于是采樣的方式,所以結(jié)果并非百分百準(zhǔn)確,即使每次執(zhí)行相同的操作也可能會(huì)有不同的結(jié)果,但是足以讓我們了解內(nèi)存分配的大體情況。
? 如何開(kāi)始
點(diǎn)擊頁(yè)面底部的 Start 按鈕或者左上角的 ? 按鈕即可開(kāi)始記錄,記錄過(guò)程中點(diǎn)擊左上角的 ?? 按鈕來(lái)結(jié)束記錄,片刻之后就會(huì)自動(dòng)展示結(jié)果。
?? 操作一下
① 打開(kāi) Memory 面板,開(kāi)始記錄分配采樣。
② 切換到 Console 面板,執(zhí)行以下代碼:
代碼看起來(lái)有點(diǎn)長(zhǎng),其實(shí)就是 4 個(gè)函數(shù)分別以不同的方式往數(shù)組里面添加對(duì)象。
//?普通單層調(diào)用
let?array_a?=?[];
function?aoo1()?{
??for?(let?i?=?0;?i?10000;?i++)?{
????array_a.push({?a:?'pp'?});
??}
}
aoo1();
//?兩層嵌套調(diào)用
let?array_b?=?[];
function?boo1()?{
??function?boo2()?{
????for?(let?i?=?0;?i?20000;?i++)?{
??????array_b.push({?b:?'pp'?});
????}
??}
??boo2();
}
boo1();
//?三層嵌套調(diào)用
let?array_c?=?[];
function?coo1()?{
??function?coo2()?{
????function?coo3()?{
??????for?(let?i?=?0;?i?30000;?i++)?{
????????array_c.push({?c:?'pp'?});
??????}
????}
????coo3();
??}
??coo2();
}
coo1();
//?兩層嵌套多個(gè)調(diào)用
let?array_d?=?[];
function?doo1()?{
??function?doo2_1()?{
????for?(let?i?=?0;?i?20000;?i++)?{
??????array_d.push({?d:?'pp'?});
????}
??}
??doo2_1();
??function?doo2_2()?{
????for?(let?i?=?0;?i?20000;?i++)?{
??????array_d.push({?d:?'pp'?});
????}
??}
??doo2_2();
}
doo1();
③ 切換回 Memory 面板,停止記錄,片刻之后會(huì)自動(dòng)進(jìn)入結(jié)果頁(yè)面。

分配采樣結(jié)果頁(yè)有 3 種視圖可選:
Chart:圖表視圖 Heavy (Bottom Up):扁平視圖(調(diào)用層級(jí)自下而上) Tree (Top Down):樹(shù)狀視圖(調(diào)用層級(jí)自上而下)
這個(gè) Heavy 我真的不知道該怎么翻譯,所以我就按照具體表現(xiàn)來(lái)命名了。
默認(rèn)會(huì)顯示 Chart 視圖。
Chart(圖表視圖)
Chart 視圖以圖形化的表格形式展現(xiàn)各個(gè)函數(shù)的內(nèi)存分配詳情,可以選擇精確到內(nèi)存分配的不同階段(以?xún)?nèi)存分配的大小為軸)。

鼠標(biāo)左鍵點(diǎn)擊、拖動(dòng)和雙擊以操作內(nèi)存分配階段軸(和時(shí)間軸一樣),選擇要查看的階段范圍。

將鼠標(biāo)移動(dòng)到函數(shù)方塊上會(huì)顯示函數(shù)的內(nèi)存分配詳情。

鼠標(biāo)左鍵點(diǎn)擊函數(shù)方塊可以跳轉(zhuǎn)到相應(yīng)代碼。

Heavy(扁平視圖)
Heavy 視圖將函數(shù)調(diào)用層級(jí)壓平,函數(shù)將以獨(dú)立的個(gè)體形式展現(xiàn)。另外也可以展開(kāi)調(diào)用層級(jí),不過(guò)是自下而上的結(jié)構(gòu),也就是一個(gè)反向的函數(shù)調(diào)用過(guò)程。

視圖中的兩種 Size(大小):
Self Size:自身大小,指的是在函數(shù)內(nèi)部直接分配的內(nèi)存空間大小。 Total Size:總大小,指的是函數(shù)總共分配的內(nèi)存空間大小,也就是包括函數(shù)內(nèi)部嵌套調(diào)用的其他函數(shù)所分配的大小。
Tree(樹(shù)狀視圖)
Tree 視圖以樹(shù)形結(jié)構(gòu)展現(xiàn)函數(shù)調(diào)用層級(jí)。我們可以從代碼執(zhí)行的源頭開(kāi)始自上而下逐層展開(kāi),呈現(xiàn)一個(gè)完整的正向的函數(shù)調(diào)用過(guò)程。

參考資料
《JavaScript 高級(jí)程序設(shè)計(jì)(第4版)》
Memory Management:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management
Visualizing memory management in V8 Engine:https://deepu.tech/memory-management-in-v8/
Trash talk: the Orinoco garbage collector:https://v8.dev/blog/trash-talk
Fast properties in V8:https://v8.dev/blog/fast-properties
Concurrent marking in V8:https://v8.dev/blog/concurrent-marking
Chrome DevTools:https://developers.google.com/web/tools/chrome-devtools
菜鳥(niǎo)小棧
??我是陳皮皮,一個(gè)還在不斷學(xué)習(xí)的游戲開(kāi)發(fā)者,一個(gè)熱愛(ài)分享的 Cocos Star Writer。
??這是我的個(gè)人公眾號(hào),專(zhuān)注但不僅限于游戲開(kāi)發(fā)和前端技術(shù)分享。
??每一篇原創(chuàng)都非常用心,你的關(guān)注就是我原創(chuàng)的動(dòng)力!
Input and output.

