在 java 开发中, 如果遇到堆内存的使用瓶颈, 或许可以借助于堆外内存解决问题; 本文总结了直接内存的使用方式和优缺点;
直接内存的介绍
定义
我们经常会提到堆外内存, 广义上的堆外内存就是指 jvm 堆内存之外的一切内存, 这块内存可以再分为 jvm 内及 jvm 外两大部分; jvm 内的堆外内存包括: Metaspace 内存、garbage collector 自身运行占用内存、线程与虚拟机栈的内存、JIT 编译器占用内存及其 codecache 等, 而 jvm 外的堆外内存则被称为 直接内存 (direct memory), 这块内存不受 jvm 管理;
狭义上的堆外内存其实就是指直接内存, 而本文为了明确概念, 将严格区分堆外内存与直接内存;
优点
使用直接内存有如下收益:
- 当进行网络 IO 操作、文件读写时, java 堆内存都需要转换为直接内存, 然后再与底层设备进行交互; 如果能使用直接内存, 可以减少拷贝次数, 降低开销;
- 直接内存不受 JVM 管理, 可降低 JVM GC 对应用程序的影响;
- 直接内存可以实现进程之间、JVM 多实例之间的数据共享, 减少虚拟机间的数据拷贝;
直接内存的使用
DirectByteBuffer
使用 ByteBuffer 类提供的统一接口申请并使用直接内存:1
2// 申请指定字节数的对外内存
final ByteBuffer byteBuf = ByteBuffer.allocateDirect(10 * 1024 * 1024L);
上述代码生成的 DirectByteBuffer, 可以为我们在该 java 对象被回收的时候间接地回收这块直接内存;
同时该方法在判断不能申请到内存时, 会触发一次 System.gc()
回收一次内存, 如果还是不能申请到内存, 则抛出 OutOfMemoryError;
直接内存回收的原理
在初始化 DirectByteBuffer 的时候, 会初始化一个 Cleaner 对象, 它初始化了一个 Deallocator 的 Runnable:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26DirectByteBuffer(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)) {
// 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;
}
调用 Cleaner 的 clean() 方法便会执行 Deallocator 的清理逻辑:1
2
3
4
5
6
7
8
9
10
11// Cleaner.java
public void clean() {
if (!remove(this))
return;
try {
// 传入的 runnable
thunk.run();
} catch (final Throwable x) {
......
}
}
Deallocator 的清理逻辑是释放 DirectByteBuffer 申请的那块内存:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22private 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.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
DirectByteBuffer 作为一个普通的 java 对象, 当其与自己内部的 cleaner 成员不可达时, garbage collector 会在适当的时机将它们的引用挂到 pending-reference 链上, 然后 Reference 中的 ReferenceHandler 线程会处理 pending-reference 链上的引用:1
2
3
4
5
6
7
8private static class ReferenceHandler extends Thread {
......
public void run() {
while (true) {
processPendingReferences();
}
}
}
针对 Cleaner 类型的引用, 直接调用其 clean() 方法完成清理:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22private static void processPendingReferences() {
waitForReferencePendingList();
Reference<Object> pendingList;
synchronized (processPendingLock) {
pendingList = getAndClearReferencePendingList();
......
}
while (pendingList != null) {
Reference<Object> ref = pendingList;
pendingList = ref.discovered;
ref.discovered = null;
if (ref instanceof Cleaner) {
((Cleaner)ref).clean();
......
} else {
ReferenceQueue<? super Object> q = ref.queue;
if (q != ReferenceQueue.NULL) q.enqueue(ref);
}
......
}
}
最大直接内存的限制
jvm 除了能为我们自动回收 DirectByteBuffer 的关联的堆外内存, 还可以限制程序申请堆外内存的最大限制, 即配置如下启动参数:1
-XX:MaxDirectMemorySize=<size>
限制原理如下:
DirectByteBuffer 的构造器会调用如下方法:1
Bits.reserveMemory(size, cap);
该方法的细节如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29// Bits#reserveMemory
static void reserveMemory(long size, long cap) {
if (!MEMORY_LIMIT_SET && VM.initLevel() >= 1) {
MAX_MEMORY = VM.maxDirectMemory();
MEMORY_LIMIT_SET = true;
}
// optimist!
if (tryReserveMemory(size, cap)) {
return;
}
......
}
// Bits#tryReserveMemory
private static boolean tryReserveMemory(long size, long cap) {
// -XX:MaxDirectMemorySize limits the total capacity rather than the
// actual memory usage, which will differ when buffers are page
// aligned.
long totalCap;
while (cap <= MAX_MEMORY - (totalCap = TOTAL_CAPACITY.get())) {
if (TOTAL_CAPACITY.compareAndSet(totalCap, totalCap + cap)) {
RESERVED_MEMORY.addAndGet(size);
COUNT.incrementAndGet();
return true;
}
}
return false;
}
当希望申请的新容量 + 已经申请的总容量 超过了 MaxDirectMemorySize 设置的最大值, 申请失败;
直接使用 Unsafe
jdk.internal.misc.Unsafe
对象不能直接获得, 但可以通过反射获取:1
2
3
4
5
6
7
8
9
10
11private static final Unsafe unsafe = null;
static {
try {
final Field getUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
getUnsafe.setAccessible(true);
unsafe = (Unsafe) getUnsafe.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new ......
}
}
通过 Unsafe 申请的内存, 需要手动回收:1
2
3
4final long addr = unsafe.allocateMemory(10 * 1024 * 1024L);
......
// 手动回收直接内存
unsafe.freeMemory(addr);
这种原始的操纵方式很不安全, 一般只有在必须要能完全自主控制直接内存的申请/释放的场景下才会遇到;
然而如果真有这样的场景, 其实更推荐使用原生的 JNI 方式;
netty 对直接内存的使用
使用 JNI malloc
1 | final long addr = Native.malloc(10 * 1024 * 1024L); |
jdk 17+: 使用 Memory Segments
直接内存使用方式的比较
- DirectByteBuffer:
使用限制比较大, jvm 对其管控介入程度较深, 内存的回收时机和容量限制均由虚拟机负责, 适用于非核心、对性能要求不高的场景; - Unsafe / Native:
完全由程序自己管控, 需要自己管控好申请与释放内存的时机, 适用于对直接内存具有重要核心依赖, 对性能要求高, 对内存管控有自身特殊要求的场景;
使用 Native.malloc 比 Unsafe.allocateMemory 效率要高: JNI faster than Unsafe;
其他使用经验
- 建议在 linux 上预装 jemalloc 取代默认的 ptmalloc 以提高内存管理性能;