Study Notes
本博主會持續(xù)更新各種前端的技術(shù)捌袜,如果各位道友喜歡说搅,可以關(guān)注、收藏虏等、點贊下本博主的文章弄唧。
JavaScript 內(nèi)存管理
簡介
像 C 語言這樣的底層語言一般都有底層的內(nèi)存管理接口,比如 malloc()和 free()霍衫。相反候引,JavaScript 是在創(chuàng)建變量(對象,字符串等)時自動進(jìn)行了分配內(nèi)存敦跌,并且在不使用它們時“自動”釋放澄干。 釋放的過程稱為垃圾回收。這個“自動”是混亂的根源柠傍,并讓 JavaScript(和其他高級語言)開發(fā)者錯誤的感覺他們可以不關(guān)心內(nèi)存管理麸俘。
內(nèi)存生命周期
不管什么程序語言,內(nèi)存生命周期基本是一致的:
- 分配你所需要的內(nèi)存
- 使用分配到的內(nèi)存(讀惧笛、寫)
- 不需要時將其釋放\歸還
所有語言第二部分都是明確的从媚。第一和第三部分在底層語言中是明確的,但在像 JavaScript 這些高級語言中患整,大部分都是隱含的拜效。
JavaScript 的內(nèi)存分配
值的初始化
為了不讓程序員費心分配內(nèi)存,JavaScript 在定義變量時就完成了內(nèi)存分配各谚。
const n = 123; // 給數(shù)值變量分配內(nèi)存
const s = 'heath'; // 給字符串分配內(nèi)存
const o = {
a: 1,
b: null,
}; // 給對象及其包含的值分配內(nèi)存
// 給數(shù)組及其包含的值分配內(nèi)存(就像對象一樣)
const a = [1, null, 'abra'];
function f(a) {
return a + 2;
} // 給函數(shù)(可調(diào)用的對象)分配內(nèi)存
// 函數(shù)表達(dá)式也能分配一個對象
someElement.addEventListener(
'click',
function () {
someElement.style.backgroundColor = 'blue';
},
false,
);
通過函數(shù)調(diào)用分配內(nèi)存
有些函數(shù)調(diào)用結(jié)果是分配對象內(nèi)存:
const d = new Date(); // 分配一個 Date 對象
const e = document.createElement('div'); // 分配一個 DOM 元素
有些方法分配新變量或者新對象:
const s = 'heath';
const s2 = s.substr(0, 3); // s2 是一個新的字符串
// 因為字符串是不變量紧憾,
// JavaScript 可能決定不分配內(nèi)存,
// 只是存儲了 [0-3] 的范圍昌渤。
const a = ['heath heath', 'nan nan'];
const a2 = ['generation', 'nan nan'];
const a3 = a.concat(a2);
// 新數(shù)組有四個元素赴穗,是 a 連接 a2 的結(jié)果
使用值
使用值的過程實際上是對分配內(nèi)存進(jìn)行讀取與寫入的操作。讀取與寫入可能是寫入一個變量或者一個對象的屬性值膀息,甚至傳遞函數(shù)的參數(shù)望抽。
當(dāng)內(nèi)存不再需要使用時釋放
大多數(shù)內(nèi)存管理的問題都在這個階段。在這里最艱難的任務(wù)是找到“哪些被分配的內(nèi)存確實已經(jīng)不再需要了”履婉。它往往要求開發(fā)人員來確定在程序中哪一塊內(nèi)存不再需要并且釋放它煤篙。
高級語言解釋器嵌入了“垃圾回收器”,它的主要工作是跟蹤內(nèi)存的分配和使用毁腿,以便當(dāng)分配的內(nèi)存不再使用時辑奈,自動釋放它苛茂。這只能是一個近似的過程,因為要知道是否仍然需要某塊內(nèi)存是無法判定的(無法通過某種算法解決)鸠窗。
垃圾回收(Garbage collection)
如上文所述自動尋找是否一些內(nèi)存“不再需要”的問題是無法判定的妓羊。因此,垃圾回收實現(xiàn)只能有限制的解決一般問題稍计。本節(jié)將解釋必要的概念躁绸,了解主要的垃圾回收算法和它們的局限性。
引用
垃圾回收算法主要依賴于引用的概念臣嚣。在內(nèi)存管理的環(huán)境中净刮,一個對象如果有訪問另一個對象的權(quán)限(隱式或者顯式),叫做一個對象引用另一個對象硅则。例如淹父,一個 Javascript 對象具有對它原型的引用(隱式引用)和對它屬性的引用(顯式引用)。
在這里怎虫,“對象”的概念不僅特指 JavaScript 對象暑认,還包括函數(shù)作用域(或者全局詞法作用域)。
可達(dá)對象
- 可以訪問到的對象就是可達(dá)對象(引用大审、作用域鏈)
- 可達(dá)的標(biāo)準(zhǔn)就是從根出發(fā)是否能夠被找到
- JavaScript 中的根就是可以理解為是全局變量對象
引用計數(shù)垃圾收集
這是最初級的垃圾收集算法蘸际。此算法把“對象是否不再需要”簡化定義為“對象有沒有其他對象引用到它”。如果沒有引用指向該對象(零引用)徒扶,對象將被垃圾回收機(jī)制回收捡鱼。
實現(xiàn)原理
每個對象在創(chuàng)建的時候,就給這個對象綁定一個計數(shù)器酷愧。每當(dāng)有一個引用指向該對象時,計數(shù)器加一缠诅;每當(dāng)有一個指向它的引用被刪除時溶浴,計數(shù)器減一。這樣管引,當(dāng)沒有引用指向該對象時士败,該對象死亡,計數(shù)器為 0褥伴,這時就應(yīng)該對這個對象進(jìn)行垃圾回收操作谅将。
let o = {
a: {
b: 2,
},
};
// 兩個對象被創(chuàng)建,一個作為另一個的屬性被引用重慢,另一個被分配給變量o
// 很顯然饥臂,沒有一個可以被垃圾收集
let o2 = o; // o2變量是第二個對“這個對象”的引用
o = 1; // 現(xiàn)在,“這個對象”只有一個o2變量的引用了似踱,“這個對象”的原始引用o已經(jīng)沒有
let oa = o2.a; // 引用“這個對象”的a屬性
// 現(xiàn)在隅熙,“這個對象”有兩個引用了稽煤,一個是o2,一個是oa
o2 = 'yo'; // 雖然最初的對象現(xiàn)在已經(jīng)是零引用了囚戚,可以被垃圾回收了
// 但是它的屬性a的對象還在被oa引用酵熙,所以還不能回收
oa = null; // a屬性的那個對象現(xiàn)在也是零引用了
// 它可以被垃圾回收了
限制:循環(huán)引用
該算法有個限制:無法處理循環(huán)引用的事例。在下面的例子中驰坊,兩個對象被創(chuàng)建匾二,并互相引用,形成了一個循環(huán)拳芙。它們被調(diào)用之后會離開函數(shù)作用域察藐,所以它們已經(jīng)沒有用了,可以被回收了态鳖。然而转培,引用計數(shù)算法考慮到它們互相都有至少一次引用,所以它們不會被回收浆竭。
function f() {
let o = {};
let o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o
return 'heath';
}
f();
實際例子
IE 6, 7 使用引用計數(shù)方式對 DOM 對象進(jìn)行垃圾回收浸须。該方式常常造成對象被循環(huán)引用時內(nèi)存發(fā)生泄漏:
let div;
window.onload = function () {
div = document.getElementById('myDivElement');
div.circularReference = div;
div.lotsOfData = new Array(10000).join('*');
};
在上面的例子里,myDivElement 這個 DOM 元素里的 circularReference 屬性引用了 myDivElement邦泄,造成了循環(huán)引用删窒。如果該屬性沒有顯示移除或者設(shè)為 null,引用計數(shù)式垃圾收集器將總是且至少有一個引用顺囊,并將一直保持在內(nèi)存里的 DOM 元素肌索,即使其從 DOM 樹中刪去了。如果這個 DOM 元素?fù)碛写罅康臄?shù)據(jù) (如上的 lotsOfData 屬性)特碳,而這個數(shù)據(jù)占用的內(nèi)存將永遠(yuǎn)不會被釋放诚亚。
引用計數(shù)優(yōu)缺點
優(yōu)點
- 發(fā)現(xiàn)垃圾時,立即回收
- 最大限度減少程序暫停
缺點
- 無法回收循環(huán)引用的對象
- 時間開銷大
標(biāo)記-清除算法
這個算法把“對象是否不再需要”簡化定義為“對象是否可以獲得”午乓。
這個算法假定設(shè)置一個叫做根(root)的對象(在 Javascript 里站宗,根是全局對象)。垃圾回收器將定期從根開始益愈,找所有從根開始引用的對象梢灭,然后找這些對象引用的對象……從根開始,垃圾回收器將找到所有可以獲得的對象和收集所有不能獲得的對象蒸其。
這個算法比前一個要好敏释,因為“有零引用的對象”總是不可獲得的,但是相反卻不一定摸袁,參考“循環(huán)引用”钥顽。
從 2012 年起,所有現(xiàn)代瀏覽器都使用了標(biāo)記-清除垃圾回收算法靠汁。所有對 JavaScript 垃圾回收算法的改進(jìn)都是基于標(biāo)記-清除算法的改進(jìn)耳鸯,并沒有改進(jìn)標(biāo)記-清除算法本身和它對“對象是否不再需要”的簡化定義湿蛔。
實現(xiàn)原理
- 核心思想:分標(biāo)記和清除二個階段完成
- 遍歷所有對象并標(biāo)記活動對象
- 遍歷所有對象清除沒有標(biāo)記的對象
- 回收相應(yīng)的空間
循環(huán)引用不再是問題了
在上面的示例中,函數(shù)調(diào)用返回之后县爬,兩個對象從全局對象出發(fā)無法獲取阳啥。因此,他們將會被垃圾回收器回收财喳。第二個示例同樣察迟,一旦 div 和其事件處理無法從根獲取到,他們將會被垃圾回收器回收耳高。
限制: 那些無法從根對象查詢到的對象都將被清除
盡管這是一個限制扎瓶,但實踐中我們很少會碰到類似的情況,所以開發(fā)者不太會去關(guān)心垃圾回收機(jī)制泌枪。
標(biāo)記-清除算法缺點
容易產(chǎn)生內(nèi)存碎片化空間
標(biāo)記算法并未在清除未標(biāo)記對象的時候概荷,進(jìn)行整理,所以在清除標(biāo)記后碌燕,會產(chǎn)生大量的不連續(xù)的內(nèi)存碎片误证。
當(dāng)分配的內(nèi)存大于現(xiàn)有連續(xù)的內(nèi)存碎片,則會提前觸發(fā)新一輪的垃圾回收動作修壕;
當(dāng)分配的內(nèi)存小于現(xiàn)有連續(xù)的內(nèi)存碎片愈捅,則可能會造成浪費。
標(biāo)記整理算法
實現(xiàn)原理
- 標(biāo)記整理可以看做是標(biāo)記清除的增強(qiáng)
- 標(biāo)記階段的操作和標(biāo)記清除一致
- 清除階段會先執(zhí)行整理慈鸠,移動對象位置
V8(JavaScript 執(zhí)行引擎)
- V8 引擎是一個 JavaScript 引擎實現(xiàn)蓝谨,最初由一些語言方面專家設(shè)計,后被谷歌收購青团,隨后谷歌對其進(jìn)行了開源譬巫。
- V8 使用 C++開發(fā),在運行 JavaScript 之前督笆,相比其它的 JavaScript 的引擎轉(zhuǎn)換成字節(jié)碼或解釋執(zhí)行芦昔,V8 將其編譯成原生機(jī)器碼(IA-32, x86-64, ARM, or MIPS CPUs),并且使用了如內(nèi)聯(lián)緩存(inline caching)等方法來提高性能胖腾。
- 有了這些功能,JavaScript 程序在 V8 引擎下的運行速度媲美二進(jìn)制程序瘪松。
- V8 支持眾多操作系統(tǒng)咸作,如 windows、linux宵睦、android 等记罚,也支持其他硬件架構(gòu),如 IA32,X64,ARM 等壳嚎,具有很好的可移植和跨平臺特性桐智。
內(nèi)存管理
V8 內(nèi)存限制
限制大小
64 位為 1.4GB末早,32 位為 0.7GB
限制原因
V8 之所以限制了內(nèi)存的大小,表面上的原因是 V8 最初是作為瀏覽器的 JavaScript 引擎而設(shè)計说庭,不太可能遇到大量內(nèi)存的場景然磷,而深層次的原因則是由于 V8 的垃圾回收機(jī)制的限制。由于 V8 需要保證 JavaScript 應(yīng)用邏輯與垃圾回收器所看到的不一樣刊驴,V8 在執(zhí)行垃圾回收時會阻塞 JavaScript 應(yīng)用邏輯姿搜,直到垃圾回收結(jié)束再重新執(zhí)行 JavaScript 應(yīng)用邏輯,這種行為被稱為“全停頓”(stop-the-world)捆憎。若 V8 的堆內(nèi)存為 1.5GB舅柜,V8 做一次小的垃圾回收需要 50ms 以上,做一次非增量式的垃圾回收甚至要 1 秒以上躲惰。這樣瀏覽器將在 1s 內(nèi)失去對用戶的響應(yīng)致份,造成假死現(xiàn)象。如果有動畫效果的話础拨,動畫的展現(xiàn)也將顯著受到影響氮块。
V8 垃圾回收策略
- 采用分代回收的思想
- 內(nèi)存分為新生代、老生代
- 針對新太伊、老生代采用不同算法來提升垃圾回收的效率
新生代的對象為存活時間較短的對象雇锡,老生代中的對象為存活時間較長或常駐內(nèi)存的對象。
V8 新生代僚焦、老生代內(nèi)存大小
V8 引擎的新生代內(nèi)存大小 32MB(64 位)锰提、16MB(32 位),老生代內(nèi)存大小為 1400MB(64 位)芳悲、700MB( 32 位)立肘。
新生代對象回收實現(xiàn)
- 回收過程采用復(fù)制算法+標(biāo)記整理
- 新生代內(nèi)存區(qū)被等分為兩個空間
- 使用空間為 From,空閑空間為 To
- 標(biāo)記整理后將活動對象拷貝至 To
- From 和 To 交換空間完成釋放
晉升
將新生代對象移到老生代
晉升條件
- 一輪 GC 還存活的新生代需要晉升
- 對象從 From 空間復(fù)制到 To 空間時名扛,如果 To 空間已經(jīng)被使用了超過 25%谅年,那么這個對象直接被復(fù)制到老生代
老生代對象回收實現(xiàn)
- 主要采取標(biāo)記清除、標(biāo)記整理肮韧、增量標(biāo)記算法
- 首先使用標(biāo)記清除完成垃圾空間的回收
- 采用標(biāo)記整理進(jìn)行空間優(yōu)化
- 采用增量標(biāo)記進(jìn)行效率優(yōu)化
細(xì)節(jié)對比
新生代區(qū)域融蹂,采用復(fù)制算法, 因此其每時每刻內(nèi)部都有空閑空間的存在(為了完成 From 到 To 的對象復(fù)制)弄企,但是新生代區(qū)域空間較小(32M)且被一分為二超燃,所以這種空間上的浪費也是比較微不足道的。
老生代因其空間較大(1.4G),如果同樣采用一分為二的做法則對空間大小是比較浪費拘领,且老生代空間較大意乓,存放對對象也較多,如果進(jìn)行復(fù)制算法约素,則其消耗對時間也會更大届良。也就是是否使用復(fù)制算法來進(jìn)行垃圾回收笆凌,是一個時間 T 關(guān)于內(nèi)存大小的關(guān)系,當(dāng)內(nèi)存大小較小時士葫,使用復(fù)制算法消耗的時間是比較短的乞而,而當(dāng)內(nèi)存較大時,采用復(fù)制算法對時間對消耗也就更大为障。
V8 的優(yōu)化
增量標(biāo)記
由于全停頓會造成了瀏覽器一段時間無響應(yīng)晦闰,所以 V8 使用了一種增量標(biāo)記的方式,將完整的標(biāo)記拆分成很多部分鳍怨,每做完一部分就停下來呻右,讓 JS 的應(yīng)用邏輯執(zhí)行一會,這樣垃圾回收與應(yīng)用邏輯交替完成鞋喇。經(jīng)過增量標(biāo)記的改進(jìn)后声滥,垃圾回收的最大停頓時間可以減少到原來的 1/6 左右
惰性清理
由于標(biāo)記完成后,所有的對象都已經(jīng)被標(biāo)記侦香,不是死對象就是活對象落塑,堆上多少空間格局已經(jīng)確定。我們可以不必著急釋放那些死對象所占用的空間罐韩,而延遲清理過程的執(zhí)行憾赁。垃圾回收器可以根據(jù)需要逐一清理死對象所占用的內(nèi)存空間
其他
V8 后續(xù)還引入了增量式整理(incremental compaction),以及并行標(biāo)記和并行清理散吵,通過并行利用多核 CPU 來提升垃圾回收的性能
監(jiān)控內(nèi)存
內(nèi)存問題的外在表現(xiàn)
- 頁面出現(xiàn)延遲加載或經(jīng)常性暫停: 可能存在頻繁當(dāng) GC 操作,存在一些代碼瞬間吃滿了內(nèi)存龙考。
- 頁面出現(xiàn)持續(xù)性的糟糕性能: 程序為了達(dá)到最優(yōu)的運行速度,向內(nèi)存申請了一片較大的內(nèi)存空間矾睦,但空間大小超過了設(shè)備所能提供的大小晦款。
- 頁面使用隨著時間延長越來越卡: 可能存在內(nèi)存泄漏。
界定內(nèi)存問題的標(biāo)準(zhǔn)
- 內(nèi)存泄漏:內(nèi)存使用持續(xù)升高
- 內(nèi)存膨脹:在多數(shù)設(shè)備上都存在性能問題
- 頻繁垃圾回收:通過內(nèi)存變化時序圖進(jìn)行分析
監(jiān)控內(nèi)存方式
任務(wù)管理器
這里以 Google 瀏覽器為例,使用 Shift + Esc 喚起 Google 瀏覽器自帶的任務(wù)管理器
- Memory(內(nèi)存) 列表示原生內(nèi)存枚冗。DOM 節(jié)點存儲在原生內(nèi)存中缓溅。 如果此值正在增大,則說明正在創(chuàng)建 DOM 節(jié)點赁温。
- JavaScript Memory(JavaScript 內(nèi)存) 列表示 JS 堆坛怪。此列包含兩個值。 您感興趣的值是實時數(shù)字(括號中的數(shù)字)股囊。 實時數(shù)字表示您的頁面上的可到達(dá)對象正在使用的內(nèi)存量袜匿。 如果此數(shù)字在增大,要么是正在創(chuàng)建新對象毁涉,要么是現(xiàn)有對象正在增長沉帮。
模擬內(nèi)存泄漏
在任務(wù)管理器里可以看到 JavaScript 內(nèi)存持續(xù)上升
document.body.innerHTML = `<button id="add">add</button>`;
document.getElementById('add').addEventListener('click', function (e) {
simulateMemoryLeak();
});
let result = [];
function simulateMemoryLeak() {
setInterval(function () {
result.push(new Array(1000000).join('x'));
document.body.innerHTML = result;
}, 100);
}
Timeline 記錄內(nèi)存
這里以 Google 瀏覽器為例,使用 F12 開啟調(diào)式锈死,選擇 Performance贫堰,點擊 record(錄制)穆壕,進(jìn)行頁面操作,點擊 stop 結(jié)束錄制之后其屏,開啟內(nèi)存勾選喇勋,拖動截圖到指定時間段查看發(fā)生內(nèi)存問題時候到頁面展示,并定位問題偎行。同時可以查看對應(yīng)出現(xiàn)紅點到執(zhí)行腳本川背,定位問題代碼。
利用瀏覽器內(nèi)存模塊蛤袒,查找分離 dom
這里以 Google 瀏覽器為例,在頁面上進(jìn)行相關(guān)操作后熄云,使用 F12 開啟調(diào)式,選擇 Memory妙真,點擊 Take snapshot(拍照)缴允,在快照中查找 Detached HTMLElement,回到代碼中查找對應(yīng)的分離 dom 存在的代碼,在相關(guān)操作代碼之后珍德,對分離 dom 進(jìn)行釋放练般,防止內(nèi)存泄漏。
只有頁面的 DOM 樹或 JavaScript 代碼不再引用 DOM 節(jié)點時锈候,DOM 節(jié)點才會被作為垃圾進(jìn)行回收薄料。 如果某個節(jié)點已從 DOM 樹移除,但某些 JavaScript 仍然引用它泵琳,我們稱此節(jié)點為“已分離”摄职。已分離的 DOM 節(jié)點是內(nèi)存泄漏的常見原因。
模擬已分離 DOM 節(jié)點
document.body.innerHTML = `<button id="add">add</button>`;
document.getElementById('add').addEventListener('click', function (e) {
create();
});
let detachedTree;
function create() {
let ul = document.createElement('ul');
for (let i = 0; i < 10; i++) {
let li = document.createElement('li');
ul.appendChild(li);
}
detachedTree = ul;
}
如何確定頻繁對垃圾回收
- GC 工作時虑稼,程序是暫停的琳钉,頻繁/過長的 GC 會導(dǎo)致程序假死,用戶會感知到卡頓蛛倦。
- 查看 Timeline 中是否存在內(nèi)存走向在短時間內(nèi)頻繁上升下降的區(qū)域歌懒。瀏覽器任務(wù)管理器是否頻繁的增加減少。
代碼優(yōu)化
jsPerf(JavaScript 性能測試)
基于 Benchmark.js
慎用全局變量
- 全局變量定義在全局執(zhí)行的上下文,是所有作用域鏈的頂端
- 全局執(zhí)行上下文一直存在于上下文執(zhí)行棧溯壶,直到程序退出
- 如果某個局部作用域出現(xiàn)了同名變量則會屏蔽或者污染全局作用域
- 全局變量的執(zhí)行速度及皂,訪問速度要低于局部變量,因此對于一些需要經(jīng)常訪問的全局變量可以在局部作用域中進(jìn)行緩存
上圖可以看出且改,test2 的性能要比 test1 的性能要好验烧,從而得知,全局變量的執(zhí)行速度又跛,訪問速度要低于局部變量
避免全局查找
上圖可以看出碍拆,test2 的性能要比 test1 的性能要好,從而得知,緩存全局變量后使用可以提升性能
通過原型對象添加附加方法提高性能
上圖可以看出感混,test2 的性能要比 test1 的性能要好端幼,從而得知,通過原型對象添加方法與直接在對象上添加成員方法相比弧满,原型對象上的屬性訪問速度較快婆跑。
避開閉包陷阱
閉包特點
- 外部具有指向內(nèi)部的引用
- 在“外”部作用域訪問“內(nèi)”部作用域的數(shù)據(jù)
function foo() {
let name = 'heath';
function fn() {
console.log(name);
}
return fn;
}
let a = foo();
a();
閉包使用不當(dāng)很容易出現(xiàn)內(nèi)存泄漏
function f5() {
// el 引用了全局變量document,假設(shè)btn節(jié)點被刪除后庭呜,因為這里被引用著滑进,所以這里不會被垃圾回收,導(dǎo)致內(nèi)存泄漏
let el = document.getElementById('btn');
el.onclick = function (e) {
console.log(e.id);
};
}
f5();
function f6() {
// el 引用了全局變量document募谎,假設(shè)btn節(jié)點被刪除后扶关,因為這里被引用著,所以這里不會被垃圾回收数冬,導(dǎo)致內(nèi)存泄漏
let el = document.getElementById('btn');
el.onclick = function (e) {
console.log(e.id);
};
el = null; // 我們這里手動將el內(nèi)存釋放驮审,從而當(dāng)btn節(jié)點被刪除后,可以被垃圾回收
}
f6();
避免屬性訪問方法使用
JavaScript 中的面向?qū)ο?/p>
- JS 不需屬性的訪問方法吉执,所有屬性都是外部可見的
- 使用屬性訪問方法只會增加一層重定義疯淫,沒有訪問的控制力
上圖可以看出,test2 的性能要比 test1 的性能要好不少戳玫,從而得知熙掺,直接訪問屬性,會比通過方法訪問屬性速度來的快咕宿。
遍歷速度
上圖可以看出币绩,loop 遍歷速度 forEach > 優(yōu)化 for > for of > for > for in
dom 節(jié)點操作
上圖可以看出,節(jié)點克隆(cloneNode)生成節(jié)點速度要快于創(chuàng)建節(jié)點府阀。
采用字面量替換 New 操作
上圖可以看出缆镣,字面量聲明的數(shù)據(jù)生成速度要快于單獨屬性賦值行為生成的數(shù)據(jù)。