什么是直接内存
我们都知道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时,程序却还在继续分配直接内存,那么就很可能会造成直接内存溢出。同时,直接内存的分配速度要比堆内存慢得多,因此在程序中大量使用直接内存的时候,请务必用内存池去缓存。