Java 文件拷贝的方式

三种方式文件拷贝的方式

  1. 通过阻塞流实现
public static void copyFileByStream(File source, File dest) throws
        IOException {
    try (InputStream is = new FileInputStream(source);
         OutputStream os = new FileOutputStream(dest);){
        byte[] buffer = new byte[1024];
        int length;
        while ((length = is.read(buffer)) > 0) {
            os.write(buffer, 0, length);
        }
    }
 }

优点是实现简单,而且在实际使用中,简单的场景下可能是最快的。

  1. 通过 transferTo/From 实现
public static void copyFileByChannel(File source, File dest) throws IOException {
    try (FileChannel sourceChannel = new FileInputStream(source).getChannel();
         FileChannel targetChannel = new FileOutputStream(dest).getChannel()){
        for (long count = sourceChannel.size() ;count>0 ;) {
             long transferred = sourceChannel.transferTo(
             sourceChannel.position(), count, targetChannel);            
             sourceChannel.position(sourceChannel.position() + transferred);
             count -= transferred;
        }
    }
 }

缺点是写起来比 stream 复杂。优点是利用直接在内核态和操作,避免了在用户态传输数据的消耗。理论上是最快的拷贝方式。

  1. 使用 Files.copy()

优点是使用最为简洁,而且不只是文件流的拷贝。

拷贝实现机制分析

前面提到的三种拷贝方式,实现流程都是一样的:从一个地方,复制一段数据到内存,再从内存中把这段数据输出到另一个地方

唯一的细节不同处,就是数据在这个过程中需不需要经过用户态空间。

  1. 当我们使用输入输出流时,实际上是进行了多次上下文切换,比如应用读取数据时,现在内核态将数据从磁盘读取到内核缓存,再切换到用户态,将数据从内核缓存读取到用户缓存。流程图如下:

显然这种方式需要额外的开销,会降低 IO 效率。

  1. 当我们使用 NIO transferTo 时,在 Linux 和 Unix 系统上,则会使用到零拷贝技术,即数据传输不需要经过用户态,省去了上下文切换的开销和不必要的内存拷贝,进而可能提高应用拷贝性能。而且,transferTo 还可以应用在 Socket 传输中,同样可以享受这种机制带来的性能和扩展性提高。

Files.copy() 源码分析

前面提到,Java 标准库直接给我们提供了文件拷贝的 API。他有三个重载版本:

从参数可以看出,这个方法不仅仅是只支持文件之间的操作,还可以在各种流中传输文件。

后两种实现方式,从底层源码可以看到,是直接利用阻塞 IO stream 配合一个 byte[] 数组作为缓冲区实现文件拷贝的。

    private static long copy(InputStream source, OutputStream sink)
        throws IOException
    {
        long nread = 0L;
        byte[] buf = new byte[BUFFER_SIZE];
        int n;
        while ((n = source.read(buf)) > 0) {
            sink.write(buf, 0, n);
            nread += n;
        }
        return nread;
    }

而第一种拷贝方式,则会先具体区分文件系统再进行处理:

public static Path copy(Path source, Path target, CopyOption... options)
    throws IOException
 {
    FileSystemProvider provider = provider(source);
    if (provider(target) == provider) {
        // same provider
        provider.copy(source, target, options);
    } else {
        // different providers
        CopyMoveHelper.copyToForeignTarget(source, target, options);
    }
    return target;
}

追踪同类型文件系统中的拷贝,发现内部实现和公共 API 之间不是直接关联的,NIO 部分甚至是定义为模板而不是 Java 源文件,在 build 过程中生成源码,下面介绍下部分 JDK 代码机制和如何绕过隐藏障碍。

  • 首先,直接跟踪 FileSystemProvider,发现这是一个抽象类,根据注释可以直接理解到,文件系统的实际逻辑存在于 JDK 的内部实现中,公共 API 其实是通过 ServiceLoader 机制加载一系列文件系统实现,然后提供服务。
  • 在 JDK 源码中搜索 FileSystemProvider 的具体实现,可以定位到 sun/nio/fs,这里存放着具体平台的部分特有文件系统逻辑。
  • 对于 Linux 下,省略掉一些细节,最后一步一步定位到 UnixFileSystemProvider -> UnixCopyFile.Transfer,可以看到这是一个本地方法。
  • 最终明确定位到 UnixCopyFile.c,其内部实现清楚说明这只是简单的用户态空间拷贝。

总结下来,可以知道,这个 JDK 提供的接口,其实只是简单的本地技术实现的用户态拷贝。

如何提高类似拷贝 IO 的性能

  1. 利用缓冲区,减少 IO 次数
  2. 使用 transferTo/From 机制,减少上下文切换和额外的 IO 操作。
  3. 减少不必要的转换过程。比如编解码、对象序列化和反序列化,比如操作文本文件或者网络通信,如果不是过程中需要使用到文本信息,可以考虑直接传输二进制信息而不用将二进制信息转换成字符串。

Direct Buffer 和垃圾收集

这里重点介绍两种特别的 buffer。

  • DirectBuffer : 在 Buffer 的方法定义中,有一个 isDirect() 方法,返回当前方法是否是 Direct 类型。这是 Java 提供的堆外 Buffer。可以使用 allocateDirect 方法直接创建。
  • MappedByteBuffer : 它将文件按照指定大小直接映射为内存区域,当程序访问这个内存区域时,将直接操作这块文件数据,省去了将数据从内核空间向用户空间传输的损耗。我们可以使用 FileChannel.map 创建 MappedByteBuffer,它本质上也是种 Direct Buffer。

在实际使用中,Java 会尽量对 Direct Buffer 仅作本地 IO 操作,对于很大数据量的 IO 密集型操作,可能会带来很大的性能优势,因为:

  • Direct Buffer 在生命周期内内存地址都不会再做改变,进而内核可以直接安全地对其访问,很多 IO 操作会很高效。
  • Direct Buffer 避免了堆内对象需要的额外的维护工作,提高了效率。

但是,高效背后也是高成本。Direct Buffer 在创建和销毁过程中,都会比一般的 Buffer 增加部分开销,所以通常应该用于长期使用、数据量较大的场景。

Direct Buffer 因为不在堆上,所以 Xmx 参数对它无效,可以使用下面的代码设置堆外内存的大小:

-XX:MaxDirectMemorySize=512M

从参数设置和内存问题排查来看,我们在设置 JVM 需要的内存时,如果用到了堆外内存,还应考虑堆外内存的开销。而出现了 OOM 问题时,也应该考虑是否是堆外内存不够的可能性。

对于 Direct Buffer 的回收,可以考虑:

  • 在应用程序中,显式调用 System.gc() 来强制触发。
  • 另一种思路是,在大量使用 Direct Buffer 的部分框架中,框架会自己在程序中调用释放方法,Netty 就是这么做的。
  • 重复使用 Direct Buffer,而不是每次需要再创建,用完立刻销毁。

跟踪诊断 Direct Buffer 的内存占用的方法

在普通的垃圾收集日志中,并不包含 Direct Buffer 等信息,所以 Direct Buffer 的内存诊断是个比较头疼的问题。在 java 8 以后,我们可以使用 Native Memory Tracking (NMT) 来诊断,在启动程序时加上下面的参数可以激活 NMT,但是会导致 JVM 出现 5%~10% 的性能下降:

-XX:NativeMemoryTracking={summary|detail}

开启 NMT 后,就可以通过下面的命令进行交互式对比:

// 打印 NMT 信息
jcmd <pid> VM.native_memory detail 

// 进行 baseline,以对比分配内存变化
jcmd <pid> VM.native_memory baseline

// 进行 baseline,以对比分配内存变化
jcmd <pid> VM.native_memory detail.diff
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • 转自JAVA IO 以及 NIO 理解 一段话总结:传统io中从磁盘中中读文件,并把文件通过网络(socket)发...
    抓兔子的猫阅读 1,394评论 0 4
  • 由于Netty,了解了一些异步IO的知识,JAVA里面NIO就是原来的IO的一个补充,本文主要记录下在JAVA中I...
    骚的掉渣阅读 714评论 0 8
  • 关于Mongodb的全面总结 MongoDB的内部构造《MongoDB The Definitive Guide》...
    中v中阅读 32,099评论 2 89
  • p_23f3阅读 129评论 0 0
  • 六月的风吹过了那片草原 六月的风吹过了那座高山 六月的风吹过了那片蓝天 六月的风吹过了大海波澜 六月的风吹来了荷塘...
    HONGYUNDANGTOU阅读 409评论 10 8