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

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

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

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

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

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

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

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

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

