Day638.IO文件操作问题 -Java业务开发常见错误

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day638.IO文件操作问题 -Java业务开发常见错误相关的知识,希望对你有一定的参考价值。

IO文件操作问题

Hi,阿昌来也,今天学习记录的是关于Java针对IO文件操作可能会出现的问题:编码不一致、资源关闭、缓冲区。

如果你对文件操作相关的 API 不够熟悉,可以查看官方API地址


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

一个案例,需要读取三方的对账文件定时对账,原先一直是单机处理的,没什么问题。

后来为了提升性能,使用双节点同时处理对账,每一个节点处理部分对账数据,但新增的节点在处理文件中中文的时候总是读取到乱码。程序代码都是一致的,为什么老节点就不会有问题呢?

我们知道,这很可能是写代码时没有注意编码问题导致的。

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

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

输出如下:

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

虽然我们打开文本文件时看到的是“你好 hi”,但不管是什么文字,计算机中都是按照一定的规则将其以二进制保存的。

这个规则就是字符集,字符集枚举了所有支持的字符映射成二进制的映射表。在处理文件读写的时候,如果是在字节层面进行操作,那么不会涉及字符编码问题;

而如果需要在字符层面进行读写的话,就需要明确字符的编码方式也就是字符集了。

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

char[] chars = new char[10];
String content = "";
try (FileReader fileReader = new FileReader("hello.txt")) 
    int count;
    while ((count = fileReader.read(chars)) != -1) 
        content += new String(chars, 0, count);
    

log.info("result:", content);

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

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

显然,这里并没有指定以什么字符集来读取文件中的字符。查看JDK 文档可以发现,FileReader 是以当前机器的默认字符集来读取文件的,如果希望指定字符集的话,需要直接使用 InputStreamReaderFileInputStream

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

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

log.info("charset: ", Charset.defaultCharset());
Files.write(Paths.get("hello2.txt"), "你好hi".getBytes(Charsets.UTF_8));
log.info("bytes:", Hex.encodeHexString(Files.readAllBytes(Paths.get("hello2.txt"))).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 = "";
    try (FileInputStream fileInputStream = new FileInputStream("hello.txt");
        InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, Charset.forName("GBK"))) 
        int count;
        while ((count = inputStreamReader.read(chars)) != -1) 
            content += new String(chars, 0, count);
        
    
    log.info("result: ", content);

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

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

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

log.info("result: ", Files.readAllLines(Paths.get("hello.txt"), Charset.forName("GBK")).stream().findFirst().orElse(""));

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


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

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

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

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


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

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

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

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

//输出文件大小
log.info("file size:", Files.size(Paths.get("test.txt")));
StopWatch stopWatch = new StopWatch();
stopWatch.start("read 200000 lines");

//使用Files.lines方法读取20万行数据
log.info("lines ", Files.lines(Paths.get("test.txt")).limit(200000).collect(Collectors.toList()).size());
stopWatch.stop();
stopWatch.start("read 2000000 lines");

//使用Files.lines方法读取200万行数据
log.info("lines ", Files.lines(Paths.get("test.txt")).limit(2000000).collect(Collectors.toList()).size());
stopWatch.stop();
log.info(stopWatch.prettyPrint());
AtomicLong atomicLong = new AtomicLong();

//使用Files.lines方法统计文件总行数
Files.lines(Paths.get("test.txt")).forEach(line->atomicLong.incrementAndGet());
log.info("total lines ", atomicLong.get());

输出结果如下:

可以看到,实现了全文件的读取、统计了整个文件的行数,并没有出现 OOM;

读取 200 万行数据耗时 760ms,读取 20 万行数据仅需 267ms。这些都可以说明,File.lines 方法并不是一次性读取整个文件的,而是按需读取。到这里,你觉得这段代码有什么问题吗?

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

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

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

Files.write(Paths.get("demo.txt"),
IntStream.rangeClosed(1, 10).mapToObj(i -> 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 -> 
    try 
        Files.lines(Paths.get("demo.txt")).forEach(line -> longAdder.increment());
     catch (IOException e) 
        e.printStackTrace();
    
);
log.info("total : ", 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 文档中有提到,注意使用 try-with-resources 方式来配合,确保流的 close 方法可以调用释放资源。

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

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

LongAdder longAdder = new LongAdder();
IntStream.rangeClosed(1, 1000000).forEach(i -> 
    try (Stream<String> lines = Files.lines(Paths.get("demo.txt"))) 
        lines.forEach(line -> longAdder.increment());
     catch (IOException e) 
        e.printStackTrace();
    
);
log.info("total : ", 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<String> 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 () -> 
        try 
            c.close();
         catch (IOException e) 
            throw new UncheckedIOException(e);
        
    ;

从命名上可以看出,使用 BufferedReader 进行字符流读取时,用到了缓冲。

这里缓冲 Buffer 的意思是,使用一块内存区域作为直接操作的中转。比如,读取文件操作就是一次性读取一大块数据(比如 8KB)到缓冲区,后续的读取可以直接从缓冲区返回数据,而不是每次都直接对应文件 IO。写操作也是类似。如果每次写几十字节到文件都对应一次 IO 操作,那么写一个几百兆的大文件可能就需要千万次的 IO 操作,耗时会非常久。

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


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

一段先进行文件读入再简单处理后写入另一个文件的业务代码,由于使用了单字节的读取写入方式,导致执行得巨慢,业务量上来后需要数小时才能完成。来模拟一下相关实现。

创建一个文件随机写入 100 万行数据,文件大小在 35MB 左右:

Files.write(Paths.get("src.txt"),
IntStream.rangeClosed(1, 1000000).mapToObj(i -> 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("src.txt");
         FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) 
        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("src.txt");
         FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) 
        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 以上是关于Day638.IO文件操作问题 -Java业务开发常见错误的主要内容,如果未能解决你的问题,请参考以下文章

day08(文件操作)

Python之路day08-文件读写操作_函数初识

Pthon教程Day14-文件操作

day3 文件操作

Day 09 文件处理

day03-文件操作