Java——堆外記憶體詳解
記憶體是好東西,我們常聽堆記憶體,很多人卻不知道還有一個堆外記憶體。
那這兩個都是個啥玩意呢?且讓本帥博主今天給你好好說道說道。
一、堆內記憶體
那什麼東西是堆記憶體呢?我們來看看官方的說法。
“Java 虛擬機器具有一個堆(Heap),堆是執行時資料區域,所有類例項和陣列的記憶體均從此處分配。堆是在 Java 虛擬機器啟動時建立的。” |
也就是說,平常我們老遇見的那位,JVM啟動時分配的,就叫作堆記憶體(即堆內記憶體)。
物件的堆記憶體由稱為垃圾回收器的自動記憶體管理系統回收。
此外,堆的記憶體不需要是連續空間,因此堆的大小沒有具體要求,既可以固定,也可以擴大和縮小。
我們在jvm引數中只要使用-Xms,-Xmx等引數就可以設定堆的大小和最大值,理解jvm的堆還需要知道下面這個公式:
堆內記憶體 = 新生代+老年代+持久代 |
如下圖:
在使用堆內記憶體(on-heap memory)的時候,完全遵守JVM虛擬機器的記憶體管理機制,採用垃圾回收器(GC)統一進行記憶體管理,GC會在某些特定的時間點進行一次徹底回收,也就是Full GC,GC會對所有分配的堆內記憶體進行掃描。
注意:在這個過程中會對JAVA應用程式的效能造成一定影響,還可能會產生Stop The World。
二、堆外記憶體
顯然,看名字就知道堆外記憶體與堆內記憶體是相對應的:Java 虛擬機器管理堆之外的記憶體,稱為非堆記憶體,即堆外記憶體。
換句話說:堆外記憶體就是把記憶體物件分配在Java虛擬機器的堆以外的記憶體,這些記憶體直接受作業系統管理(而不是虛擬機器),這樣做的結果就是能夠在一定程度上減少垃圾回收對應用程式造成的影響。
那堆外記憶體都有哪些東西呢?
Java 虛擬機器具有一個由所有執行緒共享的方法區。方法區屬於非堆記憶體。它儲存每個類結構,如執行時常數池、欄位和方法資料,以及方法和構造方法的程式碼。它是在 Java 虛擬機器啟動時建立的。
方法區在邏輯上屬於堆,但 Java 虛擬機器實現可以選擇不對其進行回收或壓縮。與堆類似,方法區的記憶體不需要是連續空間,因此方法區的大小可以固定,也可以擴大和縮小。。
除了方法區外,Java 虛擬機器實現可能需要用於內部處理或優化的記憶體,這種記憶體也是非堆記憶體。例如,JIT 編譯器需要記憶體來儲存從 Java 虛擬機器程式碼轉換而來的本機程式碼,從而獲得高效能。
下面我們來看看堆外記憶體如何申請與釋放。
三、堆外記憶體的申請和釋放
JDK的ByteBuffer
類提供了一個介面allocateDirect(int capacity)進行堆外記憶體的申請。
底層通過unsafe.allocateMemory(size)實現,Netty、Mina等框架提供的介面也是基於ByteBuffer封裝的。
現在我們先看看在JVM層面是如何實現堆外記憶體申請的。
可以發現,unsafe.allocateMemory(size)的最底層是通過malloc
方法申請的,但是這塊記憶體需要進行手動釋放,JVM並不會進行回收,幸好Unsafe
提供了另一個介面freeMemory
可以對申請的堆外記憶體進行釋放。
看完堆外記憶體申請的底層實現,想必大家對它的實現就有了一些基礎瞭解。
接下來我們再看看DirectByteBuffer()的構造方法。
其構造方法如下:
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() 方法中。
Cleaner原始碼如下:
private Cleaner(Object var1, Runnable var2) {
super(var1, dummyQueue);
this.thunk = var2;
}
public static Cleaner create(Object var0, Runnable var1) {
return var1 == null ? null : add(new Cleaner(var0, var1));
}
public void clean() {
if (remove(this)) {
try {
this.thunk.run(); //此處會呼叫Deallocator,見下個類
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}
System.exit(1);
return null;
}
});
}
}
}
Deallocator的原始碼如下:
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) {
return;
}
unsafe.freeMemory(address);//unsafe提供的方法釋放記憶體
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
四、堆外記憶體的回收機制
上文說到,“unsafe.allocateMemory(size)的最底層是通過malloc
方法申請的,但是這塊記憶體需要進行手動釋放,JVM並不會進行回收,幸好Unsafe
提供了另一個介面freeMemory
可以對申請的堆外記憶體進行釋放。”。那豈不是每一次申請堆外記憶體的時候,都需要在程式碼中顯式釋放嗎?
很明顯,並不是這樣的,這種情況的出現對於Java這門語言來說顯然不夠合理。那既然JVM不會管理這些堆外記憶體,它們又是怎麼回收的呢?
這裡就要祭出大殺器了:DirectByteBuffer。
JDK中使用DirectByteBuffer
物件來表示堆外記憶體,每個DirectByteBuffer
物件在初始化時,都會建立一個對應的Cleaner
物件,這個Cleaner
物件會在合適的時候執行unsafe.freeMemory(address)
,從而回收這塊堆外記憶體。
當初始化一塊堆外記憶體時,物件的引用關係如下:
其中first
是Cleaner
類的靜態變數,Cleaner
物件在初始化時會被新增到Clener
連結串列中,和first
形成引用關係,ReferenceQueue
是用來儲存需要回收的Cleaner
物件。
如果該DirectByteBuffer
物件在一次GC中被回收了,即
此時,只有Cleaner
物件唯一儲存了堆外記憶體的資料(開始地址、大小和容量),在下一次FGC時,把該Cleaner
物件放入到ReferenceQueue
中,並觸發clean
方法。
Cleaner
物件的clean
方法主要有兩個作用:
- 把自身從
Cleaner
連結串列刪除,從而在下次GC時能夠被回收 - 釋放堆外記憶體
原始碼如下:
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
看到這裡,可能有人會想,如果JVM一直沒有執行FGC的話,無效的Cleaner
物件就無法放入到ReferenceQueue中,從而堆外記憶體也一直得不到釋放,無效記憶體就會很大,那怎麼辦?
這個倒不用擔心,那些大神們當然早就考慮到這一種情況了。
其實,在初始化DirectByteBuffer
物件時,會自動去判斷,如果堆外記憶體的環境很友好,那麼就申請堆外記憶體;如果當前堆外記憶體的條件很苛刻時(即有很多無效記憶體沒有得到釋放),這時候就會主動呼叫System.gc()
強制執行FGC,從而釋放那些無效記憶體。
為了避免這種悲劇的發生,也可以通過-XX:MaxDirectMemorySize來指定最大的堆外記憶體大小,當使用達到了閾值的時候將呼叫System.gc來做一次full gc,以此來回收掉沒有被使用的堆外記憶體。
原始碼如下:
當然,源程式畢竟不是萬能的,做專案的時候經常有千奇百怪的情況出現。
比如很多線上環境的JVM引數有-XX:+DisableExplicitGC
,導致了System.gc()
等於一個空函式,根本不會觸發FGC,因此在使用堆外記憶體時,要格外小心,防止記憶體一直得不到釋放,造成線上故障。這一點在使用Netty框架時需要格外注意。
總而言之,不論是什麼東西,都不是絕對安全的。對於各類程式碼,我們都得多加留心。
五、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),整個程序相當於靜止了。
六、使用堆外記憶體的優點
當然,任何一個事物使用起來有優點就會有缺點,堆外記憶體的缺點就是記憶體難以控制,使用了堆外記憶體就間接失去了JVM管理記憶體的可行性,改由自己來管理,當發生記憶體溢位時排查起來非常困難。 所以,還是那句話,使用的時候要多留心呀~
- 可以擴充套件至更大的記憶體空間。比如超過1TB甚至比主存還大的空間;
- 減少了垃圾回收(因為垃圾回收會暫停其他的工作。);
- 可以在程序間共享,減少JVM間的物件複製,使得JVM的分割部署更容易實現(堆內在flush到遠端時,會先複製到直接記憶體(非堆記憶體),然後在傳送;而堆外記憶體相當於省略掉了這個工作。);
- 它的持久化儲存可以支援快速重啟,同時還能夠在測試環境中重現生產資料
- 站在系統設計的角度來看,使用堆外記憶體可以為你的設計提供更多可能。最重要的提升並不在於效能,而是決定性的
好啦,以上就是關於堆外記憶體的相關知識總結啦,如果大家有什麼不明白的地方或者發現文中有描述不好的地方,歡迎大家留言評論,我們一起學習呀。
Biu~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~pia!
參考文章:
https://www.jianshu.com/p/50be08b54bee