背景
最近生產環境一個基於 netty 的網關服務頻繁 full gc
觀察內存占用,並把時間維度拉的比較長,可以看到可用內存有明顯的下降趨勢
出現這種情況,按往常的經驗,多半是內存泄露了
問題定位
找運維在生產環境 dump 了快照文件,一分析,果然不出所料,在一個 LinkedHashSet 里面, 放入 N 多的臨時文件路徑
可以看到,該 LinkedHashSet 是被類 DeleteOnExitHook 所引用。
DeleteOnExitHook
DeleteOnExitHook 是 jdk 提供的一個刪除文件的鈎子類,作用很簡單,在 jvm 退出時,通過該類里面的鈎子刪除里面所記錄的所有文件
我們簡單的看下源碼
class DeleteOnExitHook {
private static LinkedHashSet<String> files = new LinkedHashSet<>();
static {
// 注冊鈎子, runHooks 方法在 jvm 退出的時候執行
sun.misc.SharedSecrets.getJavaLangAccess()
.registerShutdownHook(2 /* Shutdown hook invocation order */,
true /* register even if shutdown in progress */,
new Runnable() {
public void run() {
runHooks();
}
}
);
}
private DeleteOnExitHook() {}
// 添加文件全路徑到該類里面的set里
static synchronized void add(String file) {
if(files == null) {
// DeleteOnExitHook is running. Too late to add a file
throw new IllegalStateException("Shutdown in progress");
}
files.add(file);
}
static void runHooks() {
// 省略代碼。。。 該方法用做刪除 files 里面記錄的所有文件
}
}
我們基本猜測出,在應用不斷的運行過程中,不斷有程序調用 DeleteOnExitHook.add
方法,放入了大量臨時文件路徑,導致了內存泄露
其實關於 DeleteOnExitHook
類的設計,不少人認為這個類設計不合理,並且反饋給官方,但官方覺得是合理的,不打算改這個問題
有興趣的可以看下 https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6664633
原因分析
既然已經定位到了出問題的地方,那么到底是什么情況下觸發了這個 bug 了呢?
因為我們的網關是基於 netty 實現的,很快定位到了該問題是由 netty 引起的,但要說清楚這個問題並不容易
HttpPostRequestDecoder
如果我們要用 netty 處理一個普通的 post 請求,一種典型的寫法是這樣,使用 netty 提供的解碼器解析 post 請求
// request 為 FullHttpRequest 對象
HttpPostRequestDecoder decoder = new HttpPostRequestDecoder(request);
try {
for (InterfaceHttpData data : decoder.getBodyHttpDatas()) {
// TODO 根據自己的需求處理 body 數據
}
return params;
} finally {
decoder.destroy();
}
HttpPostRequestDecoder 其實是一個解碼器的代理對象, 在構造方法里使用默認使用 DefaultHttpDataFactory 作為 HttpDataFactory
同時會判斷請求是否是 Multipart 請求,如果是,使用 HttpPostMultipartRequestDecoder,否則使用 HttpPostStandardRequestDecoder
public HttpPostRequestDecoder(HttpRequest request) {
this(new DefaultHttpDataFactory(DefaultHttpDataFactory.MINSIZE), request, HttpConstants.DEFAULT_CHARSET);
}
public HttpPostRequestDecoder(HttpDataFactory factory, HttpRequest request, Charset charset) {
// 省略參數校驗相關代碼
// Fill default values
if (isMultipart(request)) {
decoder = new HttpPostMultipartRequestDecoder(factory, request, charset);
} else {
decoder = new HttpPostStandardRequestDecoder(factory, request, charset);
}
}
DefaultHttpDataFactory
HttpDataFactory 作用很簡單,就是創建 httpData 實例,httpData 有多種實現,后續我們會講到
HttpDataFactory 有兩個關鍵參數
- 參數 useDisk ,默認 false,如果設為 true,創建 httpData 優先使用磁盤存儲
- 參數 checkSize,默認 true,使用混合存儲,混合存儲會通過校驗數據大小,重新選擇存儲方式
HttpDataFactory 里方法雖然不少,其實都是相同邏輯的不同實現,我們選取一個來看下源碼
@Override
public FileUpload createFileUpload(HttpRequest request, String name, String filename,
String contentType, String contentTransferEncoding, Charset charset,
long size) {
// 如果設置了用磁盤,默認會用磁盤存儲的 httpData, userDisk 默認是 false
if (useDisk) {
FileUpload fileUpload = new DiskFileUpload(name, filename, contentType,
contentTransferEncoding, charset, size);
fileUpload.setMaxSize(maxSize);
checkHttpDataSize(fileUpload);
List<HttpData> fileToDelete = getList(request);
fileToDelete.add(fileUpload);
return fileUpload;
}
// checkSize 默認 true
if (checkSize) {
// 創建 MixedFileUpload 對象
FileUpload fileUpload = new MixedFileUpload(name, filename, contentType,
contentTransferEncoding, charset, size, minSize);
fileUpload.setMaxSize(maxSize);
checkHttpDataSize(fileUpload);
List<HttpData> fileToDelete = getList(request);
fileToDelete.add(fileUpload);
return fileUpload;
}
MemoryFileUpload fileUpload = new MemoryFileUpload(name, filename, contentType,
contentTransferEncoding, charset, size);
fileUpload.setMaxSize(maxSize);
checkHttpDataSize(fileUpload);
return fileUpload;
}
httpData
httpData 可以理解為 netty 對 body 里的數據做的一個抽象,並且抽象出了兩個維度
- 從數據類型來看,可以分為普通屬性和文件屬性
- 從存儲方式來看,可以分為磁盤存儲,內存存儲,混合存儲
類型/存儲方式 | 磁盤存儲 | 內存存儲 | 混合存儲 |
---|---|---|---|
普通屬性 | DiskAttribute | MemoryAttribute | MixedAttribute |
文件屬性 | DiskFileUpload | MemoryFileUpload | MixedFileUpload |
可以看到,根據數據屬性不同和存儲方式不同一共有六種方式
但需要注意的是,磁盤存儲和內存存儲才是真正的存儲方式,混合存儲只是對前兩者的代理
- MixedAttribute 會根據設置的數據大小限制,決定自己真正使用 DiskAttribute 還是 MemoryAttribute
- MixedFileUpload 會根據設置的數據大小限制,決定自己真正使用 DiskFileUpload 還是 MemoryFileUpload
我們來看下 MixedFileUpload 對象構造方法
public MixedFileUpload(String name, String filename, String contentType,
String contentTransferEncoding, Charset charset, long size,
long limitSize) {
this.limitSize = limitSize;
// 如果大於 16kb(默認),用磁盤存儲,否則用內存
if (size > this.limitSize) {
fileUpload = new DiskFileUpload(name, filename, contentType,
contentTransferEncoding, charset, size);
} else {
fileUpload = new MemoryFileUpload(name, filename, contentType,
contentTransferEncoding, charset, size);
}
definedSize = size;
}
后續在往 MixedFileUpload 添加內容時,會判斷內容如果大於 16kb,仍舊用磁盤存儲
@Override
public void addContent(ByteBuf buffer, boolean last)
throws IOException {
// 如果現在是用內存存儲
if (fileUpload instanceof MemoryFileUpload) {
checkSize(fileUpload.length() + buffer.readableBytes());
// 判斷內容如果大於16kb(默認),換成磁盤存儲
if (fileUpload.length() + buffer.readableBytes() > limitSize) {
DiskFileUpload diskFileUpload = new DiskFileUpload(fileUpload
.getName(), fileUpload.getFilename(), fileUpload
.getContentType(), fileUpload
.getContentTransferEncoding(), fileUpload.getCharset(),
definedSize);
diskFileUpload.setMaxSize(maxSize);
ByteBuf data = fileUpload.getByteBuf();
if (data != null && data.isReadable()) {
diskFileUpload.addContent(data.retain(), false);
}
// release old upload
fileUpload.release();
fileUpload = diskFileUpload;
}
}
fileUpload.addContent(buffer, last);
}
如果上面的解釋還沒有讓你理解 httpData 的設計,我相信看完下面這張類圖你一定會明白
httpData 磁盤存儲的問題
我們通過上面的分析可以看到,使用磁盤存儲的 httpData 實現一共有兩個,分別是 DiskAttribute 和 DiskFileUpload
從上面的類圖可以看到,這兩個類都繼承於抽象類 AbstractDiskHttpData,使用磁盤存儲會創建臨時文件,如果使用磁盤存儲,在添加內容時會調用 tempFile 方法創建臨時文件
private File tempFile() throws IOException {
String newpostfix;
String diskFilename = getDiskFilename();
if (diskFilename != null) {
newpostfix = '_' + diskFilename;
} else {
newpostfix = getPostfix();
}
File tmpFile;
if (getBaseDirectory() == null) {
// create a temporary file
tmpFile = File.createTempFile(getPrefix(), newpostfix);
} else {
tmpFile = File.createTempFile(getPrefix(), newpostfix, new File(
getBaseDirectory()));
}
// deleteOnExit 方法默認返回 ture,這個參數可配置,也就是這個參數導致了內存泄露
if (deleteOnExit()) {
tmpFile.deleteOnExit();
}
return tmpFile;
}
這里可以看到如果 deleteOnExit 方法默認返回 ture,就會執行 deleteOnExit 方法,就是這個方法導致了內存泄露
我們看下 deleteOnExit 源碼,該方法會把文件路徑添加到 DeleteOnExitHook 類中,等 java 虛擬機停止時刪除文件
至於 DeleteOnExitHook 為什么會導致內存泄露,文章開始的時候已經解釋,這里不再贅述
// 在java 虛擬機停止時刪除文件
public void deleteOnExit() {
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkDelete(path);
}
if (isInvalid()) {
return;
}
// 文件路徑會一直保存到一個linkedHashSet里面
DeleteOnExitHook.add(path);
}
到這里,我相信你也一定明白問題所在了
在請求內容大於 16kb(默認值,可設置)的時候,netty 會使用磁盤存儲請求內容,同時在默認情況下,會調用 file 的 deleteOnExit 方法,導致文件路徑不斷的被保存到 DeleteOnExitHook ,不能被 jvm 回收,造成內存泄露
解決方案
DiskAttribute 中 deleteOnExit 方法 返回的是靜態變量 DiskAttribute.deleteOnExitTemporaryFile 的值,默認 true
DiskFileUpload 中 deleteOnExit 方法 返回的是靜態變量 DiskFileUpload.deleteOnExitTemporaryFile 的值,默認 true
只需把這兩個靜態變量設為 false 即可
static {
DiskFileUpload.deleteOnExitTemporaryFile = false;
DiskAttribute.deleteOnExitTemporaryFile = false;
}
至於臨時文件的刪除我們也不用擔心,HttpPostRequestDecoder 最后調用了 destroy 方法,就能保證后續的臨時文件刪除和資源回收,因此,上述默認情況下沒必要通過 deleteOnExit 方法在 jvm 關閉時再清理資源
HttpPostRequestDecoder 解析數據的時序圖如下
官方修復
上面的解決方案其實只是避開問題,並沒有真正的解決這個 bug
我看了下官方的 issues,該問題已經被多次反饋,最終在 4.1.53.Final 版本里修復,修復邏輯也很簡單,重寫 DeleteOnExitHook 類為 DeleteFileOnExitHook ,並提供 remove 方法
在 AbstractDiskHttpData 類的刪除文件時,同時刪除 DeleteFileOnExitHook 類中存儲的路徑