Java無垃圾穩態設計

語言: CN / TW / HK

最近在重構 《簡明日誌規範》 ,就是重構我自己之前開源的一個統一日誌的元件。對於列印日誌,最重要的我認為有兩點:第一點是非同步,不能因為列印日誌而影響正常的程式執行,導致等待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是自己要監控的物件,有空不妨試一試。

不要假定,要證明!-----《程式設計師修煉之道》