前言
????????每種編程語言都有它的內(nèi)存管理機(jī)制,比如簡單的C有低級的內(nèi)存管理基元,像malloc(),free()。而對于JavaScript來說,會在創(chuàng)建變量(對象,字符串等)時分配內(nèi)存,并且在不再使用它們時“自動”釋放內(nèi)存,這個自動釋放內(nèi)存的過程稱為垃圾回收。
因為自動垃圾回收機(jī)制的存在,讓大多Javascript開發(fā)者感覺他們可以不關(guān)心內(nèi)存管理,所以會在一些情況下導(dǎo)致內(nèi)存泄漏。
內(nèi)存生命周期
JS 環(huán)境中分配的內(nèi)存有如下聲明周期:
- 內(nèi)存分配:當(dāng)我們申明變量、函數(shù)、對象的時候,系統(tǒng)會自動為他們分配內(nèi)存
- 內(nèi)存使用:即讀寫內(nèi)存,也就是使用變量、函數(shù)等
- 內(nèi)存回收:使用完畢,由垃圾回收機(jī)制自動回收不再使用的內(nèi)存
JavaScript 的內(nèi)存分配
一般來說JS的內(nèi)存空間分為棧(stack)、堆(heap)、池(一般也會歸類為棧中)。
其中棧存放變量,堆存放復(fù)雜對象,池存放常量,所以也叫常量池。
通俗點說就是:棧是存放基本數(shù)據(jù)類型及對象變量的指針的,堆是存放引用數(shù)據(jù)類型的。
堆內(nèi)存與棧內(nèi)存是有區(qū)別的:
????????棧內(nèi)存運行效率比堆內(nèi)存高,空間相對推內(nèi)存來說較小,反之則是堆內(nèi)存的特點。所以將構(gòu)造簡單的原始類型值放在棧內(nèi)存中,將構(gòu)造復(fù)雜的引用類型值放在堆中而不影響棧的效率。
數(shù)據(jù)類型:
基本數(shù)據(jù)類型:String、Number、Boolean、Null、Undefined
引用類型:Object

1. 基本數(shù)據(jù)類型與棧
????????基本數(shù)據(jù)類型保存在棧內(nèi)存中,因為基本數(shù)據(jù)類型占用空間小、大小固定,通過按值來訪問,屬于被頻繁使用的數(shù)據(jù)。(需要注意的是閉包中的基本數(shù)據(jù)類型變量不保存在棧內(nèi)存中,而是保存在堆內(nèi)存中)
舉個栗子:
????????為了不讓程序員費心分配內(nèi)存,JavaScript 在定義變量時就完成了內(nèi)存分配。(在變量對象中執(zhí)行數(shù)據(jù)復(fù)制的時候,其實系統(tǒng)會自動為新的變量分配一個新的值,所以a與b其實已經(jīng)是完全獨立的兩個變量,只是值一樣而已。)
let a = 20; // 給數(shù)值變量分配內(nèi)存
let b = a; // 給數(shù)值變量分配內(nèi)存
b = 30
console.log(a,b) // a = 20 b = 30

2. 引用數(shù)據(jù)類型與堆
????????引用數(shù)據(jù)類型存儲在堆內(nèi)存中,因為引用數(shù)據(jù)類型占據(jù)空間大、大小不固定。 如果存儲在棧中,將會影響程序運行的性能;
????????引用數(shù)據(jù)類型在棧中存儲了指針,該指針指向堆中該實體的起始地址。 當(dāng)解釋器尋找引用值時,會首先檢索其在棧中的地址,取得地址后從堆中獲得實體。
舉個栗子:
var m = {
a:10,
b:20
};
var n = m;
n.a = 15;
console.log(m.a); //15

垃圾回收
為什么要回收?程序的運行需要內(nèi)存,只要程序提出要求,操作系統(tǒng)或者運行時就必須提供內(nèi)存,那么對于持續(xù)運行的服務(wù)進(jìn)程,必須要及時釋放內(nèi)存,否則,內(nèi)存占用越來越高,輕則影響系統(tǒng)性能,重則就會導(dǎo)致進(jìn)程崩潰。
什么是內(nèi)存泄漏?不再用到的內(nèi)存,沒有及時釋放,就叫做內(nèi)存泄漏(memory leak)。
有些語言(比如 C 語言)必須手動釋放內(nèi)存,程序員負(fù)責(zé)內(nèi)存管理。
垃圾回收算法
最常見的兩種垃圾回收算法:引用計數(shù) 與 標(biāo)記清除
1. 引用計數(shù)
這種方式常常會引起內(nèi)存泄漏,低版本的IE使用這種方式。
原理:跟蹤記錄每個值被引用的次數(shù)。(引用計數(shù)算法定義“內(nèi)存不再使用”的標(biāo)準(zhǔn)很簡單,就是看一個對象是否有指向它的引用。 如果沒有其他對象指向它了,說明該對象已經(jīng)不再需了。)
工作流程:
- 當(dāng)聲明了一個變量并且將一個引用類型賦值給該變量的時候這個值的引用次數(shù)就為 1。
- 如果同一個值又被賦給另一個變量,那么引用數(shù)加 1。
- 如果該變量的值被其他的值覆蓋了,則引用次數(shù)減 1。
- 當(dāng)引用次數(shù)變成0時,說明沒辦法訪問這個值了。
- 當(dāng)垃圾收集器下一次運行時,它就會釋放引用次數(shù)是0的值所占的內(nèi)存。
let a = new Object() // 此對象的引用計數(shù)為 1(a引用)
let b = a // 此對象的引用計數(shù)是 2(a,b引用)
a = null // 此對象的引用計數(shù)為 1(b引用)
b = null // 此對象的引用計數(shù)為 0(無引用)
... // GC 回收此對象
目前看起來此方式很簡單明了,但是很快我們就會遇到一個問題:循環(huán)引用,即對象 A 有一個指針指向?qū)ο?B,而對象 B 也引用了對象 A ,如下:
function test(){
let A = new Object();
let B = new Object();
A.b = B;
B.a = A;
}
// 對象 A 和 B 通過各自的屬性相互引用著,按照上文的引用計數(shù)策略,它們的引用數(shù)量都是 2,
// 按理來說在函數(shù) test 執(zhí)行完成之后,對象 A 和 B 是要被清理的
// 但由于它們的引用數(shù)量不會變成 0,故使用引用計數(shù)則不會被清理。
// 假如此函數(shù)在程序中被多次調(diào)用,那么就會造成大量的內(nèi)存不會被釋放
綜上所述,引用計數(shù)算法是個簡單有效的算法。但它卻存在一個致命的問題:循環(huán)引用。如果兩個對象相互引用,盡管他們已不再使用,垃圾回收器不會進(jìn)行回收,導(dǎo)致內(nèi)存泄露。
2. 標(biāo)記清除
原理: 是當(dāng)變量進(jìn)入環(huán)境時,將這個變量標(biāo)記為“進(jìn)入環(huán)境”。當(dāng)變量離開環(huán)境時,則將其標(biāo)記為“離開環(huán)境”。標(biāo)記“離開環(huán)境”的就回收內(nèi)存。
工作流程:
- 垃圾收集器會在運行的時候會給存儲在內(nèi)存中的所有變量都加上標(biāo)記。
- 清除「進(jìn)入環(huán)境中的變量的標(biāo)記」或者「被進(jìn)入環(huán)境中的變量所引用的變量的標(biāo)記」。
- 那些還存在標(biāo)記的變量被視為準(zhǔn)備刪除的變量。
- 最后垃圾收集器會執(zhí)行最后一步內(nèi)存清除的工作,銷毀那些帶標(biāo)記的值并回收它們所占用的內(nèi)存空間。
標(biāo)記清除算法也有一個很大的缺點,就是在清除之后,剩余的對象內(nèi)存位置是不變的,也會導(dǎo)致空閑內(nèi)存空間是不連續(xù)的,出現(xiàn)了 內(nèi)存碎片(如下圖),并且由于剩余空閑內(nèi)存不是一整塊,它是由不同大小內(nèi)存組成的內(nèi)存列表,這就牽扯出了內(nèi)存分配的問題

假設(shè)我們新建對象分配內(nèi)存時需要大小為 size,由于空閑內(nèi)存是間斷的、不連續(xù)的,則需要對空閑內(nèi)存列表進(jìn)行一次單向遍歷找出大于等于 size 的塊才能為其分配(如下圖)

那如何找到合適的塊呢?我們可以采取下面三種內(nèi)存分配策略:
-
First-fit,找到大于等于 size 的塊立即返回 -
Best-fit,遍歷整個空閑列表,返回大于等于 size 的最小分塊 -
Worst-fit,遍歷整個空閑列表,找到最大的分塊,然后切成兩部分,一部分 size 大小,并將該部分返回
| 分類 | First-fit | Best-fit | Worst-fit |
|---|---|---|---|
| 優(yōu)點 | 分配的速度和效率更高 | -- | 空間利用率看起來是最合理 |
| 缺點 | -- | -- | 切分之后會造成更多的小塊, 形成內(nèi)存碎片, 所以不推薦使用 |
3. 引用計數(shù)PK標(biāo)記清除
| 分類 | 引用計數(shù) | 標(biāo)記清除 |
|---|---|---|
| 優(yōu)點 |
回收時機(jī):????引用計數(shù)算法:引用計數(shù)在引用值為 0 時,也就是在變成垃圾的那一刻就會被回收,所以它可以立即回收垃圾 ????標(biāo)記清除算法:需要每隔一段時間進(jìn)行一次,那在應(yīng)用程序(JS腳本)運行過程中線程就必須要暫停去執(zhí)行一段時間的 GC,另外,標(biāo)記清除算法需要遍歷堆里的活動以及非活動對象來清除,而引用計數(shù)則只需要在引用時計數(shù)就可以了。 |
算法:????比較簡單,打標(biāo)記也無非打與不打兩種情況,這使得一位二進(jìn)制位(0和1)就可以為其標(biāo)記,非常簡單 |
| 缺點 | 1. 它需要一個計數(shù)器,而此計數(shù)器需要占很大的位置,因為我們也不知道被引用數(shù)量的上限 2. 無法解決循環(huán)引用無法回收的問題 |
1. 內(nèi)存碎片化,空閑內(nèi)存塊是不連續(xù)的,容易出現(xiàn)很多空閑內(nèi)存塊,還可能會出現(xiàn)分配所需內(nèi)存過大的對象時找不到合適的塊 2. 分配速度慢,因為即便是使用 First-fit 策略,其操作仍是一個 O(n) 的操作,最壞情況是每次都要遍歷到最后,同時因為碎片化,大對象的分配效率會更慢 |
補(bǔ)充:
常見內(nèi)存泄漏的原因:
(1)全局變量引起的內(nèi)存泄露
(2)閉包引起的內(nèi)存泄露:慎用閉包
(3)dom清空或刪除時,事件未清除導(dǎo)致的內(nèi)存泄漏
(4)循環(huán)引用帶來的內(nèi)存泄露
(5)未被清空的定時器
參考文獻(xiàn):
http://www.itdecent.cn/p/3d6b82f5242c
https://juejin.cn/post/6981588276356317214
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Memory_Management
https://juejin.cn/post/6844903615300108302