java-4.2 nio

684 阅读16分钟

FileChannel & Buffer

Java NIO(New I/O)是一个可以替代标准Java I/O API的IO API(从Java 1.4开始),Java NIO提供了与标准I/O不同的I/O工作方式,目的是为了解决标准 I/O存在的以下问题:

  1. 数据多次拷贝
  2. 操作阻塞

FileChannel

FileChannel 是一个用于文件读写、映射和操作的通道,同时它在并发环境下是线程安全的,基于 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以创建并打开一个文件通道。

// 获取 fileChannel
FileChannel fileChannel = new RandomAccessFile(new File("db.data"), "rw")
    .getChannel();
// 写
byte[] data = new byte[4096];
long position = 1024L;
// 指定 position 写入 4kb 的数据
fileChannel.write(ByteBuffer.wrap(data), position);
// 从当前文件指针的位置写入 4kb 的数据
fileChannel.write(ByteBuffer.wrap(data));

// 读
ByteBuffer buffer = ByteBuffer.allocate(4096);
long position = 1024L;
// 指定 position 读取 4kb 的数据
fileChannel.read(buffer,position);
// 从当前文件指针的位置读取 4kb 的数据
fileChannel.read(buffer);

FileChannel 大多数时候是和 ByteBuffer 这个类打交道,你可以将它理解为一个 byte[] 的封装类,提供了丰富的 API 去操作字节。

write 和 read 方法均是线程安全的,FileChannel 内部通过一把 private final Object positionLock = new Object(); 锁来控制并发。

ByteBuffer 中的数据和磁盘中的数据还隔了一层,这一层便是 PageCache,是用户内存和磁盘之间的一层缓存。磁盘 IO 和内存 IO 的速度可是相差了好几个数量级。

可以认为 filechannel.write 写入 PageCache 便是完成了落盘操作,但实际上,操作系统最终帮我们完成了 PageCache 到磁盘的最终写入

理解了这个概念,你就应该能够理解 FileChannel 为什么提供了一个 force() 方法,用于通知操作系统进行及时的刷盘。

同理,当我们使用 FileChannel 进行读操作时,同样经历了:磁盘 ->PageCache-> 用户内存这三个阶段

transferTo & transferFrom

FileChannel 定义了 transferFrom() 和 transferTo() 两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。

transferTo():通过 FileChannel 把文件里面的源数据写入一个 WritableByteChannel 的目的通道。

transferFrom():把一个源通道 ReadableByteChannel 中的数据读取到当前 FileChannel 的文件里面。

public void transferTo() throws Exception {
    try (FileChannel fromChannel = new RandomAccessFile(
             getClassPath(SOURCE_FILE), "rw").getChannel();
         FileChannel toChannel = new RandomAccessFile(
             getClassPath(TARGET_FILE), "rw").getChannel()) {
        long position = 0L;
        long offset = fromChannel.size();
        fromChannel.transferTo(position, offset, toChannel);
    }
}

底层原理

transferTo() 和 transferFrom() 方法的底层实现原理,这两个方法也是 java.nio.channels.FileChannel 的抽象方法,由子类 sun.nio.ch.FileChannelImpl.java 实现。transferTo() 和 transferFrom() 底层都是基于 sendfile 实现数据传输的 其中 FileChannelImpl.java 定义了 3 个常量,用于标示当前操作系统的内核是否支持 sendfile 以及 sendfile 的相关特性。

private static volatile boolean transferSupported = true;
private static volatile boolean pipeSupported = true;
private static volatile boolean fileSupported = true;

# transferSupported:用于标记当前的系统内核是否支持 sendfile() 调用,默认为 true。 
# pipeSupported:用于标记当前的系统内核是否支持文件描述符(fd)基于管道(pipe)的 sendfile() 调用,默认为 true。 
# fileSupported:用于标记当前的系统内核是否支持文件描述符(fd)基于文件(file)的 sendfile() 调用,默认为 true

下面以 transferTo() 的源码实现为例。

FileChannelImpl 首先执行 transferToDirectly() 方法,以 sendfile 的零拷贝方式尝试数据拷贝。 如果系统内核不支持 sendfile,进一步执行 transferToTrustedChannel() 方法,以 mmap 的零拷贝方式进行内存映射,这种情况下目的通道必须是 FileChannelImpl 或者 SelChImpl 类型。

如果以上两步都失败了,则执行 transferToArbitraryChannel() 方法,基于传统的 I/O 方式完成读写,具体步骤是初始化一个临时的 DirectBuffer,将源通道 FileChannel 的数据读取到 DirectBuffer,再写入目的通道 WritableByteChannel 里面。

public long transferTo(long position, long count, WritableByteChannel target)
        throws IOException {
    // 计算文件的大小
    long sz = size();
    // 校验起始位置
    if (position > sz)
        return 0;
    int icount = (int)Math.min(count, Integer.MAX_VALUE);
    // 校验偏移量
    if ((sz - position) < icount)
        icount = (int)(sz - position);
    long n;
    if ((n = transferToDirectly(position, icount, target)) >= 0)
        return n;
    if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
        return n;
    return transferToArbitraryChannel(position, icount, target);
}

对 Linux、Solaris 以及 Apple 系统而言,transferTo0() 函数底层会执行 sendfile64 这个系统调用完成零拷贝操作,sendfile64() 函数的原型如下:

#include <sys/sendfile.h>

ssize_t sendfile64(int out_fd, int in_fd, off_t *offset, size_t count);
// out_fd:待写入的文件描述符 
// in_fd:待读取的文件描述符 
// offset:指定 in_fd 对应文件流的读取位置,如果为空,则默认从起始位置开始 
// count:指定在文件描述符 in_fd 和 out_fd 之间传输的字节数 

在 Linux 2.6.3 之前,out_fd 必须是一个 socket,而从 Linux 2.6.3 以后,out_fd 可以是任何文件。也就是说,sendfile64() 函数不仅可以进行网络文件传输,还可以对本地文件实现零拷贝操作。

Buffer

Buffer可以见到理解为一组基本数据类型,存储地址连续的的数组,支持读写操作,对应读模式和写模式,通过几个变量来保存这个数据的当前位置状态:capacity、 position、 limit:

  • capacity 缓冲区数组的总长度

  • position 下一个要操作的数据元素的位置

  • limit 缓冲区数组中不可操作的下一个元素的位置:limit <= capacity image.png

  • capacity

    作为一个内存块,Buffer有个固定的最大值,就是capacity。Buffer只能写capacity个byte、long、char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。

  • position

    当写数据到Buffer中时,position表示当前的位置。初始的position值为0。当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1.

    当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0。当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。

  • limit

    在写模式下,Buffer的limit表示最多能往Buffer里写多少数据。写模式下,limit等于capacity。

    当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。

类型

缓冲区(Buffer)分为堆内存(HeapBuffer)和堆外内存(DirectBuffer), 这是通过 malloc() 分配出来的用户态内存。

  • 缓存的申请创建

    前面 FileChannel 的示例代码中已经使用到了堆内内存: ByteBuffer.allocate(4 * 1024)

    ByteBuffer提供了另外的方式让我们可以分配堆外内存 : ByteBuffer.allocateDirect(4 * 1024)。

    DirectByteBuffer 内部的字节缓冲区位在于堆外的(用户态)直接内存,它是通过 Unsafe 的本地方法 allocateMemory() 进行内存分配,底层调用的是操作系统的 malloc() 函数。

  • 缓存的回收

    堆外内存(DirectBuffer)在使用后需要应用程序手动回收,而堆内存(HeapBuffer)的数据在 GC 时可能会被自动回收。

    初始化 DirectByteBuffer 时还会创建一个 Deallocator 线程,并通过 Cleaner 的 freeMemory() 方法来对直接内存进行回收操作,freeMemory() 底层调用的是操作系统的 free() 函数。

    由于使用 DirectByteBuffer 分配的是系统本地的内存,不在 JVM 的管控范围之内,因此直接内存的回收和堆内存的回收不同,直接内存如果使用不当,很容易造成 OutOfMemoryError。

  • 缓存的读写

    为什么要把数据从堆内复制到堆外呢?

    因此在使用 HeapBuffer 读写数据时,为了避免缓冲区数据因为 GC 而丢失,NIO 会先把 HeapBuffer 内部的数据拷贝到一个临时的 DirectBuffer 中的本地内存(native memory),这个拷贝涉及到 sun.misc.Unsafe.copyMemory() 的调用,背后的实现原理与 memcpy() 类似。 最后,将临时生成的 DirectBuffer 内部的数据的内存地址传给 I/O 调用函数,这样就避免了再去访问 Java 对象处理 I/O 读写。

    在高并发场景下,这是非常费内存的,假如每个链接发送的数据是1k, 那么堆内有1K的数据,堆外还要申请1K的数据,还要做数据拷贝,百万链接就需要2T的内存(极端场景), 无疑是瓶颈之一。

这就引来的一系列的问题,什么时候应该使用堆内内存,什么时候应该使用直接内存?

堆内内存堆外内存
底层实现数组,JVM 内存unsafe.allocateMemory(size) 返回直接内存
分配大小限制-Xms-Xmx 配置的 JVM 内存相关,并且数组的大小有限制,在做测试时发现,当 JVM free memory 大于 1.5G 时,ByteBuffer.allocate(900M) 时会报错可以通过-XX: MaxDirectMemorySize 参数从 JVM 层面去限制,同时受到机器虚拟内存(说物理内存不太准确)的限制
垃圾回收不必多说当 DirectByteBuffer 不再被使用时,会出发内部 cleaner 的钩子,保险起见,可以考虑手动回收:((DirectBuffer) buffer).cleaner().clean();
内存复制堆内内存 -> 堆外内存 -> pageCache堆外内存 -> pageCache

关于堆内内存和堆外内存的一些最佳实践:

  • 当需要申请大块的内存时,堆内内存会受到限制,只能分配堆外内存。

  • 堆外内存适用于生命周期中等或较长的对象。(如果是生命周期较短的对象,在 YGC 的时候就被回收了,就不存在大内存且生命周期较长的对象在 FGC 对应用造成的性能影响)。

  • 堆内内存刷盘的过程中,还需要复制一份到堆外内存,这部分内容可以在 FileChannel 的实现源码中看到细节

  • 同时,还可以使用池 + 堆外内存 的组合方式,来对生命周期较短,但涉及到 I/O 操作的对象进行堆外内存的再使用 (Netty 中就使用了该方式)。尽量不要出现在频繁 new byte[] ,创建内存区域再回收也是一笔不小的开销,使用 ThreadLocal 和 ThreadLocal<byte[]> 往往会给你带来意外的惊喜 ~

  • 创建堆外内存的消耗要大于创建堆内内存的消耗,所以当分配了堆外内存之后,尽可能复用它。

mmap

MappedByteBuffer

MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷贝方式的提供的一种实现,它继承自 ByteBuffer。

FileChannel 定义了一个 map() 方法,它可以把一个文件从 position 位置开始的 size 大小的区域映射为内存映像文件。

public abstract MappedByteBuffer map(MapMode mode, long position, long size)
  • mode:限定内存映射区域(MappedByteBuffer)对内存映像文件的访问模式,包括只可读(READ_ONLY)、可读可写(READ_WRITE)和写时拷贝(PRIVATE)三种模式。
  • position:文件映射的起始地址,对应内存映射区域(MappedByteBuffer)的首地址。
  • size:文件映射的字节长度,从 position 往后的字节数,对应内存映射区域(MappedByteBuffer)的大小。

MappedByteBuffer 相比 ByteBuffer 新增了 fore()、load() 和 isLoad() 三个重要的方法:

  • fore():对于处于 READ_WRITE 模式下的缓冲区,把对缓冲区内容的修改强制刷新到本地文件。
  • load():将缓冲区的内容载入物理内存中,并返回这个缓冲区的引用。
  • isLoaded():如果缓冲区的内容在物理内存中,则返回 true,否则返回 false。

下面总结一下 MappedByteBuffer 的特点和不足之处:

  • MappedByteBuffer 使用是堆外的虚拟内存,因此分配(map)的内存大小不受 JVM 的 -Xmx 参数限制,但是也是有大小限制的。如果当文件超出 Integer.MAX_VALUE 字节限制时,可以通过 position 参数重新 map 文件后面的内容。
  • MappedByteBuffer 在处理大文件时性能的确很高,但也存内存占用、文件关闭不确定等问题,被其打开的文件只有在垃圾回收的才会被关闭,而且这个时间点是不确定的。
  • MappedByteBuffer 提供了文件映射内存的 mmap() 方法,也提供了释放映射内存的 unmap() 方法。然而 unmap() 是 FileChannelImpl中的私有方法,无法直接显示调用。因此,用户程序需要通过 Java 反射的调用 sun.misc.Cleaner 类的 clean() 方法手动释放映射占用的内存区域。
public static void clean(final Object buffer) throws Exception {
    AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
        try {
            Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]);
            getCleanerMethod.setAccessible(true);
            Cleaner cleaner = (Cleaner) getCleanerMethod.invoke(buffer, new Object[0]);
            cleaner.clean();
        } catch(Exception e) {
            e.printStackTrace();
        }
    });
}

MMAP 读写

mmap 把文件映射到用户空间里的虚拟内存,省去了从内核缓冲区复制到用户空间的过程,文件中的位置在虚拟内存中有了对应的地址,可以像操作内存一样操作这个文件,相当于已经把整个文件放入内存,但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操作

只有真正使用这些数据时,也就是图像准备渲染在屏幕上时,虚拟内存管理系统 VMS 才根据缺页加载的机制从磁盘加载对应的数据块到物理内存进行渲染。这样的文件读写文件方式少了数据从内核缓存到用户空间的拷贝,效率很高

image.png

RandomAccessFile memoryMappedFile = new RandomAccessFile("largeFile.txt", "rw");
// Mapping a file into memory
MappedByteBuffer out = memoryMappedFile.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, count);

// 写
byte[] data = new byte[4];
int position = 8;
// 从当前 mmap 指针的位置写入 4b 的数据
mappedByteBuffer.put(data);
// 指定 position 写入 4b 的数据
MappedByteBuffer subBuffer = mappedByteBuffer.slice();
subBuffer.position(position);
subBuffer.put(data);

// 读
byte[] data = new byte[4];
int position = 8;
// 从当前 mmap 指针的位置读取 4b 的数据
mappedByteBuffer.get(data);
// 指定 position 读取 4b 的数据
MappedByteBuffer subBuffer = mappedByteBuffer.slice();
subBuffer.position(position);
subBuffer.get(data);

执行 fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1.5 * 1024 * 1024 * 1024); 之后,观察一下磁盘上的变化,会立刻获得一个 1.5G 的文件,但此时文件的内容全部是 0(字节 0)。这符合 MMAP 的中文描述:内存映射文件,我们之后对内存中 MappedByteBuffer 做的任何操作,都会被最终映射到文件之中,

零拷贝

零拷贝(zero copy)技术,用于在数据读写中减少甚至完全避免不必要的CPU拷贝,减少内存带宽的占用,提高执行效率,零拷贝有几种不同的实现原理,下面介绍常见开源项目中零拷贝实现

Kafka零拷贝

Kafka基于Linux 2.1内核提供,并在2.4 内核改进的的sendfile函数 + 硬件提供的DMA Gather Copy实现零拷贝,将文件通过socket传送

函数通过一次系统调用完成了文件的传送,减少了原来read/write方式的模式切换。同时减少了数据的copy, sendfile的详细过程如下

基本流程如下:

  1. 用户进程发起sendfile系统调用

  2. 内核基于DMA Copy将文件数据从磁盘拷贝到内核缓冲区

  3. 内核将内核缓冲区中的文件描述信息(文件描述符,数据长度)拷贝到Socket缓冲区

  4. 内核基于Socket缓冲区中的文件描述信息和DMA硬件提供的Gather Copy功能将内核缓冲区数据复制到网卡

  5. 用户进程sendfile系统调用完成并返回

    image.png 相比传统的I/O方式,sendfile + DMA Gather Copy方式实现的零拷贝,数据拷贝次数从4次降为2次,系统调用从2次降为1次,用户进程上下文切换次数从4次变成2次DMA Copy,大大提高处理效率

Kafka底层基于java.nio包下的FileChannel的transferTo:

public abstract long transferTo(long position, long count, WritableByteChannel target)

transferTo将FileChannel关联的文件发送到指定channel,当Comsumer消费数据,Kafka Server基于FileChannel将文件中的消息数据发送到SocketChannel

RocketMQ零拷贝

RocketMQ基于mmap + write的方式实现零拷贝: mmap() 可以将内核中缓冲区的地址与用户空间的缓冲区进行映射,实现数据共享,省去了将数据从内核缓冲区拷贝到用户缓冲区

tmp_buf = mmap(file, len); 
write(socket, tmp_buf, len);

mmap + write 实现零拷贝的基本流程如下:

  1. 用户进程向内核发起系统mmap调用

  2. 将用户进程的内核空间的读缓冲区与用户空间的缓存区进行内存地址映射

  3. 内核基于DMA Copy将文件数据从磁盘复制到内核缓冲区

  4. 用户进程mmap系统调用完成并返回

  5. 用户进程向内核发起write系统调用

  6. 内核基于CPU Copy将数据从内核缓冲区拷贝到Socket缓冲区

  7. 内核基于DMA Copy将数据从Socket缓冲区拷贝到网卡

  8. 用户进程write系统调用完成并返回

    image.png

RocketMQ中消息基于mmap实现存储和加载的逻辑写在org.apache.rocketmq.store.MappedFile中,内部实现基于nio提供的java.nio.MappedByteBuffer,基于FileChannel的map方法得到mmap的缓冲区:

// 初始化
this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);

查询CommitLog的消息时,基于mappedByteBuffer偏移量pos,数据大小size查询:

public SelectMappedBufferResult selectMappedBuffer(int pos, int size) {
    int readPosition = getReadPosition();
    // ...各种安全校验
    
    // 返回mappedByteBuffer视图
    ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
    byteBuffer.position(pos);
    ByteBuffer byteBufferNew = byteBuffer.slice();
    byteBufferNew.limit(size);
    return new SelectMappedBufferResult(this.fileFromOffset + pos, byteBufferNew, size, this);
}

transientStorePoolEnable机制Java NIO mmap的部分内存并不是常驻内存,可以被置换到交换内存(虚拟内存),RocketMQ为了提高消息发送的性能,引入了内存锁定机制,即将最近需要操作的CommitLog文件映射到内存,并提供内存锁定功能,确保这些文件始终存在内存中,该机制的控制参数就是transientStorePoolEnable

因此,MappedFile数据保存CommitLog刷盘有2种方式:

  1. 开启transientStorePoolEnable:写入内存字节缓冲区(writeBuffer) -> 从内存字节缓冲区(writeBuffer)提交(commit)到文件通道(fileChannel) -> 文件通道(fileChannel) -> flush到磁盘
  2. 未开启transientStorePoolEnable:写入映射文件字节缓冲区(mappedByteBuffer) -> 映射文件字节缓冲区(mappedByteBuffer) -> flush到磁盘

比较

RocketMQ 基于 mmap+write 实现零拷贝,适用于业务级消息这种小块文件的数据持久化和传输 Kafka 基于 sendfile 这种零拷贝方式,适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输

Kafka 的索引文件使用的是 mmap+write 方式,数据文件发送网络使用的是 sendfile 方式

image.png

优化

Direct IO

严谨来说,这并不是 JAVA 原生支持的方式,但可以通过 JNA/JNI 调用 native 方法做到。 image.png

从上图我们可以看到 :Direct IO 绕过了 PageCache,但我们前面说到过,PageCache 可是个好东西啊,干嘛不用他呢?

再仔细推敲一下,还真有一些场景下,Direct IO 可以发挥作用,没错,那就是我们前面没怎么提到的: 随机读 。

当使用 fileChannel.read() 这类会触发 PageCache 预读的 IO 方式时,我们其实并不希望操作系统帮我们干太多事,除非随机读都能命中 PageCache,但几率可想而知。

Direct IO 虽然被 Linus 无脑喷过,但在随机读的场景下,依旧存在其价值,减少了 Block IO Layed(近似理解为磁盘) 到 Page Cache 的 overhead。

话说回来,Java 怎么用 Direct IO 呢?有没有什么限制呢?前面说过,Java 目前原生并不支持,但也有封装Java 的 JNA 库,实现了 Java 的 Direct IO,github 地址:github.com/smacke/jayd…

int bufferSize = 20 * 1024 * 1024;
DirectRandomAccessFile directFile = new DirectRandomAccessFile(new File("dio.data"), "rw", bufferSize);
for(int i= 0;i< bufferSize / 4096;i++){
    byte[] buffer = new byte[4 * 1024];
    directFile.read(buffer);
    directFile.readFully(buffer);
}
directFile.close();

顺序读写

写入方式一:64 个线程,用户自己使用一个 atomic 变量记录写入指针的位置,并发写入

ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
    final int index = i;
    executor.execute(()->{
        fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
    })
}

写入方式二:给 write 加了锁,保证了同步。

ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
    final int index = i;
    executor.execute(()->{
        write(new byte[4*1024]);
    })
}

public synchronized void write(byte[] data){
    fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
}

答案是方式二才算顺序写,顺序读也是同理。对于文件操作,加锁并不是一件非常可怕的事,不敢同步 write/read 才可怕!有人会问:FileChannel 内部不是已经有 positionLock 保证写入的线程安全了吗,为什么还要自己加同步?为什么这样会快?我用大白话来回答的话就是多线程并发 write 并且不加同步,会导致文件空洞,它的执行次序可能是

时序 1:thread1 write position[0~4096)

时序 2:thread3 write position[8194~12288)

时序 3:thread2 write position[4096~8194)

所以并不是完全的“顺序写”。不过你也别担心加锁会导致性能下降,我们会在下面的小结介绍一个优化:通过文件分片来减少多线程读写时锁的冲突。 在来分析原理,顺序读为什么会比随机读要快?顺序写为什么比随机写要快?这两个对比其实都是一个东西在起作用:PageCache,前面我们已经提到了,它是位于 application buffer(用户内存) 和 disk file(磁盘) 之间的一层缓存。

以顺序读为例,当用户发起一个 fileChannel.read(4kb) 之后,实际发生了两件事

  1. 操作系统从磁盘加载了 16kb 进入 PageCache,这被称为预读
  2. 操作通从 PageCache 拷贝 4kb 进入用户内存 最终我们在用户内存访问到了 4kb,为什么顺序读快?很容量想到,当用户继续访问接下来的 [4kb,16kb] 的磁盘内容时,便是直接从 PageCache 去访问了。试想一下,当需要访问 16kb 的磁盘内容时,是发生 4 次磁盘 IO 快,还是发生 1 次磁盘 IO+4 次内存 IO 快呢?答案是显而易见的,这一切都是 PageCache 带来的优化。

深度思考:当内存吃紧时,PageCache 的分配会受影响吗?PageCache 的大小如何确定,是固定的 16kb 吗?我可以监控 PageCache 的命中情况吗? PageCache 会在哪些场景失效,如果失效了,我们又要哪些补救方式呢?

  • 当内存吃紧时,PageCache 的预读会受到影响,实测,并没有搜到到文献支持

  • PageCache 是动态调整的,可以通过 linux 的系统参数进行调整,默认是占据总内存的 20%

  • github.com/brendangreg… github 上一款工具可以监控 PageCache

  • 这是很有意思的一个优化点,如果用 PageCache 做缓存不可控,不妨自己做预读如何呢?

黑魔法:UNSAFE

public class UnsafeUtil {
    public static final Unsafe UNSAFE;
    static {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            UNSAFE = (Unsafe) field.get(null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

我们可以使用 UNSAFE 这个黑魔法实现很多无法想象的事,我这里就稍微介绍一两点吧。

实现直接内存与内存的拷贝:

ByteBuffer buffer = ByteBuffer.allocateDirect(4 * 1024 * 1024);
long addresses = ((DirectBuffer) buffer).address();
byte[] data = new byte[4 * 1024 * 1024];
UNSAFE.copyMemory(data, 16, null, addresses, 4 * 1024 * 1024);

copyMemory 方法可以实现内存之间的拷贝,无论是堆内和堆外,12 个参数是 source 方,34 是 target 方,第 5 个参数是 copy 的大小。

如果是堆内的字节数组,则传递数组的首地址和 16 这个固定的 ARRAY_BYTE_BASE_OFFSET 偏移常量; 如果是堆外内存,则传递 null 和直接内存的偏移量,可以通过 ((DirectBuffer) buffer).address() 拿到。

为什么不直接拷贝,而要借助 UNSAFE?当然是因为它快啊!另外补充:MappedByteBuffer 也可以使用 UNSAFE 来 copy 从而达到写盘 / 读盘的效果哦。

参考

www.yuque.com/michael-cm1…