在Jdk1.8之后,jvm内存模型做了一个调整,即方法区的实现从永久代(Perm Space)变成了元空间(MetaSpace)。元空间不在虚拟机中,而是直接使用了系统内存。为了方便,下文把虚拟机内存称为堆内内存,与之相对地,直接内存称之为堆外内存。
在说为什么要使用堆外内存之前,必须来探讨一下堆内内存。堆内内存即Java虚拟机内存,由虚拟机进行管理,开发者不用关心内存空间的分配和回收。但有利必有弊,堆内内存的缺点在于:
而堆外内存直接使用机器内存,受操作系统管理。在一定程度上减少了垃圾回收对应用程序造成的影响。
博主本人在实际工作中没有碰到过必须使用堆外内存的场景。不过《蚂蚁消息中间件 (MsgBroker) 在 YGC 优化上的探索》这篇文章里有个比较完整的案例(参考资料2)。
文中的业务场景是蚂蚁的消息中间件的使用。为了保证消息的可靠性,将消息持久化到数据库,而为了降低消息投递时对db的读压力,对消息进行了缓存。这样虚拟机就要为缓存的消息分配内存。当缓存中的消息由于各种原因投递不成功时,这些消息就要一直维持着,且越积越多。在堆空间里对象由年轻代变为老年代,占用的空间也越来越大。最终表现出的问题是年轻代的gc时间太长(超过100ms)。
那么问题来了,为什么老年代对象增多会导致ygc时间变长呢?根据文中的回答:
在ygc中占用大部分时间的是older-gen scanning,这个阶段主要用于扫描老年代持有的对年轻代对象的引用。在消息缓存且大量消息无法投递出去的场景中,大量年轻代对象转化为老年代对象,并且持有年轻代对象的引用。在这种情况下,扫描的时间就比较长。
最终的解决方案是使用堆外内存,减少消息对JVM内存的占用,并使用基于Netty的网络层框架,达到了理想的YGC时间。
堆外内存可以通过两种方式来使用。
sun.misc.Unsafe提供了一组方法来进行堆外内存的分配,重新分配和释放。
Unsafe是java留给开发者的后门,用于直接操作系统内存且不受jvm管辖,实现类似c++风格的操作。但一般不提倡使用,正如类名所示,它并不安全,容易造成内存泄露。
Unsafe类的大部分方法均为native方法,直接调用的其他语言的方法(大部分是c++)来进行操作,很多细节无法追溯,只能大致了解。 Unsafe类在jdk9之后移到了jdk.unsupported模块中。
Unsafe类的构造方法是私有的,提供了getUnsafe方法用于获取其实例。不过这个方法是不对普通开发者开放的,使用时会报异常:java.lang.SecurityException: Unsafe
。可以通过反射机制来使用该类。如下所示:
public class unsafeDemo {
public static void main(String[] args) {
Unsafe unsafe = null;
long memoryAddress = 0;
try {
// 获取Unsafe类型的私有单例对象
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = (Unsafe) field.get(null);
memoryAddress = unsafe.allocateMemory(1024);
// 将int型整数存入到指定地址中
unsafe.putInt(memoryAddress, 5);
// 根据地址获取到整数
int a = unsafe.getInt(memoryAddress);
System.out.println(a);
} catch (Exception e) {
e.printStackTrace();
} finally {
assert unsafe != null;
unsafe.freeMemory(memoryAddress);
}
}
}
JDK1.4引入了NIO,可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里的DirectByteBuffer对象作为这块内存的引用进行操作。
分配一块堆外内存如下所示:
ByteBuffer bf = ByteBuffer.allocateDirect(10 * 1024);
查看源码可以看到allocateDirect的函数定义:
/**
* Allocates a new direct byte buffer.
*
* The new buffer's position will be zero, its limit will be its
* capacity, its mark will be undefined, and each of its elements will be
* initialized to zero. Whether or not it has a
* {@link #hasArray backing array} is unspecified.
*
* @param capacity
* The new buffer's capacity, in bytes
*
* @return The new byte buffer
*
* @throws IllegalArgumentException
* If the capacity is a negative integer
*/
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
可以看到,该方法返回了一个DirectByteBuffer对象。进一步查看DirectByteBuffer的创建过程:
DirectByteBuffer(int cap) { // package-private
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)) {
// 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;
}
在这个过程,真正的内存分配是使用的Bits.reserveMemory方法。Bits.reserveMemory()的主要逻辑如下:
在创建DirectByteBuffer对象的最后,通过Cleaner.create(this, new Deallocator(base, size, cap))创建了一个Cleaner对象。该对象的作用是:当DirectByteBuffer对象被回收时,释放其对应的堆外内存。
上文说过,堆外内存通过堆内的DirectByteBuffer对象来进行引用。DirectByteBuffer对象本身是很小的,但它代表着所分配的一大段内存,是所谓的“冰山”对象。当DirectByteBuffer对象被gc时,它引用的堆外内存也会被回收。
也就是说,堆外内存的回收是DirectByteBuffer被回收时触发的,而DirectByteBuffer的回收则是堆内GC的事。
回忆一下堆内GC的机制:当新生代满了,就会触发YongGC,如果此时对象未失效,则不会被回收;经过几次YongGC之后仍然存活的新生代对象被移到老年代中。当老年代满了则进行Full GC。
如果DirectByteBuffer对象熬过了几次YongGC后被迁移到老年代中,即使失效了也能在老年代中一直待着。相对于YongGC,老年代发生Full GC的频率是比较低的。在老年代未发生Full GC时,失效的DirectByteBuffer对象就一直占着一大片堆外内存不释放。
当然,还有另外一种情况也能触发DirectByteBuffer回收。那就是上文提到的,当申请堆外内存而空间不足时,会主动调用System.gc()来告诉虚拟机该进行GC了。但这种方式并不靠谱,因为只有在堆外内存空间不足时才会触发。而且,如果设置了-DisableExplicitGC禁止了system.gc(),那就无法回收了。
堆外内存的主动回收指的是通过DirectByteBuffer的clean对象进行内存回收。如下所示:
public class ByteBufferTest {
public static void main(String[] args) throws Exception {
long size = Runtime.getRuntime().maxMemory();
System.out.println("默认最大堆外内存为:" + size / 1024.0 / 1024.0 + "mb");
ByteBuffer bf = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
Thread.sleep(2000);
System.out.println("cleaner start");
// clean(bf);
ByteBuffer bf2 = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
Thread.sleep(2000);
clean(bf2);
}
private static void clean(final ByteBuffer byteBuffer) throws Exception {
if (byteBuffer.isDirect()) {
Field cleanerField = byteBuffer.getClass().getDeclaredField("cleaner");
cleanerField.setAccessible(true);
Cleaner cleaner = (Cleaner) cleanerField.get(byteBuffer);
cleaner.clean();
}
// // 第二种方法获取cleaner
// if (byteBuffer.isDirect()) {
// Cleaner cleaner = ((DirectBuffer)byteBuffer).cleaner();
// cleaner.clean();
// }
}
}
在不通过-XX:MaxDirectMemorySize参数来指定最大堆外内存的情况下,默认堆外内存与堆内存差不多,通过Runtime.getRuntime().maxMemory()
可以获取到。
接着申请了1024m堆外内存,并通过cleaner做了一次堆外内存回收。然后再次申请1024m堆外内存。运行效果如下:
默认最大堆外内存为:1753.0mb
cleaner start
如果注释掉clean(bf)这一行,运行也没有问题。上文说过,在申请堆外内存而空间不足时,系统会调用System.gc()来触发Full GC,以此达到回收堆外内存的目的。
如果注释掉clean(bf)同时,指定-XX:+DisableExplicitGC参数来禁止显式地触发gc,则申请第二次堆外内存时,就会报OOM错误,因为System.gc()的调用是无效的。运行结果如下所示:
[1]. https://www.jianshu.com/p/17e72bb01bf1
[2]. https://juejin.im/post/5a9cb49df265da239706561a?utm_source=gold_browser_extension
[3]. https://www.jianshu.com/p/ae3847326e69