Java中的對象都是在JVM堆中分配的,其好處在於開發者不用關心對象的回收。但有利必有弊,堆內內存主要有兩個缺點:1.GC是有成本的,堆中的對象數量越多,GC的開銷也會越大。2.使用堆內內存進行文件、網絡的IO時,JVM會使用堆外內存作一次額外的中轉,也就是會多一次內存拷貝。java
和堆內內存相對應,堆外內存就是把內存對象分配在Java虛擬機堆之外的內存,這些內存直接受操做系統管理(而不是虛擬機),這樣作的結果就是可以在必定程度上減小垃圾回收對應用程序形成的影響。c++
咱們先看下堆外內存的實現原理,再談談它的應用場景。git
更多文章見我的博客:github.com/farmerjohng…github
Java中分配堆外內存的方式有兩種,一是經過ByteBuffer.java#allocateDirect
獲得以一個DirectByteBuffer對象,二是直接調用Unsafe.java#allocateMemory
分配內存,但Unsafe只能在JDK的代碼中調用,通常不會直接使用該方法分配內存。c#
其中DirectByteBuffer也是用Unsafe去實現內存分配的,對堆內存的分配、讀寫、回收都作了封裝。本篇文章的內容也是分析DirectByteBuffer的實現。數組
咱們從堆外內存的分配回收、讀寫兩個角度去分析DirectByteBuffer。bash
//ByteBuffer.java public static ByteBuffer allocateDirect(int capacity) { return new DirectByteBuffer(capacity); } 複製代碼
ByteBuffer#allocateDirect
中僅僅是建立了一個DirectByteBuffer對象,重點在DirectByteBuffer的構造方法中。markdown
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構造方法中還作了挺多事情的,總的來講分爲幾個步驟:網絡
Java的堆外內存回收設計是這樣的:當GC發現DirectByteBuffer對象變成垃圾時,會調用Cleaner#clean
回收對應的堆外內存,必定程度上防止了內存泄露。固然,也能夠手動的調用該方法,對堆外內存進行提早回收。app
咱們先看下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時,會先確認有沒有足夠的內存,若是沒有的話,會經過一些手段回收一部分堆外內存,直到可用內存大於須要分配的內存。具體步驟以下:
tryHandlePendingReference
方法回收已經變成垃圾的DirectByteBuffer對象對應的堆外內存,直到可用內存足夠,或目前沒有垃圾DirectByteBuffer對象-XX:+DisableExplicitGC
,那System.gc();是無效的詳細分析下第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爲何有更好的性能這點展開一下。
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的文件寫最終會調用到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的文件寫,對於堆內內存來講也是會有一次額外的內存拷貝的。
堆外內存的分析就到這裏結束了,JVM爲堆外內存作這麼多處理,其主要緣由也是由於Java畢竟不是像C這樣的徹底由開發者管理內存的語言。所以即便使用堆外內存了,JVM也但願能在合適的時候自動的對堆外內存進行回收。