詳解Java堆外內存

臨近春節,最近有點時間,準備順着上篇專欄的思路寫下去,建議先閱讀: juejin.im/post/5e19d6…java

武漢那幾個吃野味的傻[],請藏好大家的媽安全

正文開始 bash

在運行Java程序時,java虛擬機須要使用內存來存放各式各樣的數據。java虛擬機規範把這些內存區域叫作運行時數據區:多線程

而堆外內存,是指分配在java堆外的內存區域,其不受jvm管理,不會影響gc。

本文將以java.nio.DirectByteBuffer爲例,來剖析堆外內存。jvm

// Primary constructor
    //
    DirectByteBuffer(int cap) {                   // package-private

        super(-1, 0, cap, cap);
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));
        Bits.reserveMemory(size, cap);

        long base = 0;
        try {
            base = unsafe.allocateMemory(size);
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) 0);
        if (pa && (base % ps != 0)) {
            // Round up to page boundary
            address = base + ps - (base & (ps - 1));
        } else {
            address = base;
        }
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;
    }
複製代碼

預約內存

從DirectByteBuffer的構造方法中能夠看出,堆外內存的分配的開始在 Bits.reserveMemory(size, cap);中。函數

進入Bits類,先看幾個和堆外內存相關的成員屬性:post

private static volatile long maxMemory = VM.maxDirectMemory();
    private static final AtomicLong reservedMemory = new AtomicLong();
    private static final AtomicLong totalCapacity = new AtomicLong();
    private static final AtomicLong count = new AtomicLong();
    private static volatile boolean memoryLimitSet = false;
複製代碼

maxMemoryui

用戶設置的堆外內存最大分配量,由jvm參數-XX:MaxDirectMemorySize=配置。this

reservedMemoryspa

已使用堆外內存的大小。使用AtomicLong來保證多線程下的安全性。

totalCapacity

總容量。一樣使用AtomicLong。

count

記錄分配堆外內存的總份數。

memoryLimitSet

一個標記變量,有volatile關鍵字。用來記錄maxMemory字段是否已初始化。


在分配堆外內存前,jdk使用tryReserveMemory方法實現了一個樂觀鎖,來保證明際分配的堆外內存總數不會大於設計的上限。

private static boolean tryReserveMemory(long size, int cap) {

        // -XX:MaxDirectMemorySize limits the total capacity rather than the
        // actual memory usage, which will differ when buffers are page
        // aligned.
        long totalCap;
        while (cap <= maxMemory - (totalCap = totalCapacity.get())) {
            if (totalCapacity.compareAndSet(totalCap, totalCap + cap)) {
                reservedMemory.addAndGet(size);
                count.incrementAndGet();
                return true;
            }
        }

        return false;
    }
複製代碼

在tryReserveMemory中的邏輯也比較簡單,使用while循環+CAS來保證有足夠的剩餘空間,並更新總空間,剩餘空間,和堆外內存數。

能夠看出,若是CAS失敗,但還有足夠的容量,while循環會進入下一輪CAS更新嘗試,直到更新成功或容量不足。

下面的代碼段中,註釋中寫的很清楚:將pending狀態下的引用入隊並重試,若是引用中包含對應的Cleaner的話,會幫助釋放堆外內存。

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;
            }
        }
複製代碼

在tryHandlePendingReference方法中,代碼只有4行:

SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
            public boolean tryHandlePendingReference() {
                return Reference.tryHandlePending(false);
            }
        });
複製代碼

相信看過上一篇講解虛引用的專欄的讀者到這裏已經明白這裏是怎樣作的堆外內存釋放了:

jlra.tryHandlePendingReference()實際上調用方法與jdk中處理pending狀態引用Reference-handler線程調用的是同一個方法。

關於Reference-handler線程,詳見:juejin.im/post/5e19d6…

隨後,jdk會主動調用一次System.gc();

在reserveMemory方法中,只是先將堆外內存相關的屬性設值,但並無真正的分配內存。

分配內存

在預約堆外內存成功後,jdk會調用unsafe中的方法去作堆外內存分配。

base = unsafe.allocateMemory(size);
複製代碼

allocateMemory方法是一個native方法,用於分配堆外內存。 在unsafe.cpp中,能夠看到他的源碼:

UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong size)) {
  size_t sz = (size_t)size;

  sz = align_up(sz, HeapWordSize);
  void* x = os::malloc(sz, mtOther);

  return addr_to_java(x);
} UNSAFE_END
複製代碼

調用了malloc函數去分配內存,並返回地址。

釋放堆外內存

咱們知道,jvm中java對象是採用gcRoot作可達性分析來肯定是否回收的,而堆外內存是與gcRoot不關聯的,那如何知道在什麼時候應該回收堆外內存呢?

理想方案是:在對應的DirectByteBuffer對象實例被回收時,同步回收堆外內存。

這時應該有同窗想到了finalize方法。這或許是一個java向c羣體妥協的一個方法,在對象將要被回收時,由gc調用。看上去有點像c中的析構方法,But,該方法的調用是不可靠的,並不能保證對象在被回收前gc必定會調用該方法。

在jdk中,是採用的虛引用的方式去釋放堆外內存。 在DirectByteBuffer的構造方法中,有一行以下代碼:

cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
複製代碼

DirectByteBuffer中的cleaner屬性就是一個虛引用。

在Deallocator中,一樣是使用unsafe中的native方法來釋放堆外內存。

unsafe.freeMemory(address);
    address = 0;
    Bits.unreserveMemory(size, capacity);```
複製代碼
UNSAFE_ENTRY(void, Unsafe_FreeMemory0(JNIEnv *env, jobject unsafe, jlong addr)) {
  void* p = addr_from_java(addr);

  os::free(p);
} UNSAFE_END
複製代碼

cleaner的調用點位於Reference類的Reference-handler線程中。

在引用對象的可達性發生變化,引用狀態變爲pending狀態時,會在tryHandlePending方法中判斷當前引用是否爲Cleaner實例,若是是的話,則調用其clean方法,完成堆外內存回收。

其餘

在預約內存時,爲何要主動調用System.gc

如下引用自寒泉子的博客(lovestblog.cn/blog/2015/0…):

既然要調用System.gc,那確定是想經過觸發一次gc操做來回收堆外內存,不過我想先說的是堆外內存不會對gc形成什麼影響(這裏的System.gc除外),可是堆外內存的回收其實依賴於咱們的gc機制,首先咱們要知道在java層面和咱們在堆外分配的這塊內存關聯的只有與之關聯的DirectByteBuffer對象了,它記錄了這塊內存的基地址以及大小,那麼既然和gc也有關,那就是gc能經過操做DirectByteBuffer對象來間接操做對應的堆外內存了。DirectByteBuffer對象在建立的時候關聯了一個PhantomReference,說到PhantomReference它其實主要是用來跟蹤對象什麼時候被回收的,它不能影響gc決策,可是gc過程當中若是發現某個對象除了只有PhantomReference引用它以外,並無其餘的地方引用它了,那將會把這個引用放到java.lang.ref.Reference.pending隊列裏,在gc完畢的時候通知ReferenceHandler這個守護線程去執行一些後置處理,而DirectByteBuffer關聯的PhantomReference是PhantomReference的一個子類,在最終的處理裏會經過Unsafe的free接口來釋放DirectByteBuffer對應的堆外內存塊

參考資料

《本身動手寫java虛擬機》

lovestblog.cn/blog/2015/0…

openJdk源碼&註釋

相關文章
相關標籤/搜索