中高級前端必須了解的JS中的內(nèi)存管理
前言
像C語言這樣的底層語言一般都有底層的內(nèi)存管理接口,比如 malloc()和free()用于分配內(nèi)存和釋放內(nèi)存。
而對于JavaScript來說,會在創(chuàng)建變量(對象,字符串等)時分配內(nèi)存,并且在不再使用它們時“自動”釋放內(nèi)存,這個自動釋放內(nèi)存的過程稱為垃圾回收。
因為自動垃圾回收機制的存在,讓大多Javascript開發(fā)者感覺他們可以不關(guān)心內(nèi)存管理,所以會在一些情況下導(dǎo)致內(nèi)存泄漏。
內(nèi)存生命周期

JS 環(huán)境中分配的內(nèi)存有如下聲明周期:
- 內(nèi)存分配:當(dāng)我們申明變量、函數(shù)、對象的時候,系統(tǒng)會自動為他們分配內(nèi)存
- 內(nèi)存使用:即讀寫內(nèi)存,也就是使用變量、函數(shù)等
- 內(nèi)存回收:使用完畢,由垃圾回收機制自動回收不再使用的內(nèi)存
JS 的內(nèi)存分配
為了不讓程序員費心分配內(nèi)存,JavaScript 在定義變量時就完成了內(nèi)存分配。
- var n = 123; // 給數(shù)值變量分配內(nèi)存
- var s = "azerty"; // 給字符串分配內(nèi)存
- var o = {
- a: 1,
- b: null
- }; // 給對象及其包含的值分配內(nèi)存
- // 給數(shù)組及其包含的值分配內(nèi)存(就像對象一樣)
- var 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)用結(jié)果是分配對象內(nèi)存:
- var d = new Date(); // 分配一個 Date 對象
- var e = document.createElement('div'); // 分配一個 DOM 元素
有些方法分配新變量或者新對象:
- var s = "azerty";
- var s2 = s.substr(0, 3); // s2 是一個新的字符串
- // 因為字符串是不變量,
- // JavaScript 可能決定不分配內(nèi)存,
- // 只是存儲了 [0-3] 的范圍。
- var a = ["ouais ouais", "nan nan"];
- var a2 = ["generation", "nan nan"];
- var a3 = a.concat(a2);
- // 新數(shù)組有四個元素,是 a 連接 a2 的結(jié)果
JS 的內(nèi)存使用
使用值的過程實際上是對分配內(nèi)存進行讀取與寫入的操作。
讀取與寫入可能是寫入一個變量或者一個對象的屬性值,甚至傳遞函數(shù)的參數(shù)。
- var a = 10; // 分配內(nèi)存
- console.log(a); // 對內(nèi)存的使用
JS 的內(nèi)存回收
JS 有自動垃圾回收機制,那么這個自動垃圾回收機制的原理是什么呢?
其實很簡單,就是找出那些不再繼續(xù)使用的值,然后釋放其占用的內(nèi)存。
大多數(shù)內(nèi)存管理的問題都在這個階段。
在這里最艱難的任務(wù)是找到不再需要使用的變量。
不再需要使用的變量也就是生命周期結(jié)束的變量,是局部變量,局部變量只在函數(shù)的執(zhí)行過程中存在,
當(dāng)函數(shù)運行結(jié)束,沒有其他引用(閉包),那么該變量會被標(biāo)記回收。
全局變量的生命周期直至瀏覽器卸載頁面才會結(jié)束,也就是說全局變量不會被當(dāng)成垃圾回收。
因為自動垃圾回收機制的存在,開發(fā)人員可以不關(guān)心也不注意內(nèi)存釋放的有關(guān)問題,但對無用內(nèi)存的釋放這件事是客觀存在的。
不幸的是,即使不考慮垃圾回收對性能的影響,目前***的垃圾回收算法,也無法智能回收所有的極端情況。
接下來我們來探究一下 JS 垃圾回收的機制。
垃圾回收
引用
垃圾回收算法主要依賴于引用的概念。
在內(nèi)存管理的環(huán)境中,一個對象如果有訪問另一個對象的權(quán)限(隱式或者顯式),叫做一個對象引用另一個對象。
例如,一個Javascript對象具有對它原型的引用(隱式引用)和對它屬性的引用(顯式引用)。
在這里,“對象”的概念不僅特指 JavaScript 對象,還包括函數(shù)作用域(或者全局詞法作用域)。
引用計數(shù)垃圾收集
這是最初級的垃圾回收算法。
引用計數(shù)算法定義“內(nèi)存不再使用”的標(biāo)準(zhǔn)很簡單,就是看一個對象是否有指向它的引用。
如果沒有其他對象指向它了,說明該對象已經(jīng)不再需要了。
- var o = {
- a: {
- b:2
- }
- };
- // 兩個對象被創(chuàng)建,一個作為另一個的屬性被引用,另一個被分配給變量o
- // 很顯然,沒有一個可以被垃圾收集
- var o2 = o; // o2變量是第二個對“這個對象”的引用
- o = 1; // 現(xiàn)在,“這個對象”的原始引用o被o2替換了
- var oa = o2.a; // 引用“這個對象”的a屬性
- // 現(xiàn)在,“這個對象”有兩個引用了,一個是o2,一個是oa
- o2 = "yo"; // 最初的對象現(xiàn)在已經(jīng)是零引用了
- // 他可以被垃圾回收了
- // 然而它的屬性a的對象還在被oa引用,所以還不能回收
- oa = null; // a屬性的那個對象現(xiàn)在也是零引用了
- // 它可以被垃圾回收了
由上面可以看出,引用計數(shù)算法是個簡單有效的算法。但它卻存在一個致命的問題:循環(huán)引用。
如果兩個對象相互引用,盡管他們已不再使用,垃圾回收不會進行回收,導(dǎo)致內(nèi)存泄露。
來看一個循環(huán)引用的例子:
- function f(){
- var o = {};
- var o2 = {};
- o.a = o2; // o 引用 o2
- o2.a = o; // o2 引用 o 這里
- return "azerty";
- }
- f();
上面我們申明了一個函數(shù) f ,其中包含兩個相互引用的對象。
在調(diào)用函數(shù)結(jié)束后,對象 o1 和 o2 實際上已離開函數(shù)范圍,因此不再需要了。
但根據(jù)引用計數(shù)的原則,他們之間的相互引用依然存在,因此這部分內(nèi)存不會被回收,內(nèi)存泄露不可避免了。
再來看一個實際的例子:
- var div = document.createElement("div");
- div.onclick = function() {
- console.log("click");
- };
上面這種JS寫法再普通不過了,創(chuàng)建一個DOM元素并綁定一個點擊事件。
此時變量 div 有事件處理函數(shù)的引用,同時事件處理函數(shù)也有div的引用!(div變量可在函數(shù)內(nèi)被訪問)。
一個循序引用出現(xiàn)了,按上面所講的算法,該部分內(nèi)存無可避免的泄露了。
為了解決循環(huán)引用造成的問題,現(xiàn)代瀏覽器通過使用標(biāo)記清除算法來實現(xiàn)垃圾回收。
標(biāo)記清除算法
標(biāo)記清除算法將“不再使用的對象”定義為“無法達(dá)到的對象”。
簡單來說,就是從根部(在JS中就是全局對象)出發(fā)定時掃描內(nèi)存中的對象。
凡是能從根部到達(dá)的對象,都是還需要使用的。
那些無法由根部出發(fā)觸及到的對象被標(biāo)記為不再使用,稍后進行回收。
從這個概念可以看出,無法觸及的對象包含了沒有引用的對象這個概念(沒有任何引用的對象也是無法觸及的對象)。
但反之未必成立。
工作流程:
- 垃圾收集器會在運行的時候會給存儲在內(nèi)存中的所有變量都加上標(biāo)記。
- 從根部出發(fā)將能觸及到的對象的標(biāo)記清除。
- 那些還存在標(biāo)記的變量被視為準(zhǔn)備刪除的變量。
- ***垃圾收集器會執(zhí)行***一步內(nèi)存清除的工作,銷毀那些帶標(biāo)記的值并回收它們所占用的內(nèi)存空間。

循環(huán)引用不再是問題了
再看之前循環(huán)引用的例子:
- function f(){
- var o = {};
- var o2 = {};
- o.a = o2; // o 引用 o2
- o2.a = o; // o2 引用 o
- return "azerty";
- }
- f();
函數(shù)調(diào)用返回之后,兩個循環(huán)引用的對象在垃圾收集時從全局對象出發(fā)無法再獲取他們的引用。
因此,他們將會被垃圾回收器回收。
內(nèi)存泄漏
什么是內(nèi)存泄漏
程序的運行需要內(nèi)存。只要程序提出要求,操作系統(tǒng)或者運行時(runtime)就必須供給內(nèi)存。
對于持續(xù)運行的服務(wù)進程(daemon),必須及時釋放不再用到的內(nèi)存。
否則,內(nèi)存占用越來越高,輕則影響系統(tǒng)性能,重則導(dǎo)致進程崩潰。
本質(zhì)上講,內(nèi)存泄漏就是由于疏忽或錯誤造成程序未能釋放那些已經(jīng)不再使用的內(nèi)存,造成內(nèi)存的浪費。
內(nèi)存泄漏的識別方法
經(jīng)驗法則是,如果連續(xù)五次垃圾回收之后,內(nèi)存占用一次比一次大,就有內(nèi)存泄漏。
這就要求實時查看內(nèi)存的占用情況。
在 Chrome 瀏覽器中,我們可以這樣查看內(nèi)存占用情況
- 打開開發(fā)者工具,選擇 Performance 面板
- 在頂部勾選 Memory
- 點擊左上角的 record 按鈕
- 在頁面上進行各種操作,模擬用戶的使用情況
- 一段時間后,點擊對話框的 stop 按鈕,面板上就會顯示這段時間的內(nèi)存占用情況
來看一張效果圖:

我們有兩種方式來判定當(dāng)前是否有內(nèi)存泄漏:
- 多次快照后,比較每次快照中內(nèi)存的占用情況,如果呈上升趨勢,那么可以認(rèn)為存在內(nèi)存泄漏
- 某次快照后,看當(dāng)前內(nèi)存占用的趨勢圖,如果走勢不平穩(wěn),呈上升趨勢,那么可以認(rèn)為存在內(nèi)存泄漏
在服務(wù)器環(huán)境中使用 Node 提供的 process.memoryUsage 方法查看內(nèi)存情況
- console.log(process.memoryUsage());
- // {
- // rss: 27709440,
- // heapTotal: 5685248,
- // heapUsed: 3449392,
- // external: 8772
- // }
process.memoryUsage返回一個對象,包含了 Node 進程的內(nèi)存占用信息。
該對象包含四個字段,單位是字節(jié),含義如下:
- rss(resident set size):所有內(nèi)存占用,包括指令區(qū)和堆棧。
- heapTotal:"堆"占用的內(nèi)存,包括用到的和沒用到的。
- heapUsed:用到的堆的部分。
- external: V8 引擎內(nèi)部的 C++ 對象占用的內(nèi)存。
判斷內(nèi)存泄漏,以heapUsed字段為準(zhǔn)。
常見的內(nèi)存泄露案例
意外的全局變量
- function foo() {
- bar1 = 'some text'; // 沒有聲明變量 實際上是全局變量 => window.bar1
- this.bar2 = 'some text' // 全局變量 => window.bar2
- }
- foo();
在這個例子中,意外的創(chuàng)建了兩個全局變量 bar1 和 bar2
被遺忘的定時器和回調(diào)函數(shù)
在很多庫中, 如果使用了觀察者模式, 都會提供回調(diào)方法, 來調(diào)用一些回調(diào)函數(shù)。
要記得回收這些回調(diào)函數(shù)。舉一個 setInterval的例子:
- var serverData = loadData();
- setInterval(function() {
- var renderer = document.getElementById('renderer');
- if(renderer) {
- renderer.innerHTML = JSON.stringify(serverData);
- }
- }, 5000); // 每 5 秒調(diào)用一次
如果后續(xù) renderer 元素被移除,整個定時器實際上沒有任何作用。
但如果你沒有回收定時器,整個定時器依然有效, 不但定時器無法被內(nèi)存回收,
定時器函數(shù)中的依賴也無法回收。在這個案例中的 serverData 也無法被回收。
閉包
在 JS 開發(fā)中,我們會經(jīng)常用到閉包,一個內(nèi)部函數(shù),有權(quán)訪問包含其的外部函數(shù)中的變量。
下面這種情況下,閉包也會造成內(nèi)存泄露:
- var theThing = null;
- var replaceThing = function () {
- var originalThing = theThing;
- var unused = function () {
- if (originalThing) // 對于 'originalThing'的引用
- console.log("hi");
- };
- theThing = {
- longStr: new Array(1000000).join('*'),
- someMethod: function () {
- console.log("message");
- }
- };
- };
- setInterval(replaceThing, 1000);
這段代碼,每次調(diào)用 replaceThing 時,theThing 獲得了包含一個巨大的數(shù)組和一個對于新閉包 someMethod 的對象。
同時 unused 是一個引用了 originalThing 的閉包。
這個范例的關(guān)鍵在于,閉包之間是共享作用域的,盡管 unused 可能一直沒有被調(diào)用,但是 someMethod 可能會被調(diào)用,就會導(dǎo)致無法對其內(nèi)存進行回收。
當(dāng)這段代碼被反復(fù)執(zhí)行時,內(nèi)存會持續(xù)增長。
DOM 引用
很多時候, 我們對 Dom 的操作, 會把 Dom 的引用保存在一個數(shù)組或者 Map 中。
- var elements = {
- image: document.getElementById('image')
- };
- function doStuff() {
- elements.image.src = 'http://example.com/image_name.png';
- }
- function removeImage() {
- document.body.removeChild(document.getElementById('image'));
- // 這個時候我們對于 #image 仍然有一個引用, Image 元素, 仍然無法被內(nèi)存回收.
- }
上述案例中,即使我們對于 image 元素進行了移除,但是仍然有對 image 元素的引用,依然無法對齊進行內(nèi)存回收。
另外需要注意的一個點是,對于一個 Dom 樹的葉子節(jié)點的引用。
舉個例子: 如果我們引用了一個表格中的td元素,一旦在 Dom 中刪除了整個表格,我們直觀的覺得內(nèi)存回收應(yīng)該回收除了被引用的 td 外的其他元素。
但是事實上,這個 td 元素是整個表格的一個子元素,并保留對于其父元素的引用。
這就會導(dǎo)致對于整個表格,都無法進行內(nèi)存回收。所以我們要小心處理對于 Dom 元素的引用。
如何避免內(nèi)存泄漏
記住一個原則:不用的東西,及時歸還。
- 減少不必要的全局變量,使用嚴(yán)格模式避免意外創(chuàng)建全局變量。
- 在你使用完數(shù)據(jù)后,及時解除引用(閉包中的變量,dom引用,定時器清除)。
- 組織好你的邏輯,避免死循環(huán)等造成瀏覽器卡頓,崩潰的問題。