1、內存管理
1.1 為什么關注內存管理
像C語言這樣的底層語言一般都有底層的內存管理接口,比如 malloc()和free()。相反,JavaScript是在創建變量(對象,字符串等)時自動進行了分配內存,
并且在不使用它們時“自動”釋放。 釋放的過程稱為垃圾回收。這個“自動”是混亂的根源,并讓JavaScript開發者錯誤的感覺他們可以不關心內存管理。
因為JS有比較完善的垃圾回收機制,同時之前的web頁面大多是比較簡單的多頁面應用,頁面停留時間短,頁面卡頓了,刷新或者重啟一下就可以了,前端開發者不用特別的關注內存管理,
但隨著移動互聯網和前端SPA應用的流行,頁面停留的時間變長,移動APP里內置web頁面以及PWA對體驗提出了更高的要求,JS內存可能成為新的內存瓶頸。
內存問題表現
- 頁面出現延遲加載或經常性暫停
- 頁面持續性出現糟糕的性能
- 頁面的性能隨時間延長越來越差
思考:JS內存問題為什么會導致頁面卡頓?下面章節中有說明
1.2 內存聲明周期
不管什么編程語言,內存生命周期基本是一致的:
- 分配你所需要的內存
- 使用分配到的內存(讀、寫)
- 不需要時將其釋放/歸還
編程語言 | 分配 | 使用 | 釋放 |
---|---|---|---|
C之類底層語言 | 手動 malloc() | 讀寫內存 | 手動 free() |
JS等高級語言 | 聲明變量,運行時系統自動分配內存 | 讀寫內存 | 垃圾回收機制自動回收不再使用的內存 |
所有語言內存使用部分都是明確的。分配和釋放部分在底層語言中是明確的,一般都有底層的內存管理接口,比如 malloc()和free()用于手動分配內存和釋放內存。
但在像JavaScript這些高級語言中,大部分都是隱含的,會在創建變量時分配內存,并且在不再使用它們時“自動”釋放內存,這個自動釋放內存的過程稱為垃圾回收,絕大部分內存管理問題都是處于這個階段。
2、垃圾回收及常見GC算法
2.1 JS運行機制
在講垃圾回收之前先看下JS運行機制如下圖
重點關注JS引擎,有兩個重要的組成部分:
- 調用棧:這是JS代碼執行時的地方。當引擎遇到像函數調用之類的可執行單元,就會把它們推入調用棧。
- 內存堆:這是內存分配發生的地方。當JS引擎遇到復雜變量聲明和函數聲明的時候,就把它們存儲在堆里面。
下面分別講下調用棧和堆中的垃圾回收
2.2 調用棧垃圾回收
函數是一段連續的內存空間,主要用于存放函數調用信息和變量等數據。有一個記錄當前執行狀態的棧指針ESP(Extended Stack Pointer)指向調用棧的棧頂。
棧內存中變量一般在它的當前執行環境結束就會被銷毀。
以下面代碼為例,innerFn執行結束之后,ESP下移到outerFn執行上下文,innerFn1執行上下文被銷毀,往下執行到innerFn2時,innerFn2執行上下文入棧,ESP上移。
function outerFn() {
const a = { name: 'a' };
function innerFn1() {
const b = { name: 'b' };
}
function innerFn2() {
const c = { name: 'c' };
}
innerFn1();
innerFn2();
}
outerFn();
2.3 堆中的垃圾回收
與棧中的垃圾回收不同,堆中的垃圾回收需要使用 JavaScript 中的垃圾回收器。
垃圾回收器查找垃圾和回收空間所遵循的規則為GC算法,在講常見GC算法之前先了解下相關的概念。
堆內存中的垃圾
- 不再被引用的內存
- 從GC root上不可達
GC root包括但不限于以下幾種(瀏覽器中)
- 全局對象window
- 存放在棧上的變量
- 文檔DOM樹,由可以通過遍歷文檔到達所有原生 DOM 節點組成
2.3.1 常見GC算法
- 引用計數算法
- 標記清除算法
- 標記整理算法
引用計數算法
核心思想:判斷對象有沒有其他對象引用到它,引用為0時立即回收。
優點:發現垃圾時立即回收,最大限度減少程序暫停。
存在的問題:無法回收循環引用的對象,常常造成對象被循環引用時內存發生泄漏。
比如下面例子中,兩個對象互相引用,就造成了循環引用,f調用之后它們已經沒有用了,可以被回收了,但是如果采用引用計數算法他們的引用都不為0,所以它們不會被回收。
function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 引用 o2
o2.p = o1; // o2 引用 o1. 這里會形成一個循環引用
}
f();
使用現狀:IE 6, 7 使用,現代瀏覽器中已不再使用
下面例子中,div這個DOM元素里的circularReference屬性引用了div,造成了循環引用。 IE 6, 7中使用引用計數方式進行垃圾回收,會造成內存發生泄漏。現代瀏覽器通過使用標記清除算法,來解決這一問題。
var div;
window.onload = function(){
div = document.getElementById("myDivElement");
div.circularReference = div;
div.lotsOfData = new Array(10000).join("*");
};
標記清除算法
核心思想:分為標記和清除兩個階段,如下圖所示
- 第一步標記,從GC root開始遍歷可達的對象標記為活動對象,到達不了的元素可以判斷為非活動對象,也就是垃圾數據
-
第二步清除,清除未標記的非活動對象
mm06.gif
優點:可以回收循環引用的對象
存在問題:內存碎片化
從上圖中可以看出,如果對一塊內存進行多次的標記清除算法,就會產生大量的內存碎片,這樣會導致如果有一個對象需要一塊大的連續的內存出現內存不足的情況。
為了解決這個問題,于是又引入了另一種算法:標記整理算法
標記整理算法
核心思想:可以看作是算法的增強,標記階段操作和標記清除算法一致,清除階段會先執行整理,先將所有存活的對象向一端移動,然后直接清理掉這一端以外的內存。
如下圖所示
優點:解決了碎片化問題
存在的問題:需移動對象所以比較慢。
垃圾回收在主線程執行,而JS引擎采用單主線程運行機制,因此,一旦執行垃圾回收算法,需要將正在執行的 JavaScript 腳本暫停下來,待垃圾回收完畢之后再恢復腳本執行,
如果垃圾回收執行時間比較長,如果此時瀏覽器正在執行頻繁渲染和交互的操作(如動畫),勢必會造成頁面的卡頓,下面會講V8中如何優化該問題。
3、V8垃圾回收機制
V8內存設限:64位操作系統默認使用1.4G,32位操作系統默認使用0.7G
- 表層原因是V8最初為瀏覽器設計,不太可能會遇到使用大量內存的場景
- 深層原因是V8垃圾回收機制的限制(根據官方說法,對于1.5G的垃圾回收堆內存,V8做一次增量垃圾回收需要50ms,做一次非增量式的垃圾回收在1s以上)
V8采用分代回收,把堆內存空間分為新生代和老生代,如下圖所示,新老生生代因為使用場景不同采用不同的垃圾回收算法,下面分別展開敘述。
3.1 新生代存儲區
新生代存儲區用來存放存活時間較短且較小的對象
采用Scavenge算法,如下圖
- 內存分為兩個等大的空間(from對象空間和To空閑空間)
- 活動對象存儲于From空間
- 當From空間快被寫滿時,就需要執行一次垃圾清理操作
- 先標記,然后將活動對象拷貝至To中連續空間
- From與To交換空間完成釋放
新生代空間大小:64位系統:32MB;32位系統:16MB。
每次執行清理操作時,都需要將存活的對象從From對象區域復制到To空閑區域,復制操作需要時間成本,如果新生區空間設置得太大了,那么每次清理的時間就會過久,所以為了執行效率,一般新生區的空間會被設置得比較小。
對象晉升
- 移動那些經過兩次垃圾回收依然還存活的對象到老生代中。
- 當對象從from復制到to空間時,如果to空間占用已超過25%,則將直接晉升這個對象。
垃圾回收算法對比
特性 | 標記清除 | 標記整理 | Scavenge |
---|---|---|---|
速度 | 中 | 最慢 | 最快(無需移動對象,非活動對象無需分別釋放) |
空間開銷 | 少(有碎片) | 少(無碎片) | 雙倍空間(無碎片) |
是否需移動對象 | 否 | 是 | 是 |
Scavenge只使用內存的一半,是一種典型的空間換時間的算法。對于新生代,對象的周期都比較短,因此非常適合Scavenge
而對于老生代,存活對象占較大比重,繼續采用Scavenge不但浪費空間,而且復制的效率也很低。
3.2 老生代存儲區
老生代存儲區的對象一般有兩個特點
- 對象占用空間大(一些大的對象會直接被分配到老生代里)
- 對象存活時間長
老生代使用標記整理和標記清除相結合代算法,標記整理算法由于需要移動對象,執行速度不會太快,所以在取舍上,V8主要使用標記清除算法,當碎片化較多內存不足以分配時才會采用標記整理算法。
存在的問題
上面有講到垃圾回收在主線程執行,會暫停主線程上的其他任務,稱為全停頓,垃圾回收時間不宜太長10ms以內,因為16ms就會出現丟幀,頻繁和長時間的GC會造成頁面卡頓,用戶體驗不佳。
打個比方,200ms,那么在這200ms內,主線程是沒有辦法進行其他工作的,動畫也就無法執行,這樣就會造成頁面卡頓的現象出現。
提高垃圾回收效率方法
為了解決全停頓造成的用戶體驗問題,V8 團隊向現有的垃圾回收器添加并行、并發和增量等垃圾回收技術,并且也已經取得了一些成效,下面分別介紹。
增量回收
所謂增量是指將一個較長的任務拆分成多個小任務,增量回收將之前一次性標記工作拆分為更小的塊增量標記,穿插在主線程不同的任務之間執行。如下圖所示
并行回收
并行回收開啟多個協助線程,將標記、移動對象等任務轉移到到后臺輔助線程進行,標記完成后再執行并行清理操作。主線程在執行清理操作時,多個輔助線程也在執行清理操作。
另外,主垃圾回收器還采用了增量標記的方式,清理的任務會穿插在各種 JavaScript 任務之間執行。大大減少了主線程暫停時間,如下圖所示
上面用到的三幅圖引自References中的[8],如有侵權可聯系刪除
4、常見內存泄露
4.1 GC不能解決的問題
- GC自動回收不再使用的內存是一個近似的過程,要知道是否仍然需要某塊內存是無法準確判定的。
- GC對于我們像一個黑盒,我們不知道GC 什么時候會進行, 這意味著如果我們在使用過程中使用了大量的內存, 而 GC 沒有運行的情況下, 或者 GC 無法回收這些內存的情況下, 程序就有可能假死, 這個就需要我們在程序中手動做一些操作來觸發內存回收.
內存泄露,由于錯誤的編碼,不再被需要的內存未能使得GC正確的將這些內存回收的情況
4.2 內存泄露之沒有完全切斷與GC root之間的路徑
下面幾種常見的內存泄露主要是因為沒有完全切斷與GC root之間的路徑,即從GC root可達造成的
意外的全局變量
全局變量生命周期最長,直到頁面關閉才能被回收,全局變量使用不當,沒有及時回收(手動賦值null),就發生了內存泄露。
//寫法1:函數內變量未聲明
function foo(arg) {
bar = "some ";
}
//寫法2:this使用不當
function foo() {
this.var1 = "potential accidental global";
}
//可通過開啟嚴格模式避免
遺忘的定時器
setTimeout 和 setInterval 是由瀏覽器專門線程來維護它的生命周期,所以當在某個頁面使用了定時器,當該頁面銷毀時,沒有手動去釋放清理這些定時器的話,那么這些定時器還是存活著的
定時器的生命周期并不掛靠在頁面上,即使頁面銷毀了,由于定時器回調持有該頁面部分引用而造成頁面無法正常被回收,從而導致內存泄漏了。
游離的DOM引用
DOM 元素的生命周期正常是取決于是否掛載在 DOM 樹上,當從 DOM 樹上移除時,也就可以被銷毀回收了。
如果某個 DOM 元素,在 js 中也持有它的引用時,那么它的生命周期就由 js 和是否在 DOM 樹上兩者決定了,記得移除時,兩個地方都需要去清理才能正常回收它。
如下示例中,雖然tree節點已經從 DOM 樹上移除且變量引用 treeRef 置為null,但其下各個結節仍不能被移除,這是因為leafRef還持有引用,通過節點之間但引用關系依然是可達的。
var select = document.querySelector;
var treeRef = select("#tree");
var leafRef = select("#leaf");
var body = select("body");
body.removeChild(treeRef);
treeRef = null;
使用不當的閉包
函數本身會持有它定義時所在的詞法環境的引用,但通常情況下,使用完函數后,該函數所申請的內存都會被回收了,
當函數內再返回一個函數時,由于返回的函數持有外部函數的詞法環境,而返回的函數又被其他生命周期東西所持有,導致外部函數雖然執行完了,但內存卻無法被回收,
返回的函數,它的生命周期應盡量不宜過長,方便該閉包能夠及時被回收。
正常來說,閉包并不是內存泄漏,因為這種持有外部函數詞法環境本就是閉包的特性,就是為了讓這塊內存不被回收,因為可能在未來還需要用到,但這無疑會造成內存的消耗,
所以,不宜爛用就是了
4.3 內存泄露之過度占用了內存空間
無節制的循環
while(true) {
// do sth
}
過大的數組
var arr = [];
for (var i=0; i< 100000000000; i++) {
var a = { 'desc': 'an object’ }
arr.push(a);
}
5、內存分析工具
上面我們介紹了內存泄露,如果發生了內存泄露,如何發現呢?下面簡單介紹瀏覽器端Chrome瀏覽器常用的內存分析工具。
- Chrome Task Manager工具
- Chrome DevTools Performance面板
- Chrome DevTools Memory面板
5.1 Chrome Task Manager工具
- 入口:Chrome 瀏覽器右上角三個點 -> 更多工具 -> 任務管理器
- 右鍵表頭 -> 勾選內存占用空間和JS使用的內存
如上圖,重點關注圖中紅框中兩列
- 內存占用空間:表示原生內存。DOM節點存儲在原生內存中
- JS使用的內存:表示JS堆。此列包含兩個值,需要關注的是實時值(括號中的數值)。如果該數值在增大,要么是正在創建新對象,要么是現有對象正在增長
適用場景:粗略查看內存情況
5.2 Chrome DevTools Performance面板
- 入口: DevTools的Performance面板,然后勾選Memory
如上圖所示,關注紅框中圈出部分,看對應圖表如果持續增長,則可能有內存泄露,可以關注增長時的快照,大概定位內存泄露的時機。
適用場景:各個時間節點快照
5.3 Chrome DevTools Memory面板
- 入口: DevTools的Memory面板,然后選擇Heap snapshot
適用場景:可以定位到具體的變量和函數
5.4 內存泄露示例代碼
下面是一個內存泄露比較嚴重的測試代碼,示例中Test函數中有個cache內部變量,被 setInterval 定時器引用,每隔1毫秒生成一個新的對象放入cache中,
并且新生成的對象中的 junk 變量又淺拷貝了cache變量,如果不清除定時器,只是把Test類的實例手動設為null,也無濟于事,cache還會繼續占用內存。
把下面代碼粘貼到一個空白html文件中,點擊【開始測試】按鈕,內存采用以上三種工具查看增長都很明顯,如果不及時清除定時器,頁面很快會崩潰卡死。
感興趣的可以自己試下,這里不再詳細講述。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test</title>
</head>
<body>
<button id="startBtn">開始測試</button>
<br>
<br>
<button id="clearBtn">清理定時器</button>
</body>
<script>
function Test() {
this.obj= {};
this.index = 1;
this.timer = null;
var cache = []; // 內部變量,內存隱患...
this.timer = window.setInterval(() =>{
this.index += 1;
this.obj = {
val: '_timerxxxxxbbbbxx_' + this.index,
junk: [...cache]
};
cache.push(this.obj);
}, 1);
console.warn("create Test instance..");
}
Test.prototype.destroy = function(){
clearInterval(this.timer);
}
function d() {
// 取消定時器并銷毀Test 實例
test.destroy();
test = null;
console.warn("destroyed test instance..");
}
let test;
let startBtn = document.querySelector('#startBtn')
let clearBtn = document.querySelector('#clearBtn')
startBtn.addEventListener("click", () => {
console.log('開啟定時器');
test = new Test();
})
clearBtn.addEventListener("click", () => {
console.log('清除定時器');
d();
})
</script>
</html>
總結
本文簡單講述了一下JS內存管理的必要性以及常見垃圾回收算法,并以V8引擎為例簡單闡述了各種回收算法的應用,以及V8作出的改進。
然后介紹了常見的內存泄露以及Chrome提供的內存泄露檢測工具。
文中如有偏差或者遺漏,歡迎大佬指正。
References
[1] MDN內存管理定義
[2] 單頁面應用下的JS內存管理
[3] [譯] JavaScript 工作原理:內存管理 + 處理常見的4種內存泄漏
[4] V8 垃圾回收原來這么簡單?
[5] Javascript執行機制(八)垃圾回收機制
[6] js的內存泄漏場景、監控以及分析
[7] Chrome 開發者工具中文文檔>>修復內存問題
[8] JavaScript 內存詳解 & 分析指南