JavaScript 中的垃圾回收

MelindaPier 8年前發布 | 7K 次閱讀 JavaScript開發 JavaScript

根據 Wiki 的定義, 垃圾回收 是一種自動的內存管理機制。當計算機上的動態內存不再需要時,就應該予以釋放,以讓出內存。直白點講,就是程序是運行在內存里的,當聲明一個變量、定義一個函數時都會占用內存。內存的容量是有限的,如果變量、函數等只有產生沒有消亡的過程,那遲早內存有被完全占用的時候。這個時候,不僅自己的程序無法正常運行,連其他程序也會受到影響。好比生物只有出生沒有死亡,地球總有被撐爆的一天。所以,在計算機中,我們需要垃圾回收。需要注意的是,定義中的“自動”的意思是語言可以幫助我們回收內存垃圾,但并不代表我們不用關心內存管理,如果操作失當,JavaScript 中依舊會出現內存溢出的情況。

垃圾回收基于兩個原理:

  • 考慮某個變量或對象在未來的程序運行中將不會被訪問

  • 向這些對象要求歸還內存

而這兩個原理中,最主要的也是最艱難的部分就是找到“所分配的內存確實已經不再需要了”。

垃圾回收方法

下面我們看看在 JavaScript 中是如何找到不再使用的內存的。主要有兩種方式:引用計數和標記清除。

引用計數(reference counting)

在內存管理環境中,對象 A 如果有訪問對象 B 的權限,叫做對象 A 引用對象 B。引用計數的策略是將“對象是否不再需要”簡化成“對象有沒有其他對象引用到它”,如果沒有對象引用這個對象,那么這個對象將會被回收。上例子:

let obj1 = { a: 1 }; // 一個對象(稱之為 A)被創建,賦值給 obj1,A 的引用個數為 1 
let obj2 = obj1; // A 的引用個數變為 2

obj1 = 0; // A 的引用個數變為 1
obj2 = 0; // A 的引用個數變為 0,此時對象 A 就可以被垃圾回收了

但是引用計數有個最大的問題: 循環引用。

function func() {
    let obj1 = {};
    let obj2 = {};

    obj1.a = obj2; // obj1 引用 obj2
    obj2.a = obj1; // obj2 引用 obj1
}

當函數 func 執行結束后,返回值為 undefined,所以整個函數以及內部的變量都應該被回收,但根據引用計數方法,obj1 和 obj2 的引用次數都不為 0,所以他們不會被回收。

要解決循環引用的問題,最好是在不使用它們的時候手工將它們設為空。上面的例子可以這么做:

obj1 = null;
obj2 = null;

標記-清除(mark and sweep)

這是 JavaScript 中最常見的垃圾回收方式。為什么說這是種最常見的方法,因為從 2012 年起,所有現代瀏覽器都使用了標記-清除的垃圾回收方法,除了低版本 IE...它們采用的是引用計數方法。

那什么叫標記清除呢?JavaScript 中有個全局對象,瀏覽器中是 window。定期的,垃圾回收期將從這個全局對象開始,找所有從這個全局對象開始引用的對象,再找這些對象引用的對象...對這些活著的對象進行標記,這是標記階段。清除階段就是清除那些沒有被標記的對象。

標記-清除法的一個問題就是不那么有效率,因為在標記-清除階段,整個程序將會等待,所以如果程序出現卡頓的情況,那有可能是收集垃圾的過程。

2012 年起,所有現代瀏覽器都使用了這個方法,所有的改進也都是基于這個方法,比如標記-整理方法。

標記清除有一個問題,就是在清除之后,內存空間是不連續的,即出現了內存碎片。如果后面需要一個比較大的連續的內存空間時,那將不能滿足要求。而標記-整理方法可以有效地解決這個問題。標記階段沒有什么不同,只是標記結束后,標記-整理方法會將活著的對象向內存的一邊移動,最后清理掉邊界的內存。不過可以想象,這種做法的效率沒有標記-清除高。計算機中的很多做法都是互相妥協的結果,哪有什么十全十美的事兒呢。

內存泄漏

在談什么是良好實踐(這里指有益于內存管理)之前,我想先談談內存泄漏,也就是差的實踐。內存泄漏是指計算機可用的內存越來越少,主要是因為程序不能釋放那些不再使用的內存。

循環引用

這個沒什么好說的,上面已經介紹了。

需要強調的一點就是,一旦數據不再使用,最好通過將其值設為 null 來釋放其引用,這個方法被稱為“解除引用”。

無意的全局變量

function foo(arg) {
    const bar = "";
}

foo();

當 foo 函數執行后,變量 bar 就會被標記為可回收。因為當函數執行時,函數創造了一個作用域來讓函數里的變量在里面聲明。進入這個作用域后,瀏覽器就會為變量 bar 創建一個內存空間。當這個函數結束后,其所創建的作用域里的變量也會被標記為垃圾,在下一個垃圾回收周期到來時,這些變量將會被回收。

但事情并不會那么順利。

function foo(arg) {
    bar = "";
}

foo();

上面的代碼就無意中聲明了一個全局變量,會得到 window 的引用,bar 實際上是 window.bar,它的作用域在 window 上,所以 foo 函數執行結束后,bar 也不會被內存收回。

另外一種無意的全局變量的情況是:

function foo() {
    this.bar = "";
}

在 foo 函數中,this 指的是 window,犯的錯誤跟上面類似。

被遺忘的計時器和回調函數

let someResource = getData();
setInterval(() => {
    const node = document.getElementById('Node');
    if(node) {
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

上面的例子中,我們每隔一秒就將得到的數據放入到文檔節點中去。但在 setInterval 沒有結束前,回調函數里的變量以及回調函數本身都無法被回收。那什么才叫結束呢?就是調用了 clearInterval。如果回調函數內沒有做什么事情,并且也沒有被 clear 掉的話,就會造成內存泄漏。不僅如此,如果回調函數沒有被回收,那么回調函數內依賴的變量也沒法被回收。上面的例子中,someResource 就沒法被回收。同樣的,setTiemout 也會有同樣的問題。所以,當不需要 interval 或者 timeout 時,最好調用 clearInterval 或者 clearTimeout。

DOM

在 IE8 以下的版本里,DOM 對象經常會跟 JavaScript 之間產生循環引用。看一個例子:

function setHandler() {
    const ele = document.getElementById('id');
    ele.onclick = function() {};
}

在這個例子中,DOM 對象通過 onclick 引用了一個函數,然而這個函數通過外部的詞法環境引用了這個 DOM 對象,形成了循環引用。不過現在不必擔心,因為所有現代瀏覽器都采用了標記-整理方法,避免了循環引用的問題。

除了這種情況,我們現在還會在其他時候在使用 DOM 時出現內存泄漏的問題。當我們需要多次訪問同一個 DOM 元素時,一個好的做法是將 DOM 元素用一個變量存儲在內存中,因為訪問 DOM 的效率一般比較低,應該避免頻繁地反問 DOM 元素。所以我們會這樣寫:

const button = document.getElementById('button');

當刪除這個按鈕時:

document.body.removeChild(document.getElementById('button'));

雖然這樣看起來刪除了這個 DOM 元素,但這個 DOM 元素仍然被 button 這個變量引用,所以在內存上,這個 DOM 元素是沒法被回收的。所以在使用結束后,還需要將 button 設成 null。

另外一個值得注意的是,代碼中保存了一個列表 ul 的某一項 li 的引用,將來決定刪除整個列表時,我們自覺上會認為內存僅僅會保留那個特定的 li,而將其他列表項都刪除。但事實并非如此,因為 li 是 ul 的子元素,子元素與父元素是引用關系,所以如果代碼保存 li 的引用,那么整個 ul 將會繼續呆在內存里。

良好實踐

1、優化內存的一個最好的衡量方式就是只保留程序運行時需要的數據,對于已經使用的或者不需要的數據,應該將其值設為 null,這上面說過,叫“解除引用”。需要注意的是,解除一個值的引用不代表垃圾回收器會立即將這段內存回收,這樣做的目的是讓垃圾回收器在下一個回收周期到來時知道這段內存需要回收。

在內存泄漏部分,我們討論了無意的全局變量會帶來無法回收的內存垃圾。但有些時候,我們會有意識地聲明一些全局變量,這個時候需要注意,如果聲明的變量占用大量的內存,那么在使用完后將變量聲明為 null。

2、減少內存垃圾的另一個方法就是避免創建對象。new Object() 是一個比較明顯的創建對象的方式,另外 const arr = [];、const obj = {};也會創建新的對象。另外下面這種寫法在每次調用函數時都會創建一個新的對象:

function func() {
    return function() {};
}

另外,當清空一個數組時,我們通常的做法是 array = [],但這種做法的背后是新建了一個新的數組然后將原來的數組當作內存垃圾。建議的做法是 array.length = 0,這樣做不僅可以重用原來的變量,而且還避免創建了新的數組。

?

因為時間關系,關于垃圾回收的內容將在接下來1-2周內更新完畢,內容涉及更加詳細的內存管理、V8 引擎中的垃圾回收等。另外對本文其他內容還有建議的也歡迎留言,我也會一并更新。

參考:

  1. 內存管理

  2. A tour of V8: Garbage Collection

  3. Memory leaks

  4. 4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them

  5. High-Performance, Garbage-Collector-Friendly Code

 

來自:https://zhuanlan.zhihu.com/p/23992332

 

 本文由用戶 MelindaPier 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!