堆外内存定义:
内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。使用未公开的Unsafe和NIO包下ByteBuffer来创建堆外内存。
《深入理解java虚拟机》书中指出
“直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规 范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现,所以我们放到这里一起讲解。
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓 冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储 在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著 提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是 会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限 制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略 直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制), 从而导致动态扩展时出现OutOfMemoryError异常。”
减少拷贝的原因:堆内内存由JVM管理,属于“用户态”;而堆外内存由OS管理,属于“内核态”。如果从堆内向磁盘写数据时,数据会被先复制到堆外内存,即内核缓冲区,然后再由OS写入磁盘,使用堆外内存避免了数据从用户内向内核态的拷贝。
堆外内存的申请:
jdk1.4以后,ByteBuffer类提供了一个接口allocateDirect(int capacity)进行堆外内存的申请,底层通过unsafe.allocateMemory(size)实现。Netty、Mina等框架提供的接口也是基于ByteBuffer封装的
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;
}
ByteBuffer类的allocateDirect方法会创建DirectByteBuffer对象,该类初始化时做了两件事
1、unsafe.allocateMemory,通过unsafe调用jdk本地方法(c语言编写,malloc方法申请)分配直接内存
2、cleaner = Cleaner.create(this, new Deallocator(base, size, cap));维护了一个cleaner列表,列表中add cleaner对象
解释说明:cleaner对象的存在就是为了在垃圾回收时通过调用其clean方法回收unsafe分配的直接内存
下面我们看下Cleaner类,下述代码仅复制部分方法和变量
public class Cleaner
extends PhantomReference<Object>
{
// Dummy reference queue, needed because the PhantomReference constructor
// insists that we pass a queue. Nothing will ever be placed on this queue
// since the reference handler invokes cleaners explicitly.
//
private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue<>();
// Doubly-linked list of live cleaners, which prevents the cleaners
// themselves from being GC'd before their referents
//
static private Cleaner first = null;
private static synchronized Cleaner add(Cleaner cl) {
if (first != null) {
cl.next = first;
first.prev = cl;
}
first = cl;
return cl;
}
private final Runnable thunk;
private Cleaner(Object referent, Runnable thunk) {
super(referent, dummyQueue);
this.thunk = thunk;
}
/**
* Creates a new cleaner.
*
* @param ob the referent object to be cleaned
* @param thunk
* The cleanup code to be run when the cleaner is invoked. The
* cleanup code is run directly from the reference-handler thread,
* so it should be as simple and straightforward as possible.
*
* @return The new cleaner
*/
public static Cleaner create(Object ob, Runnable thunk) {
if (thunk == null)
return null;
return add(new Cleaner(ob, thunk));
}
/**
* Runs this cleaner, if it has not been run before.
*/
public void clean() {
if (!remove(this))
return;
try {
thunk.run();// 调用create方法传入的线程对象的run方法(Deallocator)
} catch (final Throwable x) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null)
new Error("Cleaner terminated abnormally", x)
.printStackTrace();
System.exit(1);
return null;
}});
}
}
}
调用Cleaner的create方法,add cleaner对象时,static 的first引用会指向该cleaner对象。
Cleaner的clean方法,实际调用的是Deallocator的run方法,该方法调用unsafe.freeMemory释放内存,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);
}
}
堆外内存垃圾回收过程:
借用博客中的图,非常形象
首先,ByteBuffer.allocateDirect创建ByteBuffer对象,会创建DerectByteBuffer对象,该对象初始化后会创建cleaner对象,并且first(静态)引用指向该cleaner对象,ReferenceQueue是用来保存需要回收的Cleaner对象,cleaner中维护了Deallocator对象,保存着堆外内存的地址、大小、容量。
当DirectByteBuffer在某一次GC中被回收(可能在某次Minor GC,也可能多次回收后该对象存入老年代在某次触发的Full GC),但cleaner对象是不会在Minor GC时被回收的,因为存在静态引用的存在,只能在Full GC触发后,将cleaner对象放入ReferenceQueue队列中,并触发clean方法。
Cleaner对象的clean方法主要有两个作用: 1、把自身从Clener链表删除,从而在下次GC时能够被回收 2、释放堆外内存
因此带来一个问题,堆外内存只有在Full GC时才有可能被清理
堆外内存使用场景
1、堆外缓存
堆外缓存优势:在高并发,高写入操作下,堆内缓存组件造成的频繁GC问题,堆外缓存应运而生,堆外缓存是不受JVM管控的,所以也不受GC的影响导致的应用暂停问题。
堆外缓存开源框架:Ehcache 3.0,OpenHFT,OHC,Ignite
2、NIO直接缓冲区
NIO缓冲区分为直接缓冲区和非直接缓存区,NIO直接缓冲区通过allocateDirect方法创建,NIO非直接缓冲区通过allocate方法创建 NIO直接缓冲区优缺点:
优点:
缓冲区建立在受操作系统管理的物理内存中,OS和JVM直接通过这块物理内存进行交互,没有了中间的拷贝环节,速度更快,效率更高
缺点:
(1)不安全
(2)消耗更多,因为它不是在JVM中直接开辟空间。这部分内存的回收只能依赖于垃圾回收机制,垃圾什么时候回收不受我们控制。
(3)数据写入物理内存缓冲区中,程序就丧失了对这些数据的管理,即什么时候这些数据被最终写入从磁盘只能由操作系统来决定,应用程序无法再干涉。 NIO直接缓冲区使用:缓冲区要长时间使用(数据本身需要长时间在内存或者缓冲区复用率很高),或者大数据量的操作(大文件才能体现出速度优势)
ps:堆外内存即NIO的直接缓冲区。