垃圾回收、內存優化#
我們知道 JS 通過堆和棧來存儲數據(分配內存)
- 原始數據類型、代碼執行時棧幀的位置存儲在棧空間
- 引用類型數據存儲在堆空間
但某些數據使用後不再需要,就變成了垃圾數據,如果不回收,內存佔用就會越來越多
一般垃圾回收分為手動回收和自動回收兩種策略
像 C/C++ 是使用手動回收策略,需要代碼來決定如何分配和銷毀內存,而 Javascript/Java/Python 等語言都由垃圾回收期來自動釋放內存,但並不代表我們不需要關心內存管理(特別是 JS,很多開發很容易被自動垃圾回收迷惑,從而忽略內存管理)
一、棧內存回收#
先看調用棧中的數據是如何回收的,從執行上下文開始
當一個函數執行上下文被創建並入棧,元素類型數據分配到棧,引用類型數據分配到堆。
與此同時,有一個記錄當前執行狀態的指針(ESP),執行當前的執行上下文 A
當函數 A 調用完畢出棧時,ESP 會下移到下一個執行上下文 B,這個下移操作就是銷毀上一個執行上下文 A 的過程,雖然 A 的執行上下文還保存在棧內存中,但已經是無效內存了,下次再調用其他函數時,會直接覆蓋這塊內存,用來存放其他函數的執行上下文
所以,當一個函數執行結束之後,JS 引擎會通過向下移動 ESP 來銷毀該函數的執行上下文
二、堆內存回收#
函數在棧中的執行上下文被銷毀後,那還有一些保存在堆中的對象怎麼處理呢,那就需要用到垃圾回收器了
2.1 分代收集與主要流程#
V8 中,會把堆分為新生代和老生代兩個區域
- 新生代:存放生存時間短的對象 -> 副垃圾回收器 Minor GC
- 老生代:存放生存時間久的對象 -> 主垃圾回收器 Major GC
垃圾回收器有一套統一執行流程
- 標記對象
- 活動對象:還在使用的對象
- 非活動對象:可以進行垃圾回收
- 回收非活動對象佔據的內存
- 內存整理:整理內存碎片,留出連續空間,方便後續分配較大連續內存
2.2 副垃圾回收器#
新加入的對象都會加入到對象區,對象區塊寫滿時,需要執行一次垃圾回收
對象區域中的垃圾做標記 -> 存活的對象複製到空閒區域中 -> 有序排列 -> 空閒區域沒有垃圾碎片 -> 對象區和空閒區的概念發生翻轉 -> 無限重複利用
副垃圾回收器一旦監控對象裝滿了,便執行垃圾回收。同時,副垃圾回收器還會採用對象晉升策略,也就是移動那些經過兩次垃圾回收依然還存活的對象到老生代中。
2.3 主垃圾回收器#
老生代堆裡的對象特點
- 一個是對象佔用空間大
- 另一個是對象存活時間長
主垃圾回收器採用的算法有兩種
- 標記 - 清除(Mark-Sweep)
- 標記 - 整理(Mark-Compact)
JS 裡還有一種最初級的垃圾回收算法:引用計數,如果沒有引用指向該對象,則被回收,但該算法的缺陷是無法處理循環引用的問題
先對垃圾數據進行標記。標記階段就是從一組根元素開始,遞歸遍歷這組根元素,在這個遍歷過程中,能到達的元素稱為活動對象,沒有到達的元素就可以判斷為垃圾數據。
然後清除,這就是標記 - 清除算法
不過對一塊內存多次執行標記 - 清除算法後,會產生大量不連續的內存碎片。而碎片過多會導致大對象無法分配到足夠的連續內存,於是又引入了另外一種算法 —— 標記 - 整理(Mark-Compact)。
先標記可回收對象,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉這一端之外的內存
2.4 垃圾回收小結#
三、垃圾回收的效率優化#
JavaScript 是運行在主線程之上的,因此,一旦執行垃圾回收算法,都需要將正在執行的 JavaScript 腳本暫停下來,待垃圾回收完畢後再恢復腳本執行。這種行為叫做全停頓(Stop-The-World)。
第一,將一個完整的垃圾回收的任務拆分成多個小的任務,這樣就消滅了單個長的垃圾回收任務;
第二,將標記對象、移動對象等任務轉移到後台線程進行,這會大大減少主線程暫停的時間,改善頁面卡頓的問題,讓動畫、滾動和用戶交互更加流暢。
3.1 優化方案#
- 第一個方案是並行回收,在執行一個完整的垃圾回收過程中,垃圾回收器會使用多個輔助線程來並行執行垃圾回收。
- 第二個方案是增量式垃圾回收,垃圾回收器將標記工作分解為更小的塊,並且穿插在主線程不同的任務之間執行。採用增量垃圾回收時,垃圾回收器沒有必要一次執行完整的垃圾回收過程,每次執行的只是整個垃圾回收過程中的一小部分工作。
- 第三個方案是並發回收,輔助線程在執行垃圾回收的時候,主線程也可以自由執行而不會被掛起,難點在於讀寫鎖機制(這裡不深入了)
主垃圾回收器就綜合採用了所有的方案,副垃圾回收器也採用了部分方案。
四、內存優化#
常見三類內存問題
- 內存泄漏 (Memory leak),它會導致頁面的性能越來越差;
- 在 JavaScript 中,造成內存泄漏 (Memory leak) 的主要原因是不再需要 (沒有作用) 的內存數據依然被其他對象引用著。
- 內存膨脹 (Memory bloat),它會導致頁面的性能會一直很差;
- 頻繁垃圾回收,它會導致頁面出現延遲或者經常暫停。
4.1 內存泄漏常見原因和解決方案#
- 全局變量:儘量避免設置比較大的全局對象
- 監聽器、定時器引用沒有及時清除
- 閉包常駐內存:比較容易形成對象的循環引用,把變量設置 null 切斷聯繫
- DOM 引用:保留了 DOM 節點的引用,導致 GC 沒有回收
除了以上,多用 ES6 的WeakMap/WeakSet
,它們都保持對象的弱引用
4.2 內存泄漏識別方法#
- 在瀏覽器中可通過查看 memory 來觀察內存是否有上升的趨勢
- 在 Node 環境可通過 headdump 抓取內存信息進行分析