全網最硬核 JVM TLAB 分析(單篇版不包含額外加菜)

乾貨滿滿張雜湊發表於2021-02-04

今天,又是乾貨滿滿的一天。這是全網最硬核 JVM 系列的開篇,首先從 TLAB 開始。由於文章很長,每個人閱讀習慣不同,所以特此拆成單篇版和多篇版

1. 觀前提醒

本期內容比較硬核,非常全面,涉及到了設計思想到實現原理以及原始碼,並且還給出了相應的日誌以及監控方式,如果有不清楚或者有疑問的地方,歡迎留言。

其中涉及到的設計思想主要為個人理解,實現原理以及原始碼解析也是個人整理,如果有不準確的地方,非常歡迎指正!提前感謝~~

2. 分配記憶體實現思路

我們經常會 new 一個物件,這個物件是需要佔用空間的,第一次 new 一個物件佔用的空間如 圖00 所示,

我們這裡先只關心堆內部的儲存,元空間中的儲存,我們會在另一個系列詳細討論。堆內部的儲存包括物件頭,物件體以及記憶體對齊填充,那麼這塊空間是如何分配的呢?

首先,物件所需的記憶體,在物件的類被解析載入進入元空間之後,就可以在分配記憶體建立前計算出來。假設現在我們自己來設計堆記憶體分配,一種最簡單的實現方式就是線性分配,也被稱為撞針分配(bump-the-pointer)。

image

每次需要分配記憶體時,先計算出需要的記憶體大小,然後 CAS 更新圖01 中所示的記憶體分配指標,標記分配的記憶體。但是記憶體一般不是這麼整齊的,可能有些記憶體在分配有些記憶體就被釋放回收了。所以一般不會只靠撞針分配。一種思路是在撞針分配的基礎上,加上一個 FreeList。

image

簡單的實現是將釋放的物件記憶體加入 FreeList,下次分配物件的時候,優先從 FreeList 中尋找合適的記憶體大小進行分配,之後再在主記憶體中撞針分配。

這樣雖然一定程度上解決了問題,但是目前大多數應用是多執行緒的,所以記憶體分配是多執行緒的,都從主記憶體中分配,CAS 更新重試過於頻繁導致效率低下。目前的應用,一般根據不同業務區分了不同的執行緒池,在這種情況下,一般每個執行緒分配記憶體的特性是比較穩定的。這裡的比較穩定指的是,每次分配物件的大小,每輪 GC 分配區間內的分配物件的個數以及總大小。所以,我們可以考慮每個執行緒分配記憶體後,就將這塊記憶體保留起來,用於下次分配,這樣就不用每次從主記憶體中分配了。如果能估算每輪 GC 內每個執行緒使用的記憶體大小,則可以提前分配好記憶體給執行緒,這樣就更能提高分配效率。這種記憶體分配的實現方式,在 JVM 中就是 TLAB (Thread Local Allocate Buffer)。

3. JVM 物件堆記憶體分配流程簡述

image

我們這裡不考慮棧上分配,這些會在 JIT 的章節詳細分析,我們這裡考慮的是無法棧上分配需要共享的物件

對於 HotSpot JVM 實現,所有的 GC 演算法的實現都是一種對於堆記憶體的管理,也就是都實現了一種堆的抽象,它們都實現了介面 CollectedHeap。當分配一個物件堆記憶體空間時,在 CollectedHeap 上首先都會檢查是否啟用了 TLAB,如果啟用了,則會嘗試 TLAB 分配;如果當前執行緒的 TLAB 大小足夠,那麼從執行緒當前的 TLAB 中分配;如果不夠,但是當前 TLAB 剩餘空間小於最大浪費空間限制(這是一個動態的值,我們後面會詳細分析),則從堆上(一般是 Eden 區) 重新申請一個新的 TLAB 進行分配。否則,直接在 TLAB 外進行分配。TLAB 外的分配策略,不同的 GC 演算法不同。例如G1:

  • 如果是 Humongous 物件(物件在超過 Region 一半大小的時候),直接在 Humongous 區域分配(老年代的連續區域)。
  • 根據 Mutator 狀況在當前分配下標的 Region 內分配

4. TLAB 的生命週期

image

TLAB 是執行緒私有的,執行緒初始化的時候,會建立並初始化 TLAB。同時,在 GC 掃描物件發生之後,執行緒第一次嘗試分配物件的時候,也會建立並初始化 TLAB。
TLAB 生命週期停止(TLAB 宣告週期停止不代表記憶體被回收,只是代表這個 TLAB 不再被這個執行緒私有管理)在:

  • 當前 TLAB 不夠分配,並且剩餘空間小於最大浪費空間限制,那麼這個 TLAB 會被退回 Eden,重新申請一個新的
  • 發生 GC 的時候,TLAB 被回收。

5. TLAB 要解決的問題以及帶來的問題與解決方案的思考

TLAB 要解決的問題很明顯,儘量避免從堆上直接分配記憶體從而避免頻繁的鎖爭用。

引入 TLAB 之後,TLAB 的設計上,也有很多值得考慮的問題。

5.1. 引入 TLAB 後,會有記憶體孔隙問題,還可能影響 GC 掃描效能

出現孔隙的情況:

  • 當前 TLAB 不夠分配時,如果剩餘空間小於最大浪費空間限制,那麼這個 TLAB 會被退回 Eden,重新申請一個新的。這個剩餘空間就會成為孔隙。
  • 當發生 GC 的時候,TLAB 沒有用完,沒有分配的記憶體也會成為孔隙。

image

如果不管這些孔隙,由於 TLAB 僅執行緒內知道哪些被分配了,在 GC 掃描發生時返回 Eden 區,如果不填充的話,外部並不知道哪一部分被使用哪一部分沒有,需要做額外的檢查,那麼會影響 GC 掃描效率。所以 TLAB 迴歸 Eden 的時候,會將剩餘可用的空間用一個 dummy object 填充滿。如果填充已經確認會被回收的物件,也就是 dummy object, GC 會直接標記之後跳過這塊記憶體,增加掃描效率。但是同時,由於需要填充這個 dummy object,所以需要預留出這個物件的物件頭的空間

5.2. 某個執行緒在一輪 GC 內分配的記憶體並不穩定

如果我們能提前知道在這一輪內每個執行緒會分配多少記憶體,那麼我們可以直接提前分配好。但是,這簡直是痴人說夢。每個執行緒在每一輪 GC 的分配情況可能都是不一樣的:

  • 不同的執行緒業務場景不同導致分配物件大小不同。我們一般會按照業務區分不同的執行緒池,做好執行緒池隔離。對於使用者請求,每次分配的物件可能比較小。對於後臺分析請求,每次分配的物件相對大一些。
  • 不同時間段內執行緒壓力並不均勻。業務是有高峰有低谷的,高峰時間段內肯定分配物件更多。
  • 同一時間段同一執行緒池內的執行緒的業務壓力也不一定不能做到很均勻。很可能只有幾個執行緒很忙,其他執行緒很閒。

所以,綜合考慮以上情況,我們應該這麼實現 TLAB:

  • 不能一下子就給一個執行緒申請一個比較大的 TLAB,而是考慮這個執行緒 TLAB 分配滿之後再申請新的,這樣更加靈活。
  • 每次申請 TLAB 的大小是變化的,並不是固定的。
  • 每次申請 TLAB 的大小需要考慮當前 GC 輪次內會分配物件的執行緒的個數期望
  • 每次申請 TLAB 的大小需要考慮所有執行緒期望 TLAB 分配滿重新申請新的 TLAB 次數

6. JVM 中的期望計算 EMA

在上面提到的 TLAB 大小設計的時候,我們經常提到期望。這個期望是根據歷史資料計算得出的,也就是每次輸入取樣值,根據歷史取樣值得出最新的期望值。不僅 TLAB 用到了這種期望計算,GC 和 JIT 等等 JVM 機制中都用到了。這裡我們來看一種 TLAB 中經常用到的 EMA(Exponential Moving Average 指數平均數) 演算法:

image

EMA 演算法的核心在於設定合適的最小權重,我們假設一個場景:首先取樣100個 100(演算法中的前 100 個是為了排除不穩定的干擾,我們這裡直接忽略前 100 個取樣),之後取樣 50 個 2,最後取樣 50 個 200,對於不同的最小權重,來看一下變化曲線。

image

可以看出,最小權重越大,變化得越快,受歷史資料影響越小。根據應用設定合適的最小權重,可以讓你的期望更加理想。

這塊對應的原始碼:gcUtil.hppAdaptiveWeightedAverage 類。

7. TLAB 相關的 JVM 引數

這裡僅僅是列出來,並附上簡介,看不懂沒關係,之後會有詳細分析,幫助你理解每一個引數。等你理解後,這個小章節就是你的工具書啦~~
以下引數以及預設值基於 OpenJDK 17

7.1. TLABStats(已過期)

從 Java 12 開始已過期,目前已經沒有相關的邏輯了。之前是用於 TLAB 統計資料從而更好地伸縮 TLAB 但是效能消耗相對較大,但是現在主要通過 EMA 計算了。

7.2. UseTLAB

說明:是否啟用 TLAB,預設是啟用的。

預設:true

舉例:如果想關閉:-XX:-UseTLAB

7.3. ZeroTLAB

說明:是否將新建立的 TLAB 內的所有位元組歸零。我們建立一個類的時候,類的 field 是有預設值的,例如 boolean 是 false,int 是 0 等等,實現的方式就是對分配好的記憶體空間賦 0。設定 ZeroTLAB 為 true 代表在 TLAB 申請好的時候就賦 0,否則會在分配物件並初始化的時候賦 0.講道理,由於 TLAB 分配的時候會涉及到 Allocation Prefetch 優化 CPU 快取,在 TLAB 分配好之後立刻更新賦 0 對於 CPU 快取應該是更友好的,並且,如果 TLAB 沒有用滿,填充的 dummy object 其實依然是 0 陣列,相當於大部分不用改。這麼看來,開啟應該更好。但是ZeroTLAB 預設還是不開啟的。

預設:false

舉例-XX:+ZeroTLAB

7.4. ResizeTLAB

說明:TLAB 是否是可變的,預設為是,也就是會根據執行緒歷史分配資料相關 EMA 計算出每次期望 TLAB 大小並以這個大小為準申請 TLAB。

預設:true

舉例:如果想關閉:-XX:-ResizeTLAB

7.5. TLABSize

說明:初始 TLAB 大小。單位是位元組

預設:0, 0 就是不主動設定 TLAB 初始大小,而是通過 JVM 自己計算每一個執行緒的初始大小

舉例-XX:TLABSize=65536

7.6. MinTLABSize

說明:最小 TLAB 大小。單位是位元組

預設:2048

舉例-XX:TLABSize=4096

7.7. TLABAllocationWeight

說明: TLAB 初始大小計算和執行緒數量有關,但是執行緒是動態建立銷燬的。所以需要基於歷史執行緒個數推測接下來的執行緒個數來計算 TLAB 大小。一般 JVM 內像這種預測函式都採用了 EMA 。這個引數就是 圖06 中的最小權重,權重越高,最近的資料佔比影響越大。TLAB 重新計算大小是根據分配比例,分配比例也是採用了 EMA 演算法,最小權重也是 TLABAllocationWeight

預設:35

舉例-XX:TLABAllocationWeight=70

7.8. TLABWasteTargetPercent

說明:TLAB 的大小計算涉及到了 Eden 區的大小以及可以浪費的比率。TLAB 浪費指的是上面提到的重新申請新的 TLAB 的時候老的 TLAB 沒有分配的空間。這個引數其實就是 TLAB 浪費佔用 Eden 的百分比,這個引數的作用會在接下來的原理說明內詳細說明

預設:1

舉例-XX:TLABWasteTargetPercent=10

7.9. TLABRefillWasteFraction

說明: 初始最大浪費空間限制計算引數,初始最大浪費空間限制 = 當前期望 TLAB 大小 / TLABRefillWasteFraction

預設:64

舉例-XX:TLABRefillWasteFraction=32

7.10. TLABWasteIncrement

說明最大浪費空間限制並不是不變的,在發生 TLAB 緩慢分配的時候(也就是當前 TLAB 空間不足以分配的時候),會增加最大浪費空間限制。這個引數就是 TLAB 緩慢分配時允許的 TLAB 浪費增量。單位不是位元組,而是 MarkWord 個數,也就是 Java 堆的記憶體最小單元,64 位虛擬機器的情況下,MarkWord 大小為 3 位元組。

預設:4

舉例-XX:TLABWasteIncrement=4

8.TLAB 基本流程

8.0. 如何設計每個執行緒的 TLAB 大小

之前我們提到了引入 TLAB 要面臨的問題以及解決方式,根據這些我們可以這麼設計 TLAB。

首先,TLAB 的初始大小,應該和每個 GC 內需要物件分配的執行緒個數相關。但是,要分配的執行緒個數並不一定是穩定的,可能這個時間段執行緒數多,下個階段執行緒數就不那麼多了,所以,需要用 EMA 的演算法採集每個 GC 內需要物件分配的執行緒個數來計算這個個數期望

接著,我們最理想的情況下,是每個 GC 內,所有用來分配物件的記憶體都處於對應執行緒的 TLAB 中。每個 GC 內用來分配物件的記憶體從 JVM 設計上來講,其實就是 Eden 區大小。在 最理想的情況下,最好只有Eden 區滿了的時候才會 GC,不會有其他原因導致的 GC,這樣是最高效的情況。Eden 區被用光,如果全都是 TLAB 內分配,也就是 Eden 區被所有執行緒的 TLAB 佔滿了,這樣分配是最快的。

然後,每輪 GC 分配記憶體的執行緒個數以及大小是不一定的,如果一下子分配一大塊會造成浪費,如果太小則會頻繁從 Eden 申請 TLAB,降低效率。這個大小比較難以控制,但是我們可以限制每個執行緒究竟在一輪 GC 內,最多從 Eden 申請多少次 TLAB,這樣對於使用者來說更好控制。

最後,每個執行緒分配的記憶體大小,在每輪 GC 並不一定穩定,只用初始大小來指導之後的 TLAB 大小,顯然不夠。我們換個思路,每個執行緒分配的記憶體和歷史有一定關係因此我們可以從歷史分配中推測,所以每個執行緒也需要採用 EMA 的演算法採集這個執行緒每次 GC 分配的記憶體,用於指導下次期望的 TLAB 的大小。

綜上所述,我們可以得出這樣一個近似的 TLAB 計算公式

每個執行緒 TLAB 初始大小 = Eden區大小 / (執行緒單個 GC 輪次內最多從 Eden 申請多少次 TLAB * 當前 GC 分配執行緒個數 EMA)

GC 後,重新計算 TLAB 大小 = Eden區大小 / (執行緒單個 GC 輪次內最多從 Eden 申請多少次 TLAB * 當前 GC 分配執行緒個數 EMA)

接下來,我們來詳細分析 TLAB 的整個生命週期的每個流程。

8.1. TLAB 初始化

執行緒初始化的時候,如果 JVM 啟用了 TLAB(預設是啟用的, 可以通過 -XX:-UseTLAB 關閉),則會初始化 TLAB,在發生物件分配時,會根據期望大小申請 TLAB 記憶體。同時,在 GC 掃描物件發生之後,執行緒第一次嘗試分配物件的時候,也會重新申請 TLAB 記憶體。我們先只關心初始化,初始化的流程圖如 圖08 所示:

image

初始化時候會計算 TLAB 初始期望大小。這涉及到了 TLAB 大小的限制

  • TLAB 的最小大小:通過MinTLABSize指定
  • TLAB 的最大大小:不同的 GC 中不同,G1 GC 中為大物件(humongous object)大小,也就是 G1 region 大小的一半。因為開頭提到過,在 G1 GC 中,大物件不能在 TLAB 分配,而是老年代。ZGC 中為頁大小的 8 分之一,類似的在大部分情況下 Shenandoah GC 也是每個 Region 大小的 8 分之一。他們都是期望至少有 8 分之 7 的區域是不用退回的減少選擇 Cset 的時候的掃描複雜度。對於其他的 GC,則是 int 陣列的最大大小,這個和之前提到的填充 dummy object 有關,後面會提到詳細流程。

之後的流程裡面,無論何時,TLAB 的大小都會在這個 TLAB 的最小大小 到 TLAB 的最大大小 的範圍內,為了避免囉嗦,我們不會再強調這個限制~~~!!! 之後的流程裡面,無論何時,TLAB 的大小都會在這個 TLAB 的最小大小 到 TLAB 的最大大小 的範圍內,為了避免囉嗦,我們不會再強調這個限制~~~!!! 之後的流程裡面,無論何時,TLAB 的大小都會在這個 TLAB 的最小大小 到 TLAB 的最大大小 的範圍內,為了避免囉嗦,我們不會再強調這個限制~~~!!! 重要的事情說三遍~

TLAB 期望大小(desired size) 在初始化的時候會計算 TLAB 期望大小,之後再 GC 等操作回收掉 TLAB 需要重計算這個期望大小。根據這個期望大小,TLAB 在申請空間的時候每次申請都會以這個期望大小作為基準的空間作為 TLAB 分配空間。

8.1.1. TLAB 初始期望大小計算

圖08 所示,如果指定了 TLABSize,就用這個大小作為初始期望大小。如果沒有指定,則按照如下的公式進行計算:

堆給TLAB的空間總大小/(當前有效分配執行緒個數期望*重填次數配置)

  1. 堆給 TLAB 的空間總大小:堆上能有多少空間分配給 TLAB,不同的 GC 演算法不一樣,但是大多數 GC 演算法的實現都是 Eden 區大小,例如:
    1. 傳統的已經棄用的 Parallel Scanvage 中,就是 Eden 區大小。參考:parallelScavengeHeap.cpp
    2. 預設的G1 GC 中是 (YoungList 區域個數減去 Survivor 區域個數) * 區域大小,其實就是 Eden 區大小。參考:g1CollectedHeap.cpp
    3. ZGC 中是 Page 剩餘空間大小,Page 類似於 Eden 區,是大部分物件分配的區域。參考:zHeap.cpp
    4. Shenandoah GC 中是 FreeSet 的大小,也是類似於 Eden 的概念。參考:shenandoahHeap.cpp
  2. 當前有效分配執行緒個數期望:這是一個全域性 EMA,EMA 是什麼之前已經說明了,是一種計算期望的方式。有效分配執行緒個數 EMA 的最小權重是 TLABAllocationWeight。有效分配執行緒個數 EMA 在有執行緒進行第一次有效物件分配的時候進行採集,在 TLAB 初始化的時候讀取這個值計算 TLAB 期望大小。
  3. TLAB 重填次數配置(refills time):根據 TLABWasteTargetPercent 計算的次數,公式為。TLABWasteTargetPercent 的意義其實是限制最大浪費空間限制,為何重填次數與之相關後面會詳細分析。

8.1.2. TLAB 初始分配比例計算

圖08 所示,接下來會計算TLAB 初始分配比例。

執行緒私有分配比例 EMA:與有效分配執行緒個數 EMA對應,有效分配執行緒個數 EMA是對於全域性來說,每個執行緒應該佔用多大的 TLAB 的描述,而分配比例 EMA 相當於對於當前執行緒應該佔用的總 TLAB 空間的大小的一種動態控制。

初始化的時候,分配比例其實就是等於 1/當前有效分配執行緒個數圖08 的公式,代入之前的計算 TLAB 期望大小的公式,消參簡化之後就是1/當前有效分配執行緒個數。這個值作為初始值,採集如執行緒私有的分配比例 EMA

8.1.3. 清零執行緒私有統計資料

這些採集資料會用於之後的當前執行緒的分配比例的計算與採集,從而影響之後的當前執行緒 TLAB 期望大小。

8.2. TLAB 分配

TLAB 分配流程如 圖09 所示。

image

8.2.1. 從執行緒當前 TLAB 分配

如果啟用了 TLAB(預設是啟用的, 可以通過 -XX:-UseTLAB 關閉),則首先從執行緒當前 TLAB 分配記憶體,如果分配成功則返回,否則根據當前 TLAB 剩餘空間與當前最大浪費空間限制大小進行不同的分配策略。在下一個流程,就會提到這個限制究竟是什麼。

8.2.2. 重新申請 TLAB 分配

如果當前 TLAB 剩餘空間大於當前最大浪費空間限制(根據 圖08 的流程,我們知道這個初始值為 期望大小/TLABRefillWasteFraction),直接在堆上分配。否則,重新申請一個 TLAB 分配。
為什麼需要最大浪費空間呢?

當重新分配一個 TLAB 的時候,原有的 TLAB 可能還有空間剩餘。原有的 TLAB 被退回堆之前,需要填充好 dummy object。由於 TLAB 僅執行緒內知道哪些被分配了,在 GC 掃描發生時返回 Eden 區,如果不填充的話,外部並不知道哪一部分被使用哪一部分沒有,需要做額外的檢查,如果填充已經確認會被回收的物件,也就是 dummy object, GC 會直接標記之後跳過這塊記憶體,增加掃描效率。反正這塊記憶體已經屬於 TLAB,其他執行緒在下次掃描結束前是無法使用的。這個 dummy object 就是 int 陣列。為了一定能有填充 dummy object 的空間,一般 TLAB 大小都會預留一個 dummy object 的 header 的空間,也是一個 int[] 的 header,所以 TLAB 的大小不能超過int 陣列的最大大小,否則無法用 dummy object 填滿未使用的空間。

但是,填充 dummy 也造成了空間的浪費,這種浪費不能太多,所以通過最大浪費空間限制來限制這種浪費。

新的 TLAB 大小,取如下兩個值中較小的那個:

  • 當前堆剩餘給 TLAB 可分配的空間,大部分 GC 的實現其實就是對應的 Eden 區剩餘大小:
    • 傳統的已經棄用的 Parallel Scanvage 中,就是 Eden 區剩餘大小。參考:parallelScavengeHeap.cpp
    • 預設的G1 GC 中是當前 Region 中剩餘大小,其實就是將 Eden 分割槽了。參考:g1CollectedHeap.cpp
    • ZGC 中是 Page 剩餘空間大小,Page 類似於 Eden 區,是大部分物件分配的區域。參考:zHeap.cpp
    • Shenandoah GC 中是 FreeSet 的剩餘大小,也是類似於 Eden 的概念。參考:shenandoahHeap.cpp
  • TLAB 期望大小 + 當前需要分配的空間大小

當分配出來 TLAB 之後,根據 ZeroTLAB 配置,決定是否將每個位元組賦 0。在建立物件的時候,本來也要對每個欄位賦初始值,大部分欄位初始值都是 0,並且,在 TLAB 返還到堆時,剩餘空間填充的也是 int[] 陣列,裡面都是 0。所以其實可以提前填充好。並且,TLAB 剛分配出來的時候,賦 0 也能利用好 Allocation prefetch 的機制適應 CPU 快取行(Allocation prefetch 的機制會在另一個系列說明),所以可以通過開啟 ZeroTLAB 來在分配 TLAB 空間之後立刻賦 0。

8.2.3. 直接從堆上分配

直接從堆上分配是最慢的分配方式。一種情況就是,如果當前 TLAB 剩餘空間大於當前最大浪費空間限制,直接在堆上分配。並且,還會增加當前最大浪費空間限制,每次有這樣的分配就會增加 TLABWasteIncrement 的大小,這樣在一定次數的直接堆上分配之後,當前最大浪費空間限制一直增大會導致當前 TLAB 剩餘空間小於當前最大浪費空間限制,從而申請新的 TLAB 進行分配。

8.3. GC 時 TLAB 回收與重計算期望大小

相關流程如 圖10 所示,在 GC 前與 GC 後,都會對 TLAB 做一些操作。

image

8.3.1. GC 前的操作

在 GC 前,如果啟用了 TLAB(預設是啟用的, 可以通過 -XX:-UseTLAB 關閉),則需要將所有執行緒的 TLAB 填充 dummy Object 退還給堆,並計算並取樣一些東西用於以後的 TLAB 大小計算。

首先為了保證本次計算具有參考意義,需要先判斷是否堆上 TLAB 空間被用了一半以上,假設不足,那麼認為本輪 GC 的資料沒有參考意義。如果被用了一半以上,那麼計算新的分配比例,新的分配比例 = 執行緒本輪 GC 分配空間的大小 / 堆上所有執行緒 TLAB 使用的空間,這麼計算主要因為分配比例描述的是當前執行緒佔用堆上所有給 TLAB 的空間的比例,每個執行緒不一樣,通過這個比例動態控制不同業務執行緒的 TLAB 大小。

執行緒本輪 GC 分配空間的大小包含 TLAB 中分配的和 TLAB 外分配的,從 圖8、圖9、圖10 流程圖中對於執行緒記錄中的執行緒分配空間大小的記錄就能看出,讀取出執行緒分配空間大小減去上一輪 GC 結束時執行緒分配空間大小就是執行緒本輪 GC 分配空間的大小

最後,將當前 TLAB 填充好 dummy object 之後,返還給堆。

8.3.2. GC 後的操作

如果啟用了 TLAB(預設是啟用的, 可以通過 -XX:-UseTLAB 關閉),以及 TLAB 大小可變(預設是啟用的, 可以通過 -XX:-ResizeTLAB 關閉),那麼在 GC 後會重新計算每個執行緒 TLAB 的期望大小,新的期望大小 = 堆給TLAB的空間總大小 * 當前分配比例 EMA / 重填次數配置。然後會重置最大浪費空間限制,為當前 期望大小 / TLABRefillWasteFraction

9. OpenJDK HotSpot TLAB 相關原始碼分析

如果這裡看的比較吃力,可以直接看第 10 章,熱門 Q&A,裡面有很多大家常問的問題

9.1. TLAB 類構成

執行緒初始化的時候,如果 JVM 啟用了 TLAB(預設是啟用的, 可以通過 -XX:-UseTLAB 關閉),則會初始化 TLAB。

TLAB 包括如下幾個 field (HeapWord* 可以理解為堆中的記憶體地址):
src/hotspot/share/gc/shared/threadLocalAllocBuffer.cpp

//靜態全域性變數
static size_t   _max_size;                          // 所有 TLAB 的最大大小
  static int      _reserve_for_allocation_prefetch;   // CPU 快取優化 Allocation Prefetch 的保留空間,這裡先不用關心
  static unsigned _target_refills;                    //每個 GC 週期內期望的重填次數

//以下是 TLAB 的主要構成 field
HeapWord* _start;                              // TLAB 起始地址,表示堆記憶體地址都用 HeapWord* 
HeapWord* _top;                                // 上次分配的記憶體地址
HeapWord* _end;                                // TLAB 結束地址
size_t    _desired_size;                       // TLAB 大小 包括保留空間,表示記憶體大小都需要通過 size_t 型別,也就是實際位元組數除以 HeapWordSize 的值
size_t    _refill_waste_limit;                 // TLAB最大浪費空間,剩餘空間不足分配浪費空間限制。在TLAB剩餘空間不足的時候,根據這個值決定分配策略,如果浪費空間大於這個值則直接在 Eden 區分配,如果小於這個值則將當前 TLAB 放回 Eden 區管理並從 Eden 申請新的 TLAB 進行分配。 
AdaptiveWeightedAverage _allocation_fraction;  // 當前 TLAB 分配比例 EMA

//以下是我們這裡不用太關心的 field
HeapWord* _allocation_end;                    // TLAB 真正可以用來分配記憶體的結束地址,這個是 _end 結束地址排除保留空間(預留給 dummy object 的物件頭空間)
HeapWord* _pf_top;                            // Allocation Prefetch CPU 快取優化機制相關需要的引數,這裡先不用考慮
size_t    _allocated_before_last_gc;          // 這個用於計算 圖10 中的執行緒本輪 GC 分配空間的大小,記錄上次 GC 時,執行緒分配的空間大小
unsigned  _number_of_refills;                 // 執行緒分配記憶體資料採集相關,TLAB 剩餘空間不足分配次數
unsigned  _fast_refill_waste;                 // 執行緒分配記憶體資料採集相關,TLAB 快速分配浪費,快速分配就是直接在 TLAB 分配,這個在現在 JVM 中已經用不到了
unsigned  _slow_refill_waste;                 // 執行緒分配記憶體資料採集相關,TLAB 慢速分配浪費,慢速分配就是重填一個 TLAB 分配
unsigned  _gc_waste;                          // 執行緒分配記憶體資料採集相關,gc浪費
unsigned  _slow_allocations;                  // 執行緒分配記憶體資料採集相關,TLAB 慢速分配計數 
size_t    _allocated_size;                    // 分配的記憶體大小
size_t    _bytes_since_last_sample_point;     // JVM TI 採集指標相關 field,這裡不用關心

9.2. TLAB 初始化

首先是 JVM 啟動的時候,全域性 TLAB 需要初始化:
src/hotspot/share/gc/shared/threadLocalAllocBuffer.cpp

void ThreadLocalAllocBuffer::startup_initialization() {
  //初始化,也就是歸零統計資料
  ThreadLocalAllocStats::initialize();

  // 假設平均下來,GC 掃描的時候,每個執行緒當前的 TLAB 都有一半的記憶體被浪費,這個每個執行緒使用記憶體的浪費的百分比率(也就是 TLABWasteTargetPercent),也就是等於(注意,僅最新的那個 TLAB 有浪費,之前 refill 退回的假設是沒有浪費的):1/2 * (每個 epoch 內每個執行緒期望 refill 次數) * 100
  //那麼每個 epoch 內每個執行緒 refill 次數配置就等於 50 / TLABWasteTargetPercent, 預設也就是 50 次。
  _target_refills = 100 / (2 * TLABWasteTargetPercent);
  // 但是初始的 _target_refills 需要設定最多不超過 2 次來減少 VM 初始化時候 GC 的可能性
  _target_refills = MAX2(_target_refills, 2U);

//如果 C2 JIT 編譯存在並啟用,則保留 CPU 快取優化 Allocation Prefetch 空間,這個這裡先不用關心,會在別的章節講述
#ifdef COMPILER2
  if (is_server_compilation_mode_vm()) {
    int lines =  MAX2(AllocatePrefetchLines, AllocateInstancePrefetchLines) + 2;
    _reserve_for_allocation_prefetch = (AllocatePrefetchDistance + AllocatePrefetchStepSize * lines) /
                                       (int)HeapWordSize;
  }
#endif

  // 初始化 main 執行緒的 TLAB
  guarantee(Thread::current()->is_Java_thread(), "tlab initialization thread not Java thread");
  Thread::current()->tlab().initialize();
  log_develop_trace(gc, tlab)("TLAB min: " SIZE_FORMAT " initial: " SIZE_FORMAT " max: " SIZE_FORMAT,
                               min_size(), Thread::current()->tlab().initial_desired_size(), max_size());
}

每個執行緒維護自己的 TLAB,同時每個執行緒的 TLAB 大小不一。TLAB 的大小主要由 Eden 的大小,執行緒數量,還有執行緒的物件分配速率決定。
在 Java 執行緒開始執行時,會先分配 TLAB:
src/hotspot/share/runtime/thread.cpp

void JavaThread::run() {
  // initialize thread-local alloc buffer related fields
  this->initialize_tlab();
  //剩餘程式碼忽略
}

分配 TLAB 其實就是呼叫 ThreadLocalAllocBuffer 的 initialize 方法。
src/hotspot/share/runtime/thread.hpp

void initialize_tlab() {
    //如果沒有通過 -XX:-UseTLAB 禁用 TLAB,則初始化TLAB
    if (UseTLAB) {
      tlab().initialize();
    }
}

// Thread-Local Allocation Buffer (TLAB) support
ThreadLocalAllocBuffer& tlab()                 {
  return _tlab; 
}

ThreadLocalAllocBuffer _tlab;

ThreadLocalAllocBuffer 的 initialize 方法初始化 TLAB 的上面提到的我們要關心的各種 field:
src/hotspot/share/gc/shared/threadLocalAllocBuffer.cpp

void ThreadLocalAllocBuffer::initialize() {
  //設定初始指標,由於還沒有從 Eden 分配記憶體,所以這裡都設定為 NULL
  initialize(NULL,                    // start
             NULL,                    // top
             NULL);                   // end
  //計算初始期望大小,並設定
  set_desired_size(initial_desired_size());
  //所有 TLAB 總大小,不同的 GC 實現有不同的 TLAB 容量, 一般是 Eden 區大小
  //例如 G1 GC,就是等於 (_policy->young_list_target_length() - _survivor.length()) * HeapRegion::GrainBytes,可以理解為年輕代減去Survivor區,也就是Eden區
  size_t capacity = Universe::heap()->tlab_capacity(thread()) / HeapWordSize;
  //計算這個執行緒的 TLAB 期望佔用所有 TLAB 總體大小比例
  //TLAB 期望佔用大小也就是這個 TLAB 大小乘以期望 refill 的次數
  float alloc_frac = desired_size() * target_refills() / (float) capacity;
  //記錄下來,用於計算 EMA
  _allocation_fraction.sample(alloc_frac);
  //計算初始 refill 最大浪費空間,並設定
  //如前面原理部分所述,初始大小就是 TLAB 的大小(_desired_size) / TLABRefillWasteFraction
  set_refill_waste_limit(initial_refill_waste_limit());
  //重置統計
  reset_statistics();
}

9.2.1. 初始期望大小是如何計算的呢?

src/hotspot/share/gc/shared/threadLocalAllocBuffer.cpp

//計算初始大小
size_t ThreadLocalAllocBuffer::initial_desired_size() {
  size_t init_sz = 0;
  //如果通過 -XX:TLABSize 設定了 TLAB 大小,則用這個值作為初始期望大小
  //表示堆記憶體佔用大小都需要用佔用幾個 HeapWord 表示,所以用TLABSize / HeapWordSize
  if (TLABSize > 0) {
    init_sz = TLABSize / HeapWordSize;
  } else {
    //獲取當前epoch內執行緒數量期望,這個如之前所述通過 EMA 預測
    unsigned int nof_threads = ThreadLocalAllocStats::allocating_threads_avg();
    //不同的 GC 實現有不同的 TLAB 容量,Universe::heap()->tlab_capacity(thread()) 一般是 Eden 區大小
    //例如 G1 GC,就是等於 (_policy->young_list_target_length() - _survivor.length()) * HeapRegion::GrainBytes,可以理解為年輕代減去Survivor區,也就是Eden區
    //整體大小等於 Eden區大小/(當前 epcoh 內會分配物件期望執行緒個數 * 每個 epoch 內每個執行緒 refill 次數配置)
    //target_refills已經在 JVM 初始化所有 TLAB 全域性配置的時候初始化好了
    init_sz  = (Universe::heap()->tlab_capacity(thread()) / HeapWordSize) /
                      (nof_threads * target_refills());
    //考慮物件對齊,得出最後的大小
    init_sz = align_object_size(init_sz);
  }
  //保持大小在  min_size() 還有 max_size() 之間
  //min_size主要由 MinTLABSize 決定
  init_sz = MIN2(MAX2(init_sz, min_size()), max_size());
  return init_sz;
}

//最小大小由 MinTLABSize 決定,需要表示為 HeapWordSize,並且考慮物件對齊,最後的 alignment_reserve 是 dummy object 填充的物件頭大小(這裡先不考慮 JVM 的 CPU 快取 prematch,我們會在其他章節詳細分析)。
static size_t min_size()                       { 
    return align_object_size(MinTLABSize / HeapWordSize) + alignment_reserve(); 
}

9.2.2. TLAB 最大大小是怎樣決定的呢?

不同的 GC 方式,有不同的方式:

G1 GC 中為大物件(humongous object)大小,也就是 G1 region 大小的一半:src/hotspot/share/gc/g1/g1CollectedHeap.cpp

// For G1 TLABs should not contain humongous objects, so the maximum TLAB size
// must be equal to the humongous object limit.
size_t G1CollectedHeap::max_tlab_size() const {
  return align_down(_humongous_object_threshold_in_words, MinObjAlignment);
}

ZGC 中為頁大小的 8 分之一,類似的在大部分情況下 Shenandoah GC 也是每個 Region 大小的 8 分之一。他們都是期望至少有 8 分之 7 的區域是不用退回的減少選擇 Cset 的時候的掃描複雜度:
src/hotspot/share/gc/shenandoah/shenandoahHeap.cpp

MaxTLABSizeWords = MIN2(ShenandoahElasticTLAB ? RegionSizeWords : (RegionSizeWords / 8), HumongousThresholdWords);

src/hotspot/share/gc/z/zHeap.cpp

const size_t      ZObjectSizeLimitSmall         = ZPageSizeSmall / 8;

對於其他的 GC,則是 int 陣列的最大大小,這個和為了填充 dummy object 表示 TLAB 的空區域有關。這個原因之前已經說明了。

9.3. TLAB 分配記憶體

當 new 一個物件時,需要呼叫instanceOop InstanceKlass::allocate_instance(TRAPS)
src/hotspot/share/oops/instanceKlass.cpp

instanceOop InstanceKlass::allocate_instance(TRAPS) {
  bool has_finalizer_flag = has_finalizer(); // Query before possible GC
  int size = size_helper();  // Query before forming handle.

  instanceOop i;

  i = (instanceOop)Universe::heap()->obj_allocate(this, size, CHECK_NULL);
  if (has_finalizer_flag && !RegisterFinalizersAtInit) {
    i = register_finalizer(i, CHECK_NULL);
  }
  return i;
}

其核心就是heap()->obj_allocate(this, size, CHECK_NULL)從堆上面分配記憶體:
src/hotspot/share/gc/shared/collectedHeap.inline.hpp

inline oop CollectedHeap::obj_allocate(Klass* klass, int size, TRAPS) {
  ObjAllocator allocator(klass, size, THREAD);
  return allocator.allocate();
}

使用全域性的 ObjAllocator 實現進行物件記憶體分配:
src/hotspot/share/gc/shared/memAllocator.cpp

oop MemAllocator::allocate() const {
  oop obj = NULL;
  {
    Allocation allocation(*this, &obj);
    //分配堆記憶體,繼續看下面一個方法
    HeapWord* mem = mem_allocate(allocation);
    if (mem != NULL) {
      obj = initialize(mem);
    } else {
      // The unhandled oop detector will poison local variable obj,
      // so reset it to NULL if mem is NULL.
      obj = NULL;
    }
  }
  return obj;
}
HeapWord* MemAllocator::mem_allocate(Allocation& allocation) const {
  //如果使用了 TLAB,則從 TLAB 分配,分配程式碼繼續看下面一個方法
  if (UseTLAB) {
    HeapWord* result = allocate_inside_tlab(allocation);
    if (result != NULL) {
      return result;
    }
  }
  //否則直接從 tlab 外分配
  return allocate_outside_tlab(allocation);
}
HeapWord* MemAllocator::allocate_inside_tlab(Allocation& allocation) const {
  assert(UseTLAB, "should use UseTLAB");

  //從當前執行緒的 TLAB 分配記憶體,TLAB 快分配
  HeapWord* mem = _thread->tlab().allocate(_word_size);
  //如果沒有分配失敗則返回
  if (mem != NULL) {
    return mem;
  }

  //如果分配失敗則走 TLAB 慢分配,需要 refill 或者直接從 Eden 分配
  return allocate_inside_tlab_slow(allocation);
}

9.3.1. TLAB 快分配

src/hotspot/share/gc/shared/threadLocalAllocBuffer.inline.hpp

inline HeapWord* ThreadLocalAllocBuffer::allocate(size_t size) {
  //驗證各個記憶體指標有效,也就是 _top 在 _start 和 _end 範圍內
  invariants();
  HeapWord* obj = top();
  //如果空間足夠,則分配記憶體
  if (pointer_delta(end(), obj) >= size) {
    set_top(obj + size);
    invariants();
    return obj;
  }
  return NULL;
}

9.3.2. TLAB 慢分配

src/hotspot/share/gc/shared/memAllocator.cpp

HeapWord* MemAllocator::allocate_inside_tlab_slow(Allocation& allocation) const {
  HeapWord* mem = NULL;
  ThreadLocalAllocBuffer& tlab = _thread->tlab();

  // 如果 TLAB 剩餘空間大於 最大浪費空間,則記錄並讓最大浪費空間遞增
  if (tlab.free() > tlab.refill_waste_limit()) {
    tlab.record_slow_allocation(_word_size);
    return NULL;
  }

  //重新計算 TLAB 大小
  size_t new_tlab_size = tlab.compute_size(_word_size);
  //TLAB 放回 Eden 區
  tlab.retire_before_allocation();
  
  if (new_tlab_size == 0) {
    return NULL;
  }

  // 計算最小大小
  size_t min_tlab_size = ThreadLocalAllocBuffer::compute_min_size(_word_size);
  //分配新的 TLAB 空間,並在裡面分配物件
  mem = Universe::heap()->allocate_new_tlab(min_tlab_size, new_tlab_size, &allocation._allocated_tlab_size);
  if (mem == NULL) {
    assert(allocation._allocated_tlab_size == 0,
           "Allocation failed, but actual size was updated. min: " SIZE_FORMAT
           ", desired: " SIZE_FORMAT ", actual: " SIZE_FORMAT,
           min_tlab_size, new_tlab_size, allocation._allocated_tlab_size);
    return NULL;
  }
  assert(allocation._allocated_tlab_size != 0, "Allocation succeeded but actual size not updated. mem at: "
         PTR_FORMAT " min: " SIZE_FORMAT ", desired: " SIZE_FORMAT,
         p2i(mem), min_tlab_size, new_tlab_size);
  //如果啟用了 ZeroTLAB 這個 JVM 引數,則將物件所有欄位置零值
  if (ZeroTLAB) {
    // ..and clear it.
    Copy::zero_to_words(mem, allocation._allocated_tlab_size);
  } else {
    // ...and zap just allocated object.
  }

  //設定新的 TLAB 空間為當前執行緒的 TLAB
  tlab.fill(mem, mem + _word_size, allocation._allocated_tlab_size);
  //返回分配的物件記憶體地址
  return mem;
}

9.3.2.1 TLAB最大浪費空間

TLAB最大浪費空間 _refill_waste_limit 初始值為 TLAB 大小除以 TLABRefillWasteFraction:
src/hotspot/share/gc/shared/threadLocalAllocBuffer.hpp

size_t initial_refill_waste_limit()            { return desired_size() / TLABRefillWasteFraction; }

每次慢分配,呼叫record_slow_allocation(size_t obj_size)記錄慢分配的同時,增加 TLAB 最大浪費空間的大小:

src/hotspot/share/gc/shared/threadLocalAllocBuffer.cpp

void ThreadLocalAllocBuffer::record_slow_allocation(size_t obj_size) {
  //每次慢分配,_refill_waste_limit 增加 refill_waste_limit_increment,也就是 TLABWasteIncrement
  set_refill_waste_limit(refill_waste_limit() + refill_waste_limit_increment());
  _slow_allocations++;
  log_develop_trace(gc, tlab)("TLAB: %s thread: " INTPTR_FORMAT " [id: %2d]"
                              " obj: " SIZE_FORMAT
                              " free: " SIZE_FORMAT
                              " waste: " SIZE_FORMAT,
                              "slow", p2i(thread()), thread()->osthread()->thread_id(),
                              obj_size, free(), refill_waste_limit());
}
//refill_waste_limit_increment 就是 JVM 引數 TLABWasteIncrement
static size_t refill_waste_limit_increment()   { return TLABWasteIncrement; }

9.3.2.2. 重新計算 TLAB 大小

重新計算會取 當前堆剩餘給 TLAB 可分配的空間 和 TLAB 期望大小 + 當前需要分配的空間大小 中的小的那個:

src/hotspot/share/gc/shared/threadLocalAllocBuffer.inline.hpp

inline size_t ThreadLocalAllocBuffer::compute_size(size_t obj_size) {
  //獲取當前堆剩餘給 TLAB 可分配的空間
  const size_t available_size = Universe::heap()->unsafe_max_tlab_alloc(thread()) / HeapWordSize;
  //取 TLAB 可分配的空間 和 TLAB 期望大小 + 當前需要分配的空間大小 以及 TLAB 最大大小中的小的那個
  size_t new_tlab_size = MIN3(available_size, desired_size() + align_object_size(obj_size), max_size());

  // 確保大小大於 dummy obj 物件頭
  if (new_tlab_size < compute_min_size(obj_size)) {
    log_trace(gc, tlab)("ThreadLocalAllocBuffer::compute_size(" SIZE_FORMAT ") returns failure",
                        obj_size);
    return 0;
  }
  log_trace(gc, tlab)("ThreadLocalAllocBuffer::compute_size(" SIZE_FORMAT ") returns " SIZE_FORMAT,
                      obj_size, new_tlab_size);
  return new_tlab_size;
}

9.3.2.3. 當前 TLAB 放回堆

src/hotspot/share/gc/shared/threadLocalAllocBuffer.cpp

//在TLAB慢分配被呼叫,當前 TLAB 放回堆
void ThreadLocalAllocBuffer::retire_before_allocation() {
  //將當前 TLAB 剩餘空間大小加入慢分配浪費空間大小
  _slow_refill_waste += (unsigned int)remaining();
  //執行 TLAB 退還給堆,這個在後面 GC 的時候還會被呼叫用於將所有的執行緒的 TLAB 退回堆
  retire();
}

//對於 TLAB 慢分配,stats 為空
//對於 GC 的時候呼叫,stats 用於記錄每個執行緒的資料
void ThreadLocalAllocBuffer::retire(ThreadLocalAllocStats* stats) {
  
  if (stats != NULL) {
    accumulate_and_reset_statistics(stats);
  }
  //如果當前 TLAB 有效
  if (end() != NULL) {
    invariants();
    //將用了的空間記錄如執行緒分配物件大小記錄
    thread()->incr_allocated_bytes(used_bytes());
    //填充dummy object
    insert_filler();
    //清空當前 TLAB 指標
    initialize(NULL, NULL, NULL);
  }
}

9.4. GC 相關 TLAB 操作

9.4.1. GC 前

不同的 GC 可能實現不一樣,但是 TLAB 操作的時機是基本一樣的,這裡以 G1 GC 為例,在真正 GC 前:

src/hotspot/share/gc/g1/g1CollectedHeap.cpp

void G1CollectedHeap::gc_prologue(bool full) {
  //省略其他程式碼

  // Fill TLAB's and such
  {
    Ticks start = Ticks::now();
    //確保堆記憶體是可以解析的
    ensure_parsability(true);
    Tickspan dt = Ticks::now() - start;
    phase_times()->record_prepare_tlab_time_ms(dt.seconds() * MILLIUNITS);
  }
  //省略其他程式碼
}

為何要確保堆記憶體是可以解析的呢?這樣有利於更快速的掃描堆上物件。確保記憶體可以解析裡面做了什麼呢?其實主要就是退還每個執行緒的 TLAB 以及填充 dummy object。

src/hotspot/share/gc/g1/g1CollectedHeap.cpp

void CollectedHeap::ensure_parsability(bool retire_tlabs) {
  //真正的 GC 肯定發生在安全點上,這個在後面安全點章節會詳細說明
  assert(SafepointSynchronize::is_at_safepoint() || !is_init_completed(),
         "Should only be called at a safepoint or at start-up");

  ThreadLocalAllocStats stats;
  for (JavaThreadIteratorWithHandle jtiwh; JavaThread *thread = jtiwh.next();) {
    BarrierSet::barrier_set()->make_parsable(thread);
    //如果全域性啟用了 TLAB
    if (UseTLAB) {
      //如果指定要回收,則回收 TLAB
      if (retire_tlabs) {
        //回收 TLAB,呼叫  9.3.2.3. 當前 TLAB 放回堆 提到的 retire 方法
        thread->tlab().retire(&stats);
      } else {
        //當前如果不回收,則將 TLAB 填充 Dummy Object 利於解析
        thread->tlab().make_parsable();
      }
    }
  }

  stats.publish();
}

9.4.2. GC 後

不同的 GC 可能實現不一樣,但是 TLAB 操作的時機是基本一樣的,這裡以 G1 GC 為例,在 GC 後:

src/hotspot/share/gc/g1/g1CollectedHeap.cpp
_desired_size是什麼時候變得呢?怎麼變得呢?

void G1CollectedHeap::gc_epilogue(bool full) {
    //省略其他程式碼
    resize_all_tlabs();
}

src/hotspot/share/gc/shared/collectedHeap.cpp

void CollectedHeap::resize_all_tlabs() {
  //需要在安全點,GC 會處於安全點的
  assert(SafepointSynchronize::is_at_safepoint() || !is_init_completed(),
         "Should only resize tlabs at safepoint");
  //如果 UseTLAB 和 ResizeTLAB 都是開啟的(預設就是開啟的)
  if (UseTLAB && ResizeTLAB) {
    for (JavaThreadIteratorWithHandle jtiwh; JavaThread *thread = jtiwh.next(); ) {
      //重新計算每個執行緒 TLAB 期望大小
      thread->tlab().resize();
    }
  }
}

重新計算每個執行緒 TLAB 期望大小:
src/hotspot/share/gc/shared/threadLocalAllocBuffer.cpp

void ThreadLocalAllocBuffer::resize() {
  assert(ResizeTLAB, "Should not call this otherwise");
  //根據 _allocation_fraction 這個 EMA 採集得出平均數乘以Eden區大小,得出 TLAB 當前預測佔用記憶體比例
  size_t alloc = (size_t)(_allocation_fraction.average() *
                          (Universe::heap()->tlab_capacity(thread()) / HeapWordSize));
  //除以目標 refill 次數就是新的 TLAB 大小,和初始化時候的計算方法差不多
  size_t new_size = alloc / _target_refills;
  //保證在 min_size 還有 max_size 之間
  new_size = clamp(new_size, min_size(), max_size());

  size_t aligned_new_size = align_object_size(new_size);

  log_trace(gc, tlab)("TLAB new size: thread: " INTPTR_FORMAT " [id: %2d]"
                      " refills %d  alloc: %8.6f desired_size: " SIZE_FORMAT " -> " SIZE_FORMAT,
                      p2i(thread()), thread()->osthread()->thread_id(),
                      _target_refills, _allocation_fraction.average(), desired_size(), aligned_new_size);
  //設定新的 TLAB 大小
  set_desired_size(aligned_new_size);
  //重置 TLAB 最大浪費空間
  set_refill_waste_limit(initial_refill_waste_limit());
}

10. TLAB 流程常見問題 Q&A

這裡我會持續更新的,解決大家的各種疑問

10.1. 為何 TLAB 在退還給堆的時候需要填充 dummy object

主要保證 GC 的時候掃描高效。由於 TLAB 僅執行緒內知道哪些被分配了,在 GC 掃描發生時返回 Eden 區,如果不填充的話,外部並不知道哪一部分被使用哪一部分沒有,需要做額外的檢查,如果填充已經確認會被回收的物件,也就是 dummy object, GC 會直接標記之後跳過這塊記憶體,增加掃描效率。反正這塊記憶體已經屬於 TLAB,其他執行緒在下次掃描結束前是無法使用的。這個 dummy object 就是 int 陣列。為了一定能有填充 dummy object 的空間,一般 TLAB 大小都會預留一個 dummy object 的 header 的空間,也是一個 int[] 的 header,所以 TLAB 的大小不能超過int 陣列的最大大小,否則無法用 dummy object 填滿未使用的空間。

10.2. 為何 TLAB 需要最大浪費空間限制

當重新分配一個 TLAB 的時候,原有的 TLAB 可能還有空間剩餘。原有的 TLAB 被退回堆之前,需要填充好 dummy object。這樣導致這塊記憶體無法分配物件,所示被稱為“浪費”。如果不限制,遇到 TLAB 剩餘空間不足的情況就會重新申請,導致分配效率降低,大部分空間被 dummy object 佔滿了,導致 GC 更加頻繁。

10.3. 為何 TLAB 重填次數配置 等於 100 / (2 * TLABWasteTargetPercent)

TLABWasteTargetPercent 描述了初始最大浪費空間配置佔 TLAB 的比例

首先,最理想的情況就是儘量讓所有物件在 TLAB 內分配,也就是 TLAB 可能要佔滿 Eden。
在下次 GC 掃描前,退回 Eden 的記憶體別的執行緒是不能用的,因為剩餘空間已經填滿了 dummy object。所以所有執行緒使用記憶體大小就是 下個 epcoh 內會分配物件期望執行緒個數 * 每個 epoch 內每個執行緒 refill 次數配置,物件一般都在 Eden 區由某個執行緒分配,也就所有執行緒使用記憶體大小就最好是整個 Eden。但是這種情況太過於理想,總會有記憶體被填充了 dummy object而造成了浪費,因為 GC 掃描隨時可能發生。假設平均下來,GC 掃描的時候,每個執行緒當前的 TLAB 都有一半的記憶體被浪費,這個每個執行緒使用記憶體的浪費的百分比率(也就是 TLABWasteTargetPercent),也就是等於(注意,僅最新的那個 TLAB 有浪費,之前 refill 退回的假設是沒有浪費的):

1/2 * (每個 epoch 內每個執行緒期望 refill 次數) * 100

那麼每個 epoch 內每個執行緒 refill 次數配置就等於 50 / TLABWasteTargetPercent, 預設也就是 50 次。

10.4. 為何考慮 ZeroTLAB

當分配出來 TLAB 之後,根據 ZeroTLAB 配置,決定是否將每個位元組賦 0。在 TLAB 申請時,由於申請 TLAB 都發生在物件分配的時候,也就是這塊記憶體會立刻被使用,並修改賦值。操作記憶體,涉及到 CPU 快取行,如果是多核環境,還會涉及到 CPU 快取行 false sharing,為了優化,JVM 在這裡做了 Allocation Prefetch,簡單理解就是分配 TLAB 的時候,會盡量載入這塊記憶體到 CPU 快取,也就是在分配 TLAB 記憶體的時候,修改記憶體是最高效的

在建立物件的時候,本來也要對每個欄位賦初始值,大部分欄位初始值都是 0,並且,在 TLAB 返還到堆時,剩餘空間填充的也是 int[] 陣列,裡面都是 0。

所以,TLAB 剛分配出來的時候,賦 0 避免了後續再賦 0。也能利用好 Allocation prefetch 的機制適應 CPU 快取行(Allocation prefetch 的機制詳情會在另一個系列說明)

10.5. 為何 JVM 需要預熱,為什麼 Java 程式碼越執行越快(這裡只提 TLAB 相關的,JIT,MetaSpace,GC等等其他系列會說)

根據之前的分析,每個執行緒的 TLAB 的大小,會根據執行緒分配的特性,不斷變化並趨於穩定,大小主要是由分配比例 EMA 決定,但是這個採集是需要一定執行次數的。並且 EMA 的前 100 次採集預設是不夠穩定的,所以 TLAB 大小也在程式一開始的時候變化頻繁。當程式執行緒趨於穩定,執行一段時間後, 每個執行緒 TLAB 大小也會趨於穩定並且調整到最適合這個執行緒物件分配特性的大小。這樣,就更接近最理想的只有 Eden 區滿了才會 GC,所有 Eden 區的物件都是通過 TLAB 分配的高效分配情況。這就是 Java 程式碼越執行越快在 TLAB 方面的原因。

相關文章