JavaScript面試必備之垃圾回收機制和內存泄漏詳解
1.垃圾回收機制
《JavaScript權威指南(第四版)》:由于字符串、對象和數組沒有固定大小,所有當他們的大小已知時,才能對他們進行動態(tài)的存儲分配。JavaScript程序每次創(chuàng)建字符串、數組或對象時,解釋器都必須分配內存來存儲那個實體。只要像這樣動態(tài)地分配了內存,最終都要釋放這些內存以便他們能夠被再用,否則,JavaScript的解釋器將會消耗完系統(tǒng)中所有可用的內存,造成系統(tǒng)崩潰。
這段話解釋了為什么需要系統(tǒng)需要垃圾回收,JavaScript不像C/C++,它有自己的一套垃圾回收機制。
JavaScript垃圾回收的機制:找出不再使用的變量,然后釋放掉其占用的內存,但是這個過程不是時時的,因為其開銷比較大,所以垃圾回收器會按照固定的時間間隔周期性的執(zhí)行。
var a = "掘金"; var b = "淘金"; var a = b;
這段代碼運行之后,“掘金”這個字符串失去了引用(之前是被a引用),系統(tǒng)檢測到這個事實之后,就會釋放該字符串的存儲空間以便這些空間可以被再利用。
那是怎么進行垃圾回收的呢?
其實垃圾回收有兩種方法:標記清除、引用計數。引用計數不太常用,標記清除較為常用。
1.1 標記清除
這是javascript中最常用的垃圾回收方式。當變量進入執(zhí)行環(huán)境是,就標記這個變量為“進入環(huán)境”。從邏輯上講,永遠不能釋放進入環(huán)境的變量所占用的內存,因為只要執(zhí)行流進入相應的環(huán)境,就可能會用到他們。當變量離開環(huán)境時,則將其標記為“離開環(huán)境”。
垃圾收集器在運行的時候會給存儲在內存中的所有變量都加上標記。然后,它會去掉環(huán)境中的變量以及被環(huán)境中的變量引用的標記。而在此之后再被加上標記的變量將被視為準備刪除的變量,原因是環(huán)境中的變量已經無法訪問到這些變量了。最后。垃圾收集器完成內存清除工作,銷毀那些帶標記的值,并回收他們所占用的內存空間。
我們用個例子,解釋下這個方法:
var m = 0,n = 19 // 把 m,n,add() 標記為進入環(huán)境。
add(m, n) // 把 a, b, c標記為進入環(huán)境。
console.log(n) // a,b,c標記為離開環(huán)境,等待垃圾回收。
function add(a, b) {
a++
var c = a + b
return c
} 1.2 引用計數
所謂"引用計數"是指語言引擎有一張"引用表",保存了內存里面所有的資源(通常是各種值)的引用次數。如果一個值的引用次數是0,就表示這個值不再用到了,因此可以將這塊內存釋放
如果一個值不再需要了,引用數卻不為0,垃圾回收機制無法釋放這塊內存,從而導致內存泄漏。
var arr = [1, 2, 3, 4];
arr = [2, 4, 5]
console.log('浪里行舟'); 上面代碼中,數組[1, 2, 3, 4]是一個值,會占用內存。變量arr是僅有的對這個值的引用,因此引用次數為1。盡管后面的代碼沒有用到arr,它還是會持續(xù)占用內存。至于如何釋放內存,我們下文介紹。
第三行代碼中,數組[1, 2, 3, 4]引用的變量arr又取得了另外一個值,則數組[1, 2, 3, 4]的引用次數就減1,此時它引用次數變成0,則說明沒有辦法再訪問這個值了,因而就可以將其所占的內存空間給收回來。
但是引用計數有個最大的問題: 循環(huán)引用
function func() {
let obj1 = {};
let obj2 = {};
obj1.a = obj2; // obj1 引用 obj2
obj2.a = obj1; // obj2 引用 obj1
} 當函數 func 執(zhí)行結束后,返回值為 undefined,所以整個函數以及內部的變量都應該被回收,但根據引用計數方法,obj1 和 obj2 的引用次數都不為 0,所以他們不會被回收。
要解決循環(huán)引用的問題,最好是在不使用它們的時候手工將它們設為空。上面的例子可以這么做:
obj1 = null; obj2 = null;
2.什么是內存泄漏
2.1 JavaScript內存分配和回收的關鍵詞:GC根、作用域
GC根:一般指全局且不會被垃圾回收的對象,比如:window、document或者是頁面上存在的dom元素。JavaScript的垃圾回收算法會判斷某塊對象內存是否是GC根可達(存在一條由GC根對象到該對象的引用),如果不是那這塊內存將會被標記回收。
作用域:在JavaScript的作用域里,我們能夠新建對象來分配內存。比如說調用函數,函數執(zhí)行的過程中就會創(chuàng)建一塊作用域,如果是創(chuàng)建的是作用域內的局部對象,當作用域運行結束后,所有的局部對象(GC根無法觸及)都會被標記回收,在JavaScript中能引起作用域分配的有函數調用、with和全局作用域。
我們知道瀏覽器會把object保存在堆內存中,它們通過索引鏈可以被訪問到。GC(Garbage Collector) 是一個JavaScript引擎的后臺進程,它可以鑒別哪些對象是已經處于無用的狀態(tài),移除它們,釋放占用的內存。
本該被GC回收的變量,如果被其他對象索引,而且可以通過root訪問到,這就意味著內存中存在了冗余的內存占用,會導致應用的性能降級,這時也就發(fā)生了內存泄漏。
總結:所謂的內存泄漏簡單來說就是不再用到的內存,沒有得到及時的釋放。
3.常見的幾種內存泄漏的方式
3.1 未被注意的全局變量
全局變量可以被root訪問,不會被GC回收。一些非嚴格模式下的局部變量可能會變成全局變量,導致內存泄漏。
- 給沒有聲明的變量賦值
- this 指向全局對象
function createGlobalVariables() {
leaking1 = 'I leak into the global scope'; // assigning value to the undeclared variable
this.leaking2 = 'I also leak into the global scope'; // 'this' points to the global object
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'如何避免?使用嚴格模式。
3.2 閉包
閉包函數執(zhí)行完成后,作用域中的變量不會被回收,可能會導致內存泄漏:
function outer() {
const potentiallyHugeArray = [];
return function inner() {
potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
console.log('Hello');
};
};
const sayHello = outer(); // contains definition of the function inner
function repeat(fn, num) {
for (let i = 0; i < num; i++){
fn();
}
}
repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray
// now imagine repeat(sayHello, 100000)3.3 定時器
使用setTimeout 或者 setInterval:
function setCallback() {
const data = {
counter: 0,
hugeString: new Array(100000).join('x')
};
return function cb() {
data.counter++; // data object is now part of the callback's scope
console.log(data.counter);
}
}
setInterval(setCallback(), 1000); // how do we stop it?只有當定時器被清理掉的時候,它回調函數內部的data才會被從內存中清理,否則在應用退出前一直會被保留。
如何避免?
function setCallback() {
// 'unpacking' the data object
let counter = 0;
const hugeString = new Array(100000).join('x'); // gets removed when the setCallback returns
return function cb() {
counter++; // only counter is part of the callback's scope
console.log(counter);
}
}
const timerId = setInterval(setCallback(), 1000); // saving the interval ID
// doing something ...
clearInterval(timerId); // stopping the timer i.e. if button pressed定時器賦值給timerId,使用clearInterval(timerId)手動清理。
3.4Event listeners
addEventListener 也會一直保留在內存中無法回收,直到我們使用了 removeEventListener,或者添加監(jiān)聽事件的DOM被移除。
const hugeString = new Array(100000).join('x');
document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
doSomething(hugeString); // hugeString is now forever kept in the callback's scope
});如何避免?
function listener() {
doSomething(hugeString);
}
document.addEventListener('keyup', listener); // named function can be referenced here...
document.removeEventListener('keyup', listener); // ...and here
// 或者
document.addEventListener('keyup', function listener() {
doSomething(hugeString);
}, {once: true}); // listener will be removed after running once4.使用chrome devtools的排查方法
下面使用幾個案例來展示在chrome devtools如何查看內存泄漏。
4.1 用全局變量緩存數據
將全局變量作為緩存數據的一種方式,將之后要用到的數據都掛載到全局變量上,用完之后也不手動釋放內存(因為全局變量引用的對象,垃圾回收機制不會自動回收),全局變量逐漸就積累了一些不用的對象,導致內存泄漏
var x = [];
function createSomeNodes() {
var div;
var i = 10000;
var frag = document.createDocumentFragment();
for (; i > 0; i--) {
div = document.createElement("div");
div.appendChild(document.createTextNode(i + " - " + new Date().toTimeString()));
frag.appendChild(div);
}
document.getElementById("nodes").appendChild(frag);
}
function grow() {
x.push(new Array(1000000).join('x'));
createSomeNodes();
setTimeout(grow, 1000);
}
grow()上面的代碼貼一張 timeline的截圖

主要看memory區(qū)域,通過分析代碼我們可以知道頁面上的dom節(jié)點是不斷增加的,所以memory里綠色的線(代表dom nodes)也是不斷升高的;而代表js heap的藍色的線是有升有降,當整體趨勢是逐漸升高,這是因為js 有內存回收機制,每當內存回收的時候藍色的線就會下降,但是存在部分內存一直得不到釋放,所以藍色的線逐漸升高
4.2 js錯誤引用DOM元素
var nodes = '';
(function () {
var item = {
name:new Array(1000000).join('x')
}
nodes = document.getElementById("nodes")
nodes.item = item
nodes.parentElement.removeChild(nodes)
})()這里的dom元素雖然已經從頁面上移除了,但是js中仍然保存這對該dom元素的引用。
因為這段代碼是只執(zhí)行一次的,所以用timeline視圖會很難分析出來是否存在內存泄漏,所以我們可以用 chrome dev tool 的 profile tab里的heap snapshot 工具來分析。
上面的代碼貼一張 heap snapshot 的summary模式的截圖

通過constructor的filter功能,我們把上面代碼中創(chuàng)建的長字符串找出來,可以看到代碼運行結束后,內存中的長字符串依然沒有被垃圾回收掉。
順帶提一下的是右邊紅框里的shadow size和 retainer size的含義
- shadow size 指的是對象本地的大小
- retainer size 指的是對象所引用內存的大小,回收該對象是會將他引用的內存也一并回收,所以retainer size 指代的是回收內存后會釋放出來的內存大小
上面我們可以看到 長字符串本身的shadow size和retainer size是一樣大的,這是引用長字符串沒有引用其他的對象,如果有引用其他對象,那shadow size 和retainer size將不一致。
4.3 閉包循環(huán)引用
(function(){
var theThing = null
var replaceThing = function () {
var originalThing = theThing
var unused = function () {
if (originalThing)
console.log("hi")
}
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function someMethod() {
console.log('someMessage')
}
};
};
setInterval(replaceThing,100)
})()首先我們明確一下,unused是一個閉包,因為它引用了自由變量 originalThing,雖然它被沒有使用,但v8引擎并不會把它優(yōu)化掉,因為 JavaScript里存在eval函數,所以v8引擎并不會隨便優(yōu)化掉暫時沒有使用的函數。
theThing 引用了someMethod,someMethod這個函數作用域隱式的和unused這個閉包共享一個閉包上下文。所以someMethod也引用了originalThing這個自由變量。
這里面的引用鏈是:
GCHandler -> replaceThing -> theThing -> someMethod -> originalThing -> someMethod(old) -> originalThing(older)-> someMethod(older)
隨著setInterval的不斷執(zhí)行,這條引用鏈是不會斷的,所以內存會不斷泄漏,直致程序崩潰。
因為是閉包作用域引起的內存泄漏,這時候最好的選擇是使用 chrome的heap snapshot的container視圖,我們通過container視圖能清楚的看到這條不斷泄漏內存的引用鏈

到此這篇關于JavaScript面試必備之垃圾回收機制和內存泄漏詳解的文章就介紹到這了,更多相關JavaScript垃圾回收 內存泄漏內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
ajaxControlToolkit AutoCompleteExtender的用法
昨天在搜索中使用了這個控件,不過不知道為什么在IE中反應比較慢2008-10-10
javascript和php使用ajax通信傳遞JSON的實例
今天小編就為大家分享一篇javascript和php使用ajax通信傳遞JSON的實例,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-08-08

