一個詭異的線上問題:線上程序使用了 NIO FileChannel 的 堆內(nèi)內(nèi)存(HeapByteBuffer)作為緩沖區(qū)厚棵,讀寫文件劫谅,邏輯可以說相當簡單祖凫,但根據(jù)監(jiān)控寝杖,卻發(fā)現(xiàn)堆外內(nèi)存(DirectByteBuffer)飆升型豁,導致了 OutOfMemeory 的異常僵蛛。
由這個線上問題,引出了這篇文章的主題迎变,主要包括:FileChannel 源碼分析充尉,堆外內(nèi)存監(jiān)控,堆外內(nèi)存回收衣形。
問題分析 & 源碼分析
根據(jù)異常日志的定位喉酌,發(fā)現(xiàn)的確使用的是 HeapByteBuffer 來進行讀寫,但卻導致堆外內(nèi)存飆升泵喘,隨即翻了 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;
}
}
可以發(fā)現(xiàn)當使用 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 這個方法會根據(jù)傳入 Buffer 的大小決定如何分配堆外內(nèi)存鲜锚,如果過大突诬,直接分配大緩沖區(qū)苫拍;如果不是太大,會使用 bufferCache 這個 ThreadLocal 變量來進行緩存旺隙,從而復用(實際上這個數(shù)值非常大绒极,幾乎不會走進直接分配堆外內(nèi)存這個分支)。這么看來似乎發(fā)現(xiàn)了兩個不得了的結(jié)論:
- 使用 HeapByteBuffer 讀寫都會經(jīng)過 DirectByteBuffer蔬捷,寫入數(shù)據(jù)的流轉(zhuǎn)方式其實是:HeapByteBuffer -> DirectByteBuffer -> PageCache -> Disk垄提,讀取數(shù)據(jù)的流轉(zhuǎn)方式正好相反。
- 使用 HeapByteBuffer 讀寫會申請一塊跟線程綁定的 DirectByteBuffer周拐。這意味著铡俐,線程越多,臨時 DirectByteBuffer 就越會占用越多的空間妥粟。
看到這兒审丘,線上的問題似乎有了一點眉目:很有可能是多線程使用 HeapByteBuffer 寫入文件,而額外分配的這塊 DirectByteBuffer 導致了內(nèi)存溢出勾给。
復現(xiàn)問題
為了復現(xiàn)線上的問題滩报,我們使用一個程序,不斷開啟線程使用堆內(nèi)內(nèi)存作為緩沖區(qū)進行文件的讀取操作播急,并監(jiā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();
}
}
}
堆外內(nèi)存的確開始瘋漲了,的確符合我們的預期旅择,堆外緩存和線程綁定惭笑,當線程非常多時,即使只使用了 4M 的堆內(nèi)內(nèi)存生真,也可能會造成極大的堆外內(nèi)存膨脹沉噩,在中間發(fā)生了一次斷崖,推測是線程執(zhí)行完畢 or GC柱蟀,導致了內(nèi)存的釋放川蒙。
知曉了這一點,相信大家今后使用堆內(nèi)內(nèi)存時可能就會更加注意了长已,我總結(jié)了兩個注意點:
- 使用 HeapByteBuffer 還需要經(jīng)過一次 DirectByteBuffer 的拷貝畜眨,在追求極致性能的場景下是可以通過直接復用堆外內(nèi)存來避免的。
- 多線程下使用 HeapByteBuffer 進行文件讀寫术瓮,要注意 ThreadLocal<Util.BufferCache> bufferCache 導致的堆外內(nèi)存膨脹的問題康聂。
問題深究
那大家有沒有想過,為什么 JDK 要如此設計胞四?為什么不直接使用堆內(nèi)內(nèi)存寫入 PageCache 進而落盤呢恬汁?為什么一定要經(jīng)過 DirectByteBuffer 的拷貝呢?
這里其實是在遷就 OpenJDK 里的 HotSpot VM 的一點實現(xiàn)細節(jié)辜伟。
HotSpot VM 里的 GC 除了 CMS 之外都是要移動對象的氓侧,是所謂“compacting GC”脊另。
如果要把一個 Java 里的 byte[] 對象的引用傳給 native 代碼,讓 native 代碼直接訪問數(shù)組的內(nèi)容的話约巷,就必須要保證 native 代碼在訪問的時候這個 byte[] 對象不能被移動偎痛,也就是要被“pin”(釘)住。
可惜 HotSpot VM 出于一些取舍而決定不實現(xiàn)單個對象層面的 object pinning独郎,要 pin 的話就得暫時禁用 GC——也就等于把整個 Java 堆都給 pin 住踩麦。
所以 Oracle/Sun JDK / OpenJDK 的這個地方就用了點繞彎的做法。它假設把 HeapByteBuffer 背后的 byte[] 里的內(nèi)容拷貝一次是一個時間開銷可以接受的操作囚聚,同時假設真正的 I/O 可能是一個很慢的操作靖榕。
于是它就先把 HeapByteBuffer 背后的 byte[] 的內(nèi)容拷貝到一個 DirectByteBuffer 背后的 native memory 去标锄,這個拷貝會涉及 sun.misc.Unsafe.copyMemory() 的調(diào)用顽铸,背后是類似 memcpy() 的實現(xiàn)。這個操作本質(zhì)上是會在整個拷貝過程中暫時不允許發(fā)生 GC 的料皇。
然后數(shù)據(jù)被拷貝到 native memory 之后就好辦了谓松,就去做真正的 I/O,把 DirectByteBuffer 背后的 native memory 地址傳給真正做 I/O 的函數(shù)践剂。這邊就不需要再去訪問 Java 對象去讀寫要做 I/O 的數(shù)據(jù)了鬼譬。
總結(jié)一下就是:
1.為了方便 GC 的實現(xiàn),DirectByteBuffer 指向的 native memory 是不受 GC 管轄的
- HeapByteBuffer 背后使用的是 byte 數(shù)組逊脯,其占用的內(nèi)存不一定是連續(xù)的优质,不太方便 JNI 方法的調(diào)用
- 數(shù)組實現(xiàn)在不同 JVM 中可能會不同
Java NIO中,關(guān)于DirectBuffer军洼,HeapBuffer的疑問巩螃?
DirectBuffer 屬于堆外存,那應該還是屬于用戶內(nèi)存匕争,而不是內(nèi)核內(nèi)存避乏?
FileChannel 的read(ByteBuffer dst)函數(shù),write(ByteBuffer src)函數(shù)中,如果傳入的參數(shù)是HeapBuffer類型,則會臨時申請一塊DirectBuffer,進行數(shù)據(jù)拷貝甘桑,而不是直接進行數(shù)據(jù)傳輸拍皮,這是出于什么原因?
Java NIO中的direct buffer(主要是DirectByteBuffer)其實是分兩部分的:
Java | native
|
DirectByteBuffer | malloc'd
[ address ] -+-> [ data ]
|
其中 DirectByteBuffer 自身是一個Java對象跑杭,在Java堆中铆帽;而這個對象中有個long類型字段address,記錄著一塊調(diào)用 malloc() 申請到的native memory德谅。
- DirectBuffer 屬于堆外存爹橱,那應該還是屬于用戶內(nèi)存,而不是內(nèi)核內(nèi)存女阀?
DirectByteBuffer 自身是(Java)堆內(nèi)的宅荤,它背后真正承載數(shù)據(jù)的buffer是在(Java)堆外——native memory中的屑迂。這是 malloc() 分配出來的內(nèi)存,是用戶態(tài)的冯键。
- FileChannel 的read(ByteBuffer dst)函數(shù),write(ByteBuffer src)函數(shù)中惹盼,如果傳入的參數(shù)是HeapBuffer類型,則會臨時申請一塊DirectBuffer,進行數(shù)據(jù)拷貝,而不是直接進行數(shù)據(jù)傳輸惫确,這是出于什么原因手报?
題主看的是OpenJDK的 sun.nio.ch.IOUtil.write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd) 的實現(xiàn)對不對:
static int write(FileDescriptor fd, ByteBuffer src, long position,
NativeDispatcher nd)
throws IOException
{
if (src instanceof DirectBuffer)
return writeFromNativeBuffer(fd, src, position, nd);
// Substitute a native buffer
int pos = src.position();
int lim = src.limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
try {
bb.put(src);
bb.flip();
// Do not update src until we see how many bytes were written
src.position(pos);
int n = writeFromNativeBuffer(fd, bb, position, nd);
if (n > 0) {
// now update src
src.position(pos + n);
}
return n;
} finally {
Util.offerFirstTemporaryDirectBuffer(bb);
}
}
這里其實是在遷就OpenJDK里的HotSpot VM的一點實現(xiàn)細節(jié)。
解釋在上面...
堆外內(nèi)存的回收
DirectByteBuffer改化?既然可以監(jiān)控堆外內(nèi)存掩蛤,那驗證堆外內(nèi)存的回收就變得很容易實現(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腥泥,手動觸發(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 時會觸發(fā)堆外空閑內(nèi)存的回收啃匿。
CASE 3:分配 1G DirectByteBuffer蛔外,等待用戶輸入后,手動回收堆外內(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é)論:手動回收可以立刻釋放堆外內(nèi)存夹厌,不需要等待到 GC 的發(fā)生。