關於JVM堆外記憶體的一切

做個好人君發表於2018-11-09

Java中的物件都是在JVM堆中分配的,其好處在於開發者不用關心物件的回收。但有利必有弊,堆內記憶體主要有兩個缺點:1.GC是有成本的,堆中的物件數量越多,GC的開銷也會越大。2.使用堆內記憶體進行檔案、網路的IO時,JVM會使用堆外記憶體做一次額外的中轉,也就是會多一次記憶體拷貝。

和堆內記憶體相對應,堆外記憶體就是把記憶體物件分配在Java虛擬機器堆以外的記憶體,這些記憶體直接受作業系統管理(而不是虛擬機器),這樣做的結果就是能夠在一定程度上減少垃圾回收對應用程式造成的影響。

我們先看下堆外記憶體的實現原理,再談談它的應用場景。

更多文章見個人部落格:github.com/farmerjohng…

堆外記憶體的實現

Java中分配堆外記憶體的方式有兩種,一是通過ByteBuffer.java#allocateDirect得到以一個DirectByteBuffer物件,二是直接呼叫Unsafe.java#allocateMemory分配記憶體,但Unsafe只能在JDK的程式碼中呼叫,一般不會直接使用該方法分配記憶體。

其中DirectByteBuffer也是用Unsafe去實現記憶體分配的,對堆記憶體的分配、讀寫、回收都做了封裝。本篇文章的內容也是分析DirectByteBuffer的實現。

我們從堆外記憶體的分配回收、讀寫兩個角度去分析DirectByteBuffer。

堆外記憶體的分配與回收

//ByteBuffer.java 
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}
複製程式碼

ByteBuffer#allocateDirect中僅僅是建立了一個DirectByteBuffer物件,重點在DirectByteBuffer的構造方法中。

DirectByteBuffer(int cap) {                   // package-private
    //主要是呼叫ByteBuffer的構造方法,為欄位賦值
    super(-1, 0, cap, cap);
    //如果是按頁對齊,則還要加一個Page的大小;我們分析只pa為false的情況就好了
    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;
    }
    //將分配的記憶體的所有值賦值為0
    unsafe.setMemory(base, size, (byte) 0);
    //為address賦值,address就是分配記憶體的起始地址,之後的資料讀寫都是以它作為基準
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        //pa為false的情況,address==base
        address = base;
    }
    //建立一個Cleaner,將this和一個Deallocator物件傳進去
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;

}
複製程式碼

DirectByteBuffer構造方法中還做了挺多事情的,總的來說分為幾個步驟:

  1. 預分配記憶體
  2. 分配記憶體
  3. 將剛分配的記憶體空間初始化為0
  4. 建立一個cleaner物件,Cleaner物件的作用是當DirectByteBuffer物件被回收時,釋放其對應的堆外記憶體

Java的堆外記憶體回收設計是這樣的:當GC發現DirectByteBuffer物件變成垃圾時,會呼叫Cleaner#clean回收對應的堆外記憶體,一定程度上防止了記憶體洩露。當然,也可以手動的呼叫該方法,對堆外記憶體進行提前回收。

Cleaner的實現

我們先看下Cleaner#clean的實現:

public class Cleaner extends PhantomReference<Object> {
   ...
    private Cleaner(Object referent, Runnable thunk) {
        super(referent, dummyQueue);
        this.thunk = thunk;
    }
    public void clean() {
        if (remove(this)) {
            try {
                //thunk是一個Deallocator物件
                this.thunk.run();
            } catch (final Throwable var2) {
              ...
            }

        }
    }
}

private static class Deallocator
    implements Runnable
    {

        private static Unsafe unsafe = Unsafe.getUnsafe();

        private long address;
        private long size;
        private int capacity;

        private Deallocator(long address, long size, int capacity) {
            assert (address != 0);
            this.address = address;
            this.size = size;
            this.capacity = capacity;
        }

        public void run() {
            if (address == 0) {
                // Paranoia
                return;
            }
            //呼叫unsafe方法回收堆外記憶體
            unsafe.freeMemory(address);
            address = 0;
            Bits.unreserveMemory(size, capacity);
        }

    }

複製程式碼

Cleaner繼承自PhantomReference,關於虛引用的知識,可以看我之前寫的文章

簡單的說,就是當欄位referent(也就是DirectByteBuffer物件)被回收時,會呼叫到Cleaner#clean方法,最終會呼叫到Deallocator#run進行堆外記憶體的回收。

Cleaner是虛引用在JDK中的一個典型應用場景。

預分配記憶體

然後再看下DirectByteBuffer構造方法中的第二步,reserveMemory

    static void reserveMemory(long size, int cap) {
        //maxMemory代表最大堆外記憶體,也就是-XX:MaxDirectMemorySize指定的值
        if (!memoryLimitSet && VM.isBooted()) {
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }

        //1.如果堆外記憶體還有空間,則直接返回
        if (tryReserveMemory(size, cap)) {
            return;
        }
		//走到這裡說明堆外記憶體剩餘空間已經不足了
        final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

        //2.堆外記憶體進行回收,最終會呼叫到Cleaner#clean的方法。如果目前沒有堆外記憶體可以回收則跳過該迴圈
        while (jlra.tryHandlePendingReference()) {
            //如果空閒的記憶體足夠了,則return
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }

       //3.主動觸發一次GC,目的是觸發老年代GC
        System.gc();

        //4.重複上面的過程
        boolean interrupted = false;
        try {
            long sleepTime = 1;
            int sleeps = 0;
            while (true) {
                if (tryReserveMemory(size, cap)) {
                    return;
                }
                if (sleeps >= MAX_SLEEPS) {
                    break;
                }
                if (!jlra.tryHandlePendingReference()) {
                    try {
                        Thread.sleep(sleepTime);
                        sleepTime <<= 1;
                        sleeps++;
                    } catch (InterruptedException e) {
                        interrupted = true;
                    }
                }
            }

            //5.超出指定的次數後,還是沒有足夠記憶體,則拋異常
            throw new OutOfMemoryError("Direct buffer memory");

        } finally {
            if (interrupted) {
                // don't swallow interrupts
                Thread.currentThread().interrupt();
            }
        }
    }
	
    private static boolean tryReserveMemory(long size, int cap) {
        //size和cap主要是page對齊的區別,這裡我們把這兩個值看作是相等的
        long totalCap;
        //totalCapacity代表通過DirectByteBuffer分配的堆外記憶體的大小
        //當已分配大小<=還剩下的堆外記憶體大小時,更新totalCapacity的值返回true
        while (cap <= maxMemory - (totalCap = totalCapacity.get())) {
            if (totalCapacity.compareAndSet(totalCap, totalCap + cap)) {
                reservedMemory.addAndGet(size);
                count.incrementAndGet();
                return true;
            }
        }
		//堆外記憶體不足,返回false
        return false;
    }
複製程式碼

在建立一個新的DirecByteBuffer時,會先確認有沒有足夠的記憶體,如果沒有的話,會通過一些手段回收一部分堆外記憶體,直到可用記憶體大於需要分配的記憶體。具體步驟如下:

  1. 如果可用堆外記憶體足夠,則直接返回
  2. 呼叫tryHandlePendingReference方法回收已經變成垃圾的DirectByteBuffer物件對應的堆外記憶體,直到可用記憶體足夠,或目前沒有垃圾DirectByteBuffer物件
  3. 觸發一次full gc,其主要目的是為了防止’冰山現象‘:一個DirectByteBuffer物件本身佔用的記憶體很小,但是它可能引用了一塊很大的堆外記憶體。如果DirectByteBuffer物件進入了老年代之後變成了垃圾,因為老年代GC一直沒有觸發,導致這塊堆外記憶體也一直沒有被回收。需要注意的是如果使用引數-XX:+DisableExplicitGC,那System.gc();是無效的
  4. 重複1,2步驟的流程,直到可用記憶體大於需要分配的記憶體
  5. 如果超出指定次數還沒有回收到足夠記憶體,則OOM

詳細分析下第2步是如何回收垃圾的:tryHandlePendingReference最終呼叫到的是Reference#tryHandlePending方法,在之前的文章中有介紹過該方法

static boolean tryHandlePending(boolean waitForNotify) {
        Reference<Object> r;
        Cleaner c;
        try {
            synchronized (lock) {
                //pending由jvm gc時設定
                if (pending != null) {
                    r = pending;
                    // 如果是cleaner物件,則記錄下來
                    c = r instanceof Cleaner ? (Cleaner) r : null;
                    // unlink 'r' from 'pending' chain
                    pending = r.discovered;
                    r.discovered = null;
                } else {
                    // waitForNotify傳入的值為false
                    if (waitForNotify) {
                        lock.wait();
                    }
                    // 如果沒有待回收的Reference物件,則返回false
                    return waitForNotify;
                }
            }
        } catch (OutOfMemoryError x) {
            ...
        } catch (InterruptedException x) {
           ...
        }

        // Fast path for cleaners
        if (c != null) {
            //呼叫clean方法
            c.clean();
            return true;
        }

        ...
        return true;
}
複製程式碼

可以看到,tryHandlePendingReference的最終效果就是:如果有垃圾DirectBytebuffer物件,則呼叫對應的Cleaner#clean方法進行回收。clean方法在上面已經分析過了。

堆外記憶體的讀寫

public ByteBuffer put(byte x) {
       unsafe.putByte(ix(nextPutIndex()), ((x)));
       return this;
}

final int nextPutIndex() {                         
    if (position >= limit)
        throw new BufferOverflowException();
    return position++;
}

private long ix(int i) {
    return address + ((long)i << 0);
}

public byte get() {
    return ((unsafe.getByte(ix(nextGetIndex()))));
}

final int nextGetIndex() {                          // package-private
    if (position >= limit)
        throw new BufferUnderflowException();
    return position++;
}

複製程式碼

讀寫的邏輯也比較簡單,address就是構造方法中分配的native記憶體的起始地址。Unsafe的putByte/getByte都是native方法,就是寫入值到某個地址/獲取某個地址的值。

堆外記憶體的使用場景

適合長期存在或能複用的場景

堆外記憶體分配回收也是有開銷的,所以適合長期存在的物件

適合注重穩定的場景

堆外記憶體能有效避免因GC導致的暫停問題。

適合簡單物件的儲存

因為堆外記憶體只能儲存位元組陣列,所以對於複雜的DTO物件,每次儲存/讀取都需要序列化/反序列化,

適合注重IO效率的場景

用堆外記憶體讀寫檔案效能更好

檔案IO

關於堆外記憶體IO為什麼有更好的效能這點展開一下。

BIO

BIO的檔案寫FileOutputStream#write最終會呼叫到native層的io_util.c#writeBytes方法

void
writeBytes(JNIEnv *env, jobject this, jbyteArray bytes,
           jint off, jint len, jboolean append, jfieldID fid)
{
    jint n;
    char stackBuf[BUF_SIZE];
    char *buf = NULL;
    FD fd;

 	...

    // 如果寫入長度為0,直接返回0
    if (len == 0) {
        return;
    } else if (len > BUF_SIZE) {
        // 如果寫入長度大於BUF_SIZE(8192),無法使用棧空間buffer
        // 需要呼叫malloc在堆空間申請buffer
        buf = malloc(len);
        if (buf == NULL) {
            JNU_ThrowOutOfMemoryError(env, NULL);
            return;
        }
    } else {
        buf = stackBuf;
    }

    // 複製Java傳入的byte陣列資料到C空間的buffer中
    (*env)->GetByteArrayRegion(env, bytes, off, len, (jbyte *)buf);
 	
     if (!(*env)->ExceptionOccurred(env)) {
        off = 0;
        while (len > 0) {
            fd = GET_FD(this, fid);
            if (fd == -1) {
                JNU_ThrowIOException(env, "Stream Closed");
                break;
            }
            //寫入到檔案,這裡傳遞的陣列是我們新建立的buf
            if (append == JNI_TRUE) {
                n = (jint)IO_Append(fd, buf+off, len);
            } else {
                n = (jint)IO_Write(fd, buf+off, len);
            }
            if (n == JVM_IO_ERR) {
                JNU_ThrowIOExceptionWithLastError(env, "Write error");
                break;
            } else if (n == JVM_IO_INTR) {
                JNU_ThrowByName(env, "java/io/InterruptedIOException", NULL);
                break;
            }
            off += n;
            len -= n;
        }
    }
}

複製程式碼

GetByteArrayRegion其實就是對陣列進行了一份拷貝,該函式的實現在jni.cpp巨集定義中,找了很久才找到

//jni.cpp
JNI_ENTRY(void, \
jni_Get##Result##ArrayRegion(JNIEnv *env, ElementType##Array array, jsize start, \
             jsize len, ElementType *buf)) \
 ...
      int sc = TypeArrayKlass::cast(src->klass())->log2_element_size(); \
      //記憶體拷貝
      memcpy((u_char*) buf, \
             (u_char*) src->Tag##_at_addr(start), \
             len << sc);                          \
...
  } \
JNI_END
複製程式碼

可以看到,傳統的BIO,在native層真正寫檔案前,會在堆外記憶體(c分配的記憶體)中對位元組陣列拷貝一份,之後真正IO時,使用的是堆外的陣列。要這樣做的原因是

1.底層通過write、read、pwrite,pread函式進行系統呼叫時,需要傳入buffer的起始地址和buffer count作為引數。如果使用java heap的話,我們知道jvm中buffer往往以byte[] 的形式存在,這是一個特殊的物件,由於java heap GC的存在,這裡物件在堆中的位置往往會發生移動,移動後我們傳入系統函式的地址引數就不是真正的buffer地址了,這樣的話無論讀寫都會發生出錯。而C Heap僅僅受Full GC的影響,相對來說地址穩定。

2.JVM規範中沒有要求Java的byte[]必須是連續的記憶體空間,它往往受宿主語言的型別約束;而C Heap中我們分配的虛擬地址空間是可以連續的,而上述的系統呼叫要求我們使用連續的地址空間作為buffer。
複製程式碼

以上內容來自於 知乎 ETIN的回答 www.zhihu.com/question/60…

BIO的檔案讀也一樣,這裡就不分析了。

NIO

NIO的檔案寫最終會呼叫到IOUtil#write

 static int write(FileDescriptor fd, ByteBuffer src, long position,
                     NativeDispatcher nd, Object lock)
        throws IOException
    {
    	//如果是堆外記憶體,則直接寫
        if (src instanceof DirectBuffer)
            return writeFromNativeBuffer(fd, src, position, nd, lock);

        // 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, lock);
            if (n > 0) {
                // now update src
                src.position(pos + n);
            }
            return n;
        } finally {
            Util.offerFirstTemporaryDirectBuffer(bb);
        }
    }
    
 	/**
     * 分配一片堆外記憶體
     */
    static ByteBuffer getTemporaryDirectBuffer(int size) {
        BufferCache cache = bufferCache.get();
        ByteBuffer buf = cache.get(size);
        if (buf != null) {
            return buf;
        } else {
            // No suitable buffer in the cache so we need to allocate a new
            // one. To avoid the cache growing then we remove the first
            // buffer from the cache and free it.
            if (!cache.isEmpty()) {
                buf = cache.removeFirst();
                free(buf);
            }
            return ByteBuffer.allocateDirect(size);
        }
    }

   
複製程式碼

可以看到,NIO的檔案寫,對於堆內記憶體來說也是會有一次額外的記憶體拷貝的。

End

堆外記憶體的分析就到這裡結束了,JVM為堆外記憶體做這麼多處理,其主要原因也是因為Java畢竟不是像C這樣的完全由開發者管理記憶體的語言。因此即使使用堆外記憶體了,JVM也希望能在合適的時候自動的對堆外記憶體進行回收。

相關文章