Javaでのファイルコピー史

レガシーなJavaで書かれたシステムのコードを見ていると、以下のようにInputStreamでファイルを開いて、OutputStreamでコピー先のファイルに書き込むみたいなものがあったりします。

```java
try(InputStream input = new FileInputStream(srcFile);
    OutputStream output = new FileOutputStream(dstFile)) {
    byte[] buffer = new byte[BUFFER_SIZE];
    int size = -1;
    while ((size = input.read(buffer)) > 0) {
      output.write(buffer, 0, size);
    }
}
```

他にはどういう方法があるのでしょうか。ファイルコピーの歴史が詰まっている、commons-ioの実装の変遷をふりかえり、それぞれの手法のベンチマークをとってみます。

## 1. :snail: メモリ上に全部バッファリングする

2002年にcommons-ioのFileUtilsに、初めてファイルコピーメソッドが誕生したときは、今となってはおぞましい以下のようなコードでした。

https://github.com/apache/commons-io/blob/acdb1679bbb4f0a1af1e5805b707b1ef23bcdce9/src/java/org/apache/commons/io/FileUtils.java#L234

```java:FileUtils.java
public static void fileCopy(String inFileName, String outFileName) throws
    Exception
{
    String content = FileUtils.fileRead(inFileName);
    FileUtils.fileWrite(outFileName, content);
}
```

今、こんなコードを書こうものならはっ倒されますね…

## 2. :turtle: InputStream / OutputStream

前述の方法はあんまりなので、2003年にFileInputStreamでファイルの中身を読み、FileOutputStreamで書き込む、当時のオーソドックス実装に変わったようです。といっても、J2SE1.4は2002年リリースなので、後述のFileChannelが使えた時代ではあるのですが。

https://github.com/apache/commons-io/blob/d9d353082503a217fa6c6510622973d018db0e26/src/java/org/apache/commons/io/FileUtils.java#L604
https://github.com/apache/commons-io/blob/d9d353082503a217fa6c6510622973d018db0e26/src/java/org/apache/commons/io/CopyUtils.java

```java:CopyUtils.java
final byte[] buffer = new byte[bufferSize];
int count = 0;
int n = 0;
while (-1 != (n = input.read(buffer))) {
    output.write(buffer, 0, n);
    count += n;
}
```

## 3. :racehorse: FileChannel (NIO)

2008年になってようやくFileChannelが使われるようになったようです。

https://github.com/apache/commons-io/blob/7bd2c82a6721c57f49f8caec8a94171cd5a2f8c7/src/java/org/apache/commons/io/FileUtils.java#L665

```java:FileUtils.java
private static void doCopyFile(File srcFile, File destFile, boolean preserveFileDate) throws IOException {
    if (destFile.exists() && destFile.isDirectory()) {
        throw new IOException("Destination '" + destFile + "' exists but is a directory");
    }

    FileChannel input = new FileInputStream(srcFile).getChannel();
    try {
        FileChannel output = new FileOutputStream(destFile).getChannel();
        try {
            output.transferFrom(input, 0, input.size());
        } finally {
            IOUtils.closeQuietly(output);
        }
     } finally {
        IOUtils.closeQuietly(input);
     }

    if (srcFile.length() != destFile.length()) {
        throw new IOException("Failed to copy full contents from '" +
            srcFile + "' to '" + destFile + "'");
    }
    if (preserveFileDate) {
        destFile.setLastModified(srcFile.lastModified());
    }
}
```

commons-ioの現在のtrunk実装もこれですが、Windowsで[ラージファイルがコピーできない(IO-175)](https://issues.apache.org/jira/browse/IO-175)とのことから、30MBごとにtransferFromをループするようになっています。

https://github.com/apache/commons-io/blob/trunk/src/main/java/org/apache/commons/io/FileUtils.java#L1148

## 4. :car: Files (NIO2)

Java7からは、NIO2になってjava.nio.file.Filesにcopyメソッドができました。なので、(ファイルコピーに関しては)commons-ioとおさらばして、Filesのcopyを直接使うことで同じ効果が期待できます。

```java
Files.copy(source.toPath(), dest, StandardCopyOption.REPLACE_EXISTING);
```

## ベンチマーク

さて、これらのベンチマークをとってみます。ベンチマークのコードは以下にあります。20MBのファイルをコピーを繰り返したときのスループットを比較します。

https://github.com/kawasima/copyfile-benchmark

### Linux

Linuxの以下のようなディスク性能のマシンで、

```
% sync; time bash -c "(dd if=/dev/zero of=bf bs=8k count=500000; sync)" 
4096000000 バイト (4.1 GB) コピーされました、 7.00345 秒、 585 MB/秒
```

次のような結果となりました。

```
Benchmark                            Mode  Cnt  Score   Error  Units
InputStream2OutputStream.benchmark  thrpt    5  1.669 ± 0.055  ops/s
NIOFileChannel.benchmark            thrpt    5  1.860 ± 0.113  ops/s
NIO2Files.benchmark                 thrpt    5  7.110 ± 1.115  ops/s
```

Filesのコピーメソッドを使うのが、圧倒的に速い結果となります。

#### Filesが速い理由

Filesのコピーメソッドを辿っていくと、[sun.nio.fs.UnixCopyFile](http://hg.openjdk.java.net/jdk8u/jdk8u60/jdk/file/935758609767/src/solaris/classes/sun/nio/fs/UnixCopyFile.java)で実際コピーしているようです。

```java
    private static void copyFile(UnixPath source,
                                 UnixFileAttributes attrs,
                                 UnixPath  target,
                                 Flags flags,
                                 long addressToPollForCancel)
        throws IOException
    {
        int fi = -1;
        try {
            fi = open(source, O_RDONLY, 0);
        } catch (UnixException x) {
            x.rethrowAsIOException(source);
        }

        try {
            // open new file
            int fo = -1;
            try {
                fo = open(target,
                           (O_WRONLY |
                            O_CREAT |
                            O_EXCL),
                           attrs.mode());
            } catch (UnixException x) {
                x.rethrowAsIOException(target);
            }


            // set to true when file and attributes copied
            boolean complete = false;
            try {
                // transfer bytes to target file
                try {
                    transfer(fo, fi, addressToPollForCancel);
```

NIOパターンと同じく、ふつうにファイル開いてバッファコピーしてそうですが… openのところをたどると、**sun.misc.Unsafe**を使って、ネイティブのバッファが使われています。それでこれだけ差がついちゃうようです。

### Windows

同じベンチマークをWindowsで流してみました。

```
Benchmark                            Mode  Cnt  Score   Error  Units
InputStream2OutputStream.benchmark  thrpt    5  1.032 ± 0.421  ops/s
NIOFileChannel.benchmark            thrpt    5  5.466 ± 9.434  ops/s
NIO2Files.benchmark                 thrpt    5  3.153 ± 2.775  ops/s
```

FileChannelとFiles.copyの結果が逆転します。Windowsには疎いので、ちょっと理由までは分かりませんでした…
(Files.copyでは、`CopyFileExW`APIが使われており、これがオーバーヘッドかかるのでしょうか?)

## まとめ

java.nio.fileのクラスは、java.ioとの互換性が弱いですが、Nativeの機能をふんだんに呼んでくれるので、性能面でメリットが大きいことが多いです。
特に巨大なファイルや多くのファイルを扱う場合は、積極的に使っていくとよいのではないでしょうか。

* Java1.4以上は、FileChannelを使おう。
* Java7以上は、Files.copyを使おう。