天天看點

Java NIO學習筆記三(堆外記憶體之 DirectByteBuffer 詳解)

堆外記憶體

堆外記憶體是相對于堆内記憶體的一個概念。堆内記憶體是由JVM所管控的Java程序記憶體,我們平時在Java中建立的對象都處于堆内記憶體中,并且它們遵循JVM的記憶體管理機制,JVM會采用垃圾回收機制統一管理它們的記憶體。那麼堆外記憶體就是存在于JVM管控之外的一塊記憶體區域,是以它是不受JVM的管控。

在講解DirectByteBuffer之前,需要先簡單了解兩個知識點。 

java引用類型,因為DirectByteBuffer是通過虛引用(Phantom Reference)來實作堆外記憶體的釋放的。 

PhantomReference 是所有“弱引用”中最弱的引用類型。不同于軟引用和弱引用,虛引用無法通過 get() 方法來取得目标對象的強引用進而使用目标對象,觀察源碼可以發現 get() 被重寫為永遠傳回 null。 

那虛引用到底有什麼作用?其實虛引用主要被用來 跟蹤對象被垃圾回收的狀态,通過檢視引用隊列中是否包含對象所對應的虛引用來判斷它是否 即将被垃圾回收,進而采取行動。它并不被期待用來取得目标對象的引用,而目标對象被回收前,它的引用會被放入一個 ReferenceQueue 對象中,進而達到跟蹤對象垃圾回收的作用。

關于java引用類型的實作和原理可以閱讀之前的文章Reference、ReferenceQueue詳解 和 Java 引用類型簡述。

關于linux的核心态和使用者态

Java NIO學習筆記三(堆外記憶體之 DirectByteBuffer 詳解)

  • 核心态:控制計算機的硬體資源,并提供上層應用程式運作的環境。比如socket I/0操作或者檔案的讀寫操作等
  • 使用者态:上層應用程式的活動空間,應用程式的執行必須依托于核心提供的資源
  • 系統調用:為了使上層應用能夠通路到這些資源,核心為上層應用提供通路的接口
Java NIO學習筆記三(堆外記憶體之 DirectByteBuffer 詳解)

是以我們可以得知當我們通過JNI調用的native方法實際上就是從使用者态切換到了核心态的一種方式。并且通過該系統調用使用作業系統所提供的功能。

Q:為什麼需要使用者程序(位于使用者态中)要通過系統調用(Java中即使JNI)來調用核心态中的資源,或者說調用作業系統的服務了? 

A:intel cpu提供Ring0-Ring3四種級别的運作模式,Ring0級别最高,Ring3最低。Linux使用了Ring3級别運作使用者态,Ring0作為核心态。Ring3狀态不能通路Ring0的位址空間,包括代碼和資料。是以使用者态是沒有權限去操作核心态的資源的,它隻能通過系統調用外完成使用者态到核心态的切換,然後在完成相關操作後再有核心态切換回使用者态。

DirectByteBuffer ———— 直接緩沖

DirectByteBuffer是Java用于實作堆外記憶體的一個重要類,我們可以通過該類實作堆外記憶體的建立、使用和銷毀。

Java NIO學習筆記三(堆外記憶體之 DirectByteBuffer 詳解)

DirectByteBuffer該類本身還是位于Java記憶體模型的堆中。堆内記憶體是JVM可以直接管控、操縱。 

而DirectByteBuffer中的unsafe.allocateMemory(size);是個一個native方法,這個方法配置設定的是堆外記憶體,通過C的malloc來進行配置設定的。配置設定的記憶體是系統本地的記憶體,并不在Java的記憶體中,也不屬于JVM管控範圍,是以在DirectByteBuffer一定會存在某種方式來操縱堆外記憶體。 

在DirectByteBuffer的父類Buffer中有個address屬性:

// Used only by direct buffers
 // NOTE: hoisted here for speed in JNI GetDirectBufferAddress
 long address;
           
  • 1
  • 2
  • 3

address隻會被直接緩存給使用到。之是以将address屬性更新放在Buffer中,是為了在JNI調用GetDirectBufferAddress時提升它調用的速率。 

address表示配置設定的堆外記憶體的位址。

Java NIO學習筆記三(堆外記憶體之 DirectByteBuffer 詳解)

unsafe.allocateMemory(size);配置設定完堆外記憶體後就會傳回配置設定的堆外記憶體基位址,并将這個位址指派給了address屬性。這樣我們後面通過JNI對這個堆外記憶體操作時都是通過這個address來實作的了。

在前面我們說過,在linux中核心态的權限是最高的,那麼在核心态的場景下,作業系統是可以通路任何一個記憶體區域的,是以作業系統是可以通路到Java堆的這個記憶體區域的。 

Q:那為什麼作業系統不直接通路Java堆内的記憶體區域了? 

A:這是因為JNI方法通路的記憶體區域是一個已經确定了的記憶體區域地質,那麼該記憶體位址指向的是Java堆内記憶體的話,那麼如果在作業系統正在通路這個記憶體位址的時候,Java在這個時候進行了GC操作,而GC操作會涉及到資料的移動操作[GC經常會進行先标志在壓縮的操作。即,将可回收的空間做标志,然後清空标志位置的記憶體,然後會進行一個壓縮,壓縮就會涉及到對象的移動,移動的目的是為了騰出一塊更加完整、連續的記憶體空間,以容納更大的新對象],資料的移動會使JNI調用的資料錯亂。是以JNI調用的記憶體是不能進行GC操作的。

Q:如上面所說,JNI調用的記憶體是不能進行GC操作的,那該如何解決了? 

A:①堆内記憶體與堆外記憶體之間資料拷貝的方式(并且在将堆内記憶體拷貝到堆外記憶體的過程JVM會保證不會進行GC操作):比如我們要完成一個從檔案中讀資料到堆内記憶體的操作,即FileChannelImpl.read(HeapByteBuffer)。這裡實際上File I/O會将資料讀到堆外記憶體中,然後堆外記憶體再講資料拷貝到堆内記憶體,這樣我們就讀到了檔案中的記憶體。

Java NIO學習筆記三(堆外記憶體之 DirectByteBuffer 詳解)
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 {
                // File I/O 操作會将資料讀入到堆外記憶體中
                int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
                var5.flip();
                if (var6 > ) {
                    // 将堆外記憶體的資料拷貝到堆外記憶體中
                    var1.put(var5);
                }

                var7 = var6;
            } finally {
                // 裡面會調用DirectBuffer.cleaner().clean()來釋放臨時的堆外記憶體
                Util.offerFirstTemporaryDirectBuffer(var5);
            }

            return var7;
        }
    }
           
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

而寫操作則反之,我們會将堆内記憶體的資料線寫到對堆外記憶體中,然後作業系統會将堆外記憶體的資料寫入到檔案中。 

② 直接使用堆外記憶體,如DirectByteBuffer:這種方式是直接在堆外配置設定一個記憶體(即,native memory)來存儲資料,程式通過JNI直接将資料讀/寫到堆外記憶體中。因為資料直接寫入到了堆外記憶體中,是以這種方式就不會再在JVM管控的堆内再配置設定記憶體來存儲資料了,也就不存在堆内記憶體和堆外記憶體資料拷貝的操作了。這樣在進行I/O操作時,隻需要将這個堆外記憶體位址傳給JNI的I/O的函數就好了。

DirectByteBuffer堆外記憶體的建立和回收的源碼解讀

堆外記憶體配置設定

DirectByteBuffer(int cap) {                   // package-private
        super(-, , cap, cap);
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        long size = Math.max(L, (long)cap + (pa ? ps : ));
        // 保留總配置設定記憶體(按頁配置設定)的大小和實際記憶體的大小
        Bits.reserveMemory(size, cap);

        long base = ;
        try {
            // 通過unsafe.allocateMemory配置設定堆外記憶體,并傳回堆外記憶體的基位址
            base = unsafe.allocateMemory(size);
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) );
        if (pa && (base % ps != )) {
            // Round up to page boundary
            address = base + ps - (base & (ps - ));
        } else {
            address = base;
        }
        // 建構Cleaner對象用于跟蹤DirectByteBuffer對象的垃圾回收,以實作當DirectByteBuffer被垃圾回收時,堆外記憶體也會被釋放
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;
    }
           
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

Bits.reserveMemory(size, cap) 方法

static void reserveMemory(long size, int cap) {

        if (!memoryLimitSet && VM.isBooted()) {
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }

        // optimist!
        if (tryReserveMemory(size, cap)) {
            return;
        }

        final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

        // retry while helping enqueue pending Reference objects
        // which includes executing pending Cleaner(s) which includes
        // Cleaner(s) that free direct buffer memory
        while (jlra.tryHandlePendingReference()) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }

        // trigger VM's Reference processing
        System.gc();

        // a retry loop with exponential back-off delays
        // (this gives VM some time to do it's job)
        boolean interrupted = false;
        try {
            long sleepTime = ;
            int sleeps = ;
            while (true) {
                if (tryReserveMemory(size, cap)) {
                    return;
                }
                if (sleeps >= MAX_SLEEPS) {
                    break;
                }
                if (!jlra.tryHandlePendingReference()) {
                    try {
                        Thread.sleep(sleepTime);
                        sleepTime <<= ;
                        sleeps++;
                    } catch (InterruptedException e) {
                        interrupted = true;
                    }
                }
            }

            // no luck
            throw new OutOfMemoryError("Direct buffer memory");

        } finally {
            if (interrupted) {
                // don't swallow interrupts
                Thread.currentThread().interrupt();
            }
        }
    }
           
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60

該方法用于在系統中儲存總配置設定記憶體(按頁配置設定)的大小和實際記憶體的大小。

其中,如果系統中記憶體( 即,堆外記憶體 )不夠的話:

final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

        // retry while helping enqueue pending Reference objects
        // which includes executing pending Cleaner(s) which includes
        // Cleaner(s) that free direct buffer memory
        while (jlra.tryHandlePendingReference()) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }
           
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

jlra.tryHandlePendingReference()會觸發一次非堵塞的Reference#tryHandlePending(false)。該方法會将已經被JVM垃圾回收的DirectBuffer對象的堆外記憶體釋放。 

因為在Reference的靜态代碼塊中定義了:

SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
            @Override
            public boolean tryHandlePendingReference() {
                return tryHandlePending(false);
            }
        });
           
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

如果在進行一次堆外記憶體資源回收後,還不夠進行本次堆外記憶體配置設定的話,則

// trigger VM's Reference processing
  System.gc();
           
  • 1
  • 2

System.gc()會觸發一個full gc,當然前提是你沒有顯示的設定-XX:+DisableExplicitGC來禁用顯式GC。并且你需要知道,調用System.gc()并不能夠保證full gc馬上就能被執行。 

是以在後面打代碼中,會進行最多9次嘗試,看是否有足夠的可用堆外記憶體來配置設定堆外記憶體。并且每次嘗試之前,都對延遲等待時間,已給JVM足夠的時間去完成full gc操作。如果9次嘗試後依舊沒有足夠的可用堆外記憶體來配置設定本次堆外記憶體,則抛出OutOfMemoryError(“Direct buffer memory”)異常。

Java NIO學習筆記三(堆外記憶體之 DirectByteBuffer 詳解)

注意,這裡之是以用使用full gc的很重要的一個原因是:System.gc()會對新生代的老生代都會進行記憶體回收,這樣會比較徹底地回收DirectByteBuffer對象以及他們關聯的堆外記憶體。

DirectByteBuffer對象本身其實是很小的,但是它後面可能關聯了一個非常大的堆外記憶體,是以我們通常稱之為冰山對象。 

我們做ygc的時候會将新生代裡的不可達的DirectByteBuffer對象及其堆外記憶體回收了,但是無法對old裡的DirectByteBuffer對象及其堆外記憶體進行回收,這也是我們通常碰到的最大的問題。( 并且堆外記憶體多用于生命期中等或較長的對象 )

如果有大量的DirectByteBuffer對象移到了old,但是又一直沒有做cms gc或者full gc,而隻進行ygc,那麼我們的實體記憶體可能被慢慢耗光,但是我們還不知道發生了什麼,因為heap明明剩餘的記憶體還很多(前提是我們禁用了System.gc – JVM參數DisableExplicitGC)。

總的來說,Bits.reserveMemory(size, cap)方法在可用堆外記憶體不足以配置設定給目前要建立的堆外記憶體大小時,會實作以下的步驟來嘗試完成本次堆外記憶體的建立: 

1. 觸發一次非堵塞的Reference#tryHandlePending(false)。該方法會将已經被JVM垃圾回收的DirectBuffer對象的堆外記憶體釋放。 

2. 如果進行一次堆外記憶體資源回收後,還不夠進行本次堆外記憶體配置設定的話,則進行 System.gc()。System.gc()會觸發一個full gc,但你需要知道,調用System.gc()并不能夠保證full gc馬上就能被執行。是以在後面打代碼中,會進行最多9次嘗試,看是否有足夠的可用堆外記憶體來配置設定堆外記憶體。并且每次嘗試之前,都對延遲等待時間,已給JVM足夠的時間去完成full gc操作。 

注意,如果你設定了-XX:+DisableExplicitGC,将會禁用顯示GC,這會使System.gc()調用無效。 

3. 如果9次嘗試後依舊沒有足夠的可用堆外記憶體來配置設定本次堆外記憶體,則抛出OutOfMemoryError(“Direct buffer memory”)異常。

那麼可用堆外記憶體到底是多少了?,即預設堆外存記憶體有多大: 

1. 如果我們沒有通過-XX:MaxDirectMemorySize來指定最大的堆外記憶體。則�� 

2. 如果我們沒通過-Dsun.nio.MaxDirectMemorySize指定了這個屬性,且它不等于-1。則�� 

3. 那麼最大堆外記憶體的值來自于directMemory = Runtime.getRuntime().maxMemory(),這是一個native方法。

JNIEXPORT jlong JNICALL
Java_java_lang_Runtime_maxMemory(JNIEnv *env, jobject this)
{
    return JVM_MaxMemory();
}

JVM_ENTRY_NO_ENV(jlong, JVM_MaxMemory(void))
  JVMWrapper("JVM_MaxMemory");
  size_t n = Universe::heap()->max_capacity();
  return convert_size_t_to_jlong(n);
JVM_END
           
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

其中在我們使用CMS GC的情況下也就是我們設定的-Xmx的值裡除去一個survivor的大小就是預設的堆外記憶體的大小了。

堆外記憶體回收

Cleaner是PhantomReference的子類,并通過自身的next和prev字段維護的一個雙向連結清單。PhantomReference的作用在于跟蹤垃圾回收過程,并不會對對象的垃圾回收過程造成任何的影響。 

是以cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); 用于對目前構造的DirectByteBuffer對象的垃圾回收過程進行跟蹤。 

當DirectByteBuffer對象從pending狀态 ——> enqueue狀态時,會觸發Cleaner的clean(),而Cleaner的clean()的方法會實作通過unsafe對堆外記憶體的釋放。

Java NIO學習筆記三(堆外記憶體之 DirectByteBuffer 詳解)

��雖然Cleaner不會調用到Reference.clear(),但Cleaner的clean()方法調用了remove(this),即将目前Cleaner從Cleaner連結清單中移除,這樣當clean()執行完後,Cleaner就是一個無引用指向的對象了,也就是可被GC回收的對象。

thunk方法:

Java NIO學習筆記三(堆外記憶體之 DirectByteBuffer 詳解)

通過配置參數的方式來回收堆外記憶體

同時我們可以通過-XX:MaxDirectMemorySize來指定最大的堆外記憶體大小,當使用達到了門檻值的時候将調用System.gc()來做一次full gc,以此來回收掉沒有被使用的堆外記憶體。

堆外記憶體那些事

使用堆外記憶體的原因

  • 對垃圾回收停頓的改善因為full gc意味着徹底回收,徹底回收時,垃圾收集器會對所有配置設定的堆内記憶體進行完整的掃描,這意味着一個重要的事實——這樣一次垃圾收集對Java應用造成的影響,跟堆的大小是成正比的。過大的堆會影響Java應用的性能。如果使用堆外記憶體的話,堆外記憶體是直接受作業系統管理( 而不是虛拟機 )。這樣做的結果就是能保持一個較小的堆内記憶體,以減少垃圾收集對應用的影響。
  • 在某些場景下可以提升程式I/O操縱的性能。少去了将資料從堆内記憶體拷貝到堆外記憶體的步驟。

什麼情況下使用堆外記憶體

  • 堆外記憶體适用于生命周期中等或較長的對象。( 如果是生命周期較短的對象,在YGC的時候就被回收了,就不存在大記憶體且生命周期較長的對象在FGC對應用造成的性能影響 )。
  • 直接的檔案拷貝操作,或者I/O操作。直接使用堆外記憶體就能少去記憶體從使用者記憶體拷貝到系統記憶體的操作,因為I/O操作是系統核心記憶體和裝置間的通信,而不是通過程式直接和外設通信的。
  • 同時,還可以使用 池+堆外記憶體 的組合方式,來對生命周期較短,但涉及到I/O操作的對象進行堆外記憶體的再使用。( Netty中就使用了該方式 )

堆外記憶體 VS 記憶體池

  • 記憶體池:主要用于兩類對象:①生命周期較短,且結構簡單的對象,在記憶體池中重複利用這些對象能增加CPU緩存的命中率,進而提高性能;②加載含有大量重複對象的大片資料,此時使用記憶體池能減少垃圾回收的時間。
  • 堆外記憶體:它和記憶體池一樣,也能縮短垃圾回收時間,但是它适用的對象和記憶體池完全相反。記憶體池往往适用于生命期較短的可變對象,而生命期中等或較長的對象,正是堆外記憶體要解決的。

堆外記憶體的特點

  • 對于大記憶體有良好的伸縮性
  • 對垃圾回收停頓的改善可以明顯感覺到
  • 在程序間可以共享,減少虛拟機間的複制

堆外記憶體的一些問題

  • 堆外記憶體回收問題,以及堆外記憶體的洩漏問題。這個在上面的源碼解析已經提到了。
  • 堆外記憶體的資料結構問題:堆外記憶體最大的問題就是你的資料結構變得不那麼直覺,如果資料結構比較複雜,就要對它進行串行化(serialization),而串行化本身也會影響性能。另一個問題是由于你可以使用更大的記憶體,你可能開始擔心虛拟記憶體(即硬碟)的速度對你的影響了。

轉載于:https://blog.csdn.net/u013096088/article/details/78774627

nio

繼續閱讀