線上服務突然告警,jvm瘋狂觸發老年代gc,登錄後臺查看gc並不能釋放老年代空間,之前這個服務一直正常運行了幾個月,第一時間下載jvm的dump文件,並對服務重啓,防止內存不足影響線上業務。
使用MAT分析dump文件,先按照retained Heap排序,
發現ThreadLocal 和StringBuilder這兩個類佔用了大量內存,ThreadLocal也是引用的StringBuilder,點開StringBuilder發現內容是一些日誌文件,初步確定是log文件導致的內存泄漏,先將線上的日誌輸出級別提高到error(我們系統支持動態調整日誌輸出級別)。
搜索Log4j2在什麼地方使用了StringBuilder,排查發現org.apache.logging.log4j.message.ParameterizedMessage這個類中有一個複用StringBuilder的代碼,線上使用log4j-api的版本是2.8
public String getFormattedMessage() {
if (formattedMessage == null) {
final StringBuilder buffer = getThreadLocalStringBuilder();
formatTo(buffer);
formattedMessage = buffer.toString();
}
return formattedMessage;
}
private static StringBuilder getThreadLocalStringBuilder() {
StringBuilder buffer = threadLocalStringBuilder.get();
if (buffer == null) {
buffer = new StringBuilder(DEFAULT_STRING_BUILDER_SIZE);
threadLocalStringBuilder.set(buffer);
}
buffer.setLength(0);
return buffer;
}
這段代碼中Log4j2引用了一個ThreadLocal中的StringBuilder,這樣複用StringBuilder可以大幅提高日誌輸出效率,但注意這段代碼buffer.setLength(0),這個操作只會將StringBuilder的寫入重置爲從0開始寫入,但不會回收StringBuilder已經佔用的內存,類似於如果StringBuilder中原有值 “哈哈哈哈哈哈”,執行setLength(0)之後再寫入"123",那StringBuilder雖然存儲的值是 “123”,但在Builder的數組中存儲的其實是 “123哈哈哈”,只是覆蓋了前三個位置,後面的三個位置仍然佔用內存,釋放不了。由於當前的ThreadLocal是tomcat的線程池裏的線程,ThreadLocal就基本沒啥可能會被釋放,導致StringBuilder也不會被回收。這個StringBuilder的內存只會增加不會減少,由此導致內存泄漏。
後來查看log4j2-api的最新版本2.13.0
public static final int MAX_REUSABLE_MESSAGE_SIZE = size("log4j.maxReusableMsgSize", (128 * 2 + 2) * 2 + 2);
public String getFormattedMessage() {
if (formattedMessage == null) {
final StringBuilder buffer = getThreadLocalStringBuilder();
formatTo(buffer);
formattedMessage = buffer.toString();
StringBuilders.trimToMaxSize(buffer, Constants.MAX_REUSABLE_MESSAGE_SIZE);
}
return formattedMessage;
}
可以看到新版本中增加一個操作trimToMaxSize,代碼如下:
/**
* Ensures that the char[] array of the specified StringBuilder does not exceed the specified number of characters.
* This method is useful to ensure that excessively long char[] arrays are not kept in memory forever.
*
* @param stringBuilder the StringBuilder to check
* @param maxSize the maximum number of characters the StringBuilder is allowed to have
* @since 2.9
*/
public static void trimToMaxSize(final StringBuilder stringBuilder, final int maxSize) {
if (stringBuilder != null && stringBuilder.capacity() > maxSize) {
stringBuilder.setLength(maxSize);
stringBuilder.trimToSize();
}
}
當buffer的長度大於某一直時會觸發stringBuilder的trimToSize操作,這個方法會回收StringBuilder的緩存。
因此只要更新log4j2的版本即可解決這個內存泄漏的問題。
這次事件還有個深刻教訓就是謹慎輸出log日誌,該輸出的一定要輸出,不該輸出的儘量不要輸出,這次事件的另一個原因在於我們之前爲了方便查找問題在線上開啓了debug日誌輸出,應用穩定之後沒有修改爲info級別輸出,以後一定要吸取教訓。