Java無垃圾穩態設計
最近在重構 《簡明日誌規範》 ,就是重構我自己之前開源的一個統一日誌的元件。對於列印日誌,最重要的我認為有兩點:第一點是非同步,不能因為列印日誌而影響正常的程式執行,導致等待IO卡頓;第二點是儘量減少垃圾, 所謂垃圾是說 許多日誌庫在日誌記錄期間分配臨時物件,如日誌事件物件,字串,字元陣列,位元組陣列等。這會對垃圾收集器造成壓力並增加GC暫停發生的頻率。
其中,非同步設計已經是很成熟的領域了。 但是低垃圾、無垃圾的研究還比較少。
在 《應用程式怎樣劃分模組?》 裡我提到使用登高類比法進行業界調研。業界調研自然不會放過Log4j、Logback這些都廣泛認可的優秀日誌元件。
Log4j2從版本2.6開始,預設情況下以“無垃圾”模式執行。 無垃 圾原理就是 《ThreadLocal&MDC記憶體洩漏問題》 中介紹的ThreadLocal。 因為 重用物件和緩衝區,並且儘可能不分配臨時物件。從物理層面上,物件的記憶體區域是通過資料覆蓋,而不是垃圾回收來達到日誌讀寫和上下文資訊儲存的目的。
以下是官網對無垃圾記錄響應時間行為進行的試驗:
下圖將 Log4j 的非同步 Logger 的“經典”記錄與無垃圾記錄響應時間行為進行了比較。在圖中,“ 100k”表示以 100,000 訊息/秒的持續負載進行記錄,“ 800k”表示 800,000 訊息/秒的持續負載。
在“經典”模式下,我們看到大量次要垃圾回收,這些垃圾回收會使應用程式執行緒暫停 3 毫秒或更長時間。這很快就增加了將近 10 毫秒的響應時間延遲。如您在圖表中所見,增加負載將曲線向左移動(存在更多尖峰)。這是有道理的:更多的日誌記錄意味著對垃圾收集器施加更大的壓力,從而導致更小的 GC 暫停。我們做了一些實驗,將負載減少到 50,000 甚至 5000 條訊息/秒,但這並沒有消除 3 毫秒的暫停,只是使它們的發生頻率降低了。請注意,此測試中的所有 GC 暫停都是次要的 GC 暫停。我們沒有看到任何完整的垃圾回收。
在“無垃圾”模式下,在各種負載下,最大響應時間仍遠低於 1 毫秒。(最大 780 us,800,000 訊息/秒,最大 407 us,600,000 訊息/秒,其中 99%圍繞 5 us 達到 800,000 訊息/秒的所有負載.)增加或減少負載不會改變響應時間。我們沒有調查在這些測試中看到的 200-300 微秒暫停的原因。
當我們進一步增加負載時,我們開始看到經典和無垃圾記錄的響應時間都有較大的暫停。在 100 萬條訊息/秒或更多的持續負載下,我們開始接近底層 RandomAccessFile Appender 的最大吞吐量(請參見下面的同步日誌記錄吞吐量圖表)。在這些負載下,環形緩衝區開始填滿,反壓開始起作用:在環形緩衝區已滿時嘗試新增另一條訊息將阻塞,直到有可用插槽可用為止。我們開始看到響應時間為數十毫秒或更長;嘗試進一步增加負載會導致越來越大的響應時間峰值。
試驗可以看出無垃圾模式對效能提升上有極大的好處。那如果是自制結構化日誌元件怎麼實現無垃圾模式呢?
我自己做的簡明日誌元件是為了公司的結構化日誌做抽象而產生。我將結構化日誌要列印的列定義為一個物件LogBuilder。屬性就是日誌要輸出的內容。這樣大家就可以用面向物件的方式來組裝日誌物件了。
LogBuilder的變數有三種情況:
第一種是全域性變數,比如機房資訊,服務啟動時機房就固定不變了。這種變數可以定義為靜態變數,全域性唯一,不會被垃圾回收器處理。
class LogBuilder {
private static String idc;
static {
idc = 從配置檔案等處讀取並初始化;
}
}
第二種是執行緒唯一,比如執行緒追蹤號,這種資訊可以通過切面在請求入口處設定一次儲存到MDC中,使用時從MDC中取得, 《ThreadLocal&MDC記憶體洩漏問題》 中有介紹,MDC相對安全無記憶體洩漏風險。MDC由於跟著執行緒走,執行緒採用執行緒池時它可以被複寫無需垃圾回收。
class LogBuilder {
private static String traceId;
public void static setTraceId(String traceId) {
MDC.set("traceId", traceId);
}
}
第三種是執行緒內變化的,比如執行階段,這種物件只能跟著LogBuilder的例項物件走,中間可以被重新賦值並列印。這時候就需要把整個LogBuilder物件放到ThreadLocal中,讓它也跟著物件例項走。但是這裡面有個關鍵,就是物件的toString方法被呼叫後這個列印的string就會被回收。所以我們列印日誌時組裝的字串最好不用toString,而是新寫一個方法來生成並將結果儲存到LogBuilder的區域性變數中。這樣列印完成物件就不會被回收。而是在下次使用時被複寫。
@Data
class LogBuilder {
private static ThreadLocal threadLocal = new ThreadLocal();
private static String idc;
private static String traceId;
private String step;
private String toString;
public static LogBuilder getInstance() {
if(threadLocal.get()==null) {
threadLocal.set(new LogBuilder());
}
return threadLocal.get();
}
public String buildString() {
toString = idc+"|"+traceId+"|"+step;
}
}
當然這裡只是為了說明無垃圾寫的示例程式碼,實際上的實現使用了一些反射等技術,程式碼很精簡,通用性強。實際原理和Log4j2的無垃圾穩態原理一致。
總結和小技巧
LogBuilder物件最初我的設計是使用靜態工廠方法new出來的。這也是《有效的Java》推薦的方式,可以很好的進行例項控制,保持清晰性和簡潔性。後來加入了ThreadLocal來進行無垃圾穩態設計。這時要注意提供clear方法清空屬性值,因為和主題無關,我在程式碼中省略了這一部分。關鍵來了,效能提升了,記憶體使用減少了怎麼來測試驗證呢?
我使用了jol工具來監控記憶體情況,使用方法,先上maven座標:
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.14</version> </dependency>
程式碼中使用時
log.info("內部資訊:[{}]", ClassLayout.parseInstance(pojo).toPrintable()); log.info("外部資訊[{}]", GraphLayout.parseInstance(pojo).toPrintable()); log.info("totalSize[{}]", GraphLayout.parseInstance(pojo).totalSize());
其中pojo是自己要監控的物件,有空不妨試一試。
不要假定,要證明!-----《程式設計師修煉之道》
- GitHub的4個神技,同桌直呼666
- Java9中2個被廢棄的使用方法
- 原始碼元宇宙-lambda表示式底層執行解析
- 系統設計的11個考慮
- 甲骨文嚴查Java授權,換openJDK要避坑
- Spring原始碼的學習方法和知識地圖
- 手撕Spring後置處理器原始碼,徹底理解Spring核心
- 手撕spring核心原始碼,徹底搞懂spring流程
- TiDB的逸聞趣事
- 尤娜,我去面試了
- 系統架構的11條原則
- 幹活很6,面試1臉懵?你需要知道這3點
- 服務治理篇-應用架構的演變
- 五個經典的破壞雙親委派場景,Java被啪啪打臉
- 四種常用的微服務架構拆分方式
- 從前,有一個簡單的通道系統叫尤娜……
- 深入理解Java類載入機制,再也不用死記硬背了
- 我用kafka兩年踩過的一些非比尋常的坑
- 【真實案例】程式設計的陷阱-警惕大塊資料
- M*N個策略造成類爆炸怎樣重構?