深度理解瀏覽器垃圾回收及相關注意事項及優化策略

深度理解瀏覽器垃圾回收及優化策略

網絡上關於瀏覽器內存管理的文章頗多,但總體來說內容基本如下:

  • GC 的兩種算法,引用計數法及標記清除法。

    • 引用計數法潛在問題——循環引用
  • 常見的內存泄漏

    • 全局變量
    • 定時器未清除
    • 閉包
    • DOM 引用
    • 等等……

本文將嘗試梳理一些其他問題:

  • 在常見的實現中(如 V8 ),內存如何分配及管理的? GC 是如何執行的?
  • 如何減少 GC 觸發次數?
  • GC 不定時觸發,那 GC 的觸發規律是什麼?
  • GC 的執行及觸發規律下,有哪些優化 GC 的技巧/策略?
  • 在極端邊界情況下,有哪些優化 GC 的技巧/策略?
  • GC 算法在日常開發工作中的應用?
  • 如何自己實現一個內存管理機制?
  • 內存泄漏如何排查?
  • 其他注意事項?

GC 的兩種回收算法

引用計數法

虛擬機記錄對象引用次數,當一個對象被 0 引用,會被標記爲“可回收內存垃圾”。

  • 問題:

    • 循環引用問題
      引用計數法無法解決循環引用問題,如 a.pro = b; b.pro = a。此時會造成無法回收。
標記清除法

標記-清除算法包含三個步驟:

  • 根:垃圾回收器會構建出一份所有根變量的完整列表。
  • 隨後,算法會檢測所有的根變量及他們的後代變量並標記它們爲激活狀態(表示它們不可回收)。任何根變量所到達不了的變量(或者對象等等)都會被標記爲內存垃圾。
  • 最後,垃圾回收器會釋放所有非激活狀態的內存片段然後返還給操作系統。

現代瀏覽器基本都採用標記清除法。

V8 中的垃圾回收算法

  • 內存分代與弱代假設

    • 多數對象的生命週期短。
    • 生命週期長的對象,一般是常駐對象。
    • 新生代中存儲存活時間較短的對象,32 位系統下空間大小爲 16MB64 位系統下爲 32MB
    • 老生代存儲存活時間較長或常駐內存的對象,32 位系統下空間大小約爲 700MB64 位系統下約爲 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 。每塊爲 8MB32 位系統)| 16MB64 位系統)。
  • 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 次數

如何減少內存佔用?(以及注意事項)

  • 合理設計頁面,按需創建對象/渲染頁面/加載圖片等。

    • 避免一次性請求全部數據。
    • 避免一次渲染全部數據。
    • 避免一次性加載/渲染全部圖片(按需加載/懶加載)。
    • 優化 Vuedata 對象的屬性,若字段較多可 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.sliceArray.mapArray.fiter 、字符串相加、$('div')ArrayBuffer.slicecanvas.getImageData()等。
  • 不再使用的對象,手動賦值爲 null 。減少虛擬機掃描內存時掃描次數等。
  • 使用 WeakMapWeekset
  • 添加的偵聽器需要移除。如在 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 、大數組、ImageDataArrayBuffer 等。因爲 console.log 的對象不會被垃圾回收。詳見Will console.log prevent garbage collection?
  • 重複使用 ArrayBuffer,而非創建新的。
  • 合理使用圖片,壓縮圖片、按需加載圖片、按需渲染圖片,使用恰當的圖片尺寸、圖片格式,如 WebP 格式等。
    這其中涉及到圖片渲染流程,網上資料較少。假設渲染一張 100KB 大小,300 * 500 的帶透明像素的圖片,粗略的可分爲三個過程(注意,這裏並不精確,實際圖片渲染會按流式邊加載邊渲染,此處爲簡略總結):

    • 加載圖片
      從緩存中或者從遠程服務器加載圖片的二進制格式到內存(並設置緩存)。此時消耗了 100KB 的內存 和 100KB 的緩存。
    • 解碼圖片
      將二進制格式的圖片解碼爲像素格式,此時佔用寬 * 高 * 24RGB 值爲 24 位,若帶透明通道,則爲 ARBG,佔用 32 位空間)比特大小的內存, 此處爲 300 * 500 * 32。約等於 585 KB。這裏約定名爲像素格式內存。個人猜測此時瀏覽器會回收加載圖片時創建的 100KB 二進制內存,但瀏覽器會緩存像素格式內存,約 585KB
    • 渲染圖片
      通過 CPU 或者 GPU 渲染圖片,若爲 GPU 渲染,則還需上傳到 GPU 顯存。該過程較爲耗時,由像素格式內存尺寸 / 顯存位寬決定。圖片像素內存尺寸越大,則上傳時間越慢,佔用顯存越多。

      其中,較舊的瀏覽器如 Firefox 回收像素內存時機較晚,若渲染了大量圖片時會內存佔用過高。

PS:瀏覽器會複用同一份圖片二進制內存及像素格式內存,瀏覽器渲染圖片會按以下順序去獲取數據:

顯存 >> 像素格式內存 >> 二進制內存 >> 緩存 >> 從服務器獲取。我們需控制和優化的是二進制內存及像素內存的大小及回收。

總結一下,瀏覽器渲染圖片時所消耗內存由圖片文件大小內存、寬高、透明度等所決定,故建議:

    • 使用 CSS3SVGIconFontCanvas 替代圖片。展示大量圖片的頁面,建議使用 Canvas 渲染而非直接使用 img 標籤。具體詳見 Javascript 的 Image 對象、圖像渲染與瀏覽器內存兩三事
    • 適當壓縮圖片,可減小帶寬消耗及圖片內存/緩存佔用。
    • 使用恰當的圖片尺寸,即響應式圖片,爲不同終端輸出不同尺寸圖片,勿使用原圖縮小代替 ICON 等。
    • 使用恰當的圖片格式,如使用 WebP 格式等。詳細圖片格式對比,使用場景等建議查看 web 前端圖片極限優化策略
    • 按需加載及按需渲染圖片。
    • 預加載圖片時(使用動態創建 img 設置 src 方式),切記要將 img 對象賦爲 null ,否則會導致圖片內存無法釋放。當實際渲染圖片時,瀏覽器會從緩存中再次讀取。
    • 將離屏 img 對象賦爲 nullsrc 賦爲 null ,督促瀏覽器及時回收內存及像素格式內存。
    • 將非可視區域圖片移除,需要時再次渲染。和按需渲染結合時實現很簡單,切換 srcv-src 即可。
    • window.URL.createObjectURL 創建的 DOMString 對象,切記使用 window.URL.revokeObjectURL 回收。
      createObjectURL是創建一個內存空間的引用,並且可用於賦值給 imgsrc 等,需要通過手動調用 revokeObjectURL 觸發回收。
      複用創建的 URL ,而非多次調用 createObjectURL

    極端邊界情況下如何優化 GC

    常見內存泄漏

    GC 算法的日常應用及實現一個內存管理機制

    • 引用計數法
      此處經驗爲此前開發 Flash 遊戲時積累的圖片緩存( BitmapData )方案,在前端工作中暫時未有使用,但圖片/二進制管理可以套用本方案。
      Flash 中, BitmapData 爲圖片的像素數據(類似於 JS 中的 ImageData )。同一份素材的圖片,可以複用同一份 BitmapData
      故爲了管理遊戲中的圖片資源,則是管理遊戲中所有的 BitmapData ,在需要時緩存,用完時 dispose (銷燬)。
      具體流程如下:

      • 圖片通過 url 引用 BitmapData 時,檢測是否有緩存,若沒有,步驟二,若有,走步驟三。
      • 使用加載隊列按優先級加載資源並按 url 緩存,走到步驟三。
      • url 將引用次數 +1 ,並更新資源使用時間。
      • 圖片清理(dispose)時,按 url 將引用次數 -1 ,並更新資源使用時間。
      • 定時檢測引用次數爲 0 的情況,並依據一定策略延遲/定期清理資源。
      • 定時上報資源管理器中,緩存的資源及引用計數,以方便排查資源引用情況/遺漏清理情況等。
      • 進入戰鬥場景或對性能吃緊的場景時,清理引用計數爲 0 的資源。

    此套方案的缺點在於:如果有圖片對象未調用 dispose ,則會內存泄露,但可通過上報排查。
    優點則是可精確控制所有圖片的資源引用情況。

    內存溢出如何排查

    Chrome 瀏覽器排查請參考: chrome 內存泄露(一)、內存泄漏分析工具
    NodeJS 排查請參考:
    [[譯]Node.js 垃圾回收與內存泄露的排查](https://www.ctolib.com/topics...
    node 內存泄漏以及定位
    輕鬆排查線上 Node 內存泄漏問題

    參考鏈接

    garbage-collector-friendly-code/

    移動 WEB 通用優化策略介紹(二)

    H5 前端性能優化高級進階

    Javascript 的 Image 對象、圖像渲染與瀏覽器內存兩三事

    web 前端圖片極限優化策略

    MDN Weakmap

    函數節流、函數防抖實現原理分析

    chrome 內存泄露(一)、內存泄漏分析工具

    [譯]Node.js 垃圾回收與內存泄露的排查

    node 內存泄漏以及定位

    輕鬆排查線上 Node 內存泄漏問題

    發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章