一文探討堆外內(nèi)存的監(jiān)控與回收
引子
記得那是一個(gè)風(fēng)和日麗的周末,太陽紅彤彤,花兒五顏六色,96 年的普哥微信找到我,描述了一個(gè)詭異的線上問題:線上程序使用了 NIO FileChannel 的 堆內(nèi)內(nèi)存作為緩沖區(qū),讀寫文件,邏輯可以說相當(dāng)簡(jiǎn)單,但根據(jù)監(jiān)控卻發(fā)現(xiàn)堆外內(nèi)存飆升,導(dǎo)致了 OutOfMemeory 的異常。
由這個(gè)線上問題,引出了這篇文章的主題,主要包括:FileChannel 源碼分析,堆外內(nèi)存監(jiān)控,堆外內(nèi)存回收。
問題分析&源碼分析
根據(jù)異常日志的定位,發(fā)現(xiàn)的確使用的是 HeapByteBuffer 來進(jìn)行讀寫,但卻導(dǎo)致堆外內(nèi)存飆升,隨即翻了 FileChannel 的源碼,來一探究竟:
FileChannel 使用的是 IOUtil 來進(jìn)行讀寫(只分析讀的邏輯,寫的邏輯行為和讀其實(shí)一致,不進(jìn)行重復(fù)分析)
//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;
}
}
可以發(fā)現(xiàn)當(dāng)使用 HeapByteBuffer 時(shí),會(huì)走到下面這行比較奇怪的代碼分支:
Util.getTemporaryDirectBuffer(var1.remaining());
這個(gè) 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 這個(gè)方法會(huì)根據(jù)傳入 Buffer 的大小決定如何分配堆外內(nèi)存,如果過大,直接分布大緩沖區(qū);如果不是太大,會(huì)使用 bufferCache 這個(gè) ThreadLocal 變量來進(jìn)行緩存,從而復(fù)用(實(shí)際上這個(gè)數(shù)值非常大,幾乎不會(huì)走進(jìn)直接分配堆外內(nèi)存這個(gè)分支)。這么看來似乎發(fā)現(xiàn)了兩個(gè)不得了的結(jié)論:
-
使用 HeapByteBuffer 讀寫都會(huì)經(jīng)過 DirectByteBuffer,寫入數(shù)據(jù)的流轉(zhuǎn)方式其實(shí)是:HeapByteBuffer -> DirectByteBuffer -> PageCache -> Disk,讀取數(shù)據(jù)的流轉(zhuǎn)方式正好相反。
-
大多數(shù)情況下,會(huì)申請(qǐng)一塊跟線程綁定的堆外緩存,這意味著,線程越多,這塊臨時(shí)的堆外緩存就越大。
看到這兒,似乎線上的問題有了一點(diǎn)眉目:很有可能是多線程使用堆內(nèi)內(nèi)存寫入文件,而額外分配這塊堆外緩存導(dǎo)致了內(nèi)存溢出。在驗(yàn)證這個(gè)猜測(cè)之前,我們最好能直觀地監(jiān)控到堆外內(nèi)存的使用量,這才能增加我們定位問題的信心。
實(shí)現(xiàn)堆外內(nèi)存的監(jiān)控
JDK 提供了一個(gè)非常好用的監(jiān)控工具 —— Java VisualVM。我們只需要為他安裝 2 個(gè)插件,即可很方便地實(shí)現(xiàn)堆外內(nèi)存的監(jiān)控。
進(jìn)入本地 JDK 的可執(zhí)行目錄(在我本地是:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/bin),找到 jvisualvm 命令,雙擊即可打開一個(gè)可視化的界面
左側(cè)樹狀目錄可以選擇需要監(jiān)控的 Java 進(jìn)程,右側(cè)是監(jiān)控的維度信息,除了 CPU、線程、堆、類等信息,還可以通過上方的【工具(T)】 安裝插件,增加 MBeans、Buffer Pools 等維度的監(jiān)控。
Buffer Pools 插件可以監(jiān)控堆外內(nèi)存(包含 DirectByteBuffer 和 MappedByteBuffer),如下圖所示:
左側(cè)對(duì)應(yīng) DirectByteBuffer,右側(cè)對(duì)應(yīng) MappedByteBuffer。
復(fù)現(xiàn)問題
為了復(fù)現(xiàn)線上的問題,我們使用一個(gè)程序,不斷開啟線程使用堆內(nèi)內(nèi)存作為緩沖區(qū)進(jìn)行文件的讀取操作,并監(jiān)控該進(jìn)程的堆外內(nèi)存使用情況。
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();
}
}
}
運(yùn)行一段時(shí)間后,我們觀察下堆外內(nèi)存的使用情況
如上圖左所示,堆外內(nèi)存的確開始瘋漲了,符合我們的預(yù)期,堆外緩存和線程綁定,當(dāng)線程非常多時(shí),即使只使用了 4M 的堆內(nèi)內(nèi)存,也可能會(huì)造成極大的堆外內(nèi)存膨脹,在中間發(fā)生了一次斷崖,推測(cè)是線程執(zhí)行完畢 or GC,導(dǎo)致了內(nèi)存的釋放。
知曉了這一點(diǎn),相信大家今后使用堆內(nèi)內(nèi)存時(shí)可能就會(huì)更加注意了,我總結(jié)了兩個(gè)注意點(diǎn):
-
使用 HeapByteBuffer 還需要經(jīng)過一次 DirectByteBuffer 的拷貝,在追求極致性能的場(chǎng)景下是可以通過直接復(fù)用堆外內(nèi)存來避免的。
-
多線程下使用 HeapByteBuffer 進(jìn)行文件讀寫,要注意
ThreadLocal<Util.BufferCache>bufferCache導(dǎo)致的堆外內(nèi)存膨脹的問題。
問題深究
問題深究
那大家有沒有想過,為什么 JDK 要如此設(shè)計(jì)?為什么不直接使用堆內(nèi)內(nèi)存寫入 PageCache 進(jìn)而落盤呢?為什么一定要經(jīng)過 DirectByteBuffer 的拷貝呢?
在知乎的相關(guān)問題中,R 大和曾澤堂 兩位同學(xué)進(jìn)行了解答,是我比較認(rèn)同的解釋:
作者:RednaxelaFX
鏈接:https://www.zhihu.com/question/57374068/answer/152691891
來源:知乎
這里其實(shí)是在遷就OpenJDK里的HotSpot VM的一點(diǎn)實(shí)現(xiàn)細(xì)節(jié)。
HotSpot VM 里的 GC 除了 CMS 之外都是要移動(dòng)對(duì)象的,是所謂“compacting GC”。
如果要把一個(gè)Java里的 byte[] 對(duì)象的引用傳給native代碼,讓native代碼直接訪問數(shù)組的內(nèi)容的話,就必須要保證native代碼在訪問的時(shí)候這個(gè) byte[] 對(duì)象不能被移動(dòng),也就是要被“pin”(釘)住。
可惜 HotSpot VM 出于一些取舍而決定不實(shí)現(xiàn)單個(gè)對(duì)象層面的 object pinning,要 pin 的話就得暫時(shí)禁用 GC——也就等于把整個(gè) Java 堆都給 pin 住。
所以 Oracle/Sun JDK / OpenJDK 的這個(gè)地方就用了點(diǎn)繞彎的做法。它假設(shè)把 HeapByteBuffer 背后的 byte[] 里的內(nèi)容拷貝一次是一個(gè)時(shí)間開銷可以接受的操作,同時(shí)假設(shè)真正的 I/O 可能是一個(gè)很慢的操作。
于是它就先把 HeapByteBuffer 背后的 byte[] 的內(nèi)容拷貝到一個(gè) DirectByteBuffer 背后的 native memory去,這個(gè)拷貝會(huì)涉及 sun.misc.Unsafe.copyMemory() 的調(diào)用,背后是類似 memcpy() 的實(shí)現(xiàn)。這個(gè)操作本質(zhì)上是會(huì)在整個(gè)拷貝過程中暫時(shí)不允許發(fā)生 GC 的。
然后數(shù)據(jù)被拷貝到 native memory 之后就好辦了,就去做真正的 I/O,把 DirectByteBuffer 背后的 native memory 地址傳給真正做 I/O 的函數(shù)。這邊就不需要再去訪問 Java 對(duì)象去讀寫要做 I/O 的數(shù)據(jù)了。
總結(jié)一下就是:
-
為了方便 GC 的實(shí)現(xiàn),DirectByteBuffer 指向的 native memory 是不受 GC 管轄的
-
HeapByteBuffer 背后使用的是 byte 數(shù)組,其占用的內(nèi)存不一定是連續(xù)的,不太方便 JNI 方法的調(diào)用
-
數(shù)組實(shí)現(xiàn)在不同 JVM 中可能會(huì)不同
堆外內(nèi)存的回收
堆外內(nèi)存的回收
繼續(xù)深究一下一個(gè)話題,也是我的微信交流群中曾經(jīng)有人提出過的一個(gè)疑問,到底該如何回收 DirectByteBuffer?既然可以監(jiān)控堆外內(nèi)存,那驗(yàn)證堆外內(nèi)存的回收就變得很容易實(shí)現(xiàn)了。
CASE 1:分配 1G 的 DirectByteBuffer,等待用戶輸入后,賦值為 null,之后阻塞持續(xù)觀察堆外內(nèi)存變化
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();
}
}
結(jié)論:變量雖然置為了 null,但內(nèi)存依舊持續(xù)占用。
CASE 2:分配 1G DirectByteBuffer,等待用戶輸入后,賦值為 null,手動(dòng)觸發(fā) GC,之后阻塞持續(xù)觀察堆外內(nèi)存變化
-
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();
-
}
-
}
結(jié)論:GC 時(shí)會(huì)觸發(fā)堆外空閑內(nèi)存的回收。
CASE 3:分配 1G DirectByteBuffer,等待用戶輸入后,手動(dòng)回收堆外內(nèi)存,之后阻塞持續(xù)觀察堆外內(nèi)存變化
-
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();
-
}
-
}
結(jié)論:手動(dòng)回收可以立刻釋放堆外內(nèi)存,不需要等待到 GC 的發(fā)生。
對(duì)于 MappedByteBuffer 這個(gè)有點(diǎn)神秘的類,它的回收機(jī)制大概和 DirectByteBuffer 類似,體現(xiàn)在右邊的 Mapped 之中,我們就不重復(fù) CASE1 和 CASE2 的測(cè)試了,直接給出結(jié)論,在 GC 發(fā)生或者操作系統(tǒng)主動(dòng)清理時(shí) MappedByteBuffer 會(huì)被回收。但也不是不進(jìn)行測(cè)試,我們會(huì)對(duì) MappedByteBuffer 進(jìn)行更有意思的研究。
CASE 4:手動(dòng)回收 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);
-
}
-
}
這個(gè)類曾經(jīng)在我的《文件 IO 的一些最佳實(shí)踐》中有所介紹,在這里我們將驗(yàn)證它的作用。編寫測(cè)試類:
-
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();
-
}
-
}
結(jié)論:通過一頓復(fù)雜的反射操作,成功地手動(dòng)回收了 Mmap 的內(nèi)存映射。
CASE 5:測(cè)試 Mmap 的內(nèi)存占用
-
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 的內(nèi)存,我的電腦顯然沒有 1000G 這么大內(nèi)存,那么監(jiān)控是如何反饋的呢?
幾乎在瞬間,控制臺(tái)打印出了 map finish 的日志,也意味著 1000G 的內(nèi)存映射幾乎是不耗費(fèi)時(shí)間的,為什么要做這個(gè)測(cè)試?就是為了解釋內(nèi)存映射并不等于內(nèi)存占用,很多文章認(rèn)為內(nèi)存映射這種方式可以大幅度提升文件的讀寫速度,并宣稱“寫 MappedByteBuffer 就等于寫內(nèi)存”,實(shí)際是非常錯(cuò)誤的認(rèn)知。通過控制面板可以查看到該 Java 進(jìn)程(pid 39040)實(shí)際占用的內(nèi)存,僅僅不到 100M。(關(guān)于 Mmap 的使用場(chǎng)景和方式可以參考我之前的文章)
結(jié)論:MappedByteBuffer 映射出一片文件內(nèi)容之后,不會(huì)全部加載到內(nèi)存中,而是會(huì)進(jìn)行一部分的預(yù)讀(體現(xiàn)在占用的那 100M 上),MappedByteBuffer 不是文件讀寫的銀彈,它仍然依賴于 PageCache 異步刷盤的機(jī)制。通過 Java VisualVM 可以監(jiān)控到 mmap 總映射的大小,但并不是實(shí)際占用的內(nèi)存量。
總結(jié)
本文借助一個(gè)線上問題,分析了使用堆內(nèi)內(nèi)存仍然會(huì)導(dǎo)致堆外內(nèi)存分析的現(xiàn)象以及背后 JDK 如此設(shè)計(jì)的原因,并借助安裝了插件之后的 Java VisualVM 工具進(jìn)行了堆外內(nèi)存的監(jiān)控,進(jìn)而討論了如何正確的回收堆外內(nèi)存,以及糾正了一個(gè)很多人對(duì)于 MappedByteBuffer 的錯(cuò)誤認(rèn)知。
免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺(tái)僅提供信息存儲(chǔ)服務(wù)。文章僅代表作者個(gè)人觀點(diǎn),不代表本平臺(tái)立場(chǎng),如有問題,請(qǐng)聯(lián)系我們,謝謝!