为什么 BufferedReader read() 比 readLine() 慢很多?

Why is BufferedReader read() much slower than readLine()?

我需要一次读取一个文件的一个字符,我正在使用 BufferedReader 中的 read() 方法。 *

我发现 read()readLine() 慢 10 倍左右。这是预期的吗?还是我做错了什么?

这是一个基准 Java 7。输入测试文件有大约 500 万行和 2.54 亿个字符(~242 MB)**:

read()方法读取所有字符大约需要7000毫秒:

@Test
public void testRead() throws IOException, UnindexableFastaFileException{

    BufferedReader fa= new BufferedReader(new FileReader(new File("chr1.fa")));

    long t0= System.currentTimeMillis();
    int c;
    while( (c = fa.read()) != -1 ){
        //
    }
    long t1= System.currentTimeMillis();
    System.err.println(t1-t0); // ~ 7000 ms

}

readLine() 方法只需要 ~700 毫秒:

@Test
public void testReadLine() throws IOException{

    BufferedReader fa= new BufferedReader(new FileReader(new File("chr1.fa")));

    String line;
    long t0= System.currentTimeMillis();
    while( (line = fa.readLine()) != null ){
        //
    }
    long t1= System.currentTimeMillis();
    System.err.println(t1-t0); // ~ 700 ms
}

* 实用目的:我需要知道每一行的长度,包括换行符(\n\r\n)和行长剥离它们之后。我还需要知道一行是否以 > 字符开头。对于给定的文件,这只在程序开始时完成一次。由于 BufferedReader.readLine() 不返回 EOL 字符,因此我求助于 read() 方法。如果有更好的方法,请说。

** gzip 文件在这里 http://hgdownload.cse.ucsc.edu/goldenpath/hg19/chromosomes/chr1.fa.gz。对于那些可能想知道的人,我正在写一个 class 来索引 fasta 文件。

如果你仔细想想,看到这种差异并不奇怪。一个测试是迭代文本文件中的行,而另一个是迭代字符。

除非每行包含一个字符,否则 readLine()read() 方法快得多。(尽管如上面的评论所指出的,这是有争议的,因为 BufferedReader缓冲输入,而物理文件读取可能不是唯一的性能操作)

如果你真的想测试两者之间的区别,我建议你在两个测试中迭代每个字符的设置。例如。类似于:

void readTest(BufferedReader r)
{
    int c;
    StringBuilder b = new StringBuilder();
    while((c = r.read()) != -1)
        b.append((char)c);
}

void readLineTest(BufferedReader r)
{
    String line;
    StringBuilder b = new StringBuilder();
    while((line = b.readLine())!= null)
        for(int i = 0; i< line.length; i++)
            b.append(line.charAt(i));
}

除上述之外,请使用 "Java performance diagnostic tool" 对您的代码进行基准测试。此外,阅读 how to microbenchmark java code

Java JIT 优化空循环体,所以你的循环实际上看起来像这样:

while((c = fa.read()) != -1);

while((line = fa.readLine()) != null);

我建议您阅读基准测试 here and the optimization of the loops here


至于为什么花费的时间不同:

  • 原因一(这仅适用于循环体包含代码的情况):在第一个示例中,您每行执行一个操作,第二,你为每个角色做一个。这加起来越多lines/characters。

    while((c = fa.read()) != -1){
        //One operation per character.
    }
    
    while((line = fa.readLine()) != null){
        //One operation per line.
    }
    
  • 原因二:在classBufferedReader中,方法readLine()没有使用read() 在幕后 - 它使用自己的代码。与使用 read() 方法读取一行相比,readLine() 方法读取一行每个字符的操作更少 - 这就是为什么 readLine() 读取整个文件更快的原因。

  • 原因三:读取每个字符比读取每一行需要更多的迭代(除非每个字符都在新行上); read()readLine().

  • 被调用的次数多

分析性能时重要的是在开始之前有一个有效的基准。因此,让我们从一个简单的 JMH 基准测试开始,它显示了我们在预热后的预期性能。

我们必须考虑的一件事是,由于现代操作系统喜欢缓存定期访问的文件数据,我们需要某种方法来清除测试之间的缓存。在 Windows 上有一个小实用程序 that does just this - 在 Linux 上你应该可以通过在某处写入一些伪文件来完成它。

代码如下所示:

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Mode;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
public class IoPerformanceBenchmark {
    private static final String FILE_PATH = "test.fa";

    @Benchmark
    public int readTest() throws IOException, InterruptedException {
        clearFileCaches();
        int result = 0;
        try (BufferedReader reader = new BufferedReader(new FileReader(FILE_PATH))) {
            int value;
            while ((value = reader.read()) != -1) {
                result += value;
            }
        }
        return result;
    }

    @Benchmark
    public int readLineTest() throws IOException, InterruptedException {
        clearFileCaches();
        int result = 0;
        try (BufferedReader reader = new BufferedReader(new FileReader(FILE_PATH))) {
            String line;
            while ((line = reader.readLine()) != null) {
                result += line.chars().sum();
            }
        }
        return result;
    }

    private void clearFileCaches() throws IOException, InterruptedException {
        ProcessBuilder pb = new ProcessBuilder("EmptyStandbyList.exe", "standbylist");
        pb.inheritIO();
        pb.start().waitFor();
    }
}

如果我们 运行 它与

chcp 65001 # set codepage to utf-8
mvn clean install; java "-Dfile.encoding=UTF-8" -server -jar .\target\benchmarks.jar

我们得到以下结果(为我清除缓存大约需要 2 秒,我在 HDD 上 运行这就是它比你慢很多的原因):

Benchmark                            Mode  Cnt  Score   Error  Units
IoPerformanceBenchmark.readLineTest  avgt   20  3.749 ± 0.039   s/op
IoPerformanceBenchmark.readTest      avgt   20  3.745 ± 0.023   s/op

惊喜!正如预期的那样,在 JVM 进入稳定模式后,这里根本没有性能差异。但是 readCharTest 方法中有一个异常值:

# Warmup Iteration   1: 6.186 s/op
# Warmup Iteration   2: 3.744 s/op

这正是您遇到的问题。我能想到的最可能的原因是 OSR 在这里做得不好,或者 JIT 只是 运行 来不及在第一次迭代中产生影响。

根据您的用例,这可能是一个大问题或可以忽略不计(如果您正在阅读一千个文件,那没关系,如果您只阅读一个,这就是一个问题)。

解决这样的问题并不容易,虽然有办法解决,但没有通用的解决方案。一个简单的测试来查看我们是否在正确的轨道上是 运行 带有 -Xcomp 选项的代码,它强制 HotSpot 在第一次调用时编译每个方法。确实这样做会导致第一次调用时的大延迟消失:

# Warmup Iteration   1: 3.965 s/op
# Warmup Iteration   2: 3.753 s/op

可能的解决方案

现在我们很清楚实际的问题是什么(我的猜测仍然是所有这些锁既没有被合并也没有使用有效的偏向锁实现),解决方案相当直接和简单:减少函数调用(所以是的,我们可以在没有上述所有内容的情况下得出这个解决方案,但是很好地掌握这个问题总是很好的,并且可能有一个不涉及更改太多代码的解决方案)。

以下代码 运行s 始终比其他两个代码中的任何一个都快 - 您可以使用数组大小​​但它出奇地不重要(大概是因为与其他方法相反 read(char[]) 没有获取锁,以便每次调用的成本较低。

private static final int BUFFER_SIZE = 256;
private char[] arr = new char[BUFFER_SIZE];

@Benchmark
public int readArrayTest() throws IOException, InterruptedException {
    clearFileCaches();
    int result = 0;
    try (BufferedReader reader = new BufferedReader(new FileReader(FILE_PATH))) {
        int charsRead;
        while ((charsRead = reader.read(arr)) != -1) {
            for (int i = 0; i < charsRead; i++) {
                result += arr[i];
            }
        }
    }
    return result;
} 

这很可能在性能方面已经足够好了,但是如果您想使用 file mapping 进一步提高性能(在这种情况下不会指望有太大的改进,但是如果你知道你的文本总是 ASCII,你可以做一些进一步的优化)进一步提高性能。

感谢@Voo 的指正。我在下面提到的从 FileReader#read() v/s BufferedReader#readLine() 的角度来看是正确的,但从 BufferedReader#read() v/s BufferedReader#readLine() 的角度来看是不正确的,所以我已划掉答案。

BufferedReader上使用read()方法不是一个好主意,它不会对您造成任何伤害,但肯定会浪费class的目的。

BufferedReader 生活的全部目的是通过缓冲内容来减少 i/o。您可以在 Java 教程中阅读 here。您可能还注意到 BufferedReader 中的 read() 方法实际上是从 Reader 继承的,而 readLine()BufferedReader 自己的方法。

如果你想使用 read() 方法,那么我会说你最好使用 FileReader,这是为了这个目的。您可以在 Java 教程中 read 这里。

所以,我认为你的问题的答案非常简单(无需进行基准测试和所有解释)-

  • 每个 read() 都由底层 OS 处理并触发磁盘访问、网络 activity 或其他一些相对昂贵的操作。
  • 当你使用 readLine() 时,你就节省了所有这些开销,所以 readLine() 总是比 read() 快,对于小数据可能不是很大但更快。

所以这是对我自己的问题的 实用 答案:不要使用 BufferedReader.read(),而是使用 FileChannel。 (显然我没有回答我在标题中输入的原因)。这是快速而肮脏的基准测试,希望其他人会发现它有用:

@Test
public void testFileChannel() throws IOException{

    FileChannel fileChannel = FileChannel.open(Paths.get("chr1.fa"));
    long n= 0;
    int noOfBytesRead = 0;

    long t0= System.nanoTime();

    while(noOfBytesRead != -1){
        ByteBuffer buffer = ByteBuffer.allocate(10000);
        noOfBytesRead = fileChannel.read(buffer);
        buffer.flip();
        while ( buffer.hasRemaining() ) {
            char x= (char)buffer.get();
            n++;
        }
    }
    long t1= System.nanoTime();
    System.err.println((float)(t1-t0) / 1e6); // ~ 250 ms
    System.err.println("nchars: " + n); // 254235640 chars read
}

用 ~250 毫秒逐字符读取整个文件,此策略比 BufferedReader.readLine()(~700 毫秒)快得多,更不用说 read() 了。在循环中添加 if 语句来检查 x == '\n'x == '>' 几乎没有区别。另外放置 StringBuilder 来重建线条不会对时间产生太大影响。所以这对我来说很好(至少现在是这样)。

感谢@Marco13 提到 FileChannel。

根据文档:

每个 read() 方法调用都会进行一次昂贵的系统调用。

每个 readLine() 方法调用仍然会进行一次昂贵的系统调用,但是,一次要处理更多的字节,因此调用次数会更少。

当我们为要更新的每条记录发出数据库 update 命令时会发生类似的情况,而不是批量更新,我们对所有记录发出一次调用。