一文探討堆外記憶體的監控與回收
來源:《艦隊 Collection》
引子
記得那是一個風和日麗的週末,太陽紅彤彤,花兒五顏六色,96 年的普哥微信找到我,描述了一個詭異的線上問題:線上程式使用了 NIO FileChannel 的 堆內記憶體作為緩衝區,讀寫檔案,邏輯可以說相當簡單,但根據監控卻發現堆外記憶體飆升,導致了 OutOfMemeory 的異常。
由這個線上問題,引出了這篇文章的主題,主要包括:FileChannel 原始碼分析,堆外記憶體監控,堆外記憶體回收。
問題分析&原始碼分析
根據異常日誌的定位,發現的確使用的是 HeapByteBuffer 來進行讀寫,但卻導致堆外記憶體飆升,隨即翻了 FileChannel 的原始碼,來一探究竟:
FileChannel 使用的是 IOUtil 來進行讀寫(只分析讀的邏輯,寫的邏輯行為和讀其實一致,不進行重複分析)
//sun.nio.ch.IOUtil#read static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException { if (var1.isReadOnly()) { throw new IllegalArgumentException("Read-only buffer"); } else if (var1 instanceof DirectBuffer) { return readIntoNativeBuffer(var0, var1, var2, var4); } else { ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining()); int var7; try { int var6 = readIntoNativeBuffer(var0, var5, var2, var4); var5.flip(); if (var6 > 0) { var1.put(var5); } var7 = var6; } finally { Util.offerFirstTemporaryDirectBuffer(var5); } return var7; } }
可以發現當使用 HeapByteBuffer 時,會走到下面這行比較奇怪的程式碼分支:
Util.getTemporaryDirectBuffer(var1.remaining());
這個 Util 封裝了更為底層的一些 IO 邏輯
package sun.nio.ch;
public class Util {
private static ThreadLocal<Util.BufferCache> bufferCache;
public static ByteBuffer getTemporaryDirectBuffer(int var0) {
if (isBufferTooLarge(var0)) {
return ByteBuffer.allocateDirect(var0);
} else {
// FOUCS ON THIS LINE
Util.BufferCache var1 = (Util.BufferCache)bufferCache.get();
ByteBuffer var2 = var1.get(var0);
if (var2 != null) {
return var2;
} else {
if (!var1.isEmpty()) {
var2 = var1.removeFirst();
free(var2);
}
return ByteBuffer.allocateDirect(var0);
}
}
}
}
isBufferTooLarge 這個方法會根據傳入 Buffer 的大小決定如何分配堆外記憶體,如果過大,直接分佈大緩衝區;如果不是太大,會使用 bufferCache 這個 ThreadLocal 變數來進行快取,從而複用(實際上這個數值非常大,幾乎不會走進直接分配堆外記憶體這個分支)。這麼看來似乎發現了兩個不得了的結論:
使用 HeapByteBuffer 讀寫都會經過 DirectByteBuffer,寫入資料的流轉方式其實是:HeapByteBuffer -> DirectByteBuffer -> PageCache -> Disk,讀取資料的流轉方式正好相反。
大多數情況下,會申請一塊跟執行緒繫結的堆外快取,這意味著,執行緒越多,這塊臨時的堆外快取就越大。
看到這兒,似乎線上的問題有了一點眉目:很有可能是多執行緒使用堆內記憶體寫入檔案,而額外分配這塊堆外快取導致了記憶體溢位。在驗證這個猜測之前,我們最好能直觀地監控到堆外記憶體的使用量,這才能增加我們定位問題的信心。
實現堆外記憶體的監控
JDK 提供了一個非常好用的監控工具 —— Java VisualVM。我們只需要為他安裝 2 個外掛,即可很方便地實現堆外記憶體的監控。
進入本地 JDK 的可執行目錄(在我本地是:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/bin),找到 jvisualvm 命令,雙擊即可開啟一個視覺化的介面
左側樹狀目錄可以選擇需要監控的 Java 程式,右側是監控的維度資訊,除了 CPU、執行緒、堆、類等資訊,還可以透過上方的【工具(T)】 安裝外掛,增加 MBeans、Buffer Pools 等維度的監控。
Buffer Pools 外掛可以監控堆外記憶體(包含 DirectByteBuffer 和 MappedByteBuffer),如下圖所示:
左側對應 DirectByteBuffer,右側對應 MappedByteBuffer。
復現問題
為了復現線上的問題,我們使用一個程式,不斷開啟執行緒使用堆內記憶體作為緩衝區進行檔案的讀取操作,並監控該程式的堆外記憶體使用情況。
public class ReadByHeapByteBufferTest { public static void main(String[] args) throws IOException, InterruptedException { File data = new File("/tmp/data.txt"); FileChannel fileChannel = new RandomAccessFile(data, "rw").getChannel(); ByteBuffer buffer = ByteBuffer.allocate(4 * 1024 * 1024); for (int i = 0; i < 1000; i++) { Thread.sleep(1000); new Thread(new Runnable() { @Override public void run() { try { fileChannel.read(buffer); buffer.clear(); } catch (IOException e) { e.printStackTrace(); } } }).start(); } } }
執行一段時間後,我們觀察下堆外記憶體的使用情況
如上圖左所示,堆外記憶體的確開始瘋漲了,符合我們的預期,堆外快取和執行緒繫結,當執行緒非常多時,即使只使用了 4M 的堆內記憶體,也可能會造成極大的堆外記憶體膨脹,在中間發生了一次斷崖,推測是執行緒執行完畢 or GC,導致了記憶體的釋放。
知曉了這一點,相信大家今後使用堆內記憶體時可能就會更加註意了,我總結了兩個注意點:
使用 HeapByteBuffer 還需要經過一次 DirectByteBuffer 的複製,在追求極致效能的場景下是可以透過直接複用堆外記憶體來避免的。
多執行緒下使用 HeapByteBuffer 進行檔案讀寫,要注意
ThreadLocal<Util.BufferCache>bufferCache
導致的堆外記憶體膨脹的問題。
問題深究
那大家有沒有想過,為什麼 JDK 要如此設計?為什麼不直接使用堆內記憶體寫入 PageCache 進而落盤呢?為什麼一定要經過 DirectByteBuffer 的複製呢?
在知乎的相關問題中,R 大和曾澤堂 兩位同學進行了解答,是我比較認同的解釋:
作者:RednaxelaFX
連結:
來源:知乎
這裡其實是在遷就OpenJDK裡的HotSpot VM的一點實現細節。
HotSpot VM 裡的 GC 除了 CMS 之外都是要移動物件的,是所謂“compacting GC”。
如果要把一個Java裡的 byte[] 物件的引用傳給native程式碼,讓native程式碼直接訪問陣列的內容的話,就必須要保證native程式碼在訪問的時候這個 byte[] 物件不能被移動,也就是要被“pin”(釘)住。
可惜 HotSpot VM 出於一些取捨而決定不實現單個物件層面的 object pinning,要 pin 的話就得暫時禁用 GC——也就等於把整個 Java 堆都給 pin 住。
所以 Oracle/Sun JDK / OpenJDK 的這個地方就用了點繞彎的做法。它假設把 HeapByteBuffer 背後的 byte[] 裡的內容複製一次是一個時間開銷可以接受的操作,同時假設真正的 I/O 可能是一個很慢的操作。
於是它就先把 HeapByteBuffer 背後的 byte[] 的內容複製到一個 DirectByteBuffer 背後的 native memory去,這個複製會涉及 sun.misc.Unsafe.copyMemory() 的呼叫,背後是類似 memcpy() 的實現。這個操作本質上是會在整個複製過程中暫時不允許發生 GC 的。
然後資料被複製到 native memory 之後就好辦了,就去做真正的 I/O,把 DirectByteBuffer 背後的 native memory 地址傳給真正做 I/O 的函式。這邊就不需要再去訪問 Java 物件去讀寫要做 I/O 的資料了。
總結一下就是:
為了方便 GC 的實現,DirectByteBuffer 指向的 native memory 是不受 GC 管轄的
HeapByteBuffer 背後使用的是 byte 陣列,其佔用的記憶體不一定是連續的,不太方便 JNI 方法的呼叫
陣列實現在不同 JVM 中可能會不同
堆外記憶體的回收
繼續深究一下一個話題,也是我的微信交流群中曾經有人提出過的一個疑問,到底該如何回收 DirectByteBuffer?既然可以監控堆外記憶體,那驗證堆外記憶體的回收就變得很容易實現了。
CASE 1:分配 1G 的 DirectByteBuffer,等待使用者輸入後,賦值為 null,之後阻塞持續觀察堆外記憶體變化
public class WriteByDirectByteBufferTest { public static void main(String[] args) throws IOException, InterruptedException { ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024); System.in.read(); buffer = null; new CountDownLatch(1).await(); } }
結論:變數雖然置為了 null,但記憶體依舊持續佔用。
CASE 2:分配 1G DirectByteBuffer,等待使用者輸入後,賦值為 null,手動觸發 GC,之後阻塞持續觀察堆外記憶體變化
pubpublic class WriteByDirectByteBufferTest { public static void main(String[] args) throws IOException, InterruptedException { ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024); System.in.read(); buffer = null; System.gc(); new CountDownLatch(1).await(); } }
結論:GC 時會觸發堆外空閒記憶體的回收。
CASE 3:分配 1G DirectByteBuffer,等待使用者輸入後,手動回收堆外記憶體,之後阻塞持續觀察堆外記憶體變化
public class WriteByDirectByteBufferTest { public static void main(String[] args) throws IOException, InterruptedException { ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024); System.in.read(); ((DirectBuffer) buffer).cleaner().clean(); new CountDownLatch(1).await(); } }
結論:手動回收可以立刻釋放堆外記憶體,不需要等待到 GC 的發生。
對於 MappedByteBuffer 這個有點神秘的類,它的回收機制大概和 DirectByteBuffer 類似,體現在右邊的 Mapped 之中,我們就不重複 CASE1 和 CASE2 的測試了,直接給出結論,在 GC 發生或者作業系統主動清理時 MappedByteBuffer 會被回收。但也不是不進行測試,我們會對 MappedByteBuffer 進行更有意思的研究。
CASE 4:手動回收 MappedByteBuffer。
public class MmapUtil {
public static void clean(MappedByteBuffer mappedByteBuffer) {
ByteBuffer buffer = mappedByteBuffer;
if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0)
return;
invoke(invoke(viewed(buffer), "cleaner"), "clean");
}
private static Object invoke(final Object target, final String methodName, final Class<?>... args) {
return AccessController.doPrivileged(new PrivilegedAction<Object>() {
public Object run() {
try {
Method method = method(target, methodName, args);
method.setAccessible(true);
return method.invoke(target);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
});
}
private static Method method(Object target, String methodName, Class<?>[] args)
throws NoSuchMethodException {
try {
return target.getClass().getMethod(methodName, args);
} catch (NoSuchMethodException e) {
return target.getClass().getDeclaredMethod(methodName, args);
}
}
private static ByteBuffer viewed(ByteBuffer buffer) {
String methodName = "viewedBuffer";
Method[] methods = buffer.getClass().getMethods();
for (int i = 0; i < methods.length; i++) {
if (methods[i].getName().equals("attachment")) {
methodName = "attachment";
break;
}
}
ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);
if (viewedBuffer == null)
return buffer;
else
return viewed(viewedBuffer);
}
}
這個類曾經在我的《檔案 IO 的一些最佳實踐》中有所介紹,在這裡我們將驗證它的作用。編寫測試類:
public class WriteByMappedByteBufferTest { public static void main(String[] args) throws IOException, InterruptedException { File data = new File("/tmp/data.txt"); data.createNewFile(); FileChannel fileChannel = new RandomAccessFile(data, "rw").getChannel(); MappedByteBuffer map = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1024L * 1024 * 1024); System.in.read(); MmapUtil.clean(map); new CountDownLatch(1).await(); }}
結論:透過一頓複雜的反射操作,成功地手動回收了 Mmap 的記憶體對映。
CASE 5:測試 Mmap 的記憶體佔用
public class MmapUtil { public static void clean(MappedByteBuffer mappedByteBuffer) { ByteBuffer buffer = mappedByteBuffer; if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0) return; invoke(invoke(viewed(buffer), "cleaner"), "clean"); } private static Object invoke(final Object target, final String methodName, final Class<?>... args) { return AccessController.doPrivileged(new PrivilegedAction<Object>() { public Object run() { try { Method method = method(target, methodName, args); method.setAccessible(true); return method.invoke(target); } catch (Exception e) { throw new IllegalStateException(e); } } }); } private static Method method(Object target, String methodName, Class<?>[] args) throws NoSuchMethodException { try { return target.getClass().getMethod(methodName, args); } catch (NoSuchMethodException e) { return target.getClass().getDeclaredMethod(methodName, args); } } private static ByteBuffer viewed(ByteBuffer buffer) { String methodName = "viewedBuffer"; Method[] methods = buffer.getClass().getMethods(); for (int i = 0; i < methods.length; i++) { if (methods[i].getName().equals("attachment")) { methodName = "attachment"; break; } } ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName); if (viewedBuffer == null) return buffer; else return viewed(viewedBuffer); } }
我嘗試對映了 1000G 的記憶體,我的電腦顯然沒有 1000G 這麼大記憶體,那麼監控是如何反饋的呢?
幾乎在瞬間,控制檯列印出了 map finish 的日誌,也意味著 1000G 的記憶體對映幾乎是不耗費時間的,為什麼要做這個測試?就是為了解釋記憶體對映並不等於記憶體佔用,很多文章認為記憶體對映這種方式可以大幅度提升檔案的讀寫速度,並宣稱“寫 MappedByteBuffer 就等於寫記憶體”,實際是非常錯誤的認知。透過控制皮膚可以檢視到該 Java 程式(pid 39040)實際佔用的記憶體,僅僅不到 100M。(關於 Mmap 的使用場景和方式可以參考我之前的文章)
結論:MappedByteBuffer 對映出一片檔案內容之後,不會全部載入到記憶體中,而是會進行一部分的預讀(體現在佔用的那 100M 上),MappedByteBuffer 不是檔案讀寫的銀彈,它仍然依賴於 PageCache 非同步刷盤的機制。透過 Java VisualVM 可以監控到 mmap 總對映的大小,但並不是實際佔用的記憶體量。
總結
本文藉助一個線上問題,分析了使用堆內記憶體仍然會導致堆外記憶體分析的現象以及背後 JDK 如此設計的原因,並藉助安裝了外掛之後的 Java VisualVM 工具進行了堆外記憶體的監控,進而討論瞭如何正確的回收堆外記憶體,以及糾正了一個很多人對於 MappedByteBuffer 的錯誤認知。
如果大家覺得這篇文章對你有幫助,你的關注和轉發是對我最大的支援,O(∩_∩)O:
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31555607/viewspace-2639605/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Java堆外直接記憶體回收Java記憶體
- 【JVM之記憶體與垃圾回收篇】堆JVM記憶體
- 探索JVM的垃圾回收(堆記憶體)JVM記憶體
- java 堆外記憶體排查Java記憶體
- JAVA的堆疊和記憶體、垃圾回收解說Java記憶體
- AIX 記憶體監控AI記憶體
- Solaris記憶體監控記憶體
- 記憶體CPU監控記憶體
- Java堆記憶體Heap與非堆記憶體Non-HeapJava記憶體
- 窺探JVM記憶體分配和回收的過程JVM記憶體
- JAVA堆外記憶體排查小結Java記憶體
- JVM堆外記憶體問題排查JVM記憶體
- [轉載] Java直接記憶體與堆記憶體Java記憶體
- iOS微信記憶體監控iOS記憶體
- RabbitMQ - 記憶體磁碟監控MQ記憶體
- Solaris記憶體監控(轉)記憶體
- 堆外記憶體及其在 RxCache 中的使用記憶體
- Java直接(堆外)記憶體使用詳解Java記憶體
- Redis 報”OutOfDirectMemoryError“(堆外記憶體溢位)RedisError記憶體溢位
- Node記憶體限制與垃圾回收記憶體
- JVM垃圾回收器、記憶體分配與回收策略JVM記憶體
- 關於JVM堆外記憶體的一切JVM記憶體
- 記一次堆外記憶體洩漏分析記憶體
- 架構設計 | 快取管理模式,監控和記憶體回收策略架構快取模式記憶體
- JS中的棧記憶體、堆記憶體JS記憶體
- Flutter 上的記憶體洩漏監控Flutter記憶體
- AIX下記憶體洩漏的監控AI記憶體
- Netty基礎系列(4) --堆外記憶體與零拷貝Netty記憶體
- netty 堆外記憶體洩露排查盛宴Netty記憶體洩露
- 什麼是堆外記憶體off-heap記憶體
- 記憶體堆疊記憶體
- nagios-新增記憶體監控iOS記憶體
- JVM 之 記憶體分配與回收策略JVM記憶體
- PHP 垃圾回收與記憶體管理指引PHP記憶體
- 使用mtrace追蹤JVM堆外記憶體洩露JVM記憶體洩露
- Netty之Java堆外記憶體掃盲貼NettyJava記憶體
- java棧記憶體和堆記憶體的詮釋Java記憶體
- nagios監控linux主機監控記憶體指令碼iOSLinux記憶體指令碼