詳解 JavaScript 各種算法在并行、并發、增量上的優化方案
作者 | xcat
在詳細介紹 JavaScript 的垃圾回收算法之前,我們先來了解下 V8 引擎中的分代布局。
一、分代布局
分代假說(The Generational Hypothesis)認為大多數對象的生命周期非常短暫,即從垃圾回收的視角來看,大多數對象在被分配后幾乎立即變成不可訪問狀態。這一規律不僅適用于 V8 或 JavaScript,對大多數動態語言都成立。
V8 的分代式堆內存布局正是基于這種對象生命周期特征而設計。堆內存被分為「年輕代」(進一步劃分為新生區和中間區兩個子代)和「老年代」。對象首先會被分配在「新生區」。如果它們在下一次垃圾回收中存活,就會被保留在年輕代但晉升為「中間區」狀態。如果它們再次在垃圾回收中存活,就會晉升至老年代。
JavaScript 的垃圾回收算法分為兩種:
- 次要垃圾回收:使用「Scavenger 算法」,回收年輕代中的垃圾
- 主要垃圾回收:使用「標記-清除算法」,回收老年代中的垃圾
二、Scavenger 算法
V8 中的次要垃圾回收(Minor GC)正是基于分代假說,使用的是 Scavenger 算法。
它分為「標記」、「轉移」和「指針更新」三個步驟:
- 標記(marking):找到年輕代中的活躍對象
- 轉移(evacuating):將標記的對象復制到中間區或老年代(取決于是否已轉移過)
- 指針更新(pointer-updating):更新被復制對象的所有引用指針
1. 標記
Scavenger 算法的第一步是找到年輕代中的活躍對象。這個類似于標記-清除算法中的標記階段,需要從 GC Roots 開始,遍歷完整個引用圖,才能確定年輕代中哪些是存活的(其他的都是死亡的)。
(1) GC Roots
GC Roots(根集合)是垃圾回收器進行可達性分析的起點,所有從 GC Roots 出發能直接或間接訪問到的對象都被視為存活對象。GC Roots 主要包括以下內容:
①全局對象
- awindow
- global
②當前執行上下文中的活動對象
- 正在執行的函數內部的變量
- 閉包中引用的外部變量
③DOM 節點
- 所有未被移除的 DOM 元素的引用
④活動線程和事件隊列中的引用
- setTimeout、Promise 回調中引用的對象
- 未解綁的事件監聽器
⑤內置對象和系統引用
- 當前正在執行的作用域鏈(Scope Chain)
- 內置對象(如 Math、JSON)的引用
(2) 跨代引用列表
根據分代假說,老年代中的對象大部分都是長期存活的,這意味本來只是為了找出年輕代中的活躍對象,結果卻遍歷了幾乎整個老年代對象。
為了避免遍歷幾乎整個老年代,V8 實現了一個機制,通過寫屏障(Write Barrier)維護了一個「老年代對年輕代的跨代引用列表」(a list of old-to-new references)。然后從 GC Roots 開始遍歷時,遇到老年代對象就直接跳過,僅遍歷其中的年輕代對象,這樣可以找到所有「從 GC Roots 出發,不經過老年代的年輕代」。接著再將跨代引用列表中的對象加入到 GC Roots 中遍歷,同樣是遇到老年代對象就直接跳過,這樣就可以找到所有「被老年代引用的年輕代」。
通過維護了一個「跨代引用列表」的方式,V8 不遍歷老年代就能找到所有年輕代中的存活對象。
(3) 寫屏障
寫屏障是一種在垃圾收集過程中用于保持對象引用完整性和一致性的重要技術。在 V8 引擎中,寫屏障不僅在次要垃圾回收的標記階段用于維護跨代引用列表,也在主要垃圾回收的并發標記和增量標記階段用于更新存活的對象。
寫屏障在 js 執行寫操作(比如 object.field = vaule)的時候會被觸發,若一個對象更新了其引用信息,則會在寫屏障中更新跨代引用列表。保證 Scavenger 算法的標記階段能夠獲得準確的跨代引用信息。寫屏障在并發標記和增量標記的應用將會在后續章節中介紹。
2. 轉移
Scavenger 算法的第二步是將標記的對象復制到中間區或老年代(取決于是否已轉移過一次)。
復制(轉移)是垃圾回收中開銷非常大的操作。不過根據分代假說,年輕代中實際存活的對象比例極低,需要復制的對象也很少。通過僅移動存活對象,其他所有內存都成為了可回收的垃圾。這意味著我們只需承擔與存活對象數量成正比(而非與總分配量成正比)的復制成本。
(1) 半空間
在針對年輕代的回收過程中,存活的對象始終會被轉移到新的內存頁。V8 為年輕代采用了半空間(Semi-Space)設計,這意味著總空間的一半始終預留為空,以支持轉移操作。
在回收期間,初始為空的部分稱為 To-Space,而需要復制的來源區域稱為 From-Space。轉移步驟會將所有存活對象移動到連續的內存塊中(位于同一內存頁內),從而完全消除內存碎片(即死對象留下的間隙)。
隨后會交換兩個半空間的角色——To-Space 變為From-Space,From-Space 變為 To-Space。垃圾回收完成后,新對象的內存分配將從新的 From-Space 的下一個空閑地址開始。
下一次垃圾回收時,From-Space 中剛分配的對象會被轉移到 To-Space,而 From-Space 中已經移動過一次的對象則會被轉移到老年代:
3. 指針更新
Scavenger 算法的最后一步是更新引用地址。注意無論對象是第一次轉移(從 From-Space 到 To-Space),還是第二次轉移(從 From-Space 到老年代),都會在原來的位置留下一個轉發地址,用于更新原始指針的地址。
接下來 V8 需要知道內存中哪些地方引用了這次轉移的對象,更新它們的指針。那么如何找到所有引用了這些對象的對象呢?由于對象的引用是單向的關系,似乎只能重新完全遍歷一次所有內存,才能找到引用了該對象的對象。這肯定是不現實的,它會非常慢。所以 V8 引入了存儲緩沖區,將對象的引用關系由單向變成了雙向的,解決了「如何找到引用了該對象的對象」這個問題。
(1) 存儲緩沖區
V8 在內存中每個對象建立引用關系時,反向記錄了一個地址,指向了引用該對象的對象,使得對象的引用關系就不是單向的了,而是雙向的。這個信息就存儲在「存儲緩沖區」(Store Buffer)中:
如圖所示,Page1 中的對象如果移動了位置,就能通過 Page1 的 Store Buffer 找到引用了 ObjectA 和 ObjectB 的所有對象,然后分別更新其指針即可。但是這樣的結構有個缺點,兩個存儲緩沖區可能包含了同一個指針記錄,當多線程并行執行指針更新時,多個線程可能同時更新同一個指針,造成數據競爭。V8 使用了「記憶集」來解決這個問題。
(2) 記憶集
為了解決多線程并行執行指針更新時的數據競爭問題,V8 使用了「記憶集」(Remembered Set)替代「存儲緩沖區」來記錄對象的引用關系。
因為每個內存頁的大小是固定的,所以可以給 Page2 分配一個固定大小的記憶集,將它「引用的對象的地址的偏移量的位置」標記為紅色:
這樣的話,一旦 Page1 中的對象移動了位置,就可以在每頁的記憶集中尋找固定偏移量的位置,看看是否為紅色,如果為紅色,則說明 Page2 需要更新該引用的指針。多線程可以按頁來分配任務,每個線程更新自己的頁的指針即可,如此一來,再也不會出現多個線程更新同一個頁的指針的情況了。
Scavenger 算法中,標記、轉移、指針更新這三個步驟是交錯進行的,而不是嚴格分階階段完成。具體來說,標記步驟會遍歷 GC Roots,一旦找到活對象,則立即將它轉移到 To-Space(或老年代),然后立即更新它的引用指針,接著再繼續遍歷 GC Roots。這樣交錯執行的好處是可以減少 GC 過程的內存占用,提高復制效率。
4. 并行
并行(Parallel)是將垃圾回收任務分配給多個線程并行執行,但它仍然會阻塞主線程(GC Stop-The-World),只是相對來說阻塞的時間變少了:
并發(Concurrent)則是將任務完全交給其他線程,完全不阻塞主線程:
并行是一種相對簡單的技術,因為主線程的 js 已經暫停了,不會再修改內存。只需要確保多個線程訪問同一個對象時能得到及時的同步。而并發則是比較困難的技術,因為 js 主線程可能隨時讀寫內存,使得垃圾回收中的標記任務變得無效,還需擔心主線程和輔助線程讀寫同一個對象時造成的數據競爭。
次要垃圾回收時,因為只需要掃描年輕代內存,所以標記階段耗時很小。大部分耗時都在轉移階段,而轉移階段是無法并發的——轉移階段肯定不能讓主線程繼續執行 js。次要垃圾回收只能在標記和指針更新階段引入并發,帶來的性能提升很小,反而還增加了寫屏障、線程同步等耗時,得不償失。所以次要垃圾回收只使用了并行技術。
在 v6.2 之前,V8 的次要垃圾回收使用的是一種沒有并行技術的「單線程 Cheney 半空間復制算法」(Single-threaded Cheney’s Semispace Copy)。這種算法其實就是前文中介紹 Scavenger 算法中不包含并行的部分,它簡單易實現,適合單核環境,但也會完全阻塞主線程,沒有利用到多核的性能優勢。
V8 對比了以下這三種算法,最終選擇了如今這種「并行的 Scavenger 算法」:
- 單線程 Cheney 半空間復制算法
- 并行標記-轉移算法
- 并行的 Scavenger 算法
這里我簡單介紹下這三種算法的差異。
(1) 單線程 Cheney 半空間復制算法
這個算法其實就是前文中介紹 Scavenger 算法中不包含并行的部分:
將內存空間分位年輕代和老年代,年輕代分位兩個半空間 From-Space 和 To-Space。垃圾回收器從 GC Roots 開始遍歷,交錯的執行這三個步驟:
- 標記(marking):找到年輕代中的活躍對象
- 轉移(evacuating):將標記的對象復制到 To-Space 或老年代(取決于是否已轉移過)
- 指針更新(pointer-updating):更新被復制對象的所有引用指針
這三個步驟交錯進行,而不是嚴格分階階段完成。
(2) 并行標記-轉移算法
將單線程 Cheney 半空間復制算法改造成多線程的難點在于:
- 單線程環境下,對引用圖的遍歷是線性的,如果多線程并行遍歷,則容易同時遍歷到同一個對象,造成數據競爭
- 多線程并行轉移對象時,內存分配容易沖突
- 指針更新時,另一個線程可能已讀取了未轉移的對象
并行標記-轉移算法(Parallel Mark-Evacuate)為了解決這幾個問題,放棄了單線程 Cheney 半空間復制算法中的交錯執行方式,改為階段性執行:
- 標記階段:多線程并行執行標記任務,即使重復標記了也沒關系
- 轉移階段:等所有標記任務全部完成后,再給多線程分配轉移任務,并行轉移到 To-Space 或老年代中。轉移時每個線程都有自己的本地分配緩沖區(local allocation buffers, LABs),轉移完成后會合并到一起
- 指針更新階段:轉移任務全部完成后,才會多線程并行執行指針更新任務
并行標記-轉移算法使用了分階段執行這種簡單的方式解決了數據競爭、內存分配等問題。但是它沒有考慮到任務的負載均衡問題,部分線程可能任務負載過重,而另一部分線程比較空閑,沒有充分利用到多線程的優勢。
(3) 并行的 Scavenger 算法
V8 最終使用的并行的 Scavenger 算法(Parallel Scavenge)則是更為極致的優化,它維護了一個全局工作列表,多線程首先從多個 GC Roots 出發并行遍歷,每個線程不會遍歷完它的整個圖,而是在遍歷時選擇性的將子節點的遍歷任務加入到全局工作列表中。當單個線程空閑時,就會從全局工作列表「竊取」(stealing)一個任務來處理。
這樣就解決了線程間的負載均衡問題。
另外,并行的 Scavenger 算法中實現了一個屏障(barrier)機制,使得在遍歷時如果遇到一些不適合并行處理的任務時,不會將它放到全局工作列表中(比如線性對象鏈 linear chain of objects)。這個屏障也保證了標記、轉移、指針更新這三個階段可以交錯執行而不會出錯。
通過升級為并行的 Scavenger 算法,次要垃圾回收總時間減少了 55%
5. 增量垃圾回收
增量垃圾回收是一種在瀏覽器空閑時段進行垃圾回收的技術。
(1) 空閑時段
大多數瀏覽器的刷新率是 60Hz,這也是衡量網頁是否卡頓的標準。所以 Chrome 在繪制每一幀之間,有 16.6 毫秒的時間來計算渲染任務。如果 Chrome 在不到 16.6 毫秒的時間內完成了任務,那么在開始渲染下一幀之前,瀏覽器就有空做一些其他時間,這個時間段就稱為空閑時段(Idel period)。瀏覽器提供了一個接口 requestIdleCallback 可以用來注冊空閑任務。
空閑時段只能執行低優先級的任務,包括 js 注冊的空閑任務和空閑垃圾回收任務,空閑任務會有一個截止期限,這是調度器對其預計空閑時間的預估,它的上限是 50ms,以確保瀏覽器能及時響應用戶突然的輸入。空閑任務使用截止期限來估計它可以完成多少工作而不會導致輸入響應卡頓或延遲。
為了在空閑期間執行這些操作,V8 會將垃圾回收的空閑任務提交給調度器。當這些空閑任務運行時,它們會設定一個應該完成的最后期限。V8 的垃圾回收空閑時間管理器會評估應該執行哪些垃圾回收任務,以減少內存消耗,同時遵守最后期限以避免未來在幀渲染或輸入延遲方面的卡頓。空閑垃圾回收任務可能包括次要垃圾回收或主要垃圾回收。
次要垃圾回收的速度很快,如果在空閑時段執行的是次要垃圾回收,則會在這次任務中完成整個次要垃圾回收任務。而主要垃圾回收只會在空閑時段執行增量標記任務。這將在下一章介紹。
三、標記-清除算法
標記-清除(Mark-and-sweep)是垃圾回收中的經典算法。JavaScript 中的主要垃圾回收(Major GC)就是使用這個算法來收集老年代中的垃圾。
它分為「標記」、「清除」兩個主要步驟,以及「壓縮」這一可選步驟:
- 標記(marking):找到活躍對象
- 清除(sweeping):回收死內存
- 壓縮(Compacting)(可選):整理內存碎片
1. 標記階段
標記階段用來確定哪些對象可以被回收,它是垃圾回收的核心環節。
垃圾回收器通過「可達性」來判斷對象的「存活狀態」。這意味著當前運行時環境中所有可達的對象必須保留,而不可達的對象則需要被回收。標記過程即尋找可達對象的過程。垃圾回收器從一組已知的指針起點(稱為 GC Roots)開始遍歷,沿著每個指向 JavaScript 對象的指針進行追蹤,將找到的對象標記為可達。回收器會遞歸地追蹤這些對象內部的所有指針,直到標記出運行時環境中所有可達對象。
(1) 并發標記
并行(Parallel)是將垃圾回收任務分配給多個線程并行執行,但它仍然會阻塞主線程,只是阻塞的時間變少了:
并發(Concurrent)則是將任務完全交給其他線程,完全不阻塞主線程:
并行是一種相對簡單的技術,因為主線程的 js 已經暫停了,不會再修改內存。只需要確保多個線程訪問同一個對象時能得到及時的同步。而并發則是比較困難的技術,因為 js 主線程可能隨時讀寫內存,使得垃圾回收中的標記任務變得無效,還需擔心主線程和輔助線程讀寫同一個對象時造成的數據競爭。
次要垃圾回收時,因為只需要掃描年輕代內存,所以標記階段耗時很小。大部分耗時都在轉移階段,而轉移階段是無法并發的。所以次要垃圾回收只使用了并行技術。引入并發只能減少標記和指針更新階段耗時,反而增加了寫屏障、線程同步等耗時,得不償失。
主要垃圾回收時,因為需要掃描整個老年代內存,所以標記階段耗時比較長。所以主要垃圾回收可以利用并發標記技術,使得 V8 的標記階段都在輔助線程中執行時,完全不阻塞 js 主線程:
(2) 三色標記
標記階段的工作可以看作是圖的遍歷。堆內存上的對象就是圖的節點,一個對象對另一個對象的指針就是圖的邊。標記階段的目標就是從 Roots 出發,找到所有引用到的對象。
假如是單線程遍歷圖,可以在一個調用棧中直接廣度或深度遍歷即可。但要實現多線程并發標記,則需要有一個標記工作列表(marking worklist),每個線程都從標記工作列表中拿取一個工作,并且把下一層級的工作推入到標記工作列表中。
V8 在標記階段會將每個節點標記為三種顏色:
- 白色:尚未發現的節點
- 灰色:發現白色節點,將其推入到標記工作列表中,變成灰色節點
- 黑色:從標記工作列表中拿取一個灰色節點,訪問其所有字段,這些字段中若有白色節點則推入到標記工作列表中變成灰色節點,訪問完所有字段后,將該節點變成黑色
當標記工作列表為空時,就意味著完成了整個標記階段,確定了圖中所有的活躍對象(黑色節點)和死亡對象(白色節點)。
(3) 寫屏障
并發標記時,主線程還在執行 js:
此時主線程可能修改內存中的引用關系,多線程的讀寫操作造成了數據競爭,這導致輔助線程中的三色標記失效。
寫屏障解決了數據競爭的問題。寫屏障在 js 執行寫操作(比如 object.field = vaule)的時候會被觸發,若一個字段從一個對象指向一個新的對象時,寫屏障會檢查并調整新對象的顏色(標記狀態),如果該對象是白色(未被標記為活的),將其改為灰色并加入待處理隊列。
寫屏障會帶來一定的性能開銷,但它確保了三色標記的正確性。
(4) 并行標記
大部分情況下,并發標記不阻塞主線程,是更優的選擇。
但有時候,對象的引用關系可能涉及復雜的線程同步問題,使得標記工作難以并發執行。
此時輔助線程會將此標記工作任務推送到一個名為救援工作列表(Bailout worklist)的列表中,求助于主線程通過阻塞 js 來執行此標記任務。
救援工作列表中的任務只會被主線程取走,此時會阻塞主線程的 js 執行,所有線程都會并行的執行標記任務:
(5) 增量標記
瀏覽器在下一幀渲染之前,可能有一些空閑時段,這個時段也可以用來做主要垃圾回收的增量標記任務。
為了減少對應用程序性能的影響,增量標記任務可以在多個空閑時段中執行,每個空閑時段內執行一段時間,然后中斷以便其他重要任務(如主線程的 JavaScript 任務)可以繼續執行。在下一個空閑時段,再繼續未完成的標記任務。
在增量標記過程中,垃圾回收并非一次性完成,而是分成許多小的步驟。在主線程 js 執行過程中,內存中對象的引用關系可能發生變化,這會導致之前的標記失效了。為了解決這個問題,V8 使用寫屏障來標記那些引用關系發生變化的對象。這樣,即便在垃圾回收暫停期間對象的引用關系發生了變化,垃圾回收器依然能夠準確地識別和處理這些變化。
由于清除任務和壓縮任務的耗時較長,空閑時段不會用來做清除任務和壓縮任務。
(6) 黑色分配
根據分代假說,大多數對象的生命周期非常短暫,在次要垃圾回收中就會被回收掉。而經歷了兩次次要垃圾回收都沒被回收的對象,就會被晉升到老年代。
我們可以認為,剛從新生代晉升到老年代的對象,大概率是一個長期活躍的對象,至少在下次主要垃圾回收時也還是活躍的對象,不會被回收——假如一個剛晉升到老年代的對象馬上就被回收了,說明這個分代假說本身就有問題了。既然它大概率在下次主要垃圾回收時還保持活躍,那么就沒必要在標記階段掃描它了,直接將它標記為活躍即可。這就是黑色分配的理論依據——剛晉升到老年代的對象,至少應該在下一次主要垃圾回收中存活下來。
在次要垃圾回收的標記階段,V8 將準備從新生代晉升到老年代的對象染成黑色。在次要垃圾回收的轉移階段,黑色對象會被移動到一個特殊的黑色內存頁中,這個內存頁的所有對象都是黑色的。在下次主要垃圾回收的標記階段,將會直接跳過黑色內存頁的掃描。
黑色分配這一優化手段將吞吐量和延遲得分提高了約 30%,同時由于標記進度更快且整體垃圾收集工作更少,內存使用量減少了約 20%。
2. 清除階段
清除過程會將死亡對象留下的內存空隙加入名為「空閑列表(free-list)」的數據結構。當標記完成后,垃圾回收器會掃描整個堆內存,找到由不可達對象形成的連續內存空隙,并將其加入對應大小的空閑列表。空閑列表按內存塊大小分類存儲以便快速檢索。后續需要分配內存時,只需查詢空閑列表即可找到合適大小的內存塊。
(1) 并發清除
因為待清除的內存都是死亡內存,絕對不會再被主線程訪問到了。所以清除任務可以完全放在輔助線程中并發執行。并且即使主線程 js 已經恢復執行時,輔助線程的清除任務還可以繼續執行。
3. 壓縮階段
基于碎片化啟發式算法(fragmentation heuristic),主要垃圾回收會選擇性地對某些內存頁執行對象遷移/壓縮操作。
這個過程可以類比老式電腦的硬盤碎片整理:我們將存活對象復制到當前未被壓縮的其他內存頁(利用其空閑列表)。通過這種方式,可以充分利用死亡對象遺留在內存中的零散小空隙。復制存活對象存在一個潛在問題:復制大量長生命周期對象會帶來高昂成本。因此我們選擇只壓縮碎片化程度較高的內存頁,而對其他內存頁僅執行清除操作(不復制存活對象)。壓縮階段會阻塞 js 主線程,避免數據競爭帶來的問題。壓縮階段會并行執行。
最后總結一下,標記-清除算法的整個流程如下:
(1) 并發、增量標記:當堆內存接近動態計算的上限時,V8 會啟動并發、增量標記。瀏覽器會在主線程空閑階段執行增量標記,在非空閑階段執行并發標記。主線程執行期間可能會產生新的對象引用關系,V8 采用寫屏障(Write Barrier)機制來記錄 js 在并發、增量標記階段創建的新對象引用,確保標記結果的準確性,當遇到難以并發執行的標記任務時,會推入到救援工作列表中,交給最終標記階段執行。
(2) 最終標記(并行標記):并發、增量標記完成后,主線程會暫停執行 js,此時進入最終標記階段(marking finalization),多線程并行執行救援工作列表的工作,然后重新掃描 GC Roots,確保所有存活對象都被標記了。
(3) 并行壓縮階段:主線程繼續暫停執行 js,多線程并行執行壓縮任務,將存活對象移動到連續內存塊以減少碎片,并更新相關指針。部分無法壓縮的頁則通過空閑列表(free-list)進行內存回收。
(4) 并發清除階段:與此同時,輔助線程執行并發清除任務,這些任務與并行壓縮及主線程代碼執行同時進行,即使主線程 js 恢復運行,清除任務仍可在輔助線程中繼續執行。
四、垃圾回收的觸發時機
JavaScirpt 的垃圾回收時機無法用程序控制,這是設計如此的:
- 程序員可能希望在時間關鍵的應用階段關閉垃圾回收,以避免因垃圾回收引起的幀丟失。然而,這會使應用邏輯變得復雜,維護變得困難。如果在代碼的某個分支中忘記重新開啟垃圾回收,可能會導致內存耗盡。
- 程序員無法預估手動觸發的垃圾回收需要多長時間,可能會導致應用程序本身引入卡頓,反而無法達到預期的性能優化效果。
- 這會給 js 引擎帶來額外的工作,手動觸發垃圾回收可能會干擾垃圾回收器的啟發式算法,導致不可靠的內存管理行為。
1. 次要垃圾回收
- 當年輕代的活動空間被填滿時觸發
- 當程序請求分配新的內存,并且年輕代沒有足夠內存時觸發
- 當空閑時段有足夠的空閑時間時觸發。但并不是一定會觸發,因為頻繁的觸發可能導致本可以在次要垃圾回收中得到回收的對象被移入了老年代
2. 主要垃圾回收
- 當老年代中的對象占用空間增長到超過某個啟發式計算的內存限制時觸發
- 如果整個堆的使用情況超過了特定的內存閾值,基于啟發式算法,系統可能觸發主要垃圾回收
- 當堆大小達到某個策略設定的開始增量標記的限制時,會開始在空閑時段執行增量標記,增量標記完成后,開始清除和壓縮,最終完成主要垃圾回收
- 在檢測到應用的長期不活躍狀態時,甚至在沒有達到內存限制的情況下,可能主動進行主要垃圾回收來減少內存占用
3. 動態的垃圾回收頻率
在一次完整的垃圾收集結束時,V8 的堆增長策略會根據存活對象的數量和內存的余量,來決定下一次垃圾收集的時間。所以垃圾回收的頻率會根據內存的狀態實時變化。
4. 低內存模式
垃圾回收的吞吐量、造成的頁面延遲以及占用內存之間是一個不可能三角。針對不同的設備,需要有不同的內存回收策略。對于內存較低的移動設備,即內存少于 512 MB 的設備,優先考慮延遲和吞吐量而不是內存消耗可能會導致內存不足而崩潰。
為了更好地平衡這些低內存移動設備的權衡,V8 引入了一種特殊的低內存模式,該模式調整了一些垃圾收集啟發模式以降低 JavaScript 垃圾收集堆的內存使用量。一般來說,在一次完整的垃圾收集結束時,V8 會根據存活對象的數量和內存余量來決定下一次垃圾收集的時間。在低內存模式下,內存余量更少,所以垃圾回收會更頻繁的觸發。
一般來說,主要垃圾回收會在內存還有空余時觸發,此時會在空閑時段執行增量標記,等標記任務完全完成后,才會阻塞主線程,開始執行清除和壓縮。但在低內存模式下,由于內存余量更少,可能在增量標記還未完成時,就觸發了主線程的垃圾回收,此時主線程 js 阻塞,主線程和輔助線程并行的執行剩余的標記任務和后續的清除、壓縮任務。低內存模式雖然使垃圾回收更頻繁了,但是也使得移動端設備上的堆內存消耗減少了 50%
5. 不活躍的網頁
一般來說,瀏覽器會對每個網頁限制內存,一旦達到限值,則會啟動主要垃圾回收。然而,如果網頁在達到分配限制之前變得不活躍,那么在網頁整個不活躍期間都不會進行主要的垃圾回收——這正是大多數網頁會遇到的情況——大多數網頁在加載頁面時會使用較多內存,因為它們正在初始化其內部數據結構,加載后不久(幾秒或幾分鐘內),網頁通常變得不活躍。如果此時還未達到內存限值,就不會進行主要垃圾回收了。
這會導致不活躍的網頁遲遲得不到內存回收。所以 Chrome 實現了一個名為「Memory Reducer」的控制器,它會檢測網頁何時變得不活躍,并主動調度一次主要垃圾回收——即使此時還未達到內存分配限制。