常見的內存泄漏場景
內存泄漏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/