深度理解瀏覽器垃圾回收及優化策略
網絡上關於瀏覽器內存管理的文章頗多,但總體來說內容基本如下:
-
GC
的兩種算法,引用計數法及標記清除法。- 引用計數法潛在問題——循環引用
-
常見的內存泄漏
- 全局變量
- 定時器未清除
- 閉包
-
DOM
引用 - 等等……
本文將嘗試梳理一些其他問題:
- 在常見的實現中(如
V8
),內存如何分配及管理的?GC
是如何執行的? - 如何減少
GC
觸發次數? -
GC
不定時觸發,那GC
的觸發規律是什麼? - 在
GC
的執行及觸發規律下,有哪些優化GC
的技巧/策略? - 在極端邊界情況下,有哪些優化
GC
的技巧/策略? -
GC
算法在日常開發工作中的應用? - 如何自己實現一個內存管理機制?
- 內存泄漏如何排查?
- 其他注意事項?
GC
的兩種回收算法
引用計數法
虛擬機記錄對象引用次數,當一個對象被 0
引用,會被標記爲“可回收內存垃圾”。
-
問題:
- 循環引用問題
引用計數法無法解決循環引用問題,如a.pro = b; b.pro = a
。此時會造成無法回收。
- 循環引用問題
標記清除法
標記-清除算法包含三個步驟:
- 根:垃圾回收器會構建出一份所有根變量的完整列表。
- 隨後,算法會檢測所有的根變量及他們的後代變量並標記它們爲激活狀態(表示它們不可回收)。任何根變量所到達不了的變量(或者對象等等)都會被標記爲內存垃圾。
- 最後,垃圾回收器會釋放所有非激活狀態的內存片段然後返還給操作系統。
現代瀏覽器基本都採用標記清除法。
V8 中的垃圾回收算法
-
內存分代與弱代假設
- 多數對象的生命週期短。
- 生命週期長的對象,一般是常駐對象。
- 新生代中存儲存活時間較短的對象,
32
位系統下空間大小爲16MB
,64
位系統下爲32MB
。 - 老生代存儲存活時間較長或常駐內存的對象,
32
位系統下空間大小約爲700MB
,64
位系統下約爲1.4GB
。 -
Node
在啓動時,可通過--max-old-space-size
或--max-new-space-size
設置大小。-
node --max-old-space-size=1700 test.js
// 單位爲MB
-
node --max-new-space-size=1024 test.js
// 單位爲KB
-
- 新生代平均分成兩塊相等的空間,叫作
SemiSpace
。每塊爲8MB
(32
位系統)|16MB
(64
位系統)。
-
V8
中的GC
算法-
Scavenge
算法
用於新生代中的對象回收,犧牲空間換取時間。具體實現採用Cheney
算法。-
Cheney
算法
採用複製的方式實現垃圾回收算法。將堆內存一分爲二,每一部分稱爲SemiSpace
。一個使用(又稱From
空間),一個閒置備用(又稱To
空間)。
先在From
空間中分配內存,GC
時,檢查From
空間中的存活對象,將存活對象複製到To
空間中。複製後,From
空間和To
空間角色對換。 -
優點:
- 只複製存活對象,時間效率表現優異。
-
缺點:
- 只能使用堆內存的一半,不能大規模用於所有
GC
算法。只適用於新生代空間GC
算法。
- 只能使用堆內存的一半,不能大規模用於所有
- 對象晉升
在一定條件下,將新生代中存活週期長的對象移動到老生代中。 -
對象晉升觸發條件
- 對象經歷過
Scavenge
回收,若已經歷過一次,則會將該對象從From
空間複製到老生代空間。 -
To
空間內存佔比。ScavengeGC
時,若To
空間已使用超過25%
,則該對象直接晉升到老生代空間。
- 對象經歷過
-
-
標記清除法(
Mark-Sweep
)GC
時,遍歷對重所有對象,標記活着的對象。在清除階段中,清除未被標記的對象。
存在兩個標記位圖:- 已分配標記位圖,針對內存頁中每一個可分配的字,使用
1bit
表示其是否已分配出去,可用於快速掃描活躍內存 - 狀態標記位圖,
V8
中對象大小以2
個字長對齊,狀態標記位圖以2bit
爲一個單元,共可表示4
種狀態
- 已分配標記位圖,針對內存頁中每一個可分配的字,使用
-
`GC` 標記階段採用三色標記法,將顏色信息記錄在狀態標記位圖中:
- 白色,尚未被 `GC` 發現
- 灰色,已被 `GC` 發現,但鄰接對象還未處理完
- 黑色,已被 `GC` 發現,且鄰接對象已處理完
初始狀態,內存頁中所有對象都是白色,標記採用深度優先搜索算法,步驟如下:
- 根可達對象標記爲灰,並 `push` 進棧
- `pop` 出棧一個對象,標記爲黑
- 將對象的鄰接對象標記爲灰,並 `push` 進棧,回到步驟二直至棧爲空
上述步驟遇到大對象可能導致棧溢出,做法是當出現溢出時只標記爲灰但不 `push` 進棧,棧爲空後 `GC` 會再次掃描,將之前的灰色對象 `push` 進棧繼續處理。因此若程序創建過多的大對象,就會觸發多次堆掃描,影響 `GC` 效率。
最終狀態,內存頁中的對象全部被標記,不存在灰色,白色爲可回收,黑色爲不可回收。
- 優點:
- 只清除死亡的對象。老生代中,死亡對象存活比例較小。故執行效率較高。
- 缺點:
- 標記清除回收後,內存空間會觸發不連續狀態(碎片空間)。會對後續內存分配造成影響,若需要分配一個大對象且所有碎片空間不滿足,則會提前觸發 GC,而這次 GC 是不必要的。
-
標記整理(
Mark-Compact
)
將活着的對象往一端移動,移動完成後,直接清理掉邊界外內存。-
優點:
- 沒有碎片空間。
-
缺點:
- 需要移動對象,執行效率慢。
-
在 V8
中,標記清除(Mark-Sweep
)和標記整理(Mark-Compact
)算法是結合使用,主要使用標記清除,在空間不足以對從新生代中晉升過來的對象進行分配時,使用標記整理算法。
三種 GC
算法對比:
回收算法 | 標記清除(Mark-Sweep ) |
標記整理(Mark-Compact ) |
Scavenge |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空間開銷 | 少(有碎片) | 少(無碎片) | 雙倍空間(無碎片) |
是否移動對象 | 否 | 是 | 是 |
- 增量標記(
Incremental Marking
)
在上述三種算法執行時,都需要將暫停應用邏輯(JS
執行),GC
完成後再執行應用邏輯。此時會有一個停頓時間(稱爲全停頓,stop-the-world
),在新生代回收時,因默認配置較小且存活對象較少,故停頓時間較小。老生代中,空間配置大 & 存活對象較多,停頓時間則會很長。故
V8
採用了增量標記方法,將標記拆分爲多個小的“步進”,每“步進”完讓 JS 應用邏輯執行一會兒,GC 與應用邏輯交替執行直到標記完成。經過增量標記的改進,GC 的最大停頓時間減少到原本 1/6 左右。
GC 的觸發規律
如前所述,GC
的觸發規律可總結爲:
-
當程序觸發內存申請時,瀏覽器會檢測是否到達一個臨界值再進行觸發
GC
。- 申請新的較小內存時(新生代空間內)。
- 申請較大內存時,可能觸發老生代空間的標記清除或標記整理。
- 老生代
GC
的時間與老生代中對象數量成正相關。
如何優化 GC
觀察 GC
觸發規律,故優化 GC
的指導思想如下:
- 減少內存佔用
當內存佔用過高時,瀏覽器可能會頻繁的觸發GC
,並且老生代GC
耗時越大。 - 減少
GC
次數
如何減少內存佔用?(以及注意事項)
-
合理設計頁面,按需創建對象/渲染頁面/加載圖片等。
- 避免一次性請求全部數據。
- 避免一次渲染全部數據。
- 避免一次性加載/渲染全部圖片(按需加載/懶加載)。
-
優化
Vue
的data
對象的屬性,若字段較多可pick
需要的字段,避免生成過多Observer
。created() { getList().then(res => { const keys = ['id', 'type']; this.data.infos = res.data.map(v => { return pick(v, keys); //pick爲 lodash.pick 方法 }); }) }
在列表渲染時,可能效果會很明顯。部分後端語言(如
Java
)深層次繼承對象之後,列表內元素的字段可能含有大量用不到的屬性。
列表元素較多時,造成的性能問題不容忽視。
- 儘可能避免創建對象
如非必要,避免使用創建對象的API
,如Array.slice
、Array.map
、Array.fiter
、字符串相加、$('div')
、ArrayBuffer.slice
、canvas.getImageData()
等。 - 不再使用的對象,手動賦值爲
null
。減少虛擬機掃描內存時掃描次數等。 - 使用
WeakMap
和Weekset
。 - 添加的偵聽器需要移除。如在
Vue
中,在mounted
週期內addEventListener
,需要在beforeDestroy
週期內removeListener
。 -
避免頻繁創建對象,儘可能複用對象。
- 複用對象可避免創建對象(申請新的內存),故如能複用對象則儘可能複用對象。如許多發佈訂閱模式中的
event
對象,都會儘可能複用,不會創建多個實例。 - 複用
DOM
等,如重複使用一個彈窗而非創建多個。
如Vue-ElementUI
框架中,PopOver/Tooltip
等組件用於表格內時會創建m * n
個實例,可優化爲只創建一個實例,動態設置位置及數據(或者有多個容器,但只插入一份內容DOM
)。
- 複用對象可避免創建對象(申請新的內存),故如能複用對象則儘可能複用對象。如許多發佈訂閱模式中的
-
使用對象池
對象池(英語:
object pool pattern
)是一種設計模式。一個對象池包含一組已經初始化過且可以使用的對象,而可以在有需求時創建和銷燬對象。池的用戶可以從池子中取得對象,對其進行操作處理,並在不需要時歸還給池子而非直接銷燬它。這是一種特殊的工廠對象。若初始化、實例化的代價高,且有需求需要經常實例化,但每次實例化的數量較少的情況下,使用對象池可以獲得顯著的效能提升。從池子中取得對象的時間是可預測的,但新建一個實例所需的時間是不確定。
以上摘自維基百科。
使用對象池技術能顯著優化需頻繁創建對象的內存消耗。但建議按不同的場景做細微優化。
- 按需創建
默認創建空對象池,按需創建對象,用完歸還池子。 - 預創建對象
避免在高頻操作下頻繁創建對象,如滾動事件、TouchMove
事件、resize
事件、for
循環內部等情況。如有需要,可提前預創建多個對象放入池子。
高頻情況下,建議使用截流/防抖、時間切片等相關技術優化。 - 定時釋放/清理
對象池內的對象不會被垃圾回收,若極端情況下創建了大量對象回收進池子卻不釋放只會適得其反。
故池子需設計定時/定量釋放對象機制,如以已用容量/最大容量/池子使用時間等參數來定時釋放對象。
- 按需創建
-
ImageData
對象是JS
內存殺手,避免重複創建ImageData
對象。 - 生產環境勿用
console.log
大對象,包括DOM
、大數組、ImageData
、ArrayBuffer
等。因爲console.log
的對象不會被垃圾回收。詳見Will console.log prevent garbage collection?。 - 重複使用
ArrayBuffer
,而非創建新的。 -
合理使用圖片,壓縮圖片、按需加載圖片、按需渲染圖片,使用恰當的圖片尺寸、圖片格式,如 WebP 格式等。
這其中涉及到圖片渲染流程,網上資料較少。假設渲染一張100KB
大小,300 * 500
的帶透明像素的圖片,粗略的可分爲三個過程(注意,這裏並不精確,實際圖片渲染會按流式邊加載邊渲染,此處爲簡略總結):- 加載圖片
從緩存中或者從遠程服務器加載圖片的二進制格式到內存(並設置緩存)。此時消耗了 100KB
的內存 和100KB
的緩存。 - 解碼圖片
將二進制格式的圖片解碼爲像素格式,此時佔用寬 * 高 * 24
(RGB
值爲24
位,若帶透明通道,則爲ARBG
,佔用32
位空間)比特大小的內存, 此處爲300 * 500 * 32
。約等於585 KB
。這裏約定名爲像素格式內存。個人猜測此時瀏覽器會回收加載圖片時創建的100KB
二進制內存,但瀏覽器會緩存像素格式內存,約585KB
。 -
渲染圖片
通過CPU
或者GPU
渲染圖片,若爲GPU
渲染,則還需上傳到GPU
顯存。該過程較爲耗時,由像素格式內存尺寸 / 顯存位寬決定。圖片像素內存尺寸越大,則上傳時間越慢,佔用顯存越多。其中,較舊的瀏覽器如
Firefox
回收像素內存時機較晚,若渲染了大量圖片時會內存佔用過高。
- 加載圖片
PS:瀏覽器會複用同一份圖片二進制內存及像素格式內存,瀏覽器渲染圖片會按以下順序去獲取數據:
顯存 >> 像素格式內存 >> 二進制內存 >> 緩存 >> 從服務器獲取。我們需控制和優化的是二進制內存及像素內存的大小及回收。
總結一下,瀏覽器渲染圖片時所消耗內存由圖片文件大小內存、寬高、透明度等所決定,故建議:
- 使用
CSS3
、SVG
、IconFont
、Canvas
替代圖片。展示大量圖片的頁面,建議使用Canvas
渲染而非直接使用img
標籤。具體詳見 Javascript 的 Image 對象、圖像渲染與瀏覽器內存兩三事。 - 適當壓縮圖片,可減小帶寬消耗及圖片內存/緩存佔用。
- 使用恰當的圖片尺寸,即響應式圖片,爲不同終端輸出不同尺寸圖片,勿使用原圖縮小代替
ICON
等。 - 使用恰當的圖片格式,如使用 WebP 格式等。詳細圖片格式對比,使用場景等建議查看 web 前端圖片極限優化策略。
- 按需加載及按需渲染圖片。
- 預加載圖片時(使用動態創建
img
設置src
方式),切記要將img
對象賦爲null
,否則會導致圖片內存無法釋放。當實際渲染圖片時,瀏覽器會從緩存中再次讀取。 - 將離屏
img
對象賦爲null
,src
賦爲null
,督促瀏覽器及時回收內存及像素格式內存。 - 將非可視區域圖片移除,需要時再次渲染。和按需渲染結合時實現很簡單,切換
src
與v-src
即可。
-
window.URL.createObjectURL
創建的 DOMString 對象,切記使用window.URL.revokeObjectURL
回收。createObjectURL
是創建一個內存空間的引用,並且可用於賦值給img
的src
等,需要通過手動調用revokeObjectURL
觸發回收。
複用創建的URL
,而非多次調用createObjectURL
。
極端邊界情況下如何優化 GC
- 如前所述,一些極端情況下,提前預創建好相應內存,避免在高頻計算裏大量申請內存。
- 若是超大量圖片展示的站點,請使用
canvas
優化或按需加載/懶加載,移除屏外圖片等策略。請參考:Javascript 的 Image 對象、圖像渲染與瀏覽器內存兩三事
常見內存泄漏
- 全局變量
- 定時器遺漏
- 閉包的返回對象未回收(會導致閉包作用域內都不能回收)
-
dom
引用(有變量引用了dom
,即便從dom
樹中移除,內存中仍有變量引用。)
上述幾點請參考內存管理及如何處理 4 類常見的內存泄漏問題 - 不恰當全局緩存
- 不合適的監聽器機制
上述兩點請參考 輕鬆排查線上 Node 內存泄漏問題
GC 算法的日常應用及實現一個內存管理機制
-
引用計數法
此處經驗爲此前開發Flash
遊戲時積累的圖片緩存(BitmapData
)方案,在前端工作中暫時未有使用,但圖片/二進制管理可以套用本方案。
在Flash
中,BitmapData
爲圖片的像素數據(類似於JS
中的ImageData
)。同一份素材的圖片,可以複用同一份BitmapData
。
故爲了管理遊戲中的圖片資源,則是管理遊戲中所有的BitmapData
,在需要時緩存,用完時dispose
(銷燬)。
具體流程如下:- 圖片通過
url
引用BitmapData
時,檢測是否有緩存,若沒有,步驟二,若有,走步驟三。 - 使用加載隊列按優先級加載資源並按
url
緩存,走到步驟三。 - 按
url
將引用次數+1
,並更新資源使用時間。 - 圖片清理(
dispose
)時,按url
將引用次數-1
,並更新資源使用時間。 - 定時檢測引用次數爲
0
的情況,並依據一定策略延遲/定期清理資源。 - 定時上報資源管理器中,緩存的資源及引用計數,以方便排查資源引用情況/遺漏清理情況等。
- 進入戰鬥場景或對性能吃緊的場景時,清理引用計數爲
0
的資源。
- 圖片通過
此套方案的缺點在於:如果有圖片對象未調用 dispose
,則會內存泄露,但可通過上報排查。
優點則是可精確控制所有圖片的資源引用情況。
- LUR 算法
請參考JS 實現緩存算法(FIFO/LRU)
內存溢出如何排查
Chrome
瀏覽器排查請參考: chrome 內存泄露(一)、內存泄漏分析工具NodeJS
排查請參考:
[[譯]Node.js 垃圾回收與內存泄露的排查](https://www.ctolib.com/topics...
node 內存泄漏以及定位
輕鬆排查線上 Node 內存泄漏問題
參考鏈接
garbage-collector-friendly-code/