從0到1起步-跟我進入堆外內存的奇妙世界
堆外內存一直是Java業務開發人員難以企及的隱藏領域,究竟他是干什么的,以及如何更好的使用呢?那就請跟著我進入這個世界吧。
一、什么是堆外內存
1、堆內內存(on-heap memory)回顧
堆外內存和堆內內存是相對的二個概念,其中堆內內存是我們平常工作中接觸比較多的,我們在jvm參數中只要使用-Xms,-Xmx等參數就可以設置堆的大小和最大值,理解jvm的堆還需要知道下面這個公式:
堆內內存 = 新生代+老年代+持久代
如下面的圖所示:
Paste_Image.png
在使用堆內內存(on-heap memory)的時候,完全遵守JVM虛擬機的內存管理機制,采用垃圾回收器(GC)統一進行內存管理,GC會在某些特定的時間點進行一次徹底回收,也就是Full GC,GC會對所有分配的堆內內存進行掃描,在這個過程中會對JAVA應用程序的性能造成一定影響,還可能會產生Stop The World。
常見的垃圾回收算法主要有:
- 引用計數器法(Reference Counting)
- 標記清除法(Mark-Sweep)
- 復制算法(Coping)
- 標記壓縮法(Mark-Compact)
- 分代算法(Generational Collecting)
- 分區算法(Region)
注: 在這里我們不對各個算法進行深入介紹,感興趣的同學可以關注我的下一篇關于垃圾回收算法的介紹分享。
2、堆外內存(off-heap memory)介紹
和堆內內存相對應,堆外內存就是把內存對象分配在Java虛擬機的堆以外的內存,這些內存直接受操作系統管理(而不是虛擬機),這樣做的結果就是能夠在一定程度上減少垃圾回收對應用程序造成的影響。
作為JAVA開發者我們經常用java.nio.DirectByteBuffer對象進行堆外內存的管理和使用,它會在對象創建的時候就分配堆外內存。
DirectByteBuffer類是在Java Heap外分配內存,對堆外內存的申請主要是通過成員變量unsafe來操作,下面介紹構造方法
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
//內存是否按頁分配對齊
boolean pa = VM.isDirectMemoryPageAligned();
//獲取每頁內存大小
int ps = Bits.pageSize();
//分配內存的大小,如果是按頁對齊方式,需要再加一頁內存的容量
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
//用Bits類保存總分配內存(按頁分配)的大小和實際內存的大小
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;
}
注:在Cleaner 內部中通過一個列表,維護了一個針對每一個 directBuffer 的一個回收堆外內存的 線程對象(Runnable),回收操作是發生在 Cleaner 的 clean() 方法中。
private static class Deallocator implements Runnable {
private static Unsafeunsafe = 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.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
二、使用堆外內存的優點
1、減少了垃圾回收
因為垃圾回收會暫停其他的工作。
2、加快了復制的速度
堆內在flush到遠程時,會先復制到直接內存(非堆內存),然后在發送;而堆外內存相當于省略掉了這個工作。
同樣任何一個事物使用起來有優點就會有缺點,堆外內存的缺點就是內存難以控制,使用了堆外內存就間接失去了JVM管理內存的可行性,改由自己來管理,當發生內存溢出時排查起來非常困難。
三、使用DirectByteBuffer的注意事項
java.nio.DirectByteBuffer對象在創建過程中會先通過Unsafe接口直接通過os::malloc來分配內存,然后將內存的起始地址和大小存到java.nio.DirectByteBuffer對象里,這樣就可以直接操作這些內存。這些內存只有在DirectByteBuffer回收掉之后才有機會被回收,因此如果這些對象大部分都移到了old,但是一直沒有觸發CMS GC或者Full GC,那么悲劇將會發生,因為你的物理內存被他們耗盡了,因此為了避免這種悲劇的發生,通過-XX:MaxDirectMemorySize來指定最大的堆外內存大小,當使用達到了閾值的時候將調用System.gc來做一次full gc,以此來回收掉沒有被使用的堆外內存。
四、DirectByteBuffer使用測試
我們在寫NIO程序經常使用ByteBuffer來讀取或者寫入數據,那么使用ByteBuffer.allocate(capability)還是使用ByteBuffer.allocteDirect(capability)來分配緩存了?第一種方式是分配JVM堆內存,屬于GC管轄范圍,由于需要拷貝所以速度相對較慢;第二種方式是分配OS本地內存,不屬于GC管轄范圍,由于不需要內存拷貝所以速度相對較快。
代碼如下:
package com.stevex.app.nio;
importjava.nio.ByteBuffer;
importjava.util.concurrent.TimeUnit;
public class DirectByteBufferTest {
public static void main(String[] args) throws InterruptedException{
//分配128MB直接內存
ByteBufferbb = ByteBuffer.allocateDirect(1024*1024*128);
TimeUnit.SECONDS.sleep(10);
System.out.println("ok");
}
}
測試用例1:設置JVM參數-Xmx100m,運行異常,因為如果沒設置-XX:MaxDirectMemorySize,則默認與-Xmx參數值相同,分配128M直接內存超出限制范圍。
Exceptionin thread "main" java.lang.OutOfMemoryError: Directbuffermemory
atjava.nio.Bits.reserveMemory(Bits.java:658)
atjava.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
atjava.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306)
atcom.stevex.app.nio.DirectByteBufferTest.main(DirectByteBufferTest.java:8)
測試用例2:設置JVM參數-Xmx256m,運行正常,因為128M小于256M,屬于范圍內分配。
測試用例3:設置JVM參數-Xmx256m -XX:MaxDirectMemorySize=100M,運行異常,分配的直接內存128M超過限定的100M。
Exceptionin thread "main" java.lang.OutOfMemoryError: Directbuffermemory
atjava.nio.Bits.reserveMemory(Bits.java:658)
atjava.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
atjava.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306)
atcom.stevex.app.nio.DirectByteBufferTest.main(DirectByteBufferTest.java:8)
測試用例4:設置JVM參數-Xmx768m,運行程序觀察內存使用變化,會發現clean()后內存馬上下降,說明使用clean()方法能有效及時回收直接緩存。
代碼如下:
package com.stevex.app.nio;
importjava.nio.ByteBuffer;
importjava.util.concurrent.TimeUnit;
importsun.nio.ch.DirectBuffer;
public class DirectByteBufferTest {
public static void main(String[] args) throws InterruptedException{
//分配512MB直接緩存
ByteBufferbb = ByteBuffer.allocateDirect(1024*1024*512);
TimeUnit.SECONDS.sleep(10);
//清除直接緩存
((DirectBuffer)bb).cleaner().clean();
TimeUnit.SECONDS.sleep(10);
System.out.println("ok");
}
}
五、細說System.gc方法
1、JDK里的System.gc的實現
/**
* Runs the garbage collector.
* <p>
* Calling the <code>gc</code> method suggests that the Java Virtual
* Machine expend effort toward recycling unused objects in order to
* make the memory they currently occupy available for quick reuse.
* When control returns from the method call, the Java Virtual
* Machine has made a best effort to reclaim space from all discarded
* objects.
* <p>
* The call <code>System.gc()</code> is effectively equivalent to the
* call:
* <blockquote><pre>
* Runtime.getRuntime().gc()
* </pre></blockquote>
*
* <a >@see</a> java.lang.Runtime#gc()
*/
public static void gc() {
Runtime.getRuntime().gc();
}
* Calling the gc method suggests that the Java Virtual * Machine expend effort toward recycling unused objects in order to * make the memory they currently occupy available for quick reuse. * When control returns from the method call, the Java Virtual * Machine has made a best effort to reclaim space from all discarded * objects. *
* The call System.gc() is effectively equivalent to the * call: *
* Runtime.getRuntime().gc()
*
* * <a >@see</a> java.lang.Runtime#gc() */ public static void gc() { Runtime.getRuntime().gc(); }
其實發現System.gc方法其實是調用的Runtime.getRuntime.gc(),我們再接著看。
/*
運行垃圾收集器。
調用此方法表明,java虛擬機擴展
努力回收未使用的對象,以便內存可以快速復用,
當控制從方法調用返回的時候,虛擬機盡力回收被丟棄的對象
*/
public native void gc();
這里看到gc方法是native的,在java層面只能到此結束了,代碼只有這么多,要了解更多,可以看方法上面的注釋,不過我們需要更深層次地來了解其實現,那還是準備好進入到jvm里去看看。
2、System.gc的作用有哪些
說起堆外內存免不了要提及System.gc方法,下面就是使用了System.gc的作用是什么?
- 做一次full gc
- 執行后會暫停整個進程。
- System.gc我們可以禁掉,使用-XX:+DisableExplicitGC,
其實一般在cms gc下我們通過-XX:+ExplicitGCInvokesConcurrent也可以做稍微高效一點的gc,也就是并行gc。 - 最常見的場景是RMI/NIO下的堆外內存分配等
注:
如果我們使用了堆外內存,并且用了DisableExplicitGC設置為true,那么就是禁止使用System.gc,這樣堆外內存將無從觸發極有可能造成內存溢出錯誤,在這種情況下可以考慮使用ExplicitGCInvokesConcurrent參數。
說起Full gc我們最先想到的就是 stop thd world ,這里要先提到VMThread,在jvm里有這么一個線程不斷輪詢它的隊列,這個隊列里主要是存一些VM_operation的動作,比如最常見的就是內存分配失敗要求做GC操作的請求等,在對gc這些操作執行的時候會先將其他業務線程都進入到安全點,也就是這些線程從此不再執行任何字節碼指令,只有當出了安全點的時候才讓他們繼續執行原來的指令,因此這其實就是我們說的stop the world(STW),整個進程相當于靜止了。
六、開源堆外緩存框架
關于堆外緩存的開源實現。查詢了一些資料后了解到的主要有:
- Ehcache 3.0:3.0基于其商業公司一個非開源的堆外組件的實現。
- Chronical Map:OpenHFT包括很多類庫,使用這些類庫很少產生垃圾,并且應用程序使用這些類庫后也很少發生Minor GC。類庫主要包括:Chronicle Map,Chronicle Queue等等。
- OHC:來源于Cassandra 3.0, Apache v2。
- Ignite: 一個規模宏大的內存計算框架,屬于Apache項目。
來自:http://blog.jobbole.com/107640/