阅读 469

Java NIO中的堆外内存、内存映射与Zero Copy

背景

在前公司时参与了一个编码竞赛,虽然只拿到一个中游成绩,但在参赛过程中学习到很多其他人优秀的思考方式,也接受了前辈的指点,尤其是在参赛时的一些知识面拓展对我帮助不小。其中一些平常很少接触到的知识对于之后的工作会有所帮助。

题目很简单,大概是这样:

  • 在4G内存的机器上实现对大文件内容的按行排序
  • 文件每行为小写字母组成的不重复的一段字符串,最长为128字节
  • 文件大小有1G/2G/5G/10G/20G多种,很明显一部分文件是无法全部加载到内存中的

具体过程及结果不细说,在这里简单介绍其中用到的部分NIO技术,这些技术无论在各种框架如Netty等,以及各种中间件如RocketMQ等都有用到。

基本概念

堆外内存

我们都知道,JVM需要申请一块内存用于进程的使用,类、对象、方法等数据均保存在JVM堆栈也就是申请的这块内存之中,JVM也会负责帮我们管理和回收再利用这块内存。

相对的,堆外内存就是直接调用系统malloc分配的内存,这部分内存不属于JVM直接管理,也不受JVM最大内存的限制,通过引用指向这段内存。

用户态和内核态

应用程序是不能直接访问内存、硬盘等资源,而是通过操作系统提供的接口调用。而操作系统为保证安全,将系统进行权限分级,分为权限高的内核态和权限低的用户态,用户态的很多操作需要借用内核态代为进行系统调度,即状态转换。

undefined

Unix系统架构

内存映射

操作系统提供了将一段磁盘文件内容映射到内存的方法,对这段内存数据的修改操作会直接由操作系统保证异步刷盘到硬盘的文件中;在内存映射的过程中可以省略中间的很多IO环节,而这个刷盘过程即使应用程序崩溃也能够完成,这就是内存映射。

使用内存映射文件处理存储于磁盘上的文件时,将不必再对文件执行I/O操作,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。 ——搜狗百科

ByteBuffer缓冲区 FileChannel通道

ByteBuffer是一个缓冲区,NIO中的所有数据都是经过缓冲区处理的,其底层一般是一个byte array,可以说ByteBuffer是一个带有多个游标的array包装类。简言之ByteBuffer是一块逻辑上连续的内存,用于NIO的读写中转,合理的设计可以实现数据零拷贝(Zero-Copy),也可以理解为减少不必要的数据复制过程。

Zero-Copy: 通常一次发送/复制文件读写处理需要经过如下过程:

  1. 从磁盘复制到内核态缓存(读数据)
  2. 从内核态读到应用所在的用户态缓存
  3. 从用户态缓存复制到内核态缓存(写数据)
  4. 从内核态缓存复制到真正的写入目标,如硬盘/网络socket缓存

可以看到数据在流转过程中读/写都复制了两次,主要问题在于内核态和用户态缓存间的复制。而如果可以合理利用内核提供的能力直接不经过用户态和内核态的来回复制,直接从内核态缓存复制到内核态的目标缓存位置,将会明显减少不必要的复制过程,也就是所谓的Zero-Copy。

关键方法

方法名 描述 用途
array 获取内部array 数据读写,对array操作等效于对ByteBuffer的操作
get系列方法 获取本Buffer中的数据 数据读写
put系列方法 数据写入本Buffer 数据读写
as系列方法 传出至WritableByteChannel 将ByteBuffer包装成其他类型的Buffer
put(ByteBuffer src) 将src ByteBuffer的内容写入自身 Channel间数据复制

一些不好理解的核心方法

为了复用Buffer实现零拷贝,Buffer内置了很多游标,这些游标的使用是Buffer最核心也是最不好理解的内容:

  • mark 用于标记一个特定的位置
  • position 当前位置
  • limit 范围限制,即Buffer的可读范围在0~limit
  • capacity 容量
方法名 描述 用途
mark 在当前位置设置mark mark=position;
reset 从当前位置回退到mark处 position=mark;
rewind 倒带,即回到初始状态(回到起点)并清空mark,一般用于再次读 position=0; mark=-1;
clear 将整个Buffer游标重置但不清理数据,新数据直接覆盖,一般用于再次写入 position=0; limit=capacity; mark=-1;
flip 特殊的“倒带”,可用数据变为0~position并回退到起点,通常在写完Buffer后flip供读取 limit=position; position=0; mark=-1;
remaining 返回还剩多少数据用于读/写 return limit-position;
limit 返回limit return limit;
capacity 返回capacity return capacity;

这些操作并没有真正区分读/写使用,一旦理解出现偏差将很难实现正确的处理逻辑,也许调一下午才能调通,血的教训

DirectByteBuffer 直接缓冲区

DirectByteBuffer是一个特殊的ByteBuffer,底层同样需要一块连续的内存,操作模式与普通的ByteBuffer一致,但这块内存是调用unsafe的native方法分配的堆外内存。

直接缓冲区的内存释放也是由unsafe的native方法完成的,DirectByteBuffer指向的内存通过PhantomReference持有,由JVM自行回收。但如果DirectByteBuffer经过数次GC后进入老年代,就很可能由于Full GC间隔较长而长期存活,进而导致指向的堆外内存也无法回收。当需要手动回收时,需要通过反射调用DirectByteBuffer内部的Cleaner的clean私有方法。

为何要使用堆外内存

Java应用一般能够操作的是JVM管理的堆内内存,一段数据从应用中发送至网络需要经过多次复制:

  1. 从堆内复制到堆外
  2. 从堆外复制到socket缓存
  3. socket缓存flush

考虑到Java内存模型,可能还存在工作内存/主内存之间的复制;

考虑到GC,可能还存在堆内内存之间的复制;

而如果使用堆外内存,则少了一步从堆内到堆外的复制过程。

使用直接缓冲区的优点:

  • 这块缓冲区内存不受JVM直接管理回收
  • 大小不受JVM分配的最大内存限制
  • 一些IO操作可以避免堆外内存和堆内内存间的复制,比如网络传输
  • 某些生命周期较长的大对象可以保存在堆外内存,减少对GC的影响

缺点:

  • 不受JVM直接管理,容易造成堆外内存泄露
  • 由于堆外内存并不能保存复杂对象而只能保存基本类型的包装类(底层都是byte array),因此要保存对象时需要序列化

必须先复制到堆外内存的原因

参考资料指出在BIO中,native读写文件前会先在堆外分配一块内存将堆内数据复制到堆外内存中:

  1. 底层通过write、read、pwrite,pread函数进行系统调用时,需要传入buffer的起始地址和buffer count作为参数。如果使用java heap的话,我们知道jvm中buffer往往以byte[] 的形式存在,这是一个特殊的对象,由于java heap GC的存在,这里对象在堆中的位置往往会发生移动,移动后我们传入系统函数的地址参数就不是真正的buffer地址了,这样的话无论读写都会发生出错。而C Heap仅仅受Full GC的影响,相对来说地址稳定。
  2. JVM规范中没有要求Java的byte[]必须是连续的内存空间,它往往受宿主语言的类型约束;而C Heap中我们分配的虚拟地址空间是可以连续的,而上述的系统调用要求我们使用连续的地址空间作为buffer。

MappedByteBuffer 内存映射缓冲区

MappedByteBuffer与其他ByteBuffer一样底层是一段连续内存,区别在于这段内存使用的是内存映射的那段内存,也就是说对于这块缓冲区的数据修改会同步到对应的文件中。

FileChannel

NIO的Channel类型是一个通道,本身不能访问数据,而是与Buffer交互。

Channel类的作用主要是操作数据、数据传输、实现内存映射。

几类Channel:

  • FileChannel(文件)
  • SocketChannel(客户端TCP)
  • ServerSocketChannel(服务端TCP)
  • DatagramChannel(UDP)

关键方法

方法名 描述 用途
transferFrom 从ReadableByteChannel传入 Channel间数据复制
transferTo 传出至WritableByteChannel Channel间数据复制
read 写到ByteBuffer中 Channel与ByteBuffer间数据复制
write 从ByteBuffer中读 Channel与ByteBuffer间数据复制
position 游标当前位置
size Channel内容长度
map 映射出一个MappedByteBuffer 从Channel映射出可操作的ByteBuffer

为何使用Channel

  • transferFrom和transferTo两个方法底层依赖操作系统API实现,由操作系统内核负责数据复制,由于省去了内核缓冲区向用户缓冲区的来回复制以及上下文切换,Channel的transferFrom和transferTo方法效率会相当高
  • 读写使用ByteBuffer,减少复制次数
  • MappedByteBuffer映射出的一块内存不需要阻塞等待刷盘完成,也不担心应用程序崩溃导致的数据丢失问题

FileChannel优点:

  • 内存映射的内容可以防止程序甭崩溃(kill -9)导致的数据丢失,这个特性在很多中间件系统中作用很大(阿里某些中间件比赛有要求kill -9不丢失)
  • 不用阻塞等待,效率高
  • 减少复制次数

缺点:

  • 由于内存映射需要指定映射文件大小,那么当映射的文件大小比写入的内容大时会产生文件间隙,即文件EOF后还有一部分无内容的填充,文件末尾乱码之类的,这个在实际应用中需要注意
  • 映射后的内存页面需要等待被置换,导致系统的整体内存管理相对复杂

一些Channel可以使用读/读写等模式操作

效率比较

public class UnitTest1 {
    private static final String prefix = "~/path/to/";

    public static void main(String[] args) throws Exception {
        streamCopy("input", "output1");
        bufferCopy("input", "output2");
        directBufferCopy("input", "output3");
        mappedByteBufferCopy("input", "output4");
        mappedByteBufferCopyByPart("input", "output5");
        channelCopy("input", "output6");
    }

    /**
     * 使用stream
     */
    private static void streamCopy(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();

        File inputFile = new File(prefix + from);
        File outputFile = new File(prefix + to);

        FileInputStream fis = new FileInputStream(inputFile);
        FileOutputStream fos = new FileOutputStream(outputFile);

        byte[] bytes = new byte[1024];
        int len;
        while ((len = fis.read(bytes)) != -1) {
            fos.write(bytes, 0, len);
        }
        fos.flush();

        fis.close();
        fos.close();

        long endTime = System.currentTimeMillis();
        System.out.println("streamCopy cost:" + (endTime - startTime));
    }

    /**
     * 使用buffer
     */
    private static void bufferCopy(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();

        RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r");
        RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw");

        FileChannel inputChannel = inputFile.getChannel();
        FileChannel outputChannel = outputFile.getChannel();

        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        while (inputChannel.read(byteBuffer) != -1) {
            byteBuffer.flip();
            outputChannel.write(byteBuffer);
            byteBuffer.clear();
        }

        inputChannel.close();
        outputChannel.close();

        long endTime = System.currentTimeMillis();
        System.out.println("bufferCopy cost:" + (endTime - startTime));
    }

    /**
     * 使用堆外内存
     */
    private static void directBufferCopy(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();

        RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r");
        RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw");

        FileChannel inputChannel = inputFile.getChannel();
        FileChannel outputChannel = outputFile.getChannel();

        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
        while (inputChannel.read(byteBuffer) != -1) {
            byteBuffer.flip();
            outputChannel.write(byteBuffer);
            byteBuffer.clear();
        }

        inputChannel.close();
        outputChannel.close();

        long endTime = System.currentTimeMillis();
        System.out.println("directBufferCopy cost:" + (endTime - startTime));
    }

    /**
     * 内存映射全量
     */
    private static void mappedByteBufferCopy(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();

        RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r");
        RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw");

        FileChannel inputChannel = inputFile.getChannel();
        FileChannel outputChannel = outputFile.getChannel();

        MappedByteBuffer iBuffer = inputChannel.map(MapMode.READ_ONLY, 0, inputFile.length());
        MappedByteBuffer oBuffer = outputChannel.map(MapMode.READ_WRITE, 0, inputFile.length());

        // 直接操作buffer,没有其他IO操作
        oBuffer.put(iBuffer);

        inputChannel.close();
        outputChannel.close();

        long endTime = System.currentTimeMillis();
        System.out.println("mappedByteBufferCopy cost:" + (endTime - startTime));
    }

    /**
     * 内存映射部分
     */
    private static void mappedByteBufferCopyByPart(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();

        RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r");
        RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw");

        FileChannel inputChannel = inputFile.getChannel();
        FileChannel outputChannel = outputFile.getChannel();

        for (long i = 0; i < inputFile.length(); i += 1024) {
            long size = 1024;
            // 避免文件产生间隙
            if (i + size > inputFile.length()) {
                size = inputFile.length() - i;
            }
            MappedByteBuffer iBuffer = inputChannel.map(MapMode.READ_ONLY, i, size);
            MappedByteBuffer oBuffer = outputChannel.map(MapMode.READ_WRITE, i, size);
            oBuffer.put(iBuffer);
        }

        inputChannel.close();
        outputChannel.close();

        long endTime = System.currentTimeMillis();
        System.out.println("mappedByteBufferCopyByPart cost:" + (endTime - startTime));
    }

    /**
     * zero copy
     */
    private static void channelCopy(String from, String to) throws IOException {
        long startTime = System.currentTimeMillis();

        RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r");
        RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw");

        FileChannel inputChannel = inputFile.getChannel();
        FileChannel outputChannel = outputFile.getChannel();

        inputChannel.transferTo(0, inputFile.length(), outputChannel);

        inputChannel.close();
        outputChannel.close();

        long endTime = System.currentTimeMillis();
        System.out.println("channelCopy cost:" + (endTime - startTime));
    }

}
复制代码

input文件大小为360MB,其实算是小文件,大文件暂时没找到,效果会更明显。

这段代码在我的开发机器上输出结果为:

streamCopy cost:2718
bufferCopy cost:2604
directBufferCopy cost:2420
mappedByteBufferCopy cost:541
mappedByteBufferCopyByPart cost:11232
channelCopy cost:330
复制代码
  • 以stream为基准
  • 使用ByteBuffer效率比基准高一点
  • 在文件复制上堆外内存效率比堆内内存要高
  • 内存映射大段文件来操作会非常快,因为节省了很多不必要的IO
  • 内存映射过小时,由于频繁的内存置换,效率反而很低
  • ZeroCopy,快,没话说

参考资料

Java中的零拷贝 - 知乎

通过零拷贝实现有效数据传输 - IBM Developer

Java NIO direct buffer的优势在哪儿? - 知乎

本文搬自我的博客,欢迎参观!

image