在Java中,JVM的运行时区域分为堆、栈、方法区等,当我们提到内存的时候,一般都是指的是堆内存,然而,堆内存是有一些限制的,首先就是堆内存的大小他不是无限的,而且堆内存中的垃圾回收机制会导致堆内存的不稳定性和延迟(产生碎片、STW等),而且对于大量的数据或需要较低的内存访问延迟的应用,堆内存可能不够高效。
堆外内存则是在堆之外的一块持久化的内存空间。这种内存通常由操作系统管理,因此对于大规模数据存储和快速访问来说,使用堆外内存可以提供更好的性能和控制。在我们熟知的C语言中,分配的就是机器内存,就和我们说的堆外内存类似了。
(图中的直接内存就是堆外内存)
但是,需要注意的是,堆外内存不受Java垃圾回收机制的管理。在不再需要堆外内存时,务必手动释放内存资源,否则可能会造成内存泄漏和应用程序异常。因此,堆外内存的使用一般在特定场景和对内存管理有丰富经验的情况下才推荐使用。
尽管堆外内存不受Java堆大小的限制,但它仍然受到系统可用内存的限制。如果操作系统没有足够的可用内存供应用程序使用,就有可能导致堆外内存分配失败,从而抛出OutOfMemoryError。
在Java中,堆外内存就可以理解为在JVM之外的机器内存,想要使用堆外内存,有两种方式,分别是借助Unsafe类以及NIO。
Unsafe这个没啥好说的了,他主要就是用来和操作系统底层交互的,关于用Unsafe来操作堆外内存的示例,在下面的文档中有,这里就不重复说了:
NIO中引入了ByteBuffer类,也可以用于处理堆外内存:
使用ByteBuffer类的allocateDirect()方法来创建一个DirectByteBuffer实例,它表示堆外内存的缓冲区。
int capacity = 1024; // 指定内存大小
ByteBuffer buffer = ByteBuffer.allocateDirect(capacity);
使用ByteBuffer的put()方法写入数据到堆外内存,使用get()方法从堆外内存读取数据。
String dataToWrite = "Hello, this is hollis testing off-heap memory!";
buffer.put(dataToWrite.getBytes());
buffer.flip(); // 切换到读模式
byte[] dataToRead = new byte[buffer.remaining()];
buffer.get(dataToRead);
System.out.println(new String(dataToRead));
由于堆外内存不受Java垃圾回收机制管理,需要手动释放内存资源,避免内存泄漏。通过调用ByteBuffer的cleaner()方法获取Cleaner对象,并调用其clean()方法来释放堆外内存。
sun.misc.Cleaner cleaner = ((sun.nio.ch.DirectBuffer) buffer).cleaner();
cleaner.clean();
虽然DirectByteBuffer分配的堆外内存不受JVM堆内存的GC直接管理,但HotSpot JVM确实提供了一种机制来间接管理这部分内存的回收。
当我们使用ByteBuffer buffer = ByteBuffer.allocateDirect(1024)
分配内存时,会在堆外占用1k的内存,同时会在堆上创建一个ByteBuffer对象,当然这个对象只占用一个对象的指针引用的大小。
堆上的ByteBuffer在创建时,会注册一个与之关联的清理器(cleaner)。当DirectBuffer对象变成垃圾时,清理器会在垃圾收集过程中被调用,从而释放堆外内存。
也就是说,当一个DirectByteBuffer实例不再有任何强引用指向它时,该实例就会成为垃圾收集的候选对象。在垃圾收集过程中,JVM会检测这些DirectByteBuffer对象。如果DirectByteBuffer对象被垃圾收集器确定为垃圾,它所关联的清理器(cleaner)会被触发。清理器的任务是释放DirectByteBuffer分配的堆外内存。
减少垃圾回收压力:在传统的Java I/O中,使用的是堆内存,而堆内存的垃圾回收是由JVM自动管理的。大量频繁的垃圾回收会导致应用程序的暂停和性能下降。而使用堆外内存,则可以避免这种情况,因为堆外内存不受JVM垃圾回收的影响。
提高I/O性能:堆外内存是直接与操作系统交互的内存,可以通过零拷贝(Zero-Copy)技术将数据从磁盘或网络读取到堆外内存,然后直接与应用程序进行数据交换,避免了数据在堆内存和堆外内存之间的复制过程。这样可以显著提高I/O性能,尤其是在处理大量数据时。