直接内存是分配在JVM堆外的,那JVM是怎么对它进行管理的呢?本文主要介绍一下在Java中,直接内存的空间分配和释放的机制。
在比较两者的性能时,我们分两方面来说。
直接内存申请空间其实是比较消耗性能的,所以并不适合频繁申请。但直接内存在IO读写上的性能要优于堆内存,所以直接内存特别适合申请以后进行多次读写。
为什么在申请空间时,堆内存会更快?堆内存的申请是直接从已分配的堆空间中取一块出来使用,不经过内存申请系统调用,而直接内存的申请则需要本地方法通过系统调用完成。
而为什么在IO读写时,直接内存比较快?因为直接内存使用的是零拷贝技术。
所以直接内存一般有两个使用场景:
直接内存由于是直接分配在堆外的,所以不受JVM堆的大小限制。但还是会受到本机总内存(包括RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制,所以还是有可能会抛出OutOfMemoryError异常。
直接内存的最大大小可以通过-XX:MaxDirectMemorySize来设置,默认是64M
在Java中,分配直接内存有三种方式:
Java提供了Unsafe类用来进行直接内存的分配与释放:
public long allocateMemory(long bytes);
public void freeMemory(long address);
虽然Java提供了Unsafe类用来操作直接内存的分配和释放,但Unsafe无法直接使用,需要通过反射来获取。Unsafe更像是一个底层设施。
DirectByteBuffer类里面使用了Unsafe,它对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.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)) {
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
// 创建Cleaner
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
这里有两个概念。一个是Bits类里面的reserveMemory,另一个是Cleaner创建的Deallocator。
reserveMemory方法源码:
static void reserveMemory(long size, int cap) {
// 初始化maxMemory,如果没有指定-XX:MaxDirectMemorySize,
// 就使用VM.maxDirectMemory()的值:64M
if (!MEMORY_LIMIT_SET && VM.initLevel() >= 1) {
MAX_MEMORY = VM.maxDirectMemory();
MEMORY_LIMIT_SET = true;
}
// 第一次先采取乐观的方式尝试告诉Bits要分配内存
if (tryReserveMemory(size, cap)) {
return;
}
final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
boolean interrupted = false;
try {
// 尝试释放内存,直到内存空间足够
boolean refprocActive;
do {
try {
refprocActive = jlra.waitForReferenceProcessing();
} catch (InterruptedException e) {
interrupted = true;
refprocActive = true;
}
if (tryReserveMemory(size, cap)) {
return;
}
} while (refprocActive);
// trigger VM's Reference processing
System.gc();
// 按照1ms,2ms,4ms,...,256ms的等待间隔尝试9次分配内存
long sleepTime = 1;
int sleeps = 0;
while (true) {
if (tryReserveMemory(size, cap)) {
return;
}
if (sleeps >= MAX_SLEEPS) {
break;
}
try {
if (!jlra.waitForReferenceProcessing()) {
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();
}
}
}
内存释放是通过Deallocator来实现的。
private static class Deallocator
implements Runnable
{
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释放内存
UNSAFE.freeMemory(address);
address = 0;
// 利用Bits管理内存的释放,就是标记一下该内存已释放
Bits.unreserveMemory(size, capacity);
}
}
很简单的一个Runnable,主要通过Cleaner来进行调度。Cleaner的数据结构为一个双向链表,采用“头插法”,每次插入新的结点是插入到“头结点”的。Cleaner继承了PhantomReference,其referent为DirectByteBuffer:
public class Cleaner
extends PhantomReference<Object>
{
private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue<>();
private static Cleaner first = null;
private Cleaner
next = null,
prev = null;
private Cleaner(Object referent, Runnable thunk) {
super(referent, dummyQueue);
this.thunk = thunk;
}
public static Cleaner create(Object ob, Runnable thunk) {
if (thunk == null)
return null;
return add(new Cleaner(ob, thunk));
}
// other methods...
}
这里用到了JVM的虚引用。JVM有四种引用类型,分别是:强引用,弱引用,软引用,虚引用。
PhantomReference的get方法总是返回null,因此无法访问对应的引用对象;其意义在于说明一个对象已经进入finalization阶段,可以被GC回收。
GC过程中如果发现某个对象除了只有PhantomReference引用它之外,并没有其他的地方引用它了,那将会把这个引用放到java.lang.ref.Reference.pending队列里,在GC完毕的时候通知ReferenceHandler这个守护线程去执行一些后置处理,这里是调用Cleaner的clean方法:
// Cleaner类
public void clean() {
if (!remove(this))
return;
try {
// 调用Deallocator的run方法,
// 进而通过Unsafe类释放内存。
thunk.run();
} catch (final Throwable x) {
AccessController.doPrivileged(new PrivilegedAction<>() {
public Void run() {
if (System.err != null)
new Error("Cleaner terminated abnormally", x)
.printStackTrace();
System.exit(1);
return null;
}});
}
}
总结成一张图:
native方法
我们知道,Java可以通过native方法来直接调用C/C++的接口。那native方法中分配的内存是否是属于DirectByteBuffer对象呢?掘金上有一篇文章《Java直接内存分配与释放原理》写了一个Demo进行了实验,发现native方法分配的内存并不会产生DirectByteBuffer对象,同样的也不受-XX:MaxDirectMemorySize影响。
所以如果你使用native方法来操作直接内存的话,也需要使用native方法来自己进行直接内存的管理。
通常来说,我们是使用DirectByteBuffer类来操作直接内存的比较多,所以可以了解一下DirectByteBuffer对直接内存的分配和回收的流程,这样如果以后遇到因为直接内存引起的性能瓶颈或者OOM异常,可以进行快速排查。