探討堆外內(nèi)存的監(jiān)控與回收

一個詭異的線上問題:線上程序使用了 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é)論:

  1. 使用 HeapByteBuffer 讀寫都會經(jīng)過 DirectByteBuffer蔬捷,寫入數(shù)據(jù)的流轉(zhuǎn)方式其實是:HeapByteBuffer -> DirectByteBuffer -> PageCache -> Disk垄提,讀取數(shù)據(jù)的流轉(zhuǎn)方式正好相反。
  2. 使用 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é)了兩個注意點:

  1. 使用 HeapByteBuffer 還需要經(jīng)過一次 DirectByteBuffer 的拷貝畜眨,在追求極致性能的場景下是可以通過直接復用堆外內(nèi)存來避免的。
  2. 多線程下使用 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 管轄的

  1. HeapByteBuffer 背后使用的是 byte 數(shù)組逊脯,其占用的內(nèi)存不一定是連續(xù)的优质,不太方便 JNI 方法的調(diào)用
  2. 數(shù)組實現(xiàn)在不同 JVM 中可能會不同

Java NIO中,關(guān)于DirectBuffer军洼,HeapBuffer的疑問巩螃?

  1. DirectBuffer 屬于堆外存,那應該還是屬于用戶內(nèi)存匕争,而不是內(nèi)核內(nèi)存避乏?

  2. 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德谅。

  1. DirectBuffer 屬于堆外存爹橱,那應該還是屬于用戶內(nèi)存,而不是內(nèi)核內(nèi)存女阀?

DirectByteBuffer 自身是(Java)堆內(nèi)的宅荤,它背后真正承載數(shù)據(jù)的buffer是在(Java)堆外——native memory中的屑迂。這是 malloc() 分配出來的內(nèi)存,是用戶態(tài)的冯键。

  1. 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ā)生。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末橙数,一起剝皮案震驚了整個濱河市尊流,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌灯帮,老刑警劉巖崖技,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異钟哥,居然都是意外死亡迎献,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進店門腻贰,熙熙樓的掌柜王于貴愁眉苦臉地迎上來吁恍,“玉大人,你說我怎么就攤上這事〖酵撸” “怎么了伴奥?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長翼闽。 經(jīng)常有香客問我拾徙,道長,這世上最難降的妖魔是什么感局? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任尼啡,我火速辦了婚禮,結(jié)果婚禮上询微,老公的妹妹穿的比我還像新娘崖瞭。我一直安慰自己,他們只是感情好撑毛,可當我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布书聚。 她就那樣靜靜地躺著,像睡著了一般代态。 火紅的嫁衣襯著肌膚如雪寺惫。 梳的紋絲不亂的頭發(fā)上疹吃,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天蹦疑,我揣著相機與錄音,去河邊找鬼萨驶。 笑死歉摧,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的腔呜。 我是一名探鬼主播叁温,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼核畴!你這毒婦竟也來了膝但?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤谤草,失蹤者是張志新(化名)和其女友劉穎跟束,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體丑孩,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡冀宴,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了温学。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片略贮。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出逃延,到底是詐尸還是另有隱情览妖,我是刑警寧澤,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布揽祥,位于F島的核電站黄痪,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏盔然。R本人自食惡果不足惜桅打,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望愈案。 院中可真熱鬧挺尾,春花似錦、人聲如沸站绪。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽恢准。三九已至魂挂,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間馁筐,已是汗流浹背涂召。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留敏沉,地道東北人果正。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓,卻偏偏與公主長得像盟迟,于是被迫代替她去往敵國和親秋泳。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,979評論 2 355

推薦閱讀更多精彩內(nèi)容