使用堆内内存HeapByteBuffer的注意事项
扫描二维码
随时随地手机看文章
分享一个很多人容易踩的一个坑:HeapByteBuffer 的使用问题。我们都知道 NIO 分装了 ByteBuffer 接口,使得 filechannel 的文件 IO API 变得非常的简单。ByteBuffer 主要有两个实现类
- HeapByteBuffer 堆内内存
- DirectByteBuffer 堆外内存
HeapByteBuffer 的复制问题
废话不多说,直接来看 HeapByteBuffer 的坑在哪儿。使用代码描述 HeapByteBuffer 的文件 IO 操作,大概率会写出如下的代码:public void readInOneThread() throws Exception {
int bufferSize = 50 * 1024 * 1024;
File file = new File("/essd");
FileChannel fileChannel = new RandomAccessFile(file, "rw").getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocate(bufferSize);
fileChannel.read(byteBuffer);
}
上述的代码,将文件中的数据缓存到了内存中,无论是赛题还是生产场景,这个行为通常都是多线程的,例如在云原生编程挑战赛的评测下,有 40 个线程进行读写,如果按照线程维度进行缓存,每个线程分到 50M 用于内存缓存自然是没有问题。而如果你直接使用上述代码,在评测中可能会直接得到内存溢出相关的异常。其实我在之前堆外内存泄漏的文章中也提到过这个问题,不过角度有所不同。原因很简单,直接来看源码。FileChannel 使用的是 IOUtil 进行读写操作static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
if (var1.isReadOnly()) {
throw new IllegalArgumentException("Read-only buffer");
} else if (var1 instanceof DirectBuffer) {
return readIntoNativeBuffer(var0, var1, var2, var4);
} else {
ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());
int var7;
try {
int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
var5.flip();
if (var6 > 0) {
var1.put(var5);
}
var7 = var6;
} finally {
Util.offerFirstTemporaryDirectBuffer(var5);
}
return var7;
}
}
可以发现当使用 HeapByteBuffer 时,会走到下面这个分支Util.getTemporaryDirectBuffer(var1.remaining());
这个 Util 封装了更为底层的一些 IO 逻辑package sun.nio.ch;
public class Util {
private static ThreadLocal bufferCache;
public static ByteBuffer getTemporaryDirectBuffer(int var0) {
if (isBufferTooLarge(var0)) {
return ByteBuffer.allocateDirect(var0);
} else {
// FOUCS ON THIS LINE
Util.BufferCache var1 = (Util.BufferCache)bufferCache.get();
ByteBuffer var2 = var1.get(var0);
if (var2 != null) {
return var2;
} else {
if (!var1.isEmpty()) {
var2 = var1.removeFirst();
free(var2);
}
return ByteBuffer.allocateDirect(var0);
}
}
}
}
isBufferTooLarge 这个方法会根据传入 Buffer 的大小决定如何分配堆外内存,如果过大,直接分配大缓冲区;如果不是太大,会使用 bufferCache 这个 ThreadLocal 变量来进行缓存,从而复用(实际上这个数值非常大,几乎不会走进直接分配堆外内存这个分支)。这么看来似乎发现了两个不得了的结论:- 使用 HeapByteBuffer 读写都会经过 DirectByteBuffer,写入数据的流转方式其实是:HeapByteBuffer -> DirectByteBuffer -> PageCache -> Disk,读取数据的流转方式正好相反。
- 使用 HeapByteBuffer 读写会申请一块跟线程绑定的 DirectByteBuffer。这意味着,线程越多,临时 DirectByteBuffer 就越会占用越多的空间。
为什么 HeapByteBuffer 在 IO 时需要复制到 DirectByteBuffer
这个我之前也介绍过,详情可以参考我的一篇旧文:《一文探讨堆外内存的监控与回收》。总结如下:- 为了方便 GC 的实现,DirectByteBuffer 指向的 native memory 是不受 GC 管辖的
- HeapByteBuffer 背后使用的是 byte 数组,其占用的内存不一定是连续的,不太方便 JNI 方法的调用
- 数组实现在不同 JVM 中可能会不同
解决方案
其实我们本质上是为了给每个线程维护一块 HeapByteBuffer,用于缓存数据,并没有必要以 ByteBuffer 的大小为维度来进行 IO。可以借鉴 IOUtil 中复制 DirectByteBuffer 的思路来优化这一过程。代码示例如下:public void directBufferCopy() throws Exception {
File file = new File("/essd");
FileChannel fileChannel = new RandomAccessFile(file, "rw").getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocate(50 * 1024 * 1024);
ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(4 * 1024);
for (int i = 0; i < 12800; i ) {
directByteBuffer.clear();
fileChannel.read(directByteBuffer, i * 4 * 1024);
directByteBuffer.flip();
byteBuffer.put(directByteBuffer);
}
}
在 Java 中,从磁盘到堆内内存,一定无法省略堆外内存的复制,但我们可以自己复制,从而使得这个过程更加直观地被我们自己操控,而不是被 FileChannel 的内部逻辑左右。这里也需要注意- 单次 IO 使用的 DirectByteBuffer 不宜过大,仅仅作为一个运输载体,起到一个运输数据的作用。这样在多线程场景下,才不至于占用过多的堆外内存
- 单次 IO 使用的 DirectByteBuffer 不宜过小,否则会出现读写放大的问题,一般建议设置 4kb 的整数倍,具体以实际测试结果为准。