常見的記憶體洩漏場景
記憶體洩漏Memory Leak
是指程式中已動態分配的堆記憶體由於疏忽或錯誤等原因程式未釋放或無法釋放,造成系統記憶體的浪費,導致程式執行速度減慢甚至系統崩潰等嚴重後果。記憶體洩漏並非指記憶體在物理上的消失,而是應用程式分配某段記憶體後,由於設計錯誤,導致在釋放該段記憶體之前就失去了對該段記憶體的控制,從而造成了記憶體的浪費。對於記憶體洩露的檢測,Chrome
提供了效能分析工具Performance
,可以比較方便的檢視記憶體的佔用情況等。
記憶體回收機制
像C
語言這樣的底層語言一般都有底層的記憶體管理介面,例如malloc()
和free()
等,對於JavaScript
而言在建立變數時其會自動進行分配記憶體,並且在不使用它們時自動釋放。在Js
七種基本型別中的引用型別Object
的變數其佔據記憶體空間大且大小不固定,在堆記憶體中實際儲存物件,在棧記憶體中儲存物件的指標,對於物件的訪問是按引用訪問的。在棧區中執行的變數等是通過值訪問,當其作用域銷燬後變數也就隨之銷燬,而使用引用訪問的堆區變數,在一個作用域消失後還可能在外層作用域或者其他作用域仍然存在引用,不能直接銷燬,此時就需要通過演算法計算該堆區變數是否屬於不再需要的變數,從而決定是否需要進行記憶體回收,在Js
中主要有引用計數與標記清除兩種垃圾回收演算法。
引用計數演算法
對於引用計數垃圾回收演算法,把物件是否不再需要簡化定義為該物件有沒有其他變數或物件引用到它,如果沒有引用指向該物件,該物件將被垃圾回收機制回收。在這裡,物件的概念不僅特指JavaScript
物件,還包括函式作用域或者全域性詞法作用域。引用計數垃圾回收演算法使用比較少,主要是在IE6
與IE7
等低版本IE
瀏覽器中使用。
var obj = {
a : {
b: 11
}
}
// 此時兩個物件被建立,一個作為另一個的a屬性被引用稱為物件1,另一個被obj變數引用稱為物件2
// 此時兩個物件都有被引用的變數,都不能回收記憶體
var obj2 = obj;
// 此時對於obj所引用的物件2,已經有obj與Obj2兩個變數的引用
obj = null;
// 將obj對於物件2的引用解除,此時物件2還存在obj2一個引用
var a2 = obj2.a;
// 引用物件1,此時物件1有a與a2兩個引用
obj2 = null;
// 解除物件2的一個引用,此時物件2的引用數量為0,可以被垃圾回收
// 物件2的a屬性引用被解除,此時物件1只有a2一個引用
a2 = null;
// 解除a2對於物件1的引用,此時物件1可以被垃圾回收
但是對於引用計數垃圾回收演算法有個限制,當物件迴圈引用時,就會造成記憶體洩漏,也就是引用計數垃圾回收演算法無法處理迴圈引用的物件。
function funct() {
var obj = {}; // 命名為物件1,此時引用數量為1
var obj2 = {}; // 命名為物件2,此時引用數量為1
obj.a = obj2; // obj的a屬性引用obj2,此時物件2的引用數量為2
obj2.a = obj; // obj2的a屬性引用obj,此時物件1的引用數量為2
return 1;
// 此時執行棧的obj變數與obj2變數被銷燬,物件1與物件2的引用數量減1
// 物件1的引用數量為1,物件2的引用數量為1,兩個物件都不會被引用計數演算法垃圾回收
}
funct();
// 兩個物件被建立,並互相引用,形成了一個迴圈,它們被呼叫之後會離開函式作用域,所以它們已經不再需要了,可以被回收了,然而引用計數演算法考慮到它們互相都有至少一次引用,所以它們不會被回收。
標記清除演算法
對於引用計數垃圾回收演算法,把物件是否不再需要簡化定義為該物件是否可以獲得,該演算法設定一個叫做根root
的物件,在Javascript
里根是全域性物件,垃圾回收器將定期從根開始,找所有從根開始引用的物件,然後找這些物件引用的物件,以此不斷向下查詢。從根開始,垃圾回收器將找到所有可以獲得的物件和收集所有不能獲得的物件,這樣便解決了迴圈引用的問題。所有現代瀏覽器都使用了標記清除垃圾回收演算法,所有對JavaScript
垃圾回收演算法的改進都是基於標記清除演算法的改進。
- 垃圾收集器在執行的時候會給儲存在記憶體中的所有變數都加上標記。
- 然後,它會去掉執行環境中的變數以及被環境中變數所引用的變數的標記。
- 此後,依然有標記的變數就被視為準備刪除的變數,原因是在執行環境中已經無法訪問到這些變數了。
- 最後,垃圾收集器完成記憶體清除工作,銷燬那些帶標記的值並回收它們所佔用的記憶體空間。
常見記憶體洩漏場景
意外的全域性變數
在JavaScript
中並未嚴格定義對未宣告變數的處理方式,即使在區域性函式作用域中依舊能夠定義全域性變數,這種意外的全域性變數可能會儲存大量資料,且由於其是能夠通過全域性物件例如window
能夠訪問到的,所以進行記憶體回收時不認為其是需要回收的記憶體而一直存在,只有在視窗關閉或者重新整理頁面時才能夠被釋放,造成意外的記憶體洩漏,在JavaScript
的嚴格模式下此種意外的全域性變數定義方式會丟擲異常,另外同樣可以使用eslint
進行此種狀態的預檢查。事實上定義全域性變數並不是一個好習慣,如果必須使用全域性變數儲存大量資料時,確保用完以後把它設定為null
或者重新定義,與全域性變數相關的增加記憶體消耗的一個主因是快取,快取資料是為了重用,快取必須有一個大小上限才有用,高記憶體消耗導致快取突破上限,因為快取內容無法被回收。
function funct(){
name = "name";
}
funct();
console.log(window.name); // name
delete window.name; // 不手動刪除則在不關閉或重新整理視窗的情況下一直存在
被遺忘的計時器
計時器setInterval
必須及時清理,否則可能由於其中引用的變數或者函式都被認為是需要的而不會進行回收,如果內部引用的變數儲存了大量資料,可能會引起頁面佔用記憶體過高,這樣就造成意外的記憶體洩漏。
<template>
<div></div>
</template>
<script>
export default {
creates: function() {
this.refreshInterval = setInterval(() => this.refresh(), 2000);
},
beforeDestroy: function() {
clearInterval(this.refreshInterval);
},
methods: {
refresh: function() {
// do something
},
},
}
</script>
脫離DOM的引用
有時儲存DOM
節點內部資料結構很有用,例如需要快速更新表格的幾行內容,把每一行DOM
存成字典或者陣列很有意義。此時同樣的DOM
元素存在兩個引用:一個在DOM
樹中,另一個在字典中。將來如果決定刪除這些行時,需要把兩個引用都清除。此外還要考慮DOM
樹內部或子節點的引用問題,假如你的JavaScript
程式碼中儲存了表格某一個<td>
的引用,將來決定刪除整個表格的時候,直覺認為GC
會回收除了已儲存的<td>
以外的其它節點,實際情況並非如此,此<td>
是表格的子節點,子元素與父元素是引用關係,由於程式碼保留了<td>
的引用,導致整個表格仍待在記憶體中,所以在儲存DOM
元素引用的時候,要小心謹慎。
var elements = {
button: document.getElementById("button"),
image: document.getElementById("image"),
text: document.getElementById("text")
};
function doStuff() {
elements.image.src = "https://www.example.com/1.jpg";
elements.button.click();
console.log(elements.text.innerHTML);
// 更多邏輯
}
function removeButton() {
// 按鈕是 body 的後代元素
document.body.removeChild(elements.button);
elements.button = null; // 清除對於這個物件的引用
}
閉包
閉包是JavaScript
開發的一個關鍵方面,閉包可以讓你從內部函式訪問外部函式作用域,簡單來說可以認為是可以從一個函式作用域訪問另一個函式作用域而非必要在函式作用域中實現作用域鏈結構。由於閉包會攜帶包含它的函式的作用域,因此會比其他函式佔用更多的記憶體,過度使用閉包可能會導致記憶體佔用過多,在不再需要的閉包使用結束後需要手動將其清除。
function debounce(wait, funct, ...args){ // 防抖函式
var timer = null;
return () => {
clearTimeout(timer);
timer = setTimeout(() => funct(...args), wait);
}
}
window.onscroll = debounce(300, (a) => console.log(a), 1);
被遺忘的監聽者模式
當實現了監聽者模式並在元件內掛載相關的事件處理函式,而在元件銷燬時不主動將其清除時,其中引用的變數或者函式都被認為是需要的而不會進行回收,如果內部引用的變數儲存了大量資料,可能會引起頁面佔用記憶體過高,這樣就造成意外的記憶體洩漏。
<template>
<div ></div>
</template>
<script>
export default {
created: function() {
global.eventBus.on("test", this.doSomething);
},
beforeDestroy: function(){
global.eventBus.off("test", this.doSomething);
},
methods: {
doSomething: function() {
// do something
},
},
}
</script>
被遺忘的事件監聽器
當事件監聽器在元件內掛載相關的事件處理函式,而在元件銷燬時不主動將其清除時,其中引用的變數或者函式都被認為是需要的而不會進行回收,如果內部引用的變數儲存了大量資料,可能會引起頁面佔用記憶體過高,這樣就造成意外的記憶體洩漏。
<template>
<div></div>
</template>
<script>
export default {
created: function() {
window.addEventListener("resize", this.doSomething);
},
beforeDestroy: function(){
window.removeEventListener("resize", this.doSomething);
},
methods: {
doSomething: function() {
// do something
},
},
}
</script>
被遺忘的Map
當使用Map
儲存物件時,類似於脫離DOM
的引用,如果不將其主動清除引用,其同樣會造成記憶體不自動進行回收,對於鍵為物件的情況,可以採用WeakMap
,WeakMap
物件同樣用來儲存鍵值對,對於鍵是弱引用的而且必須為一個物件,而值可以是任意的物件或者原始值,且由於是對於物件的弱引用,其不會干擾Js
的垃圾回收。
var elements = new Map();
elements.set("button", document.getElementById("button"));
function doStuff() {
elements.get("button").click();
// 更多邏輯
}
function removeButton() {
// 按鈕是 body 的後代元素
document.body.removeChild(elements.get("button"));
elements.delete("button"); // 清除對於這個物件的引用
}
被遺忘的Set
當使用Set
儲存物件時,類似於脫離DOM
的引用,如果不將其主動清除引用,其同樣會造成記憶體不自動進行回收,如果需要使用Set
引用物件,可以採用WeakSet
,WeakSet
物件允許儲存物件弱引用的唯一值,WeakSet
物件中的值同樣不會重複,且只能儲存物件的弱引用,且由於是對於物件的弱引用,其不會干擾Js
的垃圾回收。
var elements = new Set();
var btn = document.getElementById("button");
elements.add(btn);
function doStuff() {
btn.click();
// 更多邏輯
}
function removeButton() {
document.body.removeChild(btn); // 按鈕是 body 的後代元素
elements.delete(btn); // 清除Set中對於這個物件的引用
btn = null; // 清除引用
}
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
https://zhuanlan.zhihu.com/p/60538328
https://juejin.im/post/6844903928060985358
https://jinlong.github.io/2016/05/01/4-Types-of-Memory-Leaks-in-JavaScript-and-How-to-Get-Rid-Of-Them/