1.垃圾回收的概念
1.1 什么是垃圾回收機制:
GC
即 Garbage Collection
,程序工作過程中會產生很多"垃圾",這些垃圾是程序不用的內存或者是之前用過了,以后不會再用的內存空間,而 GC
就是負責回收垃圾的,因為他工作在引擎內部,所以對于我們前端來說,GC
過程是相對比較無感的,這一套引擎執行而對我們又相對無感的操作也就是常說的 垃圾回收機制
不是所有語言都有 GC
,一般的高級語言里面會自帶 GC
,比如 Java、Python、JavaScript
等,也有無 GC
的語言,比如 C、C++
等,那這種就需要我們程序員手動管理內存了,相對比較麻煩
在像 C/C++ 這樣的語言中,開發者需要手動分配(malloc
)和釋放(free
)內存。這種方式非常靈活,性能也高,但有兩個致命缺點:
- 忘記釋放:會導致內存泄漏,程序占用的內存會隨著時間推移越來越多,最終可能導致程序崩潰或系統變慢。
- 提前釋放:或釋放了多次,會導致懸掛指針,當程序嘗試訪問一個已經被釋放的內存地址時,會引發不可預知的錯誤(通常是程序崩潰)。
JavaScript中存在兩種變量:局部變量
和全局變量
。全局變量的生命周期會持續到頁面卸載;而局部變量聲明在函數中,它的生命周期從函數執行開始,直到函數執行結束,在這個過程中,局部變量會在堆或棧中存儲它們的值,當函數執行結束后,這些局部變量不再被使用,它們所占有的空間就會被釋放。(不過,當局部變量被外部函數使用時,其中一種情況就是閉包
,在函數執行結束后,函數外部的變量依然指向函數內部的局部變量,此時局部變量依然在被使用,所以不會回收。)
2.垃圾回收機制是如何實現的
2.1核心理念:
GC 機制的核心思想是可達性
。簡單來說,就是判斷一個對象是否“可達”,如果不可達,它就是“垃圾”。
- 可達的對象:以某種方式被訪問或使用的對象。
- 不可達的對象:無法被訪問到的對象,可以被安全地回收。
GC 會有一系列的根對象,它們是可達性的起點。在 JavaScript 中,主要的根包括:
全局對象
:比如瀏覽器環境下的 window
對象,Node.js 環境下的 global
對象。
因為全局對象在應用程序的整個生命周期內都存在。只要你的網頁開著,window
對象就永遠不會消失。它是所有全局變量和內置API(如 setTimeout
, localStorage
)的宿主。如果它被回收了,整個JavaScript環境就都將崩塌。因此,全局對象是GC最重要、最基礎的一個“根”。
函數調用棧
:當前正在執行的函數中的局部變量和參數。
因為當前正在執行的代碼和它所依賴的數據,理所當然是“存活”的。調用棧代表了程序執行的“此時此刻”。如果這些正在使用的變量被回收了,程序將立即出錯。因此,調用棧中所有棧幀里的變量和參數,都被視為臨時的“根”。
活躍的 DOM 樹
:頁面上存在的 DOM 元素。
因為DOM節點是構成用戶界面的實體。用戶能看到、能與之交互的元素,必須始終存在于內存中,瀏覽器需要依據它來進行繪制和響應事件。因此,所有在DOM樹上的節點都被認為是“可達的”。
垃圾回收器會從這些“根”
出發,沿著引用鏈進行遍歷。所有能從“根”訪問到的對象,都會被認為是“活”的(可達的);反之,所有無法從“根”訪問到的對象,就會被認為是“死”的(不可達的),并成為垃圾回收的目標。
// 創建一個根對象,并被變量 user 引用
let user = {name: "Alice"
};// 原來的 { name: "Alice" } 對象失去了引用,因此它變成了不可達對象,等待被回收。
user = null;
2.2主流垃圾回收算法
瀏覽器通常使用的垃圾回收方法有兩種:標記清除
,引用計數
。
2.2.1標記清除
這是現代瀏覽器中最常用的垃圾回收算法。它完美地解決了循環引用
的問題。
- 原理:分為兩個階段:
- 標記階段:垃圾回收器從
“根”對象
開始,遍歷所有可達的對象,并在這些對象上打上一個“標記”,表示它們是存活的。 - 清除階段:垃圾回收器遍歷整個
堆內存
,所有沒有被標記的對象都被視為垃圾,并被回收,其占用的內存被釋放。
- 標記階段:垃圾回收器從
- 優點:可以解決循環引用的問題。因為即使
objA
和objB
互相引用,但如果它們都無法從“根”訪問到,那么它們就都不會被標記,最終會被一起清除。
function createCircularReference() {let objA = {};let objB = {};objA.b = objB; objB.a = objA;
}createCircularReference();
- 缺點:
- 執行效率問題:GC 執行時,需要
暫停整個程序
的運行,如果堆內存很大,標記和清除會很耗時。 - 內存碎片化:清除后,會產生大量不連續的內存碎片。如果之后需要分配一個大對象,可能會因為沒有足夠大的
連續空間
而失敗。
- 執行效率問題:GC 執行時,需要
2.2.2引用計數
這是早期的一種 GC 算法,思想非常簡單。
- 原理:為每個對象維護一個
“引用計數器”
。當有一個引用指向該對象時,計數器加1;當引用被移除時,計數器減1。當計數器變為0時,表示該對象不再被需要,可以被回收。 - 優點:實現簡單,垃圾可以被
立即回收
,不會有“暫停”的感覺。 - 致命缺點:無法處理
循環引用
。
看下面的例子:
這種情況下,就要手動釋放變量占用的內存:
obj1.a = null
obj2.a = null
2.3 V8 引擎的優化:分代回收
為了解決標記-清除算法的效率問題,Google 的 V8 引擎(用于 Chrome 和 Node.js)采用了一種更先進的策略:分代回收
。
這個策略基于一個重要的觀察:“大部分對象都是朝生夕死的”。也就是說,很多對象在創建后很快就不再被使用,而少數對象會存活很長時間。
V8 將堆內存分為兩個主要區域:
新生代:Scavenge
算法
- 特點:存放生命周期短的對象,空間較小(通常為 1-8MB),垃圾回收頻繁且
速度快
。 - 內部結構:新生代內存被平分為兩個相等的空間:
From 空間(使用中)
和To 空間(空閑)
。 - **回收過程:
- 新對象首先被分配在 From 空間。
- 當 From 空間快要被占滿時,觸發一次新生代的 GC。
- GC 會檢查 From 空間中的存活對象,并將它們復制到 To 空間。
- 復制完成后,From 空間剩下的所有對象都是垃圾。整個 From 空間被一次性清空。
- From 空間和 To 空間的角色互換,等待下一次 GC。
- 晉升:如果一個對象在新生代中經過了多次 Scavenge 依然存活,那么它被認為是生命周期較長的對象,會被“晉升”到老生代中。此外,如果復制一個對象到 To 空間時,To 空間的使用率超過了25%,該對象也會被直接晉升到老生代。
老生代:標記-清除與 標記-整理 - 特點:存放生命周期長或體積大的對象,空間較大,GC
頻率較低
。 - 回收過程:
- 主要使用標記-清除算法,流程如前所述。
- 為了解決內存碎片化問題,V8 引入了標記-整理算法。它在標記階段之后,不是直接清除垃圾,而是將所有存活的對象向內存的一端移動,然后直接清理掉邊界之外的所有內存。這樣就得到了連續的空閑空間。
3.減少垃圾回收
3.1 手動處理:
雖然瀏覽器可以進行垃圾自動回收,但是當代碼比較復雜時,垃圾回收所帶來的代價比較大,所以應該盡量減少垃圾回收。
- 對
數組
進行優化: 在清空一個數組時,最簡單的方法就是給其賦值為[ ],但是與此同時會創建一個新的空對象,可以將數組的長度設置為0,以此來達到清空數組的目的。 - 對
對象
進行優化: 對象盡量復用,對于不再使用的對象,就將其設置為null,盡快被回收。
避免意外的全局變量:
始終使用 const
或 let
聲明變量,開啟嚴格模式('use strict';
)。
function leakyFunction() {// 如果沒有 'let' 或 'const', a 會被創建為全局變量// 它將永遠不會被回收,除非手動設為 nulla = new BigObject();
}
警惕閉包:
閉包
是 JavaScript 的強大特性,但也很容易造成內存泄漏。閉包可以使其父函數中的變量在函數執行結束后仍然存活。
function createClosure() {let largeData = new Array(1e6).fill('*'); // 這個返回的函數持有了對 largeData 的引用return function() {...return largeData.length;};
}let myClosure = createClosure();
// 即使 createClosure 執行完畢,largeData 也不會被回收,因為它被 myClosure 引用。
// 如果不再需要它,應手動解除引用。
myClosure = null;
定時器和事件監聽器:
setInterval
, setTimeout
和 addEventListener
如果不被正確清理,它們的回調函數和其引用的外部變量都不會被回收。
let element = document.getElementById('my-button');
let largeData = new BigObject();function onClick() {// do something with largeData
}element.addEventListener('click', onClick);// 正確做法:
// element.removeEventListener('click', onClick);
// element = null;
在組件銷毀或元素移除時,務必使用 clearInterval
, clearTimeout
和 removeEventListener
清理掉相關的定時器和監聽器。