Java NIO學習筆記三(堆外記憶體之 DirectByteBuffer 詳解)
堆外記憶體
堆外記憶體是相對於堆內記憶體的一個概念。堆內記憶體是由JVM所管控的Java程序記憶體,我們平時在Java中建立的物件都處於堆內記憶體中,並且它們遵循JVM的記憶體管理機制,JVM會採用垃圾回收機制統一管理它們的記憶體。那麼堆外記憶體就是存在於JVM管控之外的一塊記憶體區域,因此它是不受JVM的管控。
在講解DirectByteBuffer之前,需要先簡單瞭解兩個知識點。
java引用型別,因為DirectByteBuffer是通過虛引用(Phantom Reference)來實現堆外記憶體的釋放的。
PhantomReference 是所有“弱引用”中最弱的引用型別。不同於軟引用和弱引用,虛引用無法通過 get() 方法來取得目標物件的強引用從而使用目標物件,觀察原始碼可以發現 get() 被重寫為永遠返回 null。
那虛引用到底有什麼作用?其實虛引用主要被用來 跟蹤物件被垃圾回收的狀態,通過檢視引用佇列中是否包含物件所對應的虛引用來判斷它是否 即將被垃圾回收,從而採取行動。它並不被期待用來取得目標物件的引用,而目標物件被回收前,它的引用會被放入一個 ReferenceQueue 物件中,從而達到跟蹤物件垃圾回收的作用。
關於linux的核心態和使用者態
’
- 核心態:控制計算機的硬體資源,並提供上層應用程式執行的環境。比如socket I/0操作或者檔案的讀寫操作等
- 使用者態:上層應用程式的活動空間,應用程式的執行必須依託於核心提供的資源
- 系統呼叫:為了使上層應用能夠訪問到這些資源,核心為上層應用提供訪問的介面
因此我們可以得知當我們通過JNI呼叫的native方法實際上就是從使用者態切換到了核心態的一種方式。並且通過該系統呼叫使用作業系統所提供的功能。
Q:為什麼需要使用者程序(位於使用者態中)要通過系統呼叫(Java中即使JNI)來呼叫核心態中的資源,或者說呼叫作業系統的服務了?
A:intel cpu提供Ring0-Ring3四種級別的執行模式,Ring0級別最高,Ring3最低。Linux使用了Ring3級別執行使用者態,Ring0作為核心態。Ring3狀態不能訪問Ring0的地址空間,包括程式碼和資料。因此使用者態是沒有許可權去操作核心態的資源的,它只能通過系統呼叫外完成使用者態到核心態的切換,然後在完成相關操作後再有核心態切換回使用者態。
DirectByteBuffer ———— 直接緩衝
DirectByteBuffer是Java用於實現堆外記憶體的一個重要類,我們可以通過該類實現堆外記憶體的建立、使用和銷燬。
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;
address只會被直接快取給使用到。之所以將address屬性升級放在Buffer中,是為了在JNI呼叫GetDirectBufferAddress時提升它呼叫的速率。
address表示分配的堆外記憶體的地址。
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會將資料讀到堆外記憶體中,然後堆外記憶體再講資料拷貝到堆內記憶體,這樣我們就讀到了檔案中的記憶體。
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 > 0) {
// 將堆外記憶體的資料拷貝到堆外記憶體中
var1.put(var5);
}
var7 = var6;
} finally {
// 裡面會呼叫DirectBuffer.cleaner().clean()來釋放臨時的堆外記憶體
Util.offerFirstTemporaryDirectBuffer(var5);
}
return var7;
}
}
而寫操作則反之,我們會將堆內記憶體的資料線寫到對堆外記憶體中,然後作業系統會將堆外記憶體的資料寫入到檔案中。
② 直接使用堆外記憶體,如DirectByteBuffer:這種方式是直接在堆外分配一個記憶體(即,native memory)來儲存資料,程式通過JNI直接將資料讀/寫到堆外記憶體中。因為資料直接寫入到了堆外記憶體中,所以這種方式就不會再在JVM管控的堆內再分配記憶體來儲存資料了,也就不存在堆內記憶體和堆外記憶體資料拷貝的操作了。這樣在進行I/O操作時,只需要將這個堆外記憶體地址傳給JNI的I/O的函式就好了。
DirectByteBuffer堆外記憶體的建立和回收的原始碼解讀
堆外記憶體分配
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 {
// 通過unsafe.allocateMemory分配堆外記憶體,並返回堆外記憶體的基地址
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物件用於跟蹤DirectByteBuffer物件的垃圾回收,以實現當DirectByteBuffer被垃圾回收時,堆外記憶體也會被釋放
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
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 = 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;
}
}
}
// no luck
throw new OutOfMemoryError("Direct buffer memory");
} finally {
if (interrupted) {
// don't swallow interrupts
Thread.currentThread().interrupt();
}
}
}
該方法用於在系統中儲存總分配記憶體(按頁分配)的大小和實際記憶體的大小。
其中,如果系統中記憶體( 即,堆外記憶體 )不夠的話:
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;
}
}
jlra.tryHandlePendingReference()會觸發一次非堵塞的Reference#tryHandlePending(false)。該方法會將已經被JVM垃圾回收的DirectBuffer物件的堆外記憶體釋放。
因為在Reference的靜態程式碼塊中定義了:
SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
@Override
public boolean tryHandlePendingReference() {
return tryHandlePending(false);
}
});
如果在進行一次堆外記憶體資源回收後,還不夠進行本次堆外記憶體分配的話,則
// trigger VM's Reference processing
System.gc();
System.gc()會觸發一個full gc,當然前提是你沒有顯示的設定-XX:+DisableExplicitGC來禁用顯式GC。並且你需要知道,呼叫System.gc()並不能夠保證full gc馬上就能被執行。
所以在後面打程式碼中,會進行最多9次嘗試,看是否有足夠的可用堆外記憶體來分配堆外記憶體。並且每次嘗試之前,都對延遲等待時間,已給JVM足夠的時間去完成full gc操作。如果9次嘗試後依舊沒有足夠的可用堆外記憶體來分配本次堆外記憶體,則丟擲OutOfMemoryError(“Direct buffer memory”)異常。
注意,這裡之所以用使用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
其中在我們使用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對堆外記憶體的釋放。
��雖然Cleaner不會呼叫到Reference.clear(),但Cleaner的clean()方法呼叫了remove(this),即將當前Cleaner從Cleaner連結串列中移除,這樣當clean()執行完後,Cleaner就是一個無引用指向的物件了,也就是可被GC回收的物件。
thunk方法:
通過配置引數的方式來回收堆外記憶體
同時我們可以通過-XX:MaxDirectMemorySize來指定最大的堆外記憶體大小,當使用達到了閾值的時候將呼叫System.gc()來做一次full gc,以此來回收掉沒有被使用的堆外記憶體。
堆外記憶體那些事
使用堆外記憶體的原因
- 對垃圾回收停頓的改善因為full gc意味著徹底回收,徹底回收時,垃圾收集器會對所有分配的堆內記憶體進行完整的掃描,這意味著一個重要的事實——這樣一次垃圾收集對Java應用造成的影響,跟堆的大小是成正比的。過大的堆會影響Java應用的效能。如果使用堆外記憶體的話,堆外記憶體是直接受作業系統管理( 而不是虛擬機器 )。這樣做的結果就是能保持一個較小的堆內記憶體,以減少垃圾收集對應用的影響。
- 在某些場景下可以提升程式I/O操縱的效能。少去了將資料從堆內記憶體拷貝到堆外記憶體的步驟。
什麼情況下使用堆外記憶體
- 堆外記憶體適用於生命週期中等或較長的物件。( 如果是生命週期較短的物件,在YGC的時候就被回收了,就不存在大記憶體且生命週期較長的物件在FGC對應用造成的效能影響 )。
- 直接的檔案拷貝操作,或者I/O操作。直接使用堆外記憶體就能少去記憶體從使用者記憶體拷貝到系統記憶體的操作,因為I/O操作是系統核心記憶體和裝置間的通訊,而不是通過程式直接和外設通訊的。
- 同時,還可以使用 池+堆外記憶體 的組合方式,來對生命週期較短,但涉及到I/O操作的物件進行堆外記憶體的再使用。( Netty中就使用了該方式 )
堆外記憶體 VS 記憶體池
- 記憶體池:主要用於兩類物件:①生命週期較短,且結構簡單的物件,在記憶體池中重複利用這些物件能增加CPU快取的命中率,從而提高效能;②載入含有大量重複物件的大片資料,此時使用記憶體池能減少垃圾回收的時間。
- 堆外記憶體:它和記憶體池一樣,也能縮短垃圾回收時間,但是它適用的物件和記憶體池完全相反。記憶體池往往適用於生命期較短的可變物件,而生命期中等或較長的物件,正是堆外記憶體要解決的。
堆外記憶體的特點
- 對於大記憶體有良好的伸縮性
- 對垃圾回收停頓的改善可以明顯感覺到
- 在程序間可以共享,減少虛擬機器間的複製
堆外記憶體的一些問題
- 堆外記憶體回收問題,以及堆外記憶體的洩漏問題。這個在上面的原始碼解析已經提到了。
- 堆外記憶體的資料結構問題:堆外記憶體最大的問題就是你的資料結構變得不那麼直觀,如果資料結構比較複雜,就要對它進行序列化(serialization),而序列化本身也會影響效能。另一個問題是由於你可以使用更大的記憶體,你可能開始擔心虛擬記憶體(即硬碟)的速度對你的影響了。