背景
最近生產環境一個基於 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 類中儲存的路徑