像 C 語言這樣的底層語言一般都有底層的記憶體管理介面,比如 malloc()和 free()。相反,JavaScript 是在建立變數(物件,字串等)時自動進行了分配記憶體,並且在不使用它們時「自動」釋放。 釋放的過程稱爲垃圾回收。這個「自動」是混亂的根源,並讓 JavaScript(和其他高階語言)開發者錯誤的感覺他們可以不關心記憶體管理。
不管什麼程式語言,記憶體生命週期基本是一致的:
所有語言第二部分都是明確的。第一和第三部分在底層語言中是明確的,但在像 JavaScript 這些高階語言中,大部分都是隱含的。
JavaScript 的記憶體分配
值的初始化
爲了不讓程式設計師費心分配記憶體,JavaScript 在定義變數時就完成了記憶體分配。
const n = 123; // 給數值變數分配記憶體
const s = 'heath'; // 給字串分配記憶體
const o = {
a: 1,
b: null,
}; // 給物件及其包含的值分配記憶體
// 給陣列及其包含的值分配記憶體(就像物件一樣)
const a = [1, null, 'abra'];
function f(a) {
return a + 2;
} // 給函數(可呼叫的物件)分配記憶體
// 函數表達式也能分配一個物件
someElement.addEventListener(
'click',
function () {
someElement.style.backgroundColor = 'blue';
},
false,
);
通過函數呼叫分配記憶體
有些函數呼叫結果是分配物件記憶體:
const d = new Date(); // 分配一個 Date 物件
const e = document.createElement('div'); // 分配一個 DOM 元素
有些方法分配新變數或者新物件:
const s = 'heath';
const s2 = s.substr(0, 3); // s2 是一個新的字串
// 因爲字串是不變數,
// JavaScript 可能決定不分配記憶體,
// 只是儲存了 [0-3] 的範圍。
const a = ['heath heath', 'nan nan'];
const a2 = ['generation', 'nan nan'];
const a3 = a.concat(a2);
// 新陣列有四個元素,是 a 連線 a2 的結果
使用值
使用值的過程實際上是對分配記憶體進行讀取與寫入的操作。讀取與寫入可能是寫入一個變數或者一個物件的屬性值,甚至傳遞函數的參數。
當記憶體不再需要使用時釋放
大多數記憶體管理的問題都在這個階段。在這裏最艱難的任務是找到「哪些被分配的記憶體確實已經不再需要了」。它往往要求開發人員來確定在程式中哪一塊記憶體不再需要並且釋放它。
高階語言直譯器嵌入了「垃圾回收器」,它的主要工作是跟蹤記憶體的分配和使用,以便當分配的記憶體不再使用時,自動釋放它。這隻能是一個近似的過程,因爲要知道是否仍然需要某塊記憶體是無法判定的(無法通過某種演算法解決)。
如上文所述自動尋找是否一些記憶體「不再需要」的問題是無法判定的。因此,垃圾回收實現只能有限制的解決一般問題。本節將解釋必要的概念,瞭解主要的垃圾回收演算法和它們的侷限性。
參照
垃圾回收演算法主要依賴於參照的概念。在記憶體管理的環境中,一個物件如果有存取另一個物件的許可權(隱式或者顯式),叫做一個物件參照另一個物件。例如,一個 Javascript 物件具有對它原型的參照(隱式參照)和對它屬性的參照(顯式參照)。
在這裏,「物件」的概念不僅特指 JavaScript 物件,還包括函數作用域(或者全域性詞法作用域)。
可達物件
這是最初級的垃圾收集演算法。此演算法把「物件是否不再需要」簡化定義爲「物件有沒有其他物件參照到它」。如果沒有參照指向該物件(零參照),物件將被垃圾回收機制 機製回收。
實現原理
每個物件在建立的時候,就給這個物件系結一個計數器。每當有一個參照指向該物件時,計數器加一;每當有一個指向它的參照被刪除時,計數器減一。這樣,當沒有參照指向該物件時,該物件死亡,計數器爲 0,這時就應該對這個物件進行垃圾回收操作。
let o = {
a: {
b: 2,
},
};
// 兩個物件被建立,一個作爲另一個的屬性被參照,另一個被分配給變數o
// 很顯然,沒有一個可以被垃圾收集
let o2 = o; // o2變數是第二個對「這個物件」的參照
o = 1; // 現在,「這個物件」只有一個o2變數的參照了,「這個物件」的原始參照o已經沒有
let oa = o2.a; // 參照「這個物件」的a屬性
// 現在,「這個物件」有兩個參照了,一個是o2,一個是oa
o2 = 'yo'; // 雖然最初的物件現在已經是零參照了,可以被垃圾回收了
// 但是它的屬性a的物件還在被oa參照,所以還不能回收
oa = null; // a屬性的那個物件現在也是零參照了
// 它可以被垃圾回收了
限制:回圈參照
該演算法有個限制:無法處理回圈參照的事例。在下面 下麪的例子中,兩個物件被建立,並互相參照,形成了一個回圈。它們被呼叫之後會離開函數作用域,所以它們已經沒有用了,可以被回收了。然而,參照計數演算法考慮到它們互相都有至少一次參照,所以它們不會被回收。
function f() {
let o = {};
let o2 = {};
o.a = o2; // o 參照 o2
o2.a = o; // o2 參照 o
return 'heath';
}
f();
實際例子
IE 6, 7 使用參照計數方式對 DOM 物件進行垃圾回收。該方式常常造成物件被回圈參照時記憶體發生泄漏:
let div;
window.onload = function () {
div = document.getElementById('myDivElement');
div.circularReference = div;
div.lotsOfData = new Array(10000).join('*');
};
在上面的例子裡,myDivElement 這個 DOM 元素裡的 circularReference 屬性參照了 myDivElement,造成了回圈參照。如果該屬性沒有顯示移除或者設爲 null,參照計數式垃圾收集器將總是且至少有一個參照,並將一直保持在記憶體裡的 DOM 元素,即使其從 DOM 樹中刪去了。如果這個 DOM 元素擁有大量的數據 (如上的 lotsOfData 屬性),而這個數據佔用的記憶體將永遠不會被釋放。
優點
缺點
這個演算法把「物件是否不再需要」簡化定義爲「物件是否可以獲得」。
這個演算法假定設定一個叫做根(root)的物件(在 Javascript 裡,根是全域性物件)。垃圾回收器將定期從根開始,找所有從根開始參照的物件,然後找這些物件參照的物件……從根開始,垃圾回收器將找到所有可以獲得的物件和收集所有不能獲得的物件。
這個演算法比前一個要好,因爲「有零參照的物件」總是不可獲得的,但是相反卻不一定,參考「回圈參照」。
從 2012 年起,所有現代瀏覽器都使用了標記-清除垃圾回收演算法。所有對 JavaScript 垃圾回收演算法的改進都是基於標記-清除演算法的改進,並沒有改進標記-清除演算法本身和它對「物件是否不再需要」的簡化定義。
實現原理
回圈參照不再是問題了
在上面的範例中,函數呼叫返回之後,兩個物件從全域性物件出發無法獲取。因此,他們將會被垃圾回收器回收。第二個範例同樣,一旦 div 和其事件處理無法從根獲取到,他們將會被垃圾回收器回收。
限制: 那些無法從根物件查詢到的物件都將被清除
儘管這是一個限制,但實踐中我們很少會碰到類似的情況,所以開發者不太會去關心垃圾回收機制 機製。
容易產生記憶體碎片化空間
標記演算法並未在清除未標記物件的時候,進行整理,所以在清除標記後,會產生大量的不連續的記憶體碎片。
當分配的記憶體大於現有連續的記憶體碎片,則會提前觸發新一輪的垃圾回收動作;
當分配的記憶體小於現有連續的記憶體碎片,則可能會造成浪費。
實現原理
限制大小
64 位爲 1.4GB,32 位爲 0.7GB
限制原因
V8 之所以限制了記憶體的大小,表面上的原因是 V8 最初是作爲瀏覽器的 JavaScript 引擎而設計,不太可能遇到大量記憶體的場景,而深層次的原因則是由於 V8 的垃圾回收機制 機製的限制。由於 V8 需要保證 JavaScript 應用邏輯與垃圾回收器所看到的不一樣,V8 在執行垃圾回收時會阻塞 JavaScript 應用邏輯,直到垃圾回收結束再重新執行 JavaScript 應用邏輯,這種行爲被稱爲「全停頓」(stop-the-world)。若 V8 的堆記憶體爲 1.5GB,V8 做一次小的垃圾回收需要 50ms 以上,做一次非增量式的垃圾回收甚至要 1 秒以上。這樣瀏覽器將在 1s 內失去對使用者的響應,造成假死現象。如果有動畫效果的話,動畫的展現也將顯著受到影響。
新生代的物件爲存活時間較短的物件,老生代中的物件爲存活時間較長或常駐記憶體的物件。
V8 引擎的新生代記憶體大小 32MB(64 位)、16MB(32 位),老生代記憶體大小爲 1400MB(64 位)、700MB( 32 位)。
將新生代物件移到老生代
晉升條件
新生代區域,採用複製演算法, 因此其每時每刻內部都有空閒空間的存在(爲了完成 From 到 To 的物件複製),但是新生代區域空間較小(32M)且被一分爲二,所以這種空間上的浪費也是比較微不足道的。
老生代因其空間較大(1.4G),如果同樣採用一分爲二的做法則對空間大小是比較浪費,且老生代空間較大,存放對物件也較多,如果進行復制演算法,則其消耗對時間也會更大。也就是是否使用複製演算法來進行垃圾回收,是一個時間 T 關於記憶體大小的關係,當記憶體大小較小時,使用複製演算法消耗的時間是比較短的,而當記憶體較大時,採用複製演算法對時間對消耗也就更大。
增量標記
由於全停頓會造成了瀏覽器一段時間無響應,所以 V8 使用了一種增量標記的方式,將完整的標記拆分成很多部分,每做完一部分就停下來,讓 JS 的應用邏輯執行一會,這樣垃圾回收與應用邏輯交替完成。經過增量標記的改進後,垃圾回收的最大停頓時間可以減少到原來的 1/6 左右
惰性清理
由於標記完成後,所有的物件都已經被標記,不是死物件就是活物件,堆上多少空間格局已經確定。我們可以不必着急釋放那些死物件所佔用的空間,而延遲清理過程的執行。垃圾回收器可以根據需要逐一清理死物件所佔用的記憶體空間
其他
V8 後續還引入了增量式整理(incremental compaction),以及並行標記和並行清理,通過並行利用多核 CPU 來提升垃圾回收的效能
##界定記憶體問題的標準
這裏以 Google 瀏覽器爲例,使用 Shift + Esc 喚起 Google 瀏覽器自帶的工作管理員
模擬記憶體漏失
在工作管理員裡可以看到 JavaScript 記憶體持續上升
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);
}
這裏以 Google 瀏覽器爲例,使用 F12 開啓調式,選擇 Performance,點選 record(錄製),進行頁面操作,點選 stop 結束錄製之後,開啓記憶體勾選,拖動截圖到指定時間段檢視發生記憶體問題時候到頁面展示,並定位問題。同時可以檢視對應出現紅點到執行指令碼,定位問題程式碼。
這裏以 Google 瀏覽器爲例,在頁面上進行相關操作後,使用 F12 開啓調式,選擇 Memory,點選 Take snapshot(拍照),在快照中查詢 Detached HTMLElement,回到程式碼中查詢對應的分離 dom 存在的程式碼,在相關操作程式碼之後,對分離 dom 進行釋放,防止記憶體漏失。
只有頁面的 DOM 樹或 JavaScript 程式碼不再參照 DOM 節點時,DOM 節點纔會被作爲垃圾進行回收。 如果某個節點已從 DOM 樹移除,但某些 JavaScript 仍然參照它,我們稱此節點爲「已分離」。已分離的 DOM 節點是記憶體漏失的常見原因。
模擬已分離 DOM 節點
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;
}
基於 Benchmark.js
上圖可以看出,test2 的效能要比 test1 的效能要好,從而得知,全域性變數的執行速度,存取速度要低於區域性變數
上圖可以看出,test2 的效能要比 test1 的效能要好,從而得知,快取全域性變數後使用可以提升效能
上圖可以看出,test2 的效能要比 test1 的效能要好,從而得知,通過原型物件新增方法與直接在物件上新增成員方法相比,原型物件上的屬性存取速度較快。
閉包特點
function foo() {
let name = 'heath';
function fn() {
console.log(name);
}
return fn;
}
let a = foo();
a();
閉包使用不當很容易出現記憶體漏失
function f5() {
// el 參照了全域性變數document,假設btn節點被刪除後,因爲這裏被參照着,所以這裏不會被垃圾回收,導致記憶體漏失
let el = document.getElementById('btn');
el.onclick = function (e) {
console.log(e.id);
};
}
f5();
function f6() {
// el 參照了全域性變數document,假設btn節點被刪除後,因爲這裏被參照着,所以這裏不會被垃圾回收,導致記憶體漏失
let el = document.getElementById('btn');
el.onclick = function (e) {
console.log(e.id);
};
el = null; // 我們這裏手動將el記憶體釋放,從而當btn節點被刪除後,可以被垃圾回收
}
f6();
JavaScript 中的物件導向
上圖可以看出,test2 的效能要比 test1 的效能要好不少,從而得知,直接存取屬性,會比通過方法存取屬性速度來的快。
上圖可以看出,loop 遍歷速度 forEach > 優化 for > for of > for > for in
上圖可以看出,節點克隆(cloneNode)生成節點速度要快於建立節點。
上圖可以看出,字面量宣告的數據生成速度要快於單獨屬性賦值行爲生成的數據。