偷偷摘套内射激情视频,久久精品99国产国产精,中文字幕无线乱码人妻,中文在线中文a,性爽19p

詳解 JavaScript 各種算法在并行、并發(fā)、增量上的優(yōu)化方案

開發(fā)
本文詳細介紹了 JavaScript 中次要垃圾回收的 Scavenger 算法和主要垃圾回收的標(biāo)記-清除算法的實現(xiàn)細節(jié),以及各種算法在并行、并發(fā)、增量上的優(yōu)化方案,最后介紹了 JS 中垃圾回收的觸發(fā)時機。

作者 | xcat

在詳細介紹 JavaScript 的垃圾回收算法之前,我們先來了解下 V8 引擎中的分代布局。

一、分代布局

分代假說(The Generational Hypothesis)認為大多數(shù)對象的生命周期非常短暫,即從垃圾回收的視角來看,大多數(shù)對象在被分配后幾乎立即變成不可訪問狀態(tài)。這一規(guī)律不僅適用于 V8 或 JavaScript,對大多數(shù)動態(tài)語言都成立。

V8 的分代式堆內(nèi)存布局正是基于這種對象生命周期特征而設(shè)計。堆內(nèi)存被分為「年輕代」(進一步劃分為新生區(qū)和中間區(qū)兩個子代)和「老年代」。對象首先會被分配在「新生區(qū)」。如果它們在下一次垃圾回收中存活,就會被保留在年輕代但晉升為「中間區(qū)」?fàn)顟B(tài)。如果它們再次在垃圾回收中存活,就會晉升至老年代。

JavaScript 的垃圾回收算法分為兩種:

  • 次要垃圾回收:使用「Scavenger 算法」,回收年輕代中的垃圾
  • 主要垃圾回收:使用「標(biāo)記-清除算法」,回收老年代中的垃圾

二、Scavenger 算法

V8 中的次要垃圾回收(Minor GC)正是基于分代假說,使用的是 Scavenger 算法。

它分為「標(biāo)記」、「轉(zhuǎn)移」和「指針更新」三個步驟:

  • 標(biāo)記(marking):找到年輕代中的活躍對象
  • 轉(zhuǎn)移(evacuating):將標(biāo)記的對象復(fù)制到中間區(qū)或老年代(取決于是否已轉(zhuǎn)移過)
  • 指針更新(pointer-updating):更新被復(fù)制對象的所有引用指針

1. 標(biāo)記

Scavenger 算法的第一步是找到年輕代中的活躍對象。這個類似于標(biāo)記-清除算法中的標(biāo)記階段,需要從 GC Roots 開始,遍歷完整個引用圖,才能確定年輕代中哪些是存活的(其他的都是死亡的)。

(1) GC Roots

GC Roots(根集合)是垃圾回收器進行可達性分析的起點,所有從 GC Roots 出發(fā)能直接或間接訪問到的對象都被視為存活對象。GC Roots 主要包括以下內(nèi)容:

①全局對象

  • awindow
  • global

②當(dāng)前執(zhí)行上下文中的活動對象

  • 正在執(zhí)行的函數(shù)內(nèi)部的變量
  • 閉包中引用的外部變量

③DOM 節(jié)點

  • 所有未被移除的 DOM 元素的引用

④活動線程和事件隊列中的引用

  • setTimeout、Promise 回調(diào)中引用的對象
  • 未解綁的事件監(jiān)聽器

⑤內(nèi)置對象和系統(tǒng)引用

  • 當(dāng)前正在執(zhí)行的作用域鏈(Scope Chain)
  • 內(nèi)置對象(如 Math、JSON)的引用

(2) 跨代引用列表

根據(jù)分代假說,老年代中的對象大部分都是長期存活的,這意味本來只是為了找出年輕代中的活躍對象,結(jié)果卻遍歷了幾乎整個老年代對象。

為了避免遍歷幾乎整個老年代,V8 實現(xiàn)了一個機制,通過寫屏障(Write Barrier)維護了一個「老年代對年輕代的跨代引用列表」(a list of old-to-new references)。然后從 GC Roots 開始遍歷時,遇到老年代對象就直接跳過,僅遍歷其中的年輕代對象,這樣可以找到所有「從 GC Roots 出發(fā),不經(jīng)過老年代的年輕代」。接著再將跨代引用列表中的對象加入到 GC Roots 中遍歷,同樣是遇到老年代對象就直接跳過,這樣就可以找到所有「被老年代引用的年輕代」。

通過維護了一個「跨代引用列表」的方式,V8 不遍歷老年代就能找到所有年輕代中的存活對象。

(3) 寫屏障

寫屏障是一種在垃圾收集過程中用于保持對象引用完整性和一致性的重要技術(shù)。在 V8 引擎中,寫屏障不僅在次要垃圾回收的標(biāo)記階段用于維護跨代引用列表,也在主要垃圾回收的并發(fā)標(biāo)記和增量標(biāo)記階段用于更新存活的對象。

寫屏障在 js 執(zhí)行寫操作(比如 object.field = vaule)的時候會被觸發(fā),若一個對象更新了其引用信息,則會在寫屏障中更新跨代引用列表。保證 Scavenger 算法的標(biāo)記階段能夠獲得準(zhǔn)確的跨代引用信息。寫屏障在并發(fā)標(biāo)記和增量標(biāo)記的應(yīng)用將會在后續(xù)章節(jié)中介紹。

2. 轉(zhuǎn)移

Scavenger 算法的第二步是將標(biāo)記的對象復(fù)制到中間區(qū)或老年代(取決于是否已轉(zhuǎn)移過一次)。

復(fù)制(轉(zhuǎn)移)是垃圾回收中開銷非常大的操作。不過根據(jù)分代假說,年輕代中實際存活的對象比例極低,需要復(fù)制的對象也很少。通過僅移動存活對象,其他所有內(nèi)存都成為了可回收的垃圾。這意味著我們只需承擔(dān)與存活對象數(shù)量成正比(而非與總分配量成正比)的復(fù)制成本。

(1) 半空間

在針對年輕代的回收過程中,存活的對象始終會被轉(zhuǎn)移到新的內(nèi)存頁。V8 為年輕代采用了半空間(Semi-Space)設(shè)計,這意味著總空間的一半始終預(yù)留為空,以支持轉(zhuǎn)移操作。

在回收期間,初始為空的部分稱為 To-Space,而需要復(fù)制的來源區(qū)域稱為 From-Space。轉(zhuǎn)移步驟會將所有存活對象移動到連續(xù)的內(nèi)存塊中(位于同一內(nèi)存頁內(nèi)),從而完全消除內(nèi)存碎片(即死對象留下的間隙)。

隨后會交換兩個半空間的角色——To-Space 變?yōu)镕rom-Space,F(xiàn)rom-Space 變?yōu)?To-Space。垃圾回收完成后,新對象的內(nèi)存分配將從新的 From-Space 的下一個空閑地址開始。

下一次垃圾回收時,F(xiàn)rom-Space 中剛分配的對象會被轉(zhuǎn)移到 To-Space,而 From-Space 中已經(jīng)移動過一次的對象則會被轉(zhuǎn)移到老年代:

3. 指針更新

Scavenger 算法的最后一步是更新引用地址。注意無論對象是第一次轉(zhuǎn)移(從 From-Space 到 To-Space),還是第二次轉(zhuǎn)移(從 From-Space 到老年代),都會在原來的位置留下一個轉(zhuǎn)發(fā)地址,用于更新原始指針的地址。

接下來 V8 需要知道內(nèi)存中哪些地方引用了這次轉(zhuǎn)移的對象,更新它們的指針。那么如何找到所有引用了這些對象的對象呢?由于對象的引用是單向的關(guān)系,似乎只能重新完全遍歷一次所有內(nèi)存,才能找到引用了該對象的對象。這肯定是不現(xiàn)實的,它會非常慢。所以 V8 引入了存儲緩沖區(qū),將對象的引用關(guān)系由單向變成了雙向的,解決了「如何找到引用了該對象的對象」這個問題。

(1) 存儲緩沖區(qū)

V8 在內(nèi)存中每個對象建立引用關(guān)系時,反向記錄了一個地址,指向了引用該對象的對象,使得對象的引用關(guān)系就不是單向的了,而是雙向的。這個信息就存儲在「存儲緩沖區(qū)」(Store Buffer)中:

如圖所示,Page1 中的對象如果移動了位置,就能通過 Page1 的 Store Buffer 找到引用了 ObjectA 和 ObjectB 的所有對象,然后分別更新其指針即可。但是這樣的結(jié)構(gòu)有個缺點,兩個存儲緩沖區(qū)可能包含了同一個指針記錄,當(dāng)多線程并行執(zhí)行指針更新時,多個線程可能同時更新同一個指針,造成數(shù)據(jù)競爭。V8 使用了「記憶集」來解決這個問題。

(2) 記憶集

為了解決多線程并行執(zhí)行指針更新時的數(shù)據(jù)競爭問題,V8 使用了「記憶集」(Remembered Set)替代「存儲緩沖區(qū)」來記錄對象的引用關(guān)系。

因為每個內(nèi)存頁的大小是固定的,所以可以給 Page2 分配一個固定大小的記憶集,將它「引用的對象的地址的偏移量的位置」標(biāo)記為紅色:

這樣的話,一旦 Page1 中的對象移動了位置,就可以在每頁的記憶集中尋找固定偏移量的位置,看看是否為紅色,如果為紅色,則說明 Page2 需要更新該引用的指針。多線程可以按頁來分配任務(wù),每個線程更新自己的頁的指針即可,如此一來,再也不會出現(xiàn)多個線程更新同一個頁的指針的情況了。

Scavenger 算法中,標(biāo)記、轉(zhuǎn)移、指針更新這三個步驟是交錯進行的,而不是嚴格分階階段完成。具體來說,標(biāo)記步驟會遍歷 GC Roots,一旦找到活對象,則立即將它轉(zhuǎn)移到 To-Space(或老年代),然后立即更新它的引用指針,接著再繼續(xù)遍歷 GC Roots。這樣交錯執(zhí)行的好處是可以減少 GC 過程的內(nèi)存占用,提高復(fù)制效率。

4. 并行

并行(Parallel)是將垃圾回收任務(wù)分配給多個線程并行執(zhí)行,但它仍然會阻塞主線程(GC Stop-The-World),只是相對來說阻塞的時間變少了:

并發(fā)(Concurrent)則是將任務(wù)完全交給其他線程,完全不阻塞主線程:

并行是一種相對簡單的技術(shù),因為主線程的 js 已經(jīng)暫停了,不會再修改內(nèi)存。只需要確保多個線程訪問同一個對象時能得到及時的同步。而并發(fā)則是比較困難的技術(shù),因為 js 主線程可能隨時讀寫內(nèi)存,使得垃圾回收中的標(biāo)記任務(wù)變得無效,還需擔(dān)心主線程和輔助線程讀寫同一個對象時造成的數(shù)據(jù)競爭。

次要垃圾回收時,因為只需要掃描年輕代內(nèi)存,所以標(biāo)記階段耗時很小。大部分耗時都在轉(zhuǎn)移階段,而轉(zhuǎn)移階段是無法并發(fā)的——轉(zhuǎn)移階段肯定不能讓主線程繼續(xù)執(zhí)行 js。次要垃圾回收只能在標(biāo)記和指針更新階段引入并發(fā),帶來的性能提升很小,反而還增加了寫屏障、線程同步等耗時,得不償失。所以次要垃圾回收只使用了并行技術(shù)。

在 v6.2 之前,V8 的次要垃圾回收使用的是一種沒有并行技術(shù)的「單線程 Cheney 半空間復(fù)制算法」(Single-threaded Cheney’s Semispace Copy)。這種算法其實就是前文中介紹 Scavenger 算法中不包含并行的部分,它簡單易實現(xiàn),適合單核環(huán)境,但也會完全阻塞主線程,沒有利用到多核的性能優(yōu)勢。

V8 對比了以下這三種算法,最終選擇了如今這種「并行的 Scavenger 算法」:

  • 單線程 Cheney 半空間復(fù)制算法
  • 并行標(biāo)記-轉(zhuǎn)移算法
  • 并行的 Scavenger 算法

這里我簡單介紹下這三種算法的差異。

(1) 單線程 Cheney 半空間復(fù)制算法

這個算法其實就是前文中介紹 Scavenger 算法中不包含并行的部分:

將內(nèi)存空間分位年輕代和老年代,年輕代分位兩個半空間 From-Space 和 To-Space。垃圾回收器從 GC Roots 開始遍歷,交錯的執(zhí)行這三個步驟:

  • 標(biāo)記(marking):找到年輕代中的活躍對象
  • 轉(zhuǎn)移(evacuating):將標(biāo)記的對象復(fù)制到 To-Space 或老年代(取決于是否已轉(zhuǎn)移過)
  • 指針更新(pointer-updating):更新被復(fù)制對象的所有引用指針

這三個步驟交錯進行,而不是嚴格分階階段完成。

(2) 并行標(biāo)記-轉(zhuǎn)移算法

將單線程 Cheney 半空間復(fù)制算法改造成多線程的難點在于:

  • 單線程環(huán)境下,對引用圖的遍歷是線性的,如果多線程并行遍歷,則容易同時遍歷到同一個對象,造成數(shù)據(jù)競爭
  • 多線程并行轉(zhuǎn)移對象時,內(nèi)存分配容易沖突
  • 指針更新時,另一個線程可能已讀取了未轉(zhuǎn)移的對象

并行標(biāo)記-轉(zhuǎn)移算法(Parallel Mark-Evacuate)為了解決這幾個問題,放棄了單線程 Cheney 半空間復(fù)制算法中的交錯執(zhí)行方式,改為階段性執(zhí)行:

  • 標(biāo)記階段:多線程并行執(zhí)行標(biāo)記任務(wù),即使重復(fù)標(biāo)記了也沒關(guān)系
  • 轉(zhuǎn)移階段:等所有標(biāo)記任務(wù)全部完成后,再給多線程分配轉(zhuǎn)移任務(wù),并行轉(zhuǎn)移到 To-Space 或老年代中。轉(zhuǎn)移時每個線程都有自己的本地分配緩沖區(qū)(local allocation buffers, LABs),轉(zhuǎn)移完成后會合并到一起
  • 指針更新階段:轉(zhuǎn)移任務(wù)全部完成后,才會多線程并行執(zhí)行指針更新任務(wù)

并行標(biāo)記-轉(zhuǎn)移算法使用了分階段執(zhí)行這種簡單的方式解決了數(shù)據(jù)競爭、內(nèi)存分配等問題。但是它沒有考慮到任務(wù)的負載均衡問題,部分線程可能任務(wù)負載過重,而另一部分線程比較空閑,沒有充分利用到多線程的優(yōu)勢。

(3) 并行的 Scavenger 算法

V8 最終使用的并行的 Scavenger 算法(Parallel Scavenge)則是更為極致的優(yōu)化,它維護了一個全局工作列表,多線程首先從多個 GC Roots 出發(fā)并行遍歷,每個線程不會遍歷完它的整個圖,而是在遍歷時選擇性的將子節(jié)點的遍歷任務(wù)加入到全局工作列表中。當(dāng)單個線程空閑時,就會從全局工作列表「竊取」(stealing)一個任務(wù)來處理。

這樣就解決了線程間的負載均衡問題。

另外,并行的 Scavenger 算法中實現(xiàn)了一個屏障(barrier)機制,使得在遍歷時如果遇到一些不適合并行處理的任務(wù)時,不會將它放到全局工作列表中(比如線性對象鏈 linear chain of objects)。這個屏障也保證了標(biāo)記、轉(zhuǎn)移、指針更新這三個階段可以交錯執(zhí)行而不會出錯。

通過升級為并行的 Scavenger 算法,次要垃圾回收總時間減少了 55%

5. 增量垃圾回收

增量垃圾回收是一種在瀏覽器空閑時段進行垃圾回收的技術(shù)。

(1) 空閑時段

大多數(shù)瀏覽器的刷新率是 60Hz,這也是衡量網(wǎng)頁是否卡頓的標(biāo)準(zhǔn)。所以 Chrome 在繪制每一幀之間,有 16.6 毫秒的時間來計算渲染任務(wù)。如果 Chrome 在不到 16.6 毫秒的時間內(nèi)完成了任務(wù),那么在開始渲染下一幀之前,瀏覽器就有空做一些其他時間,這個時間段就稱為空閑時段(Idel period)。瀏覽器提供了一個接口 requestIdleCallback 可以用來注冊空閑任務(wù)。

空閑時段只能執(zhí)行低優(yōu)先級的任務(wù),包括 js 注冊的空閑任務(wù)和空閑垃圾回收任務(wù),空閑任務(wù)會有一個截止期限,這是調(diào)度器對其預(yù)計空閑時間的預(yù)估,它的上限是 50ms,以確保瀏覽器能及時響應(yīng)用戶突然的輸入??臻e任務(wù)使用截止期限來估計它可以完成多少工作而不會導(dǎo)致輸入響應(yīng)卡頓或延遲。

為了在空閑期間執(zhí)行這些操作,V8 會將垃圾回收的空閑任務(wù)提交給調(diào)度器。當(dāng)這些空閑任務(wù)運行時,它們會設(shè)定一個應(yīng)該完成的最后期限。V8 的垃圾回收空閑時間管理器會評估應(yīng)該執(zhí)行哪些垃圾回收任務(wù),以減少內(nèi)存消耗,同時遵守最后期限以避免未來在幀渲染或輸入延遲方面的卡頓??臻e垃圾回收任務(wù)可能包括次要垃圾回收或主要垃圾回收。

次要垃圾回收的速度很快,如果在空閑時段執(zhí)行的是次要垃圾回收,則會在這次任務(wù)中完成整個次要垃圾回收任務(wù)。而主要垃圾回收只會在空閑時段執(zhí)行增量標(biāo)記任務(wù)。這將在下一章介紹。

三、標(biāo)記-清除算法

標(biāo)記-清除(Mark-and-sweep)是垃圾回收中的經(jīng)典算法。JavaScript 中的主要垃圾回收(Major GC)就是使用這個算法來收集老年代中的垃圾。

它分為「標(biāo)記」、「清除」兩個主要步驟,以及「壓縮」這一可選步驟:

  • 標(biāo)記(marking):找到活躍對象
  • 清除(sweeping):回收死內(nèi)存
  • 壓縮(Compacting)(可選):整理內(nèi)存碎片

1. 標(biāo)記階段

標(biāo)記階段用來確定哪些對象可以被回收,它是垃圾回收的核心環(huán)節(jié)。

垃圾回收器通過「可達性」來判斷對象的「存活狀態(tài)」。這意味著當(dāng)前運行時環(huán)境中所有可達的對象必須保留,而不可達的對象則需要被回收。標(biāo)記過程即尋找可達對象的過程。垃圾回收器從一組已知的指針起點(稱為 GC Roots)開始遍歷,沿著每個指向 JavaScript 對象的指針進行追蹤,將找到的對象標(biāo)記為可達?;厥掌鲿f歸地追蹤這些對象內(nèi)部的所有指針,直到標(biāo)記出運行時環(huán)境中所有可達對象。

(1) 并發(fā)標(biāo)記

并行(Parallel)是將垃圾回收任務(wù)分配給多個線程并行執(zhí)行,但它仍然會阻塞主線程,只是阻塞的時間變少了:

并發(fā)(Concurrent)則是將任務(wù)完全交給其他線程,完全不阻塞主線程:

并行是一種相對簡單的技術(shù),因為主線程的 js 已經(jīng)暫停了,不會再修改內(nèi)存。只需要確保多個線程訪問同一個對象時能得到及時的同步。而并發(fā)則是比較困難的技術(shù),因為 js 主線程可能隨時讀寫內(nèi)存,使得垃圾回收中的標(biāo)記任務(wù)變得無效,還需擔(dān)心主線程和輔助線程讀寫同一個對象時造成的數(shù)據(jù)競爭。

次要垃圾回收時,因為只需要掃描年輕代內(nèi)存,所以標(biāo)記階段耗時很小。大部分耗時都在轉(zhuǎn)移階段,而轉(zhuǎn)移階段是無法并發(fā)的。所以次要垃圾回收只使用了并行技術(shù)。引入并發(fā)只能減少標(biāo)記和指針更新階段耗時,反而增加了寫屏障、線程同步等耗時,得不償失。

主要垃圾回收時,因為需要掃描整個老年代內(nèi)存,所以標(biāo)記階段耗時比較長。所以主要垃圾回收可以利用并發(fā)標(biāo)記技術(shù),使得 V8 的標(biāo)記階段都在輔助線程中執(zhí)行時,完全不阻塞 js 主線程:

(2) 三色標(biāo)記

標(biāo)記階段的工作可以看作是圖的遍歷。堆內(nèi)存上的對象就是圖的節(jié)點,一個對象對另一個對象的指針就是圖的邊。標(biāo)記階段的目標(biāo)就是從 Roots 出發(fā),找到所有引用到的對象。

假如是單線程遍歷圖,可以在一個調(diào)用棧中直接廣度或深度遍歷即可。但要實現(xiàn)多線程并發(fā)標(biāo)記,則需要有一個標(biāo)記工作列表(marking worklist),每個線程都從標(biāo)記工作列表中拿取一個工作,并且把下一層級的工作推入到標(biāo)記工作列表中。

V8 在標(biāo)記階段會將每個節(jié)點標(biāo)記為三種顏色:

  • 白色:尚未發(fā)現(xiàn)的節(jié)點
  • 灰色:發(fā)現(xiàn)白色節(jié)點,將其推入到標(biāo)記工作列表中,變成灰色節(jié)點
  • 黑色:從標(biāo)記工作列表中拿取一個灰色節(jié)點,訪問其所有字段,這些字段中若有白色節(jié)點則推入到標(biāo)記工作列表中變成灰色節(jié)點,訪問完所有字段后,將該節(jié)點變成黑色

當(dāng)標(biāo)記工作列表為空時,就意味著完成了整個標(biāo)記階段,確定了圖中所有的活躍對象(黑色節(jié)點)和死亡對象(白色節(jié)點)。

(3) 寫屏障

并發(fā)標(biāo)記時,主線程還在執(zhí)行 js:

此時主線程可能修改內(nèi)存中的引用關(guān)系,多線程的讀寫操作造成了數(shù)據(jù)競爭,這導(dǎo)致輔助線程中的三色標(biāo)記失效。

寫屏障解決了數(shù)據(jù)競爭的問題。寫屏障在 js 執(zhí)行寫操作(比如 object.field = vaule)的時候會被觸發(fā),若一個字段從一個對象指向一個新的對象時,寫屏障會檢查并調(diào)整新對象的顏色(標(biāo)記狀態(tài)),如果該對象是白色(未被標(biāo)記為活的),將其改為灰色并加入待處理隊列。

寫屏障會帶來一定的性能開銷,但它確保了三色標(biāo)記的正確性。

(4) 并行標(biāo)記

大部分情況下,并發(fā)標(biāo)記不阻塞主線程,是更優(yōu)的選擇。

但有時候,對象的引用關(guān)系可能涉及復(fù)雜的線程同步問題,使得標(biāo)記工作難以并發(fā)執(zhí)行。

此時輔助線程會將此標(biāo)記工作任務(wù)推送到一個名為救援工作列表(Bailout worklist)的列表中,求助于主線程通過阻塞 js 來執(zhí)行此標(biāo)記任務(wù)。

救援工作列表中的任務(wù)只會被主線程取走,此時會阻塞主線程的 js 執(zhí)行,所有線程都會并行的執(zhí)行標(biāo)記任務(wù):

(5) 增量標(biāo)記

瀏覽器在下一幀渲染之前,可能有一些空閑時段,這個時段也可以用來做主要垃圾回收的增量標(biāo)記任務(wù)。

為了減少對應(yīng)用程序性能的影響,增量標(biāo)記任務(wù)可以在多個空閑時段中執(zhí)行,每個空閑時段內(nèi)執(zhí)行一段時間,然后中斷以便其他重要任務(wù)(如主線程的 JavaScript 任務(wù))可以繼續(xù)執(zhí)行。在下一個空閑時段,再繼續(xù)未完成的標(biāo)記任務(wù)。

在增量標(biāo)記過程中,垃圾回收并非一次性完成,而是分成許多小的步驟。在主線程 js 執(zhí)行過程中,內(nèi)存中對象的引用關(guān)系可能發(fā)生變化,這會導(dǎo)致之前的標(biāo)記失效了。為了解決這個問題,V8 使用寫屏障來標(biāo)記那些引用關(guān)系發(fā)生變化的對象。這樣,即便在垃圾回收暫停期間對象的引用關(guān)系發(fā)生了變化,垃圾回收器依然能夠準(zhǔn)確地識別和處理這些變化。

由于清除任務(wù)和壓縮任務(wù)的耗時較長,空閑時段不會用來做清除任務(wù)和壓縮任務(wù)。

(6) 黑色分配

根據(jù)分代假說,大多數(shù)對象的生命周期非常短暫,在次要垃圾回收中就會被回收掉。而經(jīng)歷了兩次次要垃圾回收都沒被回收的對象,就會被晉升到老年代。

我們可以認為,剛從新生代晉升到老年代的對象,大概率是一個長期活躍的對象,至少在下次主要垃圾回收時也還是活躍的對象,不會被回收——假如一個剛晉升到老年代的對象馬上就被回收了,說明這個分代假說本身就有問題了。既然它大概率在下次主要垃圾回收時還保持活躍,那么就沒必要在標(biāo)記階段掃描它了,直接將它標(biāo)記為活躍即可。這就是黑色分配的理論依據(jù)——剛晉升到老年代的對象,至少應(yīng)該在下一次主要垃圾回收中存活下來。

在次要垃圾回收的標(biāo)記階段,V8 將準(zhǔn)備從新生代晉升到老年代的對象染成黑色。在次要垃圾回收的轉(zhuǎn)移階段,黑色對象會被移動到一個特殊的黑色內(nèi)存頁中,這個內(nèi)存頁的所有對象都是黑色的。在下次主要垃圾回收的標(biāo)記階段,將會直接跳過黑色內(nèi)存頁的掃描。

黑色分配這一優(yōu)化手段將吞吐量和延遲得分提高了約 30%,同時由于標(biāo)記進度更快且整體垃圾收集工作更少,內(nèi)存使用量減少了約 20%。

2. 清除階段

清除過程會將死亡對象留下的內(nèi)存空隙加入名為「空閑列表(free-list)」的數(shù)據(jù)結(jié)構(gòu)。當(dāng)標(biāo)記完成后,垃圾回收器會掃描整個堆內(nèi)存,找到由不可達對象形成的連續(xù)內(nèi)存空隙,并將其加入對應(yīng)大小的空閑列表??臻e列表按內(nèi)存塊大小分類存儲以便快速檢索。后續(xù)需要分配內(nèi)存時,只需查詢空閑列表即可找到合適大小的內(nèi)存塊。

(1) 并發(fā)清除

因為待清除的內(nèi)存都是死亡內(nèi)存,絕對不會再被主線程訪問到了。所以清除任務(wù)可以完全放在輔助線程中并發(fā)執(zhí)行。并且即使主線程 js 已經(jīng)恢復(fù)執(zhí)行時,輔助線程的清除任務(wù)還可以繼續(xù)執(zhí)行。

3. 壓縮階段

基于碎片化啟發(fā)式算法(fragmentation heuristic),主要垃圾回收會選擇性地對某些內(nèi)存頁執(zhí)行對象遷移/壓縮操作。

這個過程可以類比老式電腦的硬盤碎片整理:我們將存活對象復(fù)制到當(dāng)前未被壓縮的其他內(nèi)存頁(利用其空閑列表)。通過這種方式,可以充分利用死亡對象遺留在內(nèi)存中的零散小空隙。復(fù)制存活對象存在一個潛在問題:復(fù)制大量長生命周期對象會帶來高昂成本。因此我們選擇只壓縮碎片化程度較高的內(nèi)存頁,而對其他內(nèi)存頁僅執(zhí)行清除操作(不復(fù)制存活對象)。壓縮階段會阻塞 js 主線程,避免數(shù)據(jù)競爭帶來的問題。壓縮階段會并行執(zhí)行。

最后總結(jié)一下,標(biāo)記-清除算法的整個流程如下:

(1) 并發(fā)、增量標(biāo)記:當(dāng)堆內(nèi)存接近動態(tài)計算的上限時,V8 會啟動并發(fā)、增量標(biāo)記。瀏覽器會在主線程空閑階段執(zhí)行增量標(biāo)記,在非空閑階段執(zhí)行并發(fā)標(biāo)記。主線程執(zhí)行期間可能會產(chǎn)生新的對象引用關(guān)系,V8 采用寫屏障(Write Barrier)機制來記錄 js 在并發(fā)、增量標(biāo)記階段創(chuàng)建的新對象引用,確保標(biāo)記結(jié)果的準(zhǔn)確性,當(dāng)遇到難以并發(fā)執(zhí)行的標(biāo)記任務(wù)時,會推入到救援工作列表中,交給最終標(biāo)記階段執(zhí)行。

(2) 最終標(biāo)記(并行標(biāo)記):并發(fā)、增量標(biāo)記完成后,主線程會暫停執(zhí)行 js,此時進入最終標(biāo)記階段(marking finalization),多線程并行執(zhí)行救援工作列表的工作,然后重新掃描 GC Roots,確保所有存活對象都被標(biāo)記了。

(3) 并行壓縮階段:主線程繼續(xù)暫停執(zhí)行 js,多線程并行執(zhí)行壓縮任務(wù),將存活對象移動到連續(xù)內(nèi)存塊以減少碎片,并更新相關(guān)指針。部分無法壓縮的頁則通過空閑列表(free-list)進行內(nèi)存回收。

(4) 并發(fā)清除階段:與此同時,輔助線程執(zhí)行并發(fā)清除任務(wù),這些任務(wù)與并行壓縮及主線程代碼執(zhí)行同時進行,即使主線程 js 恢復(fù)運行,清除任務(wù)仍可在輔助線程中繼續(xù)執(zhí)行。

四、垃圾回收的觸發(fā)時機

JavaScirpt 的垃圾回收時機無法用程序控制,這是設(shè)計如此的:

  • 程序員可能希望在時間關(guān)鍵的應(yīng)用階段關(guān)閉垃圾回收,以避免因垃圾回收引起的幀丟失。然而,這會使應(yīng)用邏輯變得復(fù)雜,維護變得困難。如果在代碼的某個分支中忘記重新開啟垃圾回收,可能會導(dǎo)致內(nèi)存耗盡。
  • 程序員無法預(yù)估手動觸發(fā)的垃圾回收需要多長時間,可能會導(dǎo)致應(yīng)用程序本身引入卡頓,反而無法達到預(yù)期的性能優(yōu)化效果。
  • 這會給 js 引擎帶來額外的工作,手動觸發(fā)垃圾回收可能會干擾垃圾回收器的啟發(fā)式算法,導(dǎo)致不可靠的內(nèi)存管理行為。

1. 次要垃圾回收

  • 當(dāng)年輕代的活動空間被填滿時觸發(fā)
  • 當(dāng)程序請求分配新的內(nèi)存,并且年輕代沒有足夠內(nèi)存時觸發(fā)
  • 當(dāng)空閑時段有足夠的空閑時間時觸發(fā)。但并不是一定會觸發(fā),因為頻繁的觸發(fā)可能導(dǎo)致本可以在次要垃圾回收中得到回收的對象被移入了老年代

2. 主要垃圾回收

  • 當(dāng)老年代中的對象占用空間增長到超過某個啟發(fā)式計算的內(nèi)存限制時觸發(fā)
  • 如果整個堆的使用情況超過了特定的內(nèi)存閾值,基于啟發(fā)式算法,系統(tǒng)可能觸發(fā)主要垃圾回收
  • 當(dāng)堆大小達到某個策略設(shè)定的開始增量標(biāo)記的限制時,會開始在空閑時段執(zhí)行增量標(biāo)記,增量標(biāo)記完成后,開始清除和壓縮,最終完成主要垃圾回收
  • 在檢測到應(yīng)用的長期不活躍狀態(tài)時,甚至在沒有達到內(nèi)存限制的情況下,可能主動進行主要垃圾回收來減少內(nèi)存占用

3. 動態(tài)的垃圾回收頻率

在一次完整的垃圾收集結(jié)束時,V8 的堆增長策略會根據(jù)存活對象的數(shù)量和內(nèi)存的余量,來決定下一次垃圾收集的時間。所以垃圾回收的頻率會根據(jù)內(nèi)存的狀態(tài)實時變化。

4. 低內(nèi)存模式

垃圾回收的吞吐量、造成的頁面延遲以及占用內(nèi)存之間是一個不可能三角。針對不同的設(shè)備,需要有不同的內(nèi)存回收策略。對于內(nèi)存較低的移動設(shè)備,即內(nèi)存少于 512 MB 的設(shè)備,優(yōu)先考慮延遲和吞吐量而不是內(nèi)存消耗可能會導(dǎo)致內(nèi)存不足而崩潰。

為了更好地平衡這些低內(nèi)存移動設(shè)備的權(quán)衡,V8 引入了一種特殊的低內(nèi)存模式,該模式調(diào)整了一些垃圾收集啟發(fā)模式以降低 JavaScript 垃圾收集堆的內(nèi)存使用量。一般來說,在一次完整的垃圾收集結(jié)束時,V8 會根據(jù)存活對象的數(shù)量和內(nèi)存余量來決定下一次垃圾收集的時間。在低內(nèi)存模式下,內(nèi)存余量更少,所以垃圾回收會更頻繁的觸發(fā)。

一般來說,主要垃圾回收會在內(nèi)存還有空余時觸發(fā),此時會在空閑時段執(zhí)行增量標(biāo)記,等標(biāo)記任務(wù)完全完成后,才會阻塞主線程,開始執(zhí)行清除和壓縮。但在低內(nèi)存模式下,由于內(nèi)存余量更少,可能在增量標(biāo)記還未完成時,就觸發(fā)了主線程的垃圾回收,此時主線程 js 阻塞,主線程和輔助線程并行的執(zhí)行剩余的標(biāo)記任務(wù)和后續(xù)的清除、壓縮任務(wù)。低內(nèi)存模式雖然使垃圾回收更頻繁了,但是也使得移動端設(shè)備上的堆內(nèi)存消耗減少了 50%

5. 不活躍的網(wǎng)頁

一般來說,瀏覽器會對每個網(wǎng)頁限制內(nèi)存,一旦達到限值,則會啟動主要垃圾回收。然而,如果網(wǎng)頁在達到分配限制之前變得不活躍,那么在網(wǎng)頁整個不活躍期間都不會進行主要的垃圾回收——這正是大多數(shù)網(wǎng)頁會遇到的情況——大多數(shù)網(wǎng)頁在加載頁面時會使用較多內(nèi)存,因為它們正在初始化其內(nèi)部數(shù)據(jù)結(jié)構(gòu),加載后不久(幾秒或幾分鐘內(nèi)),網(wǎng)頁通常變得不活躍。如果此時還未達到內(nèi)存限值,就不會進行主要垃圾回收了。

這會導(dǎo)致不活躍的網(wǎng)頁遲遲得不到內(nèi)存回收。所以 Chrome 實現(xiàn)了一個名為「Memory Reducer」的控制器,它會檢測網(wǎng)頁何時變得不活躍,并主動調(diào)度一次主要垃圾回收——即使此時還未達到內(nèi)存分配限制。

責(zé)任編輯:趙寧寧 來源: 騰訊技術(shù)工程
相關(guān)推薦

2015-11-10 09:34:58

JavaScript方式

2023-07-10 16:18:18

性能優(yōu)化開發(fā)

2009-10-26 16:38:16

接入網(wǎng)方案

2010-05-07 11:00:25

Oracle多表查詢

2016-08-04 13:19:06

MySQL數(shù)據(jù)庫大優(yōu)化

2017-11-22 14:20:07

前端JavaScript排序算法

2014-10-09 09:48:14

JavaScript

2016-08-12 11:04:17

JavaScript物聯(lián)網(wǎng)應(yīng)用

2017-03-17 14:18:34

JavaScript算法問題詳解

2009-06-03 15:27:07

CPU網(wǎng)絡(luò)優(yōu)化網(wǎng)康

2021-09-18 09:53:48

京東客服IM消息消息處理

2010-03-29 10:55:38

Oracle優(yōu)化

2010-04-07 16:41:50

Oracle SQL優(yōu)

2009-08-04 14:48:26

并發(fā)和并行的區(qū)別

2019-11-11 15:10:37

FedoraLinuxbash

2025-03-31 10:42:31

2011-05-19 09:10:17

JavaScriptLinux

2023-10-07 08:54:28

項目httpPost對象

2015-08-26 10:13:55

排序算法總結(jié)

2017-03-03 16:50:01

點贊
收藏

51CTO技術(shù)棧公眾號