一文探討堆外內存的監控與回收

來源:《艦隊 Collectionjava


引子數組

記得那是一個風和日麗的週末,太陽紅彤彤,花兒五光十色,96 年的普哥微信找到我,描述了一個詭異的線上問題:線上程序使用了 NIO FileChannel 的 堆內內存做爲緩衝區,讀寫文件,邏輯能夠說至關簡單,但根據監控卻發現堆外內存飆升,致使了 OutOfMemeory 的異常。緩存

由這個線上問題,引出了這篇文章的主題,主要包括:FileChannel 源碼分析,堆外內存監控,堆外內存回收。微信

問題分析&源碼分析

根據異常日誌的定位,發現的確使用的是 HeapByteBuffer 來進行讀寫,但卻致使堆外內存飆升,隨即翻了 FileChannel 的源碼,來一探究竟:多線程

FileChannel 使用的是 IOUtil 來進行讀寫(只分析讀的邏輯,寫的邏輯行爲和讀其實一致,不進行重複分析)app

  
    
  
  
  
   
   
            
   
   
  1. dom

  2. 異步

  3. ide

  4. 函數

//sun.nio.ch.IOUtil#readstatic 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 變量來進行緩存,從而複用(實際上這個數值很是大,幾乎不會走進直接分配堆外內存這個分支)。這麼看來彷佛發現了兩個不得了的結論:

  1. 使用 HeapByteBuffer 讀寫都會通過 DirectByteBuffer,寫入數據的流轉方式實際上是:HeapByteBuffer -> DirectByteBuffer -> PageCache -> Disk,讀取數據的流轉方式正好相反。

  2. 大多數狀況下,會申請一塊跟線程綁定的堆外緩存,這意味着,線程越多,這塊臨時的堆外緩存就越大。

看到這兒,彷佛線上的問題有了一點眉目:頗有多是多線程使用堆內內存寫入文件,而額外分配這塊堆外緩存致使了內存溢出。在驗證這個猜想以前,咱們最好能直觀地監控到堆外內存的使用量,這才能增長咱們定位問題的信心。

實現堆外內存的監控

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,致使了內存的釋放。

知曉了這一點,相信你們從此使用堆內內存時可能就會更加註意了,我總結了兩個注意點:

  1. 使用 HeapByteBuffer 還須要通過一次 DirectByteBuffer 的拷貝,在追求極致性能的場景下是能夠經過直接複用堆外內存來避免的。

  2. 多線程下使用 HeapByteBuffer 進行文件讀寫,要注意 ThreadLocal<Util.BufferCache>bufferCache 致使的堆外內存膨脹的問題。

問題深究

那你們有沒有想過,爲何 JDK 要如此設計?爲何不直接使用堆內內存寫入 PageCache 進而落盤呢?爲何必定要通過 DirectByteBuffer 的拷貝呢?

在知乎的相關問題中,R 大和曾澤堂 兩位同窗進行了解答,是我比較認同的解釋:

做者:RednaxelaFX

連接:https://www.zhihu.com/question/57374068/answer/152691891

來源:知乎

這裏實際上是在遷就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,以後阻塞持續觀察堆外內存變化

  
    
  
  
  
   
   
            
   
   
public 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 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(); for (int i = 0; i < 1000; i++) { fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1024L * 1024 * 1024); } System.out.println("map finish"); new CountDownLatch(1).await(); }}

我嘗試映射了 1000G 的內存,個人電腦顯然沒有 1000G 這麼大內存,那麼監控是如何反饋的呢?

幾乎在瞬間,控制檯打印出了 map finish 的日誌,也意味着 1000G 的內存映射幾乎是不耗費時間的,爲何要作這個測試?就是爲了解釋內存映射並不等於內存佔用,不少文章認爲內存映射這種方式能夠大幅度提高文件的讀寫速度,並宣稱「寫 MappedByteBuffer 就等於寫內存」,實際是很是錯誤的認知。經過控制面板能夠查看到該 Java 進程(pid 39040)實際佔用的內存,僅僅不到 100M。(關於 Mmap 的使用場景和方式能夠參考我以前的文章)

結論:MappedByteBuffer 映射出一片文件內容以後,不會所有加載到內存中,而是會進行一部分的預讀(體如今佔用的那 100M 上),MappedByteBuffer 不是文件讀寫的銀彈,它仍然依賴於 PageCache 異步刷盤的機制。經過 Java VisualVM 能夠監控到 mmap 總映射的大小,但並非實際佔用的內存量

總結

本文藉助一個線上問題,分析了使用堆內內存仍然會致使堆外內存分析的現象以及背後 JDK 如此設計的緣由,並藉助安裝了插件以後的 Java VisualVM 工具進行了堆外內存的監控,進而討論瞭如何正確的回收堆外內存,以及糾正了一個不少人對於 MappedByteBuffer 的錯誤認知。

若是你們以爲這篇文章對你有幫助,你的關注和轉發是對我最大的支持,O(∩_∩)O:


本文分享自微信公衆號 - 咖啡拿鐵(close_3092860495)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索