零拷贝在Java代码中的应用

一提到零拷贝,大部分都是涉及网络编程中,对代码的性能优化,一直存在操作系统层面。
WIKI对零拷贝的定义如下:
"Zero-copy" describes computer operations in which the CPU does not perform the task of copying data from one memory area to another.
我们看到“零拷贝”是指计算机操作的过程中,CPU不需要为数据在内存之间的拷贝消耗资源。而它通常是指计算机在网络上发送文件时,不需要将文件内容拷贝到用户空间(User Space)而直接在内核空间(Kernel Space)中传输到网络的方式。

  1. 小时候, 我们自己在写代码时,经常会写这样的代码:
File file = new File("filepath/a.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");

byte[] arr = new byte[(int) file.length()];
raf.read(arr);

Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);

当我们需要向 用户返回他需要的信息时,从服务器硬盘读取文件到byte[]中,然后经过server端代码疯狂的操作,最后把一些byte[] 写入到Socker缓冲区。
不过这中间可是发生了很多操作。
在这里插入图片描述
图中,上半部分表示用户态和内核态的上下文切换。下半部分表示数据复制操作。下面说说他们的步骤:

  1. read 调用导致用户态到内核态的一次变化,同时,第一次复制开始:DMA(Direct Memory Access,直接内存存取,即不使用 CPU 拷贝数据到内存,而是 DMA 引擎传输数据到内存,用于解放 CPU) 引擎从磁盘读取 a.txt 文件,并将数据放入到内核缓冲区。

  2. 发生第二次数据拷贝,即:将内核缓冲区的数据拷贝到用户缓冲区,同时,发生了一次用内核态到用户态的上下文切换。

  3. 发生第三次数据拷贝,我们调用 write 方法,系统将用户缓冲区的数据拷贝到 Socket 缓冲区。此时,又发生了一次用户态到内核态的上下文切换。

  4. 第四次拷贝,数据异步的从 Socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换。

  5. write 方法返回,再次从内核态切换到用户态。
    产生了四次数据拷贝,三次状态切换。
    那么零拷贝这时候,就出现了,在这里如何优化这些繁琐而又看似正常的网络IO操作呢?

mmap

mmap系统调用,通过内存映射,可以把文件映射到内存缓冲区,也就我们在操作系统课程中学习到的pageCache, pageCache是堆外内存,不受JVM的管控。多个进程可以共享内存缓冲区。
在这里插入图片描述
从图中,可以看到原来需要从内核缓冲区拷贝到 用户进程的数据,可以通过内存映射的方式,在用户空间直接操作缓冲区的数据,把他们直接拷贝到socker缓冲区,减少了一次数据拷贝操作,但是状态切换依然是 read, write之间进行了三次。
光知道概念是不行滴,对应的Java代码长啥样子呢?
基于mmap系统调用的零拷贝,基本都是mmp + write组合而成的,write没啥讲的,就是在代码中操作内存映射区写入到Socker Buffer中,或者是NIO中的SockerChannel中。
那么如何映射本地文件到内存映射区中呢? 答案是 MappedByteBuffer
下面是使用MappedByteBuffer的一个小例子:把文件映射到内存缓冲区中之后,再读取之…

public class MappedByteBufferTest {
    public static void main(String[] args) {
        File file = new File("/Users/sailongren/myblog/a.txt");
        long len = file.length();
        System.out.println(len);
        byte[] ds = new byte[(int) len];
        try {
            MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r")
                    .getChannel()
                    .map(FileChannel.MapMode.READ_ONLY, 0, len);
            for (int offset = 0; offset < len; offset++) {
                byte b = mappedByteBuffer.get();
                ds[offset] = b;
            }
            String string = new String(ds);
            System.out.println(string);

        } catch (IOException e) {

        }
    }
}

其中的RandomFileChannel.getChannel()代码

    public final FileChannel getChannel() {
        synchronized (this) {
            if (channel == null) {
                channel = FileChannelImpl.open(fd, path, true, rw, this);
            }
            return channel;
        }
    }

比较重要是哪个map()方法,
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
从方法签名上也可以看出来这个函数就是做 映射 之用的。

  • MapMode mode:内存映像文件访问的方式,共三种:
  1. MapMode.READ_ONLY:只读,试图修改得到的缓冲区将导致抛出异常。
  2. MapMode.READ_WRITE:读/写,对得到的缓冲区的更改最终将写入文件;但该更改对映 射到同一文件的其他程序不一定是可见的。
  3. MapMode.PRIVATE:私用,可读可写,但是修改的内容不会写入文件,相当于 "copy on write "
  • position:文件映射时的起始位置
  • size 需要映射的文件大小.
**只保留了核心代码**
public MappedByteBuffer map(MapMode mode, long position, long size)  throws IOException {
        int pagePosition = (int)(position % allocationGranularity);
        long mapPosition = position - pagePosition;
        long mapSize = size + pagePosition;
        try {
            addr = map0(imode, mapPosition, mapSize);
        } catch (OutOfMemoryError x) {
            System.gc();
            try {
                Thread.sleep(100);
            } catch (InterruptedException y) {
                Thread.currentThread().interrupt();
            }
            try {
                addr = map0(imode, mapPosition, mapSize);
            } catch (OutOfMemoryError y) {
                // After a second OOME, fail
                throw new IOException("Map failed", y);
            }
        }
        int isize = (int)size;
        Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
        if ((!writable) || (imode == MAP_RO)) {
            return Util.newMappedByteBufferR(isize,
                                             addr + pagePosition,
                                             mfd,
                                             um);
        } else {
            return Util.newMappedByteBuffer(isize,
                                            addr + pagePosition,
                                            mfd,
                                            um);
        }
}

这是狼哥的代码,??

  1. 如果第一次文件映射导致OOM,则手动触发垃圾回收,休眠100ms后再次尝试映射,如果失败,则抛出异常。
  2. 通过newMappedByteBuffer方法初始化MappedByteBuffer实例,不过其最终返回的是DirectByteBuffer的实例
    然后就可以使用MappedByteBuffer来直接访问映射区。
public byte get() {
        return ((unsafe.getByte(ix(nextGetIndex()))));
}

从这里可以看出,MappedByteBuffer通过UnSafe来访问直接。(unSafe中的native方法,都是使用address + offset来访问内存)
总结下就是: map0()函数返回一个地址address,这样就无需调用read或write方法对文件进行读写,通过address就能够操作文件。底层采用unsafe.getByte方法,通过(address + 偏移量)获取指定内存的数据。
当我们第一次访问address所指向的内存区域,导致缺页中断,中断响应函数会在交换区中查找相对应的页面,如果找不到(也就是该文件从来没有被读入内存的情况),则从硬盘上将文件指定页读取到物理内存中(非jvm堆内存)。
如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘的虚拟内存中。

sendFile

sendFile是Linux提供的另一个系统调用,
其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换。

参考:
占小狼的博客
莫娜-鲁道博客
[Netty权威指南]
Efficient data transfer through zero copy

原创文章 132 获赞 23 访问量 3万+

猜你喜欢

转载自blog.csdn.net/qq_33797928/article/details/92420001