一文搞懂JavaScript中的內存泄露
以前我們說的內存泄漏,通常發(fā)生在后端,但是不代表前端就不會有內存泄漏。特別是當前端項目變得越來越復雜后,前端也逐漸稱為內存泄漏的高發(fā)區(qū)。本文就帶你認識一下Javascript的內存泄漏。
什么是內存泄漏
什么是內存?內存其實就是程序在運行時,系統(tǒng)為其分配的一塊存儲空間。每一塊內存都有對應的生命周期:
- 內存分配:在聲明變量、函數時,系統(tǒng)分配的內存空間
- 內存使用:對分配到的內存進行讀/寫操作,即訪問并使用變量、函數等
- 釋放內存:內存使用完畢后,釋放掉不再被使用的內存
不像C語言等底層語言需要程序員在開發(fā)的時候自己通過malloc和free來申請或者釋放內存,JavaScript同大多數現(xiàn)代編程語言一樣,都實現(xiàn)了給變量自動分配內存,并且在不使用變量的時候“自動”釋放內存,這個釋放內存的過程就被稱為垃圾回收。
每一個程序的運行都需要一塊內存空間,如果某一塊內存空間在使用后未被釋放,并且持續(xù)累積,導致未釋放的內存空間越積越多,直至用盡全部的內存空間。程序將無法正常運行,直觀體現(xiàn)就是程序卡死,系統(tǒng)崩潰,這一現(xiàn)象就被稱為內存泄漏。
我們來舉幾個例子說明一下:
內存分配
const obj = {
name: '張三'
} // 給{name: '張三'}分配內存
function foo() {
console.log('hello world')
} // 給函數分配內存
let date = new Date(); // 根據函數返回的結果創(chuàng)建變量,會分配一個Date對象
可能發(fā)生內存泄漏
function foo() {
const obj = {name: '張三'}
window.obj = obj;
console.log(obj)
}
foo(); // foo()執(zhí)行完畢,{name: '張三'}對應的內存空間本應該被釋放,但是由于又被全局變量所引用,因此其對應的內存空間不會被垃圾回收
閉包的內存占用
function bar() {
const data = {}
return {
get(key) {
return data[key]
},
set(key, value) {
data[key] = value
}
}; // 閉包對象
}
const {get, set} = bar; // 結構
set('name', '張三')
get('name'); // 張三
// 函數執(zhí)行完畢,data對象并不會被垃圾回收,這是閉包的機制
內存泄漏聽起來可能會有點抽象,怎么能比較直觀的看到內存泄漏的過程呢?
怎么檢測內存泄漏
內存泄漏主要是指的是內存持續(xù)升高,但是如果是正常的內存增長的話,不應該被當作內存泄漏來排查。排查內存泄漏,我們可以借助Chrome DevTools的Performance和Memory選項。舉個栗子:
我們新建一個memory.html的文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body {
text-align: center;
}
</style>
</head>
<body>
<p>檢測內存變化</p>
<button id="btn">開始</button>
<script>
const arr = [];
// 數組中添加100萬個數據
for (let i = 0; i < 100 * 10000; i++) {
arr.push(i)
}
function bind() {
const obj = {
str: JSON.stringify(arr) // 淺拷貝的方式創(chuàng)建一個比較大的字符串
}
// 每次調用bind函數,都在全局綁定一個onclick監(jiān)聽事件,不一定非要執(zhí)行
// 使用綁定事件,主要是為了保持obj被全局標記
window.addEventListener('click', () => {
// 引用對象obj
console.log(obj);
})
}
let n = 0;
function start() {
setTimeout(() => {
bind(); // 調用bind函數
n++; // 循環(huán)次數增加
if (n < 50) {
start(); // 循環(huán)執(zhí)行50次,注意這里并沒有使用setInterval定時器
} else {
alert('done');
}
}, 200);
}
document.getElementById('btn').addEventListener('click', () => {
start();
})
</script>
</body>
</html>
在無法確定是否發(fā)生內存泄漏時,我們可以先使用Performance來錄制一段頁面加載的性能變化,先判斷是否有內存泄漏發(fā)生。
Performance
本次案例僅以Chrome瀏覽器展開描述,其他瀏覽器可能會有些許差異。首先我們鼠標右鍵選擇檢查或者直接F12進入DevTools頁面,面板上選擇Performance,選擇后應該是如下頁面:

在開始之前,我們先點擊一下Collect garbage和clear來保證內存干凈,沒有其他遺留內存的干擾。然后我們點擊Record來開始錄制,并且同時我們也要點擊頁面上的開始按鈕,讓我們的代碼跑起來。等到代碼結束后,我們再點擊Record按鈕以停止錄制,錄制的時間跟代碼執(zhí)行的時間相比會有出入,只要保證代碼是完全執(zhí)行完畢的即可。停止錄制后,我們會得到如下的結果:

Performance的內容很多,我們只需要關注內存的變化,由此圖可見,內存這塊區(qū)域的曲線是在一直升高的并且到達頂點后并沒有回落,這就有可能發(fā)生了內存泄漏。因為正常的內存變化曲線應該是類似于“鋸齒”,也就是有上有下,正常增長后會有一定的回落,但不一定回落到和初始值一樣。而且我們還可以隱約看到程序運行結束后,內存從初始的6.2MB增加到了差不多351MB,這個數量級的增加還是挺明顯的。我們只是執(zhí)行了50次循環(huán),如果執(zhí)行的次數更多,將會耗盡瀏覽器的內存空間,導致頁面卡死。
雖然是有內存泄漏,但是如果我們想進一步看內存泄漏發(fā)生的地方,那么Performance就不夠用了,這個時候我們就需要使用Memory面板。
Memory
DevTools的Memory選項主要是用來錄制堆內存的快照,為的是進一步分析內存泄漏的詳細信息。有人可能會說,為啥不一開始就直接使用Memory呢,反而是先使用Performance。因為我們剛開始就說了,內存增長不表示就一定出現(xiàn)了內存泄漏,有可能是正常的增長,直接使用Memory來分析可能得不到正確的結果。
我們先來看一下怎么使用Memory:

首先選擇Memory選項,然后清除緩存,在配置選項中選擇堆內存快照。內存快照每次點擊錄制按鈕都會記錄當前的內存使用情況,我們可以在程序開始前點擊一下記錄初始的內存使用,代碼結束后再點一下記錄最終的內存使用,中間可以點擊也可以不點擊。最后在快照列表中至少可以得到兩個內存記錄:

初始內存我們暫時不深究,我們選擇列表的最后一條記錄,然后在篩選下拉框選擇最后一個,即第一個快照和第二個快照的差異。

這里我們重點說一下Shallow Size和Retained Size的區(qū)別:
- Shallow Size:對象自身占用的內存大小,一般來說字符串、數組的Shallow Size都會比較大
- Retained Size:這個是對象自身占用的內存加上無法被GC釋放的內存的大小,如果Retained Size和Shallow Size相差不大,基本上可以判定沒有發(fā)生內存泄漏,但是如果相差很大,例如上圖的
Object,這就表明發(fā)生了內存泄漏。
我們再來細看一下Object,任意展開一個對象,可以在樹結構中發(fā)現(xiàn)每一個對象都有一個全局事件綁定,并且占用了較大的內存空間。解決本案例涉及的內存泄漏也比較簡單,就是及時釋放綁定的全局事件。

關于Performance和Memory的詳細使用可以參考:手把手教你排查Javascript內存泄漏
內存泄漏的場景
大多數情況下,垃圾回收器會幫我們及時釋放內存,一般不會發(fā)生內存泄漏。但是有些場景是內存泄漏的高發(fā)區(qū),我們在使用的時候一定要注意:
我們在開發(fā)的時候經常會使用console在控制臺打印信息,但這也會帶來一個問題:被console使用的對象是不能被垃圾回收的,這就可能會導致內存泄漏。因此在生產環(huán)境中不建議使用console.log()的理由就又可以加上一條了
被全局變量、全局函數引用的對象,在Vue組件銷毀時未清除,可能會導致內存泄漏
// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
const arr = reactive([1,2,3]);
onMounted(() => {
window.arr = arr; // 被全局變量引用
window.arrFunc = () => {
console.log(arr); // 被全局函數引用
}
})
// 正確的方式
onBeforeUnmount(() => {
window.arr = null;
window.arrFunc = null;
})
</script>
定時器未及時在Vue組件銷毀時清除,可能會導致內存泄漏
// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
const arr = reactive([1,2,3]);
const timer = reactive(null);
onMounted(() => {
setInterval(() => {
console.log(arr); // arr被定時器占用,無法被垃圾回收
}, 200);
// 正確的方式
timer = setInterval(() => {
console.log(arr);
}, 200);
})
// 正確的方式
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
})
</script>
setTimeout和setInterval兩個定時器在使用時都應該注意是否需要清理定時器,特別是setInterval,一定要注意清除。
綁定的事件未及時在Vue組件銷毀時清除,可能會導致內存泄漏
綁定事件在實際開發(fā)中經常遇到,我們一般使用addEventListener來創(chuàng)建。
// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
const arr = reactive([1,2,3]);
const printArr = () => {
console.log(arr)
}
onMounted(() => {
// 監(jiān)聽事件綁定的函數為匿名函數,將無法被清除
window.addEventListener('click', () => {
console.log(arr); // 全局綁定的click事件,arr被引用,將無法被垃圾回收
})
// 正確的方式
window.addEventListener('click', printArr);
})
// 正確的方式
onBeforeUnmount(() => {
// 注意清除綁定事件需要前后是同一個函數,如果函數不同將不會清除
window.removeEventListener('click', printArr);
})
</script>
被自定義事件引用,在Vue組件銷毀時未清除,可能會導致內存泄漏
自定義事件通過emit/on來發(fā)起和監(jiān)聽,清除自定義事件和綁定事件差不多,不同的是需要調用off方法
// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
import event from './event.js'; // 自定義事件
const arr = reactive([1,2,3]);
const printArr = () => {
console.log(arr)
}
onMounted(() => {
// 使用匿名函數,會導致自定義事件無法被清除
event.on('printArr', () => {
console.log(arr)
})
// 正確的方式
event.on('printArr', printArr)
})
// 正確的方式
onBeforeUnmount(() => {
// 注意清除自定義事件需要前后是同一個函數,如果函數不同將不會清除
event.off('printArr', printArr)
})
</script>
除了及時清除監(jiān)聽器、事件等,對于全局變量的引用,我們可以選擇WeakMap、WeakSet等弱引用數據類型。這樣的話,即使我們引用的對象數據要被垃圾回收,弱引用的全局變量并不會阻止GC。
垃圾回收算法
我們知道了內存泄漏的含義,也知道了怎么來檢測內存泄漏,甚至可以一定程度上規(guī)避內存泄漏了。除了那些容易產生內存泄漏的場景,js是使用什么樣的機制來保證垃圾會被盡可能的回收呢?垃圾回收算法早期使用的是引用計數,現(xiàn)在主流都采用標記清除的方式了。
引用計數
我們創(chuàng)建一個對象,js會在堆內存中分配一塊區(qū)域用于存儲對象信息,并且在棧中存在對象數據的引用地址。舉個栗子:
let obj = {name: '張三'};
let obj2 = obj;
// 對象數據{name: '張三'}被obj和obj2引用,引用計數為2,此時{name: '張三'}不能被垃圾回收
obj = 0; // obj雖然不引用{name: '張三'},但是obj2還在引用,此時{name: '張三'}也不能被垃圾回收
obj2 = 0; // 此時的{name: '張三'}已經是零引用了,可以被垃圾回收
下圖為創(chuàng)建變量時的內存管理:

對于函數來說,正常情況下函數執(zhí)行完畢,其占用的內存就會被垃圾回收:
function foo() {
const obj = {name: '張三'}
console.log(obj)
}
foo(); // 函數執(zhí)行完畢,obj作為局部變量,會隨著函數的結束而結束,{name: '張三'}由于零引用,其占用的內存空間會被釋放
但是如果函數中存在全局引用,那么函數結束后,全局引用占用的內存將無法被釋放
function foo() {
const obj = {name: '張三'}
window.obj = obj;
console.log(obj)
}
foo(); // 函數執(zhí)行完畢,{name: '張三'}被全局引用
引用計數有一個缺陷,那就是無法處理循環(huán)引用。
循環(huán)引用
循環(huán)引用指的是兩個或者多個對象之間,存在相互引用,并形成了一個循環(huán)。如果是在函數中,函數運行結束后應該釋放掉所有局部變量引用的對象,但是按照引用計數算法,循環(huán)引用間并不是零引用,因此它們就不會被釋放。舉個例子:
function foo() {
const obj = {name: '張三'};
const obj2 = {age: 0};
obj.a = obj2; // obj對象引用了obj2
obj2.a = obj; // obj2引用了obj
}
foo(); // 由于存在循環(huán)引用,{name: '張三'}和{age: 0}所占用的堆內存都不會被釋放
上面的例子可能比較抽象,我們再來舉一個實際的例子。在IE6、7版本中(IE已于2022年6月15日正式退出了歷史舞臺),在對DOM對象進行垃圾回收時,就有可能因為循環(huán)引用導致內存泄漏。
let div = document.getElementById('div1');
div.a = div; // 循環(huán)引用自己
div.someBigData = new Array(10000).fill('*');
在上述例子中,div對象的a屬性引用了div對象自身,造成了最簡單的循環(huán)引用,并且該屬性并沒有被移除或者顯示設置為null,這對于引用計數器來說就是一個有意義的引用。因此,div對象會一直保持在內存中,即使在DOM樹中將div1刪除,而且div對象的someBigData所引用的數據也會一直保持在內存中,不會被釋放。如果div對象本身很大,或者其屬性引用的數據很大,那么持續(xù)累積就可能造成內存泄漏 。
標記清除
標記清除算法是對引用計數算法的改進,如果說引用計數算法是判斷對象是否不再需要,那么標記清楚算法就是判斷對象是否可以獲得??梢垣@得的對象就保留在內存中,不可獲得的對象就會被垃圾回收。
垃圾回收并不是實時的,使用標記清除算法的垃圾回收器,會定期從根對象開始,在js中就是從window對象開始,找出所有從根開始引用的對象,以及找到這些對象引用的對象,直到全部遍歷。垃圾回收器就可以收集到所有可獲得的對象以及所有不可獲得的對象,然后將不可獲得的對象回收。
使用標記清除算法可以有效解決引用計數算法的循環(huán)引用問題,還是剛剛那個例子:
function foo() {
const obj = {name: '張三'};
const obj2 = {age: 0};
obj.a = obj2; // obj對象引用了obj2
obj2.a = obj; // obj2引用了obj
}
foo();
foo()函數執(zhí)行完畢后,垃圾回收器從window對象開始找,發(fā)現(xiàn)obj和obj2是不可獲得的對象,那么其引用的數據就會被回收。我們簡單修改一下代碼:
function foo() {
const obj = {name: '張三'};
const obj2 = {age: 0};
obj.a = obj2; // obj對象引用了obj2
obj2.a = obj; // obj2引用了obj
window.obj = obj;
}
foo();
console.log(window.obj)
foo()函數執(zhí)行完畢,垃圾回收期從window對象開始找,發(fā)現(xiàn)可以在window對象上找到obj屬性,obj和obj2存在循環(huán)引用,最終obj和obj2引用的數據都不會被垃圾回收。

這個例子也告訴我們,使用全局變量時一定要注意是否可能造成內存泄漏,詳細可查看內存泄漏的場景。使用標記清楚算法就基本上能滿足垃圾回收的需求了,而且從2012年開始,現(xiàn)代主流瀏覽器的垃圾回收器都實現(xiàn)了標記清除算法,后續(xù)的改進也是基于該算法來實現(xiàn)的。
閉包是內存泄漏嗎
使用過閉包的都知道,其數據是保持在內存中的,不會被回收,那么閉包是內存泄漏嗎?答案:閉包不是內存泄漏。因為我們說的內存泄漏是不符合預期的內存持續(xù)增長,閉包雖然也會占著內存不釋放,但是這個是符合我們預期的效果。
閉包不是內存泄漏,難道就可以隨便使用了嗎。那肯定不是的,閉包中的數據如果很大,也會消耗大量的內存,造成網頁卡頓,甚至崩潰。因此我們不能濫用閉包,在閉包函數退出前,一些不必要的局部變量該清除的還是要清除。
總結
本文詳細的解釋了什么是內存泄漏,泄漏了該怎么檢測,瀏覽器是怎么來進行垃圾回收的,以及一些常見的內存泄漏場景??偟膩碚f,內存泄漏就是指發(fā)生非預期的內存占用持續(xù)增長,以至于耗盡內存,導致系統(tǒng)崩潰。內存泄漏在實際開發(fā)過程中還是比較容易犯的,在寫代碼的時候一定要留意高發(fā)場景,盡可能在寫代碼的時候就避免潛在的內存泄漏,而不是等到系統(tǒng)崩潰了才來一句:重啟(刷新)大法好。
以上就是一文搞懂JavaScript中的內存泄露的詳細內容,更多關于JavaScript內存泄露的資料請關注腳本之家其它相關文章!
相關文章
JavaScript標準對象_動力節(jié)點Java學院整理
這篇文章主要為大家詳細介紹了JavaScript標準對象的相關資料,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-06-06
一文教你如何實現(xiàn)localStorage的過期機制
要知道localStorage本身并沒有提供過期機制,既然如此那就只能我們自己來實現(xiàn)了,這篇文章主要給大家介紹了關于如何實現(xiàn)localStorage過期機制的相關資料,需要的朋友可以參考下2022-02-02
Bootstrap輪播插件中圖片變形的終極解決方案 使用jqthumb.js
這篇文章主要介紹了Bootstrap輪播插件中圖片變形的終極解決方案,使用jqthumb.js,感興趣的小伙伴們可以參考一下2016-07-07

