1. 概述

本教程将演示如何更高效地使用Java读取大文件

本文是《Java - 回归基础教程》系列教程之一。

2. 一次性读取所有行

一般做法是将文件中的所有行都读到内存中,我们可以使用Guava和Apache Commons IO提供的API快速实现:

Files.readLines(new File(path), Charsets.UTF_8);

FileUtils.readLines(new File(path));

问题在于,这种方式会把文件中的所有行都保存在内存中,如果文件过大,会导致OutOfMemoryError内存溢出问题。

例如 – 读取约 1GB 大小的文件:

@Test
public void givenUsingGuava_whenIteratingAFile_thenWorks() throws IOException {
    String path = ...
    Files.readLines(new File(path), Charsets.UTF_8);
}

开始前有少量内存消耗: (~0 Mb 消耗)

[main] INFO  org.baeldung.java.CoreJavaIoUnitTest - Total Memory: 128 Mb
[main] INFO  org.baeldung.java.CoreJavaIoUnitTest - Free Memory: 116 Mb

但,全部处理完后: (~2 Gb 消耗)

[main] INFO  org.baeldung.java.CoreJavaIoUnitTest - Total Memory: 2666 Mb
[main] INFO  org.baeldung.java.CoreJavaIoUnitTest - Free Memory: 490 Mb

约2.1 Gb的内存被消耗,因为文件中的所有行都被保存到内存中了,这会导致内存很快被消完。

很明显,将文件中的所有内容都读到内存中,会很快耗尽可用内存,不管实际内存是多少。

事实上,我们不需要将文件中的内容一次性都读到内存中,我们只需要能够遍历每行,处理完毕之后就可以丢弃。 因此,下面我们将遍历每一行,而不是将所有行都保存到内存中。

3. 文件流

下面我们介绍几种按位读取文件的方法。

3.1 使用 Scanner

使用java.util.Scanner 逐行遍历文件内容:

FileInputStream inputStream = null;
Scanner sc = null;
try {
    inputStream = new FileInputStream(path);
    sc = new Scanner(inputStream, "UTF-8");
    while (sc.hasNextLine()) {
        String line = sc.nextLine();
        // System.out.println(line);
    }
    // note that Scanner suppresses exceptions
    if (sc.ioException() != null) {
        throw sc.ioException();
    }
} finally {
    if (inputStream != null) {
        inputStream.close();
    }
    if (sc != null) {
        sc.close();
    }
}

我们使用这种方式,我们一行一行的遍历并处理,无需把所有行都保存到内存中:(~150 Mb 消耗)

[main] INFO  org.baeldung.java.CoreJavaIoUnitTest - Total Memory: 763 Mb
[main] INFO  org.baeldung.java.CoreJavaIoUnitTest - Free Memory: 605 Mb

3.2 使用 BufferedReader

另一种方案是使用 BufferedReader 类实现。

try (BufferedReader br = new BufferedReader(new FileReader(fileName))) {
    while (br.readLine() != null) {
        // do something with each line
    }
}

BufferedReader 通过逐块读取文件并将这些块缓存在内部缓冲区中来减少I/O操作次数。

与 Scanner 相比,它表现出更好的性能,因为它只关注数据读取而不进行解析。

3.3 使用 Files.newBufferedReader()

我们也可通过 Files.newBufferedReader() 实现

try (BufferedReader br = java.nio.file.Files.newBufferedReader(Paths.get(fileName))) {
    while (br.readLine() != null) {
        // do something with each line
    }
}

3.4 使用 SeekableByteChannel

SeekableByteChannel 提供读取和操作文件的管道。它比标准 I/O 类具有更快的性能,因为它由自动调整大小的字节数组支持。

try (SeekableByteChannel ch = java.nio.file.Files.newByteChannel(Paths.get(fileName), StandardOpenOption.READ)) {
    ByteBuffer bf = ByteBuffer.allocate(1000);
    while (ch.read(bf) > 0) {
        bf.flip();
        // System.out.println(new String(bf.array()));
        bf.clear();
    }
}

如上所示,使用 read() 方法将字节读入 ByteBuffer 表示的缓冲区中。

flip() 方法使缓冲区再次准备好进行写入。clear()重置并清除缓冲区。

这种方法的唯一缺点是我们需要使用 allocate() 方法明确指定缓冲区大小。

3.5 使用 Stream

Files.lines() 返回一个 String 流

try (Stream<String> lines = java.nio.file.Files.lines(Paths.get(fileName))) {
    lines.forEach(line -> {
        // do something with each line
    });
}

注意文件是懒加载方式进行处理的,即只有一部分内容存储在内存中。

4. Apache Commons IO 流

同样,我们可以利用Commons IO库提供的lineIterator方法,实现逐行遍历:

LineIterator it = FileUtils.lineIterator(theFile, "UTF-8");
try {
    while (it.hasNext()) {
        String line = it.nextLine();
        // do something with line
    }
} finally {
    LineIterator.closeQuietly(it);
}

同样,因为没有把整个文件内容都保存到内存中,所以内存消耗也很低:(~150 Mb 消耗)

[main] INFO  o.b.java.CoreJavaIoIntegrationTest - Total Memory: 752 Mb
[main] INFO  o.b.java.CoreJavaIoIntegrationTest - Free Memory: 564 Mb

5. 总结

本文演示了如何在不消耗所有可用内存情况下,读取大文件。

文中所有示例和代码片段源码,可以从GitHub上获取。