什麼是直接內存
我們都知道Java程序是運行在Java虛擬機中的,Java對象的分配一般情況下是在虛擬機的堆內存空間,俗稱堆內內存。這一塊的內存垃圾回收是受JVM控制的,程序員無需爲此處的內存回收而操心。Java對象除了能分配在堆中,也能分配在堆外,這部分內存叫堆外內存,也就是直接內存。
直接內存和堆內內存的比較
堆內內存的分配是在JVM中,因此分配速度很快,但是堆內內存在進行網絡I/O的時候,需要先將內存從堆內複製到native堆。堆外內存的分配是直接調用的C的malloc函數在堆外空間分配的,因此分配速度相對較慢,但是在進行網絡I/O的時候,由於沒有將內存從堆內複製到native堆這一步,因此較快。下圖是發起一個網絡請求時的內存數據流向圖(網絡響應是原路返回,我就不畫了):
直接內存的回收
直接內存的回收受JVM控制嗎?答案是YES!很多人肯定覺得不可思議,直接內存在堆外,爲什麼還會受JVM控制呢?其實道理是這樣的,當我們使用代碼ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024)
來分配1MB的直接內存的時候,byteBuffer對象仍然是在堆內的,它持有了直接內存的地址,可以肯定的是它的大小遠遠小於1MB,我們把它叫做冰山對象。當冰山對象被GC時,它所關聯的直接內存也會被釋放。口說無憑,下面用代碼來證明:
- 示例一(YGC回收直接內存)
import java.nio.ByteBuffer;
/**
* -Xms120m
* -Xmx120m
* -XX:+UseParNewGC
* -XX:+PrintCommandLineFlags
* -XX:+PrintGCDetails
* -XX:+PrintHeapAtGC
* -XX:+DisableExplicitGC
* -XX:MaxDirectMemorySize=15m
*
* @author debo
* @date 2020-02-07
*/
public class DirectByteBufferTest {
public static final int _1K = 1024;
public static void main(String[] args) {
// 分配20MB直接內存
for (int i = 0; i < 20 * _1K; i++) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1K);
}
}
}
在類註釋上的JVM參數中,-XX:+PrintGCDetails
表示打印GC詳細信息,-XX:+PrintHeapAtGC
表示在GC時分別打印GC前和GC後的堆內存信息,XX:+DisableExplicitGC
表示禁用System.gc() 的顯式Full GC調用,-XX:MaxDirectMemorySize=15m
表示最大可分配15MB直接內存。
使用類註釋上的JVM參數運行程序,輸出信息如下
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:658)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306)
at DirectByteBufferTest.main(DirectByteBufferTest.java:15)
Heap
par new generation total 36864K, used 5450K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
eden space 32768K, 16% used [0x00000000f3600000, 0x00000000f3b52858, 0x00000000f5600000)
from space 4096K, 0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
to space 4096K, 0% used [0x00000000f5a00000, 0x00000000f5a00000, 0x00000000f5e00000)
tenured generation total 81920K, used 0K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
the space 81920K, 0% used [0x00000000f5e00000, 0x00000000f5e00000, 0x00000000f5e00200, 0x00000000fae00000)
compacting perm gen total 21248K, used 3105K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 14% used [0x00000000fae00000, 0x00000000fb108740, 0x00000000fb108800, 0x00000000fc2c0000)
No shared spaces configured.
很明顯出現了直接內存溢出,且這時候新生代和老年代尚有足夠的內存空間。
修改程序如下:
import java.nio.ByteBuffer;
/**
* -Xms120m
* -Xmx120m
* -XX:+UseParNewGC
* -XX:+PrintCommandLineFlags
* -XX:+PrintGCDetails
* -XX:+PrintHeapAtGC
* -XX:+DisableExplicitGC
* -XX:MaxDirectMemorySize=15m
*
* @author debo
* @date 2020-02-07
*/
public class DirectByteBufferTest {
public static final int _1K = 1024;
public static void main(String[] args) {
// 分配28MB堆內存
for (int i = 0; i < 28 * _1K; i++) {
byte[] bytes = new byte[_1K];
}
// 分配20MB直接內存
for (int i = 0; i < 20 * _1K; i++) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1K);
}
}
}
使用相同的JVM參數運行程序,輸出如下
{Heap before GC invocations=0 (full 0):
par new generation total 36864K, used 32768K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
eden space 32768K, 100% used [0x00000000f3600000, 0x00000000f5600000, 0x00000000f5600000)
from space 4096K, 0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
to space 4096K, 0% used [0x00000000f5a00000, 0x00000000f5a00000, 0x00000000f5e00000)
tenured generation total 81920K, used 0K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
the space 81920K, 0% used [0x00000000f5e00000, 0x00000000f5e00000, 0x00000000f5e00200, 0x00000000fae00000)
compacting perm gen total 21248K, used 3074K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 14% used [0x00000000fae00000, 0x00000000fb100a60, 0x00000000fb100c00, 0x00000000fc2c0000)
No shared spaces configured.
[GC[ParNew: 32768K->1141K(36864K), 0.0047660 secs] 32768K->1141K(118784K), 0.0047850 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
Heap after GC invocations=1 (full 0):
par new generation total 36864K, used 1141K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
eden space 32768K, 0% used [0x00000000f3600000, 0x00000000f3600000, 0x00000000f5600000)
from space 4096K, 27% used [0x00000000f5a00000, 0x00000000f5b1d760, 0x00000000f5e00000)
to space 4096K, 0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
tenured generation total 81920K, used 0K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
the space 81920K, 0% used [0x00000000f5e00000, 0x00000000f5e00000, 0x00000000f5e00200, 0x00000000fae00000)
compacting perm gen total 21248K, used 3074K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 14% used [0x00000000fae00000, 0x00000000fb100a60, 0x00000000fb100c00, 0x00000000fc2c0000)
No shared spaces configured.
}
Heap
par new generation total 36864K, used 2948K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
eden space 32768K, 5% used [0x00000000f3600000, 0x00000000f37c3960, 0x00000000f5600000)
from space 4096K, 27% used [0x00000000f5a00000, 0x00000000f5b1d760, 0x00000000f5e00000)
to space 4096K, 0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
tenured generation total 81920K, used 0K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
the space 81920K, 0% used [0x00000000f5e00000, 0x00000000f5e00000, 0x00000000f5e00200, 0x00000000fae00000)
compacting perm gen total 21248K, used 3083K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 14% used [0x00000000fae00000, 0x00000000fb102e40, 0x00000000fb103000, 0x00000000fc2c0000)
No shared spaces configured.
發現並沒有出現直接內存溢出,且出現了一次YGC,下面解讀這次的現象:根據JVM參數得知,最大堆內存分配了120M,根據默認的比例,新生代佔用1/3(40M),老年代佔用2/3(80M),新生代中,S區(from+to)分別佔用1/10(4M),E區佔用8/10(32M)。根據代碼得知,一開始分配了28M堆內存,雖然是28M,但我在自己機器上測試發現,分配了28M堆內存以後,E區就已經被佔用98%了,後面繼續分配20M的直接內存,我用了循環分配的方法,每次循環只分配1K直接內存,這樣做的目的是爲了產生儘可能多的冰山對象。在分配直接內存的過程中,冰山對象逐漸填充完E區剩餘的2%,於是觸發YGC,且此時直接內存的分配還沒有達到最大值15M。YGC回收的過程中,會釋放冰山對象所引用的直接內存,於是經過一次YGC後,又有15M的空閒直接內存可供分配,因此後面的分配得以順利進行。
不僅YGC,FGC的時候也會回收直接內存,代碼如下
- 示例二(FGC回收直接內存)
import java.nio.ByteBuffer;
/**
* -Xms120m
* -Xmx120m
* -XX:+UseParNewGC
* -XX:+PrintCommandLineFlags
* -XX:+PrintGCDetails
* -XX:+PrintHeapAtGC
* -XX:MaxDirectMemorySize=15m
*
* @author debo
* @date 2020-02-07
*/
public class DirectByteBufferTest {
public static final int _1K = 1024;
public static void main(String[] args) {
// 分配10MB直接內存
for (int i = 0; i < 10 * _1K; i++) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1K);
}
// 觸發FGC
System.gc();
// 分配10MB直接內存
for (int i = 0; i < 10 * _1K; i++) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1K);
}
}
}
JVM參數中,去掉了-XX:+DisableExplicitGC
,輸出如下
{Heap before GC invocations=0 (full 0):
par new generation total 36864K, used 4139K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
eden space 32768K, 12% used [0x00000000f3600000, 0x00000000f3a0ad58, 0x00000000f5600000)
from space 4096K, 0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
to space 4096K, 0% used [0x00000000f5a00000, 0x00000000f5a00000, 0x00000000f5e00000)
tenured generation total 81920K, used 0K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
the space 81920K, 0% used [0x00000000f5e00000, 0x00000000f5e00000, 0x00000000f5e00200, 0x00000000fae00000)
compacting perm gen total 21248K, used 3074K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 14% used [0x00000000fae00000, 0x00000000fb100ad8, 0x00000000fb100c00, 0x00000000fc2c0000)
No shared spaces configured.
[Full GC[Tenured: 0K->1042K(81920K), 0.0048550 secs] 4139K->1042K(118784K), [Perm : 3074K->3074K(21248K)], 0.0048750 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
Heap after GC invocations=1 (full 1):
par new generation total 36864K, used 0K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
eden space 32768K, 0% used [0x00000000f3600000, 0x00000000f3600000, 0x00000000f5600000)
from space 4096K, 0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
to space 4096K, 0% used [0x00000000f5a00000, 0x00000000f5a00000, 0x00000000f5e00000)
tenured generation total 81920K, used 1042K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
the space 81920K, 1% used [0x00000000f5e00000, 0x00000000f5f04850, 0x00000000f5f04a00, 0x00000000fae00000)
compacting perm gen total 21248K, used 3074K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 14% used [0x00000000fae00000, 0x00000000fb100ad8, 0x00000000fb100c00, 0x00000000fc2c0000)
No shared spaces configured.
}
Heap
par new generation total 36864K, used 1947K [0x00000000f3600000, 0x00000000f5e00000, 0x00000000f5e00000)
eden space 32768K, 5% used [0x00000000f3600000, 0x00000000f37e6d08, 0x00000000f5600000)
from space 4096K, 0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f5a00000)
to space 4096K, 0% used [0x00000000f5a00000, 0x00000000f5a00000, 0x00000000f5e00000)
tenured generation total 81920K, used 1042K [0x00000000f5e00000, 0x00000000fae00000, 0x00000000fae00000)
the space 81920K, 1% used [0x00000000f5e00000, 0x00000000f5f04850, 0x00000000f5f04a00, 0x00000000fae00000)
compacting perm gen total 21248K, used 3083K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 14% used [0x00000000fae00000, 0x00000000fb102eb8, 0x00000000fb103000, 0x00000000fc2c0000)
No shared spaces configured.
前後各分配10M的直接內存,沒有發生直接內存溢出,說明FGC時回收掉了第一次分配的10M直接內存。
從源碼層面分析直接內存的回收
ByteBuffer.allocateDirect()
源碼如下:
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;
}
static void reserveMemory(long size, int cap) {
synchronized (Bits.class) {
if (!memoryLimitSet && VM.isBooted()) {
maxMemory = VM.maxDirectMemory();
memoryLimitSet = true;
}
// -XX:MaxDirectMemorySize limits the total capacity rather than the
// actual memory usage, which will differ when buffers are page
// aligned.
if (cap <= maxMemory - totalCapacity) {
reservedMemory += size;
totalCapacity += cap;
count++;
return;
}
}
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException x) {
// Restore interrupt status
Thread.currentThread().interrupt();
}
synchronized (Bits.class) {
if (totalCapacity + cap > maxMemory)
throw new OutOfMemoryError("Direct buffer memory");
reservedMemory += size;
totalCapacity += cap;
count++;
}
}
通過查看Bits.reserveMemory
方法發現,默認情況下,當分配的直接內存超過指定的最大值時,源碼中會調用System.gc()觸發FGC,從而回收直接內存。回收有可能會失效,比如JVM參數中禁用了顯式GC,再比如直接內存的冰山對象引用鏈可達,不滿足回收條件等。
爲什麼YGC或者FGC時,回收了冰山對象就能回收直接內存呢?看第一段源碼,倒數第二行創建了Cleaner對象,Cleaner類繼承了PhantomReference類,在這裏Cleaner對象就是DirectByteBuffer對象的虛引用。虛引用有什麼用呢?當DirectByteBuffer對象被回收了以後,Cleaner對象自己會被放入到引用隊列,JVM中的Reference Handler線程負責處理這個隊列,從隊列裏面拿到Cleaner對象,然後調用該對象的clean()
方法,這個方法就只幹一件事——釋放Cleaner對象相關的DirectByteBuffer對象所引用的直接內存。
完全繞開JVM的直接內存
通常我們會調用ByteBuffer.allocateDirect()
方法進行直接內存的分配,這種直接內存會被GC回收,但是有些高級的網絡編程框架(比如netty)會使用如下方式分配直接內存:
import sun.misc.Unsafe;
import java.lang.reflect.Field;
/**
* @author debo
* @date 2020-02-07
*/
public class DirectByteBufferTest {
public static final int _1K = 1024;
public static void main(String[] args) throws Exception {
Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
// 分配1KB直接內存並返回內存地址
long address = unsafe.allocateMemory(_1K);
}
}
通過反射拿到Unsafe對象,然後用來分配直接內存。通過這種方式來分配的直接內存就完全不受JVM控制了,你必須主動釋放。
結束語
直接內存是把雙刃劍,用好了,它可以大大提升網絡I/O的性能,用的不好,就會造成猝不及防的內存溢出。試想,當大部分冰山對象進入了老年代而遲遲等不到FGC時,程序卻還在繼續分配直接內存,那麼就很可能會造成直接內存溢出。同時,直接內存的分配速度要比堆內存慢得多,因此在程序中大量使用直接內存的時候,請務必用內存池去緩存。