文章目錄
- CMS在并發標記階段,已經被標記的對象,又被新生代跨帶引用,這時JVM會怎么處理?
- 為什么 Minor GC 會發生 STW?
- 有哪些對象是在棧上分配的?
- 對象在 JVM 中的內存結構
- 為什么需要對齊填充?
- JVM 對象分配空間機制
- JVM多線程并發分配對象如何解決堆搶占?
- JVM容量分配實戰案例分析
- JVM吞吐量和響應時間?
CMS在并發標記階段,已經被標記的對象,又被新生代跨帶引用,這時JVM會怎么處理?
CMS 垃圾回收器 中使用 “記憶集(Remembered Set)”+“寫屏障(Write Barrier)”機制 來解決并發標記期間 跨代引用變更 問題。
為什么需要“記憶集” + “寫屏障”?
在 CMS 的 并發標記階段:
GC 與應用線程并發執行;
這期間應用線程可能讓年輕代對象指向老年代對象;
但 CMS 默認只標記老年代;
年輕代未被掃描,容易 遺漏跨代引用;
所以必須 追蹤引用變更的位置 —— 這就是“寫屏障 + 記憶集”組合機制的作用。
寫屏障:屏障是一種插入到對象引用寫操作前后的特殊代碼邏輯,用來記錄引用的變化,在 CMS 里主要用于:
- 記錄跨代引用的變化(新生代指向老年代的引用);
- 追蹤老年代中對象引用的新增或變化,保證并發標記不遺漏
什么是記憶集?
記憶集是一種 記錄哪些區域可能包含跨代引用 的輔助數據結構。
在 CMS 中使用的是:Card Table + Dirty Card Tracking
寫屏障的實現:
Card Table 工作機制:
老年代內存被劃分為很多小塊,每塊稱為一個 Card(比如 512 Bytes 一塊);
JVM 為每個 Card 維護一個字節標記(位于 Card Table 中);
這個字節初始值為 clean(0);
一旦寫屏障檢測到某個 Card 中的對象引用發生變化,就將對應字節標記為 dirty(1)。
記憶集:
記憶集的本質是一個輔助數據結構,用來記錄老年代對象中引用了新生代對象的“引用源地址”。
分代垃圾回收中,Minor GC 只掃描新生代,但可能有老年代對象引用了新生代;
為了避免掃描整個老年代,JVM用記憶集來記錄那些引用過新生代的老年代對象;
在 Minor GC 時,只需要掃描記憶集中的那部分老年代對象
為什么 Minor GC 會發生 STW?
- 避免對象引用混亂
Eden 和 Survivor 區中的對象需要移動到 To 區或老年代;
在對象“復制”過程中,如果程序繼續運行,會有引用被修改、丟失或讀取臟數據;
所以,必須暫停所有線程,保證引用關系不變、對象移動安全。 - 根可達性分析需要一致的對象圖
GC 需要做 可達性分析(Root Tracing),從 GC Roots 開始向下遍歷;
如果線程在動,分析得到的對象圖會是錯誤的。
有哪些對象是在棧上分配的?
在 JVM 中,“對象在棧上分配” 并不是默認行為,而是一種通過逃逸分析(Escape Analysis)+ 棧上分配優化才有可能實現的高級性能優化機制。默認情況下,Java 中的所有對象都是在堆上分配的,只有局部變量的引用在棧幀中分配。
在棧上分配的條件:
- 對象不會逃逸出當前方法,也就是對象只在方法內被使用,不會被返回、不會賦給其他線程可訪問的變量。
- JVM 啟用了逃逸分析優化
棧上分配的優點:
3. 分配速度極快 棧是連續內存,分配只需移動指針(非常快);
4. 自動回收 隨方法調用結束,局部變量出棧即自動回收;
5. 不會觸發 GC 不在堆上,就不會進入 GC 管理范圍;
6. 減少內存碎片 不參與堆整理,降低 Full GC 頻率;
7. 標量替換優化可能性 JVM 可能將對象拆分為基本類型變量,進一步提升性能;
對象在 JVM 中的內存結構
- 對象頭(Header)
包含兩部分信息:
(1)Mark Word(標記字段) - 占 8 字節(32位JVM)或 12 字節(64位JVM 非壓縮指針)
存儲內容因對象狀態而異,如:
HashCode(如果調用過 hashCode())
GC 分代年齡
鎖信息(輕量級鎖、重量級鎖、偏向鎖)
標記位(是否為偏向鎖/是否為垃圾對象等)
(2)Class Pointer(類型指針) - 指向類的元數據
指向方法區中該對象所屬類的 class 元信息,用于支持虛方法調用等。 - 實例數據(Instance Data)
真正存放類的 字段(成員變量)值 的地方,包括:
父類繼承下來的字段
自身定義的字段
按照 字段聲明順序、類型大小對齊 安排內存布局 - 對齊填充(Padding)
為了滿足 8 字節對齊(HotSpot 默認對象起始地址對齊規則),可能會在對象末尾填充一些字節
不參與邏輯數據存儲,只是內存對齊需要
為什么需要對齊填充?
- CPU 訪問效率
CPU 訪問內存時,更快的訪問方式是**按固定字節邊界對齊(alignment)**訪問,比如 4 字節對齊、8 字節對齊。
如果數據沒有對齊,CPU 需要分多次訪問內存才能讀取完整數據,導致性能下降。
對齊保證數據起始地址是特定邊界的整數倍,能讓 CPU 一次性高效讀取。 - 硬件平臺的限制
一些處理器架構要求特定類型數據必須對齊訪問,不對齊訪問會引發硬件異常(bus error),或者不得不做額外處理,降低性能。
例如,64位系統一般要求 8 字節對齊,32位系統一般要求 4 字節對齊。 - 簡化內存地址計算
對齊后,硬件和編譯器可以更簡單更快地計算內存地址和偏移,方便高效的指令執行。
JVM 對象分配空間機制
- 堆內存分配
Java 對象主要在 **堆(Heap)**上分配。
堆又分為 新生代(Young Gen)和 老年代(Old Gen)。
新生代通常使用 Eden 區 + 兩個 Survivor 區。 - 對象分配流程
新生代 Eden 區是對象分配的主要場所。
JVM 默認采用 指針碰撞(Pointer Bump) 或 空閑列表(Free List) 分配策略:
指針碰撞:Eden 是連續空間,有個指針指向下一分配位置,分配對象時指針往后移,速度快。
空閑列表:如果 Eden 有碎片,可能采用空閑列表分配。
大對象(如大數組、字符串)可能直接進入老年代,避免在新生代頻繁復制。 - 分配失敗與垃圾回收
Eden 空間不足時觸發 Minor GC,回收無用對象。
Minor GC 后仍空間不足時,可能觸發 Full GC 或對象晉升到老年代。
JVM多線程并發分配對象如何解決堆搶占?
- 多線程分配對象時,如果都操作同一內存區域,會產生同步開銷,降低性能。
- JVM 采用 線程本地分配緩存(TLAB,Thread Local Allocation Buffer) 來緩解:
每個線程分配一個小的 Eden 子區域(TLAB)。
線程先從自己 TLAB 分配,避免和其他線程競爭堆主區域鎖。
只有當 TLAB 空了,才去堆中申請新的 TLAB。 - TLAB 分配流程示意
線程啟動時,JVM 為它分配一個初始的 TLAB。
分配新對象時,線程檢查 TLAB 剩余空間是否足夠。
足夠,直接在 TLAB 內分配,調整指針。
不足,從堆中申請新的 TLAB,再分配。
對象生命周期結束后,垃圾回收釋放對象,回收空間。
JVM容量分配實戰案例分析
每天100w次登陸請求, 8G 內存該如何設置JVM參數,大概可以分為以下幾個步驟。
- 任何新的業務在上線之前我們都需要預估其占用的內存大小,而我們分配空間的大小主要來根據以下步驟來判斷?
- 計算業務系統每秒鐘創建的對象會占用多大的內存空間,然后計算集群下的每個系統每秒的內存占用空間(對象創建速度)
- 設置一個機器配置,估算新生代的空間,比較不同新生代大小之下,多久觸發一次MinorGC。
為了避免頻繁GC,就可以重新估算需要多少機器配置,部署多少臺機器,給JVM多大內存空間,新生代多大空間。 - 根據這套配置,基本可以推算出整個系統的運行模型,每秒創建多少對象,1s以后成為垃圾,系統運行多久新生代會觸發一次GC,頻率多高。
具體的案例分析:
新增計費業務,預計每天1000萬次請求,高峰時期,每秒處理2000筆的并發,一共5臺機器內存大小8G,怎么算每臺機器分配多大內存能撐住并發?
1.每秒2000筆,分到每臺機器上的請求為400筆,加入每個請求所產生的對象大小為300字節,每個請求大概需要10個對象處理,我們暫估3KB,如果算上RPC和DB、寫庫、寫緩存一頓操作下來6KB的數據,每秒大概產生1~2M的數據,如果高峰期,我們分配的內存為6G,分配給新生代的大小,大概是2G,我們算出大概1000秒才會進行一次MinorGC。
JVM吞吐量和響應時間?
吞吐量是指程序用于處理業務的時間與總運行時間的比值。
吞吐量 = (總運行時間 - GC 時間) / 總運行時間
吞吐量越高,說明更多時間用于業務處理,GC 開銷更小。
高吞吐通常意味著:
較少 GC 次數
較長 GC 停頓時間
適合批處理、后臺計算任務。
響應時間是指系統對單個請求的響應速度,包括平均響應時間和最大響應時間(99%、99.9% 等分位)。
對交互式系統、低延遲系統(如支付系統、API 網關)非常關鍵。
低響應時間通常意味著:
更頻繁的 GC(避免長時間停頓)
GC 停頓更短
適合前臺服務、實時交互系統
堆內存大小 和GC 頻率和停頓時間之間的關系:
堆大 ? GC 少發生 ? 每次 GC 回收更多對象 ? 吞吐量大
但:每次 GC 停頓時間長 ? 阻塞線程多 ? 響應延遲上升
堆小 ? GC 更頻繁發生 ? 每次 GC 停頓短
但:每次處理對象少 ? 更頻繁打斷業務執行 ? 吞吐下降 ? CPU 被 GC 占用比例升高