在 JDK NIO 针对堆外内存的分配场景中,我们经常会看到 System.gc 的身影,比如当我们通过 FileChannel#map
对文件进行内存映射的时候,如果 JVM 进程虚拟内存空间中的虚拟内存不足,JVM 在 native 层就会抛出 OutOfMemoryError
。
当 JDK 捕获到 OutOfMemoryError
异常的时候,就会意识到此时进程虚拟内存空间中的虚拟内存已经不足了,无法支持本次内存映射,于是就会调用 System.gc
强制触发一次 GC ,试图释放一些虚拟内存出来,然后再次尝试来 mmap 一把,如果进程地址空间中的虚拟内存还是不足,则抛出 IOException
。
private Unmapper mapInternal(MapMode mode, long position, long size, int prot, boolean isSync)
throws IOException
{
try {
// If map0 did not throw an exception, the address is valid
addr = map0(prot, mapPosition, mapSize, isSync);
} catch (OutOfMemoryError x) {
// An OutOfMemoryError may indicate that we've exhausted
// memory so force gc and re-attempt map
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(prot, mapPosition, mapSize, isSync);
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
}
}
再比如,我们通过 ByteBuffer#allocateDirect
申请堆外内存的时候
public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>
{
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
}
会首先通过 Bits.reserveMemory
检查当前 JVM 进程的堆外内存用量是否超过了 -XX:MaxDirectMemorySize
指定的最大堆外内存限制,通过检查之后才会调用 UNSAFE.allocateMemory
申请堆外内存。
class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer
{
DirectByteBuffer(int cap) { // package-private
...... 省略 .....
// 检查堆外内存整体用量是否超过了 -XX:MaxDirectMemorySize
// 如果超过则尝试对堆外内存进行回收,回收之后还不够的话则抛出 OutOfMemoryError
Bits.reserveMemory(size, cap);
// 底层调用 malloc 申请虚拟内存
base = UNSAFE.allocateMemory(size);
...... 省略 .....
}
}
如果没有通过 Bits.reserveMemory
的检查,则 JVM 先会尝试通过释放当前已经被回收的 direct buffer 背后引用的 native memory 挽救一下,如果释放之后堆外内存容量还是不够,那么就触发 System.gc()。
class Bits {
static void reserveMemory(long size, long cap) {
...... 省略 .......
// 如果本次申请的堆外内容容量 cap 已经超过了 -XX:MaxDirectMemorySize
// 则返回 false,表示无法满足本次堆外内存的申请
if (tryReserveMemory(size, cap)) {
return;
}
...... 尝试释放已经被回收的 directBuffer 背后的 native memory .......
// 在已经被回收的 direct buffer 背后引用的 native memory 被释放之后
// 如果还是不够,则走到这里
System.gc();
Thread.sleep(sleepTime);
...... 省略 .......
}
}
通常情况下我们应当避免在应用程序中主动调用 System.gc
,因为这会导致 JVM 立即触发一次 Full GC,使得整个 JVM 进程陷入到 Stop The World 阶段,对性能会有很大的影响。
但是在 NIO 的场景中,调用 System.gc
却是有必要的,因为 NIO 中的 DirectByteBuffer 非常特殊,当然了 MappedByteBuffer 其实也属于 DirectByteBuffer 的一种。它们背后依赖的内存均属于 JVM 之外(Native Memory),因此不会受垃圾回收的控制。
前面我们多次提过,DirectByteBuffer 只是 OS 中的这些 Native Memory 在 JVM 中的封装形式,DirectByteBuffer 这个 Java 类的实例是分配在 JVM 堆中的,但是这个实例的背后可能会引用着一大片的 Native Memory ,这些 Native Memory 是不会被 JVM 察觉的。
当这些 DirectByteBuffer 实例(位于 JVM 堆中)没有任何引用的时候,如果又恰巧碰到 GC 的话,那么 GC 在回收这些 DirectByteBuffer 实例的同时,也会将与其关联的 Cleaner 放到一个 pending 队列中。
protected DirectByteBuffer(int cap, long addr,
FileDescriptor fd,
Runnable unmapper,
boolean isSync, MemorySegmentProxy segment)
{
super(-1, 0, cap, cap, fd, isSync, segment);
address = addr;
// 对于 MappedByteBuffer 来说,在它被 GC 的时候,JVM 会调用这里的 cleaner
// cleaner 近而会调用 Unmapper#unmap 释放背后的 native memory
cleaner = Cleaner.create(this, unmapper);
att = null;
}
当 GC 结束之后,JVM 会唤醒 ReferenceHandler 线程去执行 pending 队列中的这些 Cleaner,在 Cleaner 中会释放其背后引用的 Native Memory。
但在现实的 NIO 使用场景中,DirectByteBuffer 却很难触发 GC,因为 DirectByteBuffer 的实例实在太小了(在 JVM 堆中的内存占用),而且通常情况下这些实例是被应用程序长期持有的,很容易就会晋升到老年代。
即使 DirectByteBuffer 实例已经没有任何引用关系了,由于它的实例足够的小,一时很难把老年代撑爆,所以需要等很久才能触发一次 Full GC,在这之前,这些没有任何引用关系的 DirectByteBuffer 实例将会持续在老年代中堆积,其背后所引用的大片 Native Memory 将一直不会得到释放。
DirectByteBuffer 的实例可以形象的比喻为冰山对象,JVM 可以看到的只是 DirectByteBuffer 在 JVM 堆中的内存占用,但这部分内存占用很小,就相当于是冰山的一角。
而位于冰山下面的大一片 Native Me