<audio id="audio" title="14 | 文件IO：实现高效正确的文件读写并非易事" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/49/965eeca1fbb99db6f2df80c30d845649.mp3"></audio>

你好，我是朱晔。今天，我们来聊聊如何实现高效、正确的文件操作。

随着数据库系统的成熟和普及，需要直接做文件IO操作的需求越来越少，这就导致我们对相关API不够熟悉，以至于遇到类似文件导出、三方文件对账等需求时，只能临时抱佛脚，随意搜索一些代码完成需求，出现性能问题或者Bug后不知从何处入手。

今天这篇文章，我就会从字符编码、缓冲区和文件句柄释放这3个常见问题出发，和你分享如何解决与文件操作相关的性能问题或者Bug。如果你对文件操作相关的API不够熟悉，可以查看[Oracle官网的介绍](https://docs.oracle.com/javase/tutorial/essential/io/)。

## 文件读写需要确保字符编码一致

有一个项目需要读取三方的对账文件定时对账，原先一直是单机处理的，没什么问题。后来为了提升性能，使用双节点同时处理对账，每一个节点处理部分对账数据，但新增的节点在处理文件中中文的时候总是读取到乱码。

程序代码都是一致的，为什么老节点就不会有问题呢？我们知道，这很可能是写代码时没有注意编码问题导致的。接下来，我们就分析下这个问题吧。

为模拟这个场景，我们使用GBK编码把“你好hi”写入一个名为hello.txt的文本文件，然后直接以字节数组形式读取文件内容，转换为十六进制字符串输出到日志中：

```
Files.deleteIfExists(Paths.get(&quot;hello.txt&quot;));
Files.write(Paths.get(&quot;hello.txt&quot;), &quot;你好hi&quot;.getBytes(Charset.forName(&quot;GBK&quot;)));
log.info(&quot;bytes:{}&quot;, Hex.encodeHexString(Files.readAllBytes(Paths.get(&quot;hello.txt&quot;))).toUpperCase());

```

输出如下：

```
13:06:28.955 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - bytes:C4E3BAC36869

```

虽然我们打开文本文件时看到的是“你好hi”，但不管是什么文字，计算机中都是按照一定的规则将其以二进制保存的。这个规则就是字符集，字符集枚举了所有支持的字符映射成二进制的映射表。在处理文件读写的时候，如果是在字节层面进行操作，那么不会涉及字符编码问题；而如果需要在字符层面进行读写的话，就需要明确字符的编码方式也就是字符集了。

当时出现问题的文件读取代码是这样的：

```
char[] chars = new char[10];
String content = &quot;&quot;;
try (FileReader fileReader = new FileReader(&quot;hello.txt&quot;)) {
    int count;
    while ((count = fileReader.read(chars)) != -1) {
        content += new String(chars, 0, count);
    }
}
log.info(&quot;result:{}&quot;, content);

```

可以看到，是使用了FileReader类以字符方式进行文件读取，日志中读取出来的“你好”变为了乱码：

```
13:06:28.961 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - result:���hi

```

显然，这里并没有指定以什么字符集来读取文件中的字符。查看[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/io/FileReader.html)可以发现，**FileReader是以当前机器的默认字符集来读取文件的**，如果希望指定字符集的话，需要直接使用InputStreamReader和FileInputStream。

到这里我们就明白了，FileReader虽然方便但因为使用了默认字符集对环境产生了依赖，这就是为什么老的机器上程序可以正常运作，在新节点上读取中文时却产生了乱码。

那，怎么确定当前机器的默认字符集呢？写一段代码输出当前机器的默认字符集，以及UTF-8方式编码的“你好hi”的十六进制字符串：

```
log.info(&quot;charset: {}&quot;, Charset.defaultCharset());
Files.write(Paths.get(&quot;hello2.txt&quot;), &quot;你好hi&quot;.getBytes(Charsets.UTF_8));
log.info(&quot;bytes:{}&quot;, Hex.encodeHexString(Files.readAllBytes(Paths.get(&quot;hello2.txt&quot;))).toUpperCase());

```

输出结果如下：

```
13:06:28.961 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - charset: UTF-8
13:06:28.962 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - bytes:E4BDA0E5A5BD6869

```

可以看到，当前机器默认字符集是UTF-8，当然无法读取GBK编码的汉字。UTF-8编码的“你好”的十六进制是E4BDA0E5A5BD，每一个汉字需要三个字节；而GBK编码的汉字，每一个汉字两个字节。字节长度都不一样，以GBK编码后保存的汉字，以UTF8进行解码读取，必然不会成功。

定位到问题后，修复就很简单了。按照文档所说，直接使用FileInputStream拿文件流，然后使用InputStreamReader读取字符流，并指定字符集为GBK：

```
private static void right1() throws IOException {
    char[] chars = new char[10];
    String content = &quot;&quot;;
    try (FileInputStream fileInputStream = new FileInputStream(&quot;hello.txt&quot;);
        InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, Charset.forName(&quot;GBK&quot;))) {
        int count;
        while ((count = inputStreamReader.read(chars)) != -1) {
            content += new String(chars, 0, count);
        }
    }
    log.info(&quot;result: {}&quot;, content);
}

```

从日志中看到，修复后的代码正确读取到了“你好Hi”。

```
13:06:28.963 [main] INFO org.geekbang.time.commonmistakes.io.demo3.FileBadEncodingIssueApplication - result: 你好hi

```

如果你觉得这种方式比较麻烦的话，使用JDK1.7推出的Files类的readAllLines方法，可以很方便地用一行代码完成文件内容读取：

```
log.info(&quot;result: {}&quot;, Files.readAllLines(Paths.get(&quot;hello.txt&quot;), Charset.forName(&quot;GBK&quot;)).stream().findFirst().orElse(&quot;&quot;));

```

**但这种方式有个问题是，读取超出内存大小的大文件时会出现OOM**。为什么呢？

打开readAllLines方法的源码可以看到，readAllLines读取文件所有内容后，放到一个List&lt;String&gt;中返回，如果内存无法容纳这个List，就会OOM：

```
public static List&lt;String&gt; readAllLines(Path path, Charset cs) throws IOException {
    try (BufferedReader reader = newBufferedReader(path, cs)) {
        List&lt;String&gt; result = new ArrayList&lt;&gt;();
        for (;;) {
            String line = reader.readLine();
            if (line == null)
                break;
            result.add(line);
        }
        return result;
    }
}

```

那么，有没有办法实现按需的流式读取呢？比如，需要消费某行数据时再读取，而不是把整个文件一次性读取到内存？

当然有，解决方案就是File类的lines方法。接下来，我就与你说说使用lines方法时需要注意的一些问题。

## 使用Files类静态方法进行文件操作注意释放文件句柄

与readAllLines方法返回List&lt;String&gt;不同，lines方法返回的是Stream&lt;String&gt;。这，使得我们在需要时可以不断读取、使用文件中的内容，而不是一次性地把所有内容都读取到内存中，因此避免了OOM。

接下来，我通过一段代码测试一下。我们尝试读取一个1亿1万行的文件，文件占用磁盘空间超过4GB。如果使用-Xmx512m -Xms512m启动JVM控制最大堆内存为512M的话，肯定无法一次性读取这样的大文件，但通过Files.lines方法就没问题。

在下面的代码中，首先输出这个文件的大小，然后计算读取20万行数据和200万行数据的耗时差异，最后逐行读取文件，统计文件的总行数：

```
//输出文件大小
log.info(&quot;file size:{}&quot;, Files.size(Paths.get(&quot;test.txt&quot;)));
StopWatch stopWatch = new StopWatch();
stopWatch.start(&quot;read 200000 lines&quot;);
//使用Files.lines方法读取20万行数据
log.info(&quot;lines {}&quot;, Files.lines(Paths.get(&quot;test.txt&quot;)).limit(200000).collect(Collectors.toList()).size());
stopWatch.stop();
stopWatch.start(&quot;read 2000000 lines&quot;);
//使用Files.lines方法读取200万行数据
log.info(&quot;lines {}&quot;, Files.lines(Paths.get(&quot;test.txt&quot;)).limit(2000000).collect(Collectors.toList()).size());
stopWatch.stop();
log.info(stopWatch.prettyPrint());
AtomicLong atomicLong = new AtomicLong();
//使用Files.lines方法统计文件总行数
Files.lines(Paths.get(&quot;test.txt&quot;)).forEach(line-&gt;atomicLong.incrementAndGet());
log.info(&quot;total lines {}&quot;, atomicLong.get());

```

输出结果如下：

<img src="https://static001.geekbang.org/resource/image/29/77/29ee0fd687642ed87badaa96f9bdfd77.png" alt="">

可以看到，实现了全文件的读取、统计了整个文件的行数，并没有出现OOM；读取200万行数据耗时760ms，读取20万行数据仅需267ms。这些都可以说明，File.lines方法并不是一次性读取整个文件的，而是按需读取。

到这里，你觉得这段代码有什么问题吗？

问题在于读取完文件后没有关闭。我们通常会认为静态方法的调用不涉及资源释放，因为方法调用结束自然代表资源使用完成，由API释放资源，但对于Files类的一些返回Stream的方法并不是这样。这，是一个很容易被忽略的严重问题。

我就曾遇到过一个案例：程序在生产上运行一段时间后就会出现too many files的错误，我们想当然地认为是OS设置的最大文件句柄太小了，就让运维放开这个限制，但放开后还是会出现这样的问题。经排查发现，其实是文件句柄没有释放导致的，问题就出在Files.lines方法上。

我们来重现一下这个问题，随便写入10行数据到一个demo.txt文件中：

```
Files.write(Paths.get(&quot;demo.txt&quot;),
IntStream.rangeClosed(1, 10).mapToObj(i -&gt; UUID.randomUUID().toString()).collect(Collectors.toList())
, UTF_8, CREATE, TRUNCATE_EXISTING);

```

然后使用Files.lines方法读取这个文件100万次，每读取一行计数器+1：

```
LongAdder longAdder = new LongAdder();
IntStream.rangeClosed(1, 1000000).forEach(i -&gt; {
    try {
        Files.lines(Paths.get(&quot;demo.txt&quot;)).forEach(line -&gt; longAdder.increment());
    } catch (IOException e) {
        e.printStackTrace();
    }
});
log.info(&quot;total : {}&quot;, longAdder.longValue());	

```

运行后马上可以在日志中看到如下错误：

```
java.nio.file.FileSystemException: demo.txt: Too many open files
at sun.nio.fs.UnixException.translateToIOException(UnixException.java:91)
at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:102)
at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:107)

```

使用lsof命令查看进程打开的文件，可以看到打开了1万多个demo.txt：

```
lsof -p 63937
...
java    63902 zhuye *238r   REG                1,4      370         12934160647 /Users/zhuye/Documents/common-mistakes/demo.txt
java    63902 zhuye *239r   REG                1,4      370         12934160647 /Users/zhuye/Documents/common-mistakes/demo.txt
...

lsof -p 63937 | grep demo.txt | wc -l
   10007

```

**其实，在[JDK文档](https://docs.oracle.com/javase/8/docs/api/java/nio/file/Files.html)中有提到，注意使用try-with-resources方式来配合，确保流的close方法可以调用释放资源。**

这也很容易理解，使用流式处理，如果不显式地告诉程序什么时候用完了流，程序又如何知道呢，它也不能帮我们做主何时关闭文件。

修复方式很简单，使用try来包裹Stream即可：

```
LongAdder longAdder = new LongAdder();
IntStream.rangeClosed(1, 1000000).forEach(i -&gt; {
    try (Stream&lt;String&gt; lines = Files.lines(Paths.get(&quot;demo.txt&quot;))) {
        lines.forEach(line -&gt; longAdder.increment());
    } catch (IOException e) {
        e.printStackTrace();
    }
});
log.info(&quot;total : {}&quot;, longAdder.longValue());

```

修改后的代码不再出现错误日志，因为读取了100万次包含10行数据的文件，所以最终正确输出了1000万：

```
14:19:29.410 [main] INFO org.geekbang.time.commonmistakes.io.demo2.FilesStreamOperationNeedCloseApplication - total : 10000000

```

查看lines方法源码可以发现，Stream的close注册了一个回调，来关闭BufferedReader进行资源释放：

```
public static Stream&lt;String&gt; lines(Path path, Charset cs) throws IOException {
    BufferedReader br = Files.newBufferedReader(path, cs);
    try {
        return br.lines().onClose(asUncheckedRunnable(br));
    } catch (Error|RuntimeException e) {
        try {
            br.close();
        } catch (IOException ex) {
            try {
                e.addSuppressed(ex);
            } catch (Throwable ignore) {}
        }
        throw e;
    }
}

private static Runnable asUncheckedRunnable(Closeable c) {
    return () -&gt; {
        try {
            c.close();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    };
}

```

从命名上可以看出，使用BufferedReader进行字符流读取时，用到了缓冲。这里缓冲Buffer的意思是，使用一块内存区域作为直接操作的中转。

比如，读取文件操作就是一次性读取一大块数据（比如8KB）到缓冲区，后续的读取可以直接从缓冲区返回数据，而不是每次都直接对应文件IO。写操作也是类似。如果每次写几十字节到文件都对应一次IO操作，那么写一个几百兆的大文件可能就需要千万次的IO操作，耗时会非常久。

接下来，我就通过几个实验，和你说明使用缓冲Buffer的重要性，并对比下不同使用方式的文件读写性能，来帮助你用对、用好Buffer。

## 注意读写文件要考虑设置缓冲区

我曾遇到过这么一个案例，一段先进行文件读入再简单处理后写入另一个文件的业务代码，由于开发人员使用了单字节的读取写入方式，导致执行得巨慢，业务量上来后需要数小时才能完成。

我们来模拟一下相关实现。创建一个文件随机写入100万行数据，文件大小在35MB左右：

```
Files.write(Paths.get(&quot;src.txt&quot;),
IntStream.rangeClosed(1, 1000000).mapToObj(i -&gt; UUID.randomUUID().toString()).collect(Collectors.toList())
, UTF_8, CREATE, TRUNCATE_EXISTING);

```

当时开发人员写的文件处理代码大概是这样的：使用FileInputStream获得一个文件输入流，然后调用其read方法每次读取一个字节，最后通过一个FileOutputStream文件输出流把处理后的结果写入另一个文件。

为了简化逻辑便于理解，这里我们不对数据进行处理，直接把原文件数据写入目标文件，相当于文件复制：

```
private static void perByteOperation() throws IOException {
    try (FileInputStream fileInputStream = new FileInputStream(&quot;src.txt&quot;);
         FileOutputStream fileOutputStream = new FileOutputStream(&quot;dest.txt&quot;)) {
        int i;
        while ((i = fileInputStream.read()) != -1) {
            fileOutputStream.write(i);
        }
    }
}

```

这样的实现，复制一个35MB的文件居然耗时190秒。

**显然，每读取一个字节、每写入一个字节都进行一次IO操作，代价太大了**。解决方案就是，考虑使用缓冲区作为过渡，一次性从原文件读取一定数量的数据到缓冲区，一次性写入一定数量的数据到目标文件。

改良后，使用100字节作为缓冲区，使用FileInputStream的byte[]的重载来一次性读取一定字节的数据，同时使用FileOutputStream的byte[]的重载实现一次性从缓冲区写入一定字节的数据到文件：

```
private static void bufferOperationWith100Buffer() throws IOException {
    try (FileInputStream fileInputStream = new FileInputStream(&quot;src.txt&quot;);
         FileOutputStream fileOutputStream = new FileOutputStream(&quot;dest.txt&quot;)) {
        byte[] buffer = new byte[100];
        int len = 0;
        while ((len = fileInputStream.read(buffer)) != -1) {
            fileOutputStream.write(buffer, 0, len);
        }
    }
}

```

仅仅使用了100个字节的缓冲区作为过渡，完成35M文件的复制耗时缩短到了26秒，是无缓冲时性能的7倍；如果把缓冲区放大到1000字节，耗时可以进一步缩短到342毫秒。可以看到，**在进行文件IO处理的时候，使用合适的缓冲区可以明显提高性能**。

你可能会说，实现文件读写还要自己new一个缓冲区出来，太麻烦了，不是有一个BufferedInputStream和BufferedOutputStream可以实现输入输出流的缓冲处理吗？

是的，它们在内部实现了一个默认8KB大小的缓冲区。但是，在使用BufferedInputStream和BufferedOutputStream时，我还是建议你再使用一个缓冲进行读写，不要因为它们实现了内部缓冲就进行逐字节的操作。

接下来，我写一段代码比较下使用下面三种方式读写一个字节的性能：

- 直接使用BufferedInputStream和BufferedOutputStream；
- 额外使用一个8KB缓冲，使用BufferedInputStream和BufferedOutputStream；
- 直接使用FileInputStream和FileOutputStream，再使用一个8KB的缓冲。

```
//使用BufferedInputStream和BufferedOutputStream
private static void bufferedStreamByteOperation() throws IOException {
   try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(&quot;src.txt&quot;));
        BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(&quot;dest.txt&quot;))) {
        int i;
        while ((i = bufferedInputStream.read()) != -1) {
            bufferedOutputStream.write(i);
        }
    }
}
//额外使用一个8KB缓冲，再使用BufferedInputStream和BufferedOutputStream
private static void bufferedStreamBufferOperation() throws IOException {
    try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(&quot;src.txt&quot;));
         BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(&quot;dest.txt&quot;))) {
        byte[] buffer = new byte[8192];
        int len = 0;
        while ((len = bufferedInputStream.read(buffer)) != -1) {
            bufferedOutputStream.write(buffer, 0, len);
        }
    }
}
//直接使用FileInputStream和FileOutputStream，再使用一个8KB的缓冲
private static void largerBufferOperation() throws IOException {
    try (FileInputStream fileInputStream = new FileInputStream(&quot;src.txt&quot;);
        FileOutputStream fileOutputStream = new FileOutputStream(&quot;dest.txt&quot;)) {
        byte[] buffer = new byte[8192];
        int len = 0;
        while ((len = fileInputStream.read(buffer)) != -1) {
            fileOutputStream.write(buffer, 0, len);
        }
    }
}

```

结果如下：

```
---------------------------------------------
ns         %     Task name
---------------------------------------------
1424649223  086%  bufferedStreamByteOperation
117807808  007%  bufferedStreamBufferOperation
112153174  007%  largerBufferOperation

```

可以看到，第一种方式虽然使用了缓冲流，但逐字节的操作因为方法调用次数实在太多还是慢，耗时1.4秒；后面两种方式的性能差不多，耗时110毫秒左右。虽然第三种方式没有使用缓冲流，但使用了8KB大小的缓冲区，和缓冲流默认的缓冲区大小相同。

看到这里，你可能会疑惑了，既然这样使用BufferedInputStream和BufferedOutputStream有什么意义呢？

其实，这里我是为了演示所以示例三使用了固定大小的缓冲区，但在实际代码中每次需要读取的字节数很可能不是固定的，有的时候读取几个字节，有的时候读取几百字节，这个时候有一个固定大小较大的缓冲，也就是使用BufferedInputStream和BufferedOutputStream做为后备的稳定的二次缓冲，就非常有意义了。

最后我要补充说明的是，对于类似的文件复制操作，如果希望有更高性能，可以使用FileChannel的transfreTo方法进行流的复制。在一些操作系统（比如高版本的Linux和UNIX）上可以实现DMA（直接内存访问），也就是数据从磁盘经过总线直接发送到目标文件，无需经过内存和CPU进行数据中转：

```
private static void fileChannelOperation() throws IOException {
    FileChannel in = FileChannel.open(Paths.get(&quot;src.txt&quot;), StandardOpenOption.READ);
    FileChannel out = FileChannel.open(Paths.get(&quot;dest.txt&quot;), CREATE, WRITE);
    in.transferTo(0, in.size(), out);
}

```

你可以通过[这篇文章](https://developer.ibm.com/articles/j-zerocopy/)，了解transferTo方法的更多细节。

在测试FileChannel性能的同时，我再运行一下这一小节中的所有实现，比较一下读写35MB文件的耗时。

```
---------------------------------------------
ns         %     Task name
---------------------------------------------
183673362265  098%  perByteOperation
2034504694  001%  bufferOperationWith100Buffer
749967898  000%  bufferedStreamByteOperation
110602155  000%  bufferedStreamBufferOperation
114542834  000%  largerBufferOperation
050068602  000%  fileChannelOperation

```

可以看到，最慢的是单字节读写文件流的方式，耗时183秒，最快的是FileChannel.transferTo方式进行流转发的方式，耗时50毫秒。两者耗时相差达到3600倍！

## 重点回顾

今天，我通过三个案例和你分享了文件读写操作中最重要的几个方面。

第一，如果需要读写字符流，那么需要确保文件中字符的字符集和字符流的字符集是一致的，否则可能产生乱码。

第二，使用Files类的一些流式处理操作，注意使用try-with-resources包装Stream，确保底层文件资源可以释放，避免产生too many open files的问题。

第三，进行文件字节流操作的时候，一般情况下不考虑进行逐字节操作，使用缓冲区进行批量读写减少IO次数，性能会好很多。一般可以考虑直接使用缓冲输入输出流BufferedXXXStream，追求极限性能的话可以考虑使用FileChannel进行流转发。

最后我要强调的是，文件操作因为涉及操作系统和文件系统的实现，JDK并不能确保所有IO API在所有平台的逻辑一致性，代码迁移到新的操作系统或文件系统时，要重新进行功能测试和性能测试。

今天用到的代码，我都放在了GitHub上，你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。

## 思考与讨论

1. Files.lines方法进行流式处理，需要使用try-with-resources进行资源释放。那么，使用Files类中其他返回Stream包装对象的方法进行流式处理，比如newDirectoryStream方法返回DirectoryStream&lt;Path&gt;，list、walk和find方法返回Stream&lt;Path&gt;，也同样有资源释放问题吗？
1. Java的File类和Files类提供的文件复制、重命名、删除等操作，是原子性的吗？

对于文件操作，你还遇到过什么坑吗？我是朱晔，欢迎在评论区与我留言分享你的想法，也欢迎你把这篇文章分享给你的朋友或同事，一起交流。
