写点什么

百万并发「零拷贝」技术系列之 Java 实现

用户头像
码农神说
关注
发布于: 2020 年 07 月 28 日
百万并发「零拷贝」技术系列之Java实现

在上一篇推文百万并发「零拷贝」技术系列之Linux实现 讲解了零拷贝思想在Linux系统中主要有mmap、sendfile、splice、tee等实现,但在Java中目前主要实现了mmap和sendfile。



Java I/O的发展史

百万并发「零拷贝」技术系列之初探门径 推文中,我们了解到为了降低内核接口调用的复杂度和提高编码效率,高级语言一般都为程序开发者提供了封装的类库,如C语言的标准库、Java的JDK等。



在JDK1.3之前Java的I/O一直比较传统,是采用Stream阻塞模式。在JDK1.4 的发布版中正式引入NIO,加入了缓冲区Buffer和通道Channel的概念,提供了非阻塞的方式。然而JDK1.4主要是为Socket通讯进行的优化,随后在JDK1.7版本中的NIO2不仅增强了文件系统的处理能力,还做到了真正的异步I/O—AIO。



mmap的实现 - MappedByteBuffer

JDK NIO提供的MappedByteBuffer底层就是调用mmap来实现的,FileChannel.map用来建立内存映射关系:把用户空间和内存空间的虚拟内存地址映射到同一块物理内存。mmap对大文件比较合适,对小文件则容易造成内存碎片,反而不是最佳使用场景。



编码示例如下

public void mmap4zeroCopy(String from, String to) throws IOException {
  FileChannel source = null;
  FileChannel destination = null;
  try {
    source = new RandomAccessFile(from, "r").getChannel();
    destination = new RandomAccessFile(to, "rw").getChannel();
    MappedByteBuffer inMappedBuf = 
      source.map(FileChannel.MapMode.READ_ONLY, 0, source.size());
    destination.write(inMappedBuf);
  } finally {
    if (source != null) {
      source.close();
    }
    if (destination != null) {
      destination.close();
    }
  }
}

sendfile的实现 - transferTo

NIO提供的FileChannel.transferTo方法可以直接将一个channel传递给另一个channel,结合上一篇推文看,channel像极了内核缓冲区。



编码示例如下

public void sendfile4zeroCopy(String from, String to) throws IOException{
  FileChannel source = null;
  FileChannel destination = null;
  try {
    source = new FileInputStream(from).getChannel();
    destination = new FileOutputStream(to).getChannel();
    source.transferTo(0, source.size(), destination);
  } finally {
    if (source != null) {
      source.close();
    }
    if (destination != null) {
      destination.close();
    }
  }
}

传统I/O vs mmap vs sendfile

通过实战来对比下传统I/O、mmap、sendfile的性能及在用户空间和内核空间中消耗的CPU时间,代码如下

import java.io.*;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
/**
 * 公众号:码农神说 示例代码
 */
public class JioChannel {
  public static void main(String[] args) {
    JioChannel channel = new JioChannel();
    try {
      if (args.length < 3) {
        System.out.println("usage: JioChannel <source> "+
                    "<destination> <mode>\n");
        return;
      }
      if ("1".equals(args[2])) { //传统方式的复制
        channel.copy(args[0], args[1]);
      } else if ("2".equals(args[2])) { //mmap的方式
        channel.mmap4zeroCopy(args[0], args[1]);
      } else if ("3".equals(args[2])) { //sendfile的方式
        channel.sendfile4zeroCopy(args[0], args[1]);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
  /**
   * 传统方式的复制
   *
   * @param from
   * @param to
   * @throws IOException
   */
  public void copy(String from, String to) throws IOException {
    byte[] data = new byte[8 * 1024];
    FileInputStream fis = null;
    FileOutputStream fos = null;
    long bytesToCopy = new File(from).length();
    long bytesCopied = 0;
    try {
      fis = new FileInputStream(from);
      fos = new FileOutputStream(to);
      while (bytesCopied < bytesToCopy) {
        fis.read(data);
        fos.write(data);
        bytesCopied += data.length;
      }
      fos.flush();
    } finally {
      if (fis != null) {
        fis.close();
      }
      if (fos != null) {
        fos.close();
      }
    }
  }
  /**
   * mmap的方式复制
   *
   * @param from
   * @param to
   * @throws IOException
   */
  public void mmap4zeroCopy(String from, String to) throws IOException {
    FileChannel source = null;
    FileChannel destination = null;
    try {
      source = new RandomAccessFile(from, "r").getChannel();
      destination = new RandomAccessFile(to, "rw").getChannel();
      MappedByteBuffer inMappedBuf = 
          source.map(FileChannel.MapMode.READ_ONLY, 0, source.size());
      destination.write(inMappedBuf);
    } finally {
      if (source != null) {
        source.close();
      }
      if (destination != null) {
        destination.close();
      }
    }
  }
  /**
   * sendfile的方式复制文件
   *
   * @param from
   * @param to
   * @throws IOException
   */
  public void sendfile4zeroCopy(String from, String to) throws IOException {
    FileChannel source = null;
    FileChannel destination = null;
    try {
      source = new FileInputStream(from).getChannel();
      destination = new FileOutputStream(to).getChannel();
      source.transferTo(0, source.size(), destination);
    } finally {
      if (source != null) {
        source.close();
      }
      if (destination != null) {
        destination.close();
      }
    }
  }
}

首先进行代码编译java javac JioChannel.java,它的执行方法是JioChannel <source> <destination> <mode>,其中mode值1为传统方式I/O,2为mmap方式I/O,3为sendfile方式I/O。



执行和输出如下(a.zip为130M的压缩文件)

$ time java JioChannel a.zip b.zip 1
real 0m0.199s
user 0m0.090s
sys  0m0.117s
$ time java JioChannel a.zip b.zip 2
real 0m0.172s
user 0m0.074s
sys  0m0.102s
    
$ time java JioChannel a.zip b.zip 3
real 0m0.162s
user 0m0.057s
sys  0m0.108s

user+sys之和是该执行进程的耗费CPU的总时间,可见mmap和sendfile方式效率高于传统方式,而且用户空间user耗费CPU的时间占比总耗费时间也有所降低。



Linux的time命令

time是linux shell内置的命令,它用于统计/测量系统的资源使用情况,如CPU、内存、I/O等,用法如下

time [ -apqvV ] [ -f FORMAT ] [ -o FILE ]
      [ --append ] [ --verbose ] [ --quiet ] [ --portability ]
      [ --format=FORMAT ] [ --output=FILE ] [ --version ]
      [ --help ] COMMAND [ ARGS ]

内存、I/O等资源可参看time手册,不展开叙述。测量CPU的主要角度是其耗费的时间:实际总耗费时间、用户空间和内核空间各自耗费的时间。

  • real:实际总耗费时间,从会话开始到结束,包括其他进程的使用时间和本进程阻塞的时间;

  • user:该执行进程在用户空间耗费的CPU时间;

  • sys:该执行进程在内核空间耗费的CPU时间(CPU耗费在系统调用(system calls)执行上);

  • user + sys:该执行进程实际耗费的CPU总时间,real时间远大于user+sys,因为它不仅包含其他进程消耗的时间,还有文件寻址等时间消耗。



写在最后

虽然JDK没有实现所有的Linux零拷贝模式,但如果能把mmap和sendfile发挥到极致在性能上也能具有非常可观的提升,比如kafka、netty都是以零拷贝而业界瞩目。下一篇推文将简单介绍下kafka、netty的零拷贝思想及实现,这也是面试经常遇到的问题,敬请关注。



最新、更多漫画请关注微信公众号:码农神说。

码农神说:图解码农技术,大话码农故事,漫画感悟码农人生,助力码农翻身!





发布于: 2020 年 07 月 28 日阅读数: 102
用户头像

码农神说

关注

欢迎关注公众号【码农神说】 2018.11.09 加入

图解码农技术,大话码农故事,漫画感悟码农人生,助力码农翻身!

评论

发布
暂无评论
百万并发「零拷贝」技术系列之Java实现