背景###
最近發生了一起 Java 大對象引起的 FullGC 事件。記錄一下。
有一位商家刷單,每單內有 50+ 商品。然后進行訂單導出。訂單導出每次會從訂單詳情服務取100條訂單數據。由於 100 條訂單數據對象很大,導致詳情 FullGC ,影響了服務的穩定性。
本文借此來梳理下 Java 垃圾回收算法及分析 JVM 垃圾回收運行的方法。
案例分析###
如果對GC不太熟悉,可以先看看“GC姿勢”部分,對 JVM 垃圾回收有一個比較清晰的理解。
測定大小####
回頭看這個案例,顯然它很可能觸犯了“大對象容易觸發 FullGC ” 的忌諱。先來測定下,這個大數據量的訂單大小究竟有多少?
在 “HBase指定大量列集合的場景下並發拉取數據時卡住的問題排查” 有一段可以用來計算對象 deep-size 的方法。用法如下:
try {
ClassIntrospector.ObjectInfo objectInfo = new ClassIntrospector().introspect(orderDetailInfoList);
logger.info("object-deep-size: {} MB", (double)objectInfo.getDeepSize() / 1024.0 / 1024.0);
} catch (IllegalAccessException e) {
logger.warn("failed to introspect object size");
}
計算一個含有50個商品及優惠信息的訂單,大小為 335KB,100 個就是 33M 這個商家導出了 4 次,每次有幾百多單,會觸發詳情服務這邊接受請求的幾台服務器 FullGC ,進而影響詳情服務的穩定性。
優化方法####
有兩個方法可以組合使用:
-
檢測這個訂單是個大對象,將批量獲取的條數改為更小,比如 10;
-
將大訂單對象與小訂單對象混合打散,降低大對象占用大量連續空間的概率。
可以做個問題抽象:有一個 0 與 1 組成的數組, 0 表示小對象, 1 表示大對象, 問題描述為:將一個 [0,1] 組成的數組打散,使得 1 的分布更加稀疏。 其中稀疏度可以如下衡量: 所有 1 之間的元素數目的平均值和方差。
這個問題看上去像洗牌,但實際是有區別的。洗牌是將有序的數排列打散變成無序,而現在是要使某些元素的分布更加均勻或稀疏。 一個簡單的算法是:
STEP1: 遍歷數組,將 0 和 1 分別放在列表 zeroList 和 oneList 里;
STEP2: 計算 0 與 1 的比值 ratio ; 創建一個結果列表 resultList ;
STEP3: 遍歷 oneList ,對於每一個 1 , 將其加入 resultList ,同時加入 ratio 個 0 ;如果 0 不夠,則僅返回剩余的 0 。
代碼實現如下:
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class DistributedUtil {
/**
* 一個列表,要求將滿足條件 cond 的元素均勻分布到列表中。
*/
public static <T> List<T> even(List<T> alist, Predicate<T> cond) {
List<T> specialElements = alist.stream().filter(cond).collect(Collectors.toList());
List<T> normalElements = alist.stream().filter(e -> !cond.test(e)).collect(Collectors.toList());
int normalElemSize = normalElements.size();
int specialElemSize = specialElements.size();
if (normalElemSize == 0 || specialElemSize == 0) {
return alist;
}
// 只要 normalElements 充足 , 每一個 specialElement 插入 ratio 個 normalElements
int ratio = normalElemSize % specialElemSize ==
0 ? (normalElemSize / specialElemSize) : (normalElemSize / specialElemSize + 1);
List<T> finalList = new ArrayList<>();
int pos = 0;
for (T one: specialElements) {
finalList.add(one);
List<T> normalFetched = get(normalElements, ratio, pos);
pos += normalFetched.size();
finalList.addAll(normalFetched);
}
return finalList;
}
/**
* 從指定位置 position 取出 n 個元素 , 不足返回剩余元素或空元素
*/
public static <T> List<T> get(List<T> normalList, int n, int position) {
int size = normalList.size();
int num = size - position;
int realNum = Math.min(num, n);
return normalList.subList(position, position+realNum);
}
}
寫個簡單的單測驗證下:
import org.junit.Test
import spock.lang.Specification
import spock.lang.Unroll
import java.util.function.Predicate
class DistributedUtilTest extends Specification {
@Unroll
@Test
def "testEven"() {
expect:
result == DistributedUtil.even(originList, { it == 1 } as Predicate)
where:
originList | result
[1, 1, 1, 1, 1] | [1, 1, 1, 1, 1]
[0, 0, 0, 0, 0] | [0, 0, 0, 0, 0]
[1, 0, 0, 0, 0, 0] | [1, 0, 0, 0, 0, 0]
[1, 0, 1, 0, 0, 0, 0] | [1, 0, 0, 0, 1, 0, 0]
[1, 0, 1, 1, 0, 0, 0, 0] | [1, 0, 0, 1, 0, 0, 1, 0]
[1, 0, 1, 1, 1, 0, 0, 0, 0] | [1, 0, 0, 1, 0, 0, 1, 0, 1]
[1, 0, 1, 1, 1, 1, 0, 0, 0, 0] | [1, 0, 1, 0, 1, 0, 1, 0, 1, 0]
[1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0] | [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]
}
}
GC姿勢###
Java 垃圾回收采用的算法主要是:分代垃圾回收。垃圾回收算法簡稱 GC ,下文將以 GC 代之。
分代 GC 的主要理念是:大部分生成的對象都是生命周期短暫的對象,可以被很快回收掉;很少的對象能活動比較久。因此,分代回收算法,將垃圾回收分為兩個階段:新生代 GC 和 老年代 GC。
新生代 GC 采用算法基於 GC 復制算法,老年代 GC 采用的算法基於 標記-清除算法。
基礎概念####
變量的分配
棧與堆。
棧:臨時變量,作用域結束或函數執行完成后即被釋放;
堆: 數組與對象的存儲,不會隨函數執行完成而釋放。
棧的變量引用堆中的數組與對象。棧的變量就是根引用。引用通過指針來實現。
根引用與活動對象
從根引用出發,遍歷所能引用和抵達的所有對象,這些對象都是活動對象。而其他則是非活動對象。
GC 的目標就是銷毀非活動對象,騰出內存空間分配給新的對象和活動對象。
根引用(引用自 MAT 工具的文檔):
-
Class loaded by bootstrap/system class loader
-
Object referred to from a currently active thread block.
-
A started, but not stopped, thread.
-
Everything that has called wait() or notify() or that is synchronized. For example, by calling synchronized(Object) or by entering a synchronized method. Static method means class, non-static method means object.
-
Local variable. For example, input parameters or locally created objects of methods that are still in the stack of a thread.
-
A Java stack frame, holding local variables. Only generated when the dump is parsed with the preference set to treat Java stack frames as objects.
NOTE ! GC 不僅僅是GC,還要與內存分配綜合考慮。
四種引用
-
強引用: 有強引用的對象不會被回收。
-
軟引用: 在空間不足時拋出OOM前會回收軟引用的對象。內存敏感的緩存對象,比如cache的value對象
-
弱引用: 當JVM進行垃圾回收時,無論內存是否充足,都會回收被弱引用關聯的對象。比如canonicalizing mappings
-
虛引用:often used for scheduling pre-mortem cleanup actions in a more flexible way than is possible with the Java finalization mechanism . get 總是返回 null
算法指標
吞吐量: HEAP_SIZE / Cost(GCa+GCb+...+GCx)
最大暫停時間: max(GCi)
堆使用效率:HEAP_SIZE / Heap(GC)
分代回收算法####
-
不同對象的活動周期不同;年輕代更快地回收,老年代回收頻率相對少。分代回收 = YoungGC + OldGC
-
YoungGC: GC 復制算法。 比較頻繁;
-
OldGC: GC 標記-清除算法。 頻度低,回收慢。
GC復制算法
基本思路:
-
復制活動對象從From空間到To空間;復制活動對象也包括該活動對象引用所抵達的所有對象,是遞歸的。
-
吞吐量優秀(只需復制活動對象),堆利用率比較低。高速分配、無碎片化。
局部優化:
-
迭代復制:避免棧溢出
-
近似深度搜索復制
-
多空間復制
GC標記-清除算法
就像插入排序,優點是:簡單而且適合小數據量。
基本流程:
-
標記階段: 從根引用出發,將所有可抵達的對象打上標記;
-
清理階段: 遍歷堆,將沒有標記的對象清理回收。
耗費時間與堆大小成正比,堆使用效率最高。
就地回收 -> 碎片化問題 -> 分配效率問題
局部優化:
-
多空閑鏈表: 不同分塊,方便不同大小的分配。空間回收時創建和更新。
-
BiBOP:將堆分為相同大小的塊【跳過】
-
位圖標記: 活動對象標記采用位圖技術來標記
-
延遲清除法: 分配空間時進行清除操作,減少最大暫停時間。
現實GC####
垃圾收集器#####
選擇垃圾收集器時,需要考慮 新生代收集器與老生代收集的配合使用。
新生代收集器
-
Serial : 單線程, stop the world ; 簡單高效,桌面應用場景下,停頓時間可控制在幾十毫秒不超過一百毫秒, Client 模式下的默認;
-
ParNew: Serial 的多線程版本,Server 模式下的首選,可以與 CMS 收集器配合使用;
-
Parallel Scavenge: 基於復制算法,多線程; 其目標是達到好的吞吐量,即使“用戶代碼CPU時間/CPU總耗時”比值更大,吞吐量優先的收集器,適合后台任務。具有自適應調節參數控制,適合新用戶使用。
老生代收集器
-
SerialOld: 單線程,基於 標記-清理 算法,Client 模式下的默認。若用於 Server 模式,可以與 收集Parallel Scavenge 搭配使用,以及作為 CMS 的預備(在並發收集發生 Concurrent Mode Failure 時使用)。
-
ParallalOld: 多線程,基於 標記-清理 算法,Server 模式, 可以與 Parallel Scavenge 配合使用,吞吐量及CPU時間敏感型應用。
-
CMS : 並發,基於 標記-清理 算法,目標是獲取最短停頓時間,可以與用戶線程同時工作;
-
G1:並發,基於 標記-整理 算法,可預測的停頓時間模型,“隱藏級收集器”。
摘錄自《深入理解Java虛擬機》(周志明著)
運行參數#####
堆內存
-
-Xms 初始堆大小 ; -Xmx 初始堆大小最大值;
-
-Xmn 新生代(包括Eden和兩個Surivior)的堆大小 ;-XX:SurvivorRation=N來調整Eden Space及SurvivorSpace的大小,表示 Eden 與一個 SurvivorSpace 的比值是 N:1
-
-XX:NewRatio=N : 新生代與老年代的比值 1: N , 年輕代的空間占 1/(N+1)
-
-Xss : 每個線程的棧大小
收集器
-
-XX:+UseParNewGC : 使用 ParNew 收集器 ; -XX:+UseParallelOldGC 使用 ParallalOld 收集器;
-
-XX:MaxGCPauseMillis=N : 可接受最大停頓時間,毫秒數 ;-XX:GCTimeRatio=N : 可接受GC時間占比(目標吞吐量), 1 / (N+1), 吞吐量=1-1/(1+N)
-
-XX:+UseConcMarkSweepGC : 使用 CMS 收集器 ; -XX:+UseCMSCompactAtFullCollection :FullGC 后對老年代進行壓縮整理,減少碎片化;-XX:+CMSInitiatingOccupancyFraction=80 老年代占用內存 80% 以上時,觸發 FullGC。
-
-XX:+UseParallelGC : 並行收集器的線程數
-
-XX:+ DisableExplicitGC : 禁止RMI調用System.gc
-
-XX:PretenureSizeThreshold :大於這個設置值的大對象將直接進入老年代。
-XX:MaxTenuringThreshold=15 :在 Eden 區出生的對象,經過第一次 MinorGC 之后仍然存活,且被 Surivior 容納,則年齡記為 1 ; 每經過一次依然能在 Surivior 年齡增長一 ;當到達 XX:MaxTenuringThreshold 指定的值時,就會進入老年代空間。
GC事件#####
-
MinorGC : 大多數情況,新生代對象直接分配在 Eden 區。 當 Eden 區沒有足夠空間分配時,將發生一次 MinorGC 。 特點是: 頻繁,回收快。
-
MajorGC / FullGC: 老年代GC,特點是:很少, 慢。 FullGC 指 MajorGC 中 stop the world 的部分,是需要盡量避免的事件。
-
大對象觸發的 FullGC :大對象,是指需要大量連續內存空間的java對象,例如很長的對象列表。此類對象會直接進入老年代,而老年代雖然有很大的剩余空間,但是無法找到足夠大的連續空間來分配給當前對象,此種情況就會觸發JVM進行Full GC。
-
promotion failed和concurrent mode failure 觸發 FullGC : 采用 CMS 進行老年代 GC,尤其要注意 GC 日志中是否有 promotion failed 和 concurrent mode failure 兩種狀況,當這兩種狀況出現時可能會觸發 Full GC。promotion failed 是在進行 Minor GC 時,survivor space 放不下、對象只能放入老年代,而此時老年代也放不下造成的;concurrent mode failure 是在執行 CMS GC 的過程中同時有對象要放入老年代,而此時老年代空間不足造成的(有時候“空間不足”是 CMS GC時當前的浮動垃圾過多導致暫時性的空間不足觸發Full GC)。
-
空間分配擔保觸發 FullGC: 在進行 MinorGC 之前,虛擬機會檢查老年代連續最大可用空間是否大於新生代所有活動對象總大小。如果大於,則可以保證 MinorGC 是安全的;如果不成立,會查看 HandlePromotionFailure 是否允許擔保失敗;如果可以,則會檢查老年代連續最大可用空間是否大於歷次晉升到老年代的對象的平均大小,如果大於,則會進行有風險的 MinorGC ;否則,會進行一次 FullGC 。
-
System.gc()方法的調用來建議觸發 FullGC 。
GC日志#####
-
GC (Allocaion Failure) : 當在新生代中沒有足夠空間分配對象時會發生 Allocaion Failure,觸發Young GC。 [ParNew: 1887487K->209664K(1887488K), 0.0814271 secs]表示 新生代 ParNew 收集器,GC 前該內存區域使用了 1887487K ,GC 后該內存區域使用了 209664K ,回收了 1677823K , 總容量 1887488K ; 該內存區域 GC 耗時 0.0814271 secs 。 3579779K->2056421K(3984640K), 0.0822273 secs 表示 堆區 GC 前 3579779K, GC 后 2056421K ,回收了 1523358K,GC 耗時 0.0822273 secs 。
-
concurrent mode failure : 一個是在老年代被用完之前不能完成對非活動對象的回收;一個是當新空間分配請求在老年代的剩余空間中不能得到滿足。
小結###
線上的服務運行,會遇到各種的突發情況。比如大流量導出,多個大數據對象的訂單導出,對於通用的處理措施來說,常常會觸發一些潛在的問題,亦能引導人收獲一些新知。僅僅是滿足功能服務要求是遠遠不夠的。
然而, 反過來思考,為什么總是要到問題發生的時候,才會意識到和去處理呢 ? 是否可以預知和處理問題呢 ? 這涉及到參悟本質: 事物的原理及關聯。冥冥之中,因果早已注定,只是很多情況沒有達到臨界閾值,沒有達到誘發條件。
深入理解原理,審視現有的架構設計和實現,預知和解決問題,才是更上一層樓的方式。