Подтвердить что ты не робот

Почему BufferedReader read() намного медленнее, чем readLine()?

Мне нужно прочитать файл по одному символу за раз, и я использую метод read() от BufferedReader. *

Я обнаружил, что read() примерно в 10 раз медленнее, чем readLine(). Ожидается ли это? Или я делаю что-то неправильно?

Здесь контрольный показатель с Java 7. Входной тестовый файл содержит около 5 миллионов строк и 254 миллиона символов (~ 242 МБ) **:

Метод 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) и длину строки после ее удаления. Мне также нужно знать, начинается ли строка с символа >. Для данного файла это делается только один раз в начале программы. Поскольку символы EOL не возвращаются BufferedReader.readLine(), я использую метод read(). Если есть лучшие способы сделать это, скажите, пожалуйста.

** Файл с gzipped находится здесь http://hgdownload.cse.ucsc.edu/goldenpath/hg19/chromosomes/chr1.fa.gz. Для тех, кому может быть интересно, я пишу класс для индексации файлов fasta.

4b9b3361

Ответ 1

Важная вещь при анализе производительности - иметь действительный ориентир перед началом работы. Поэтому давайте начнем с простого теста JMH, который показывает, какова ожидаемая производительность после разминки.

Одна вещь, которую мы должны учитывать, состоит в том, что, поскольку современные операционные системы любят кэшировать данные файла, к которым регулярно обращаются, нам нужен способ очистки кэшей между тестами. В Windows есть небольшая небольшая утилита которая делает именно это - в 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 секунд необходимы, чтобы очистить кеши для меня, и я запускаю это на жестком диске, поэтому, почему это намного медленнее, чем для вас):

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 есть один outlier:

# 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

Возможное решение

Теперь, когда у нас есть хорошая идея, какова фактическая проблема (по моему мнению, все эти блокировки не объединяются и не используют эффективную реализацию смещенных блокировок), решение довольно прямолинейно и просто: уменьшите количество вызовов функций (так что да, мы могли бы прийти к этому решению без всего выше, но всегда приятно хорошо разбираться в этой проблеме, и, возможно, было решение, которое не связано с изменением кода).

Следующий код работает последовательно быстрее, чем любой из двух других - вы можете играть с размером массива, но это удивительно неважно (по-видимому, потому что вопреки другим методам 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;
} 

Это, скорее всего, достаточно хорошая производительность, но если вы хотите улучшить производительность еще больше, используя сопоставление файлов, возможно, на слишком большом улучшении в таком случае, но если вы знаете, что ваш текст всегда ASCII, вы можете сделать некоторые дальнейшие оптимизации), то дальнейшая поддержка производительности.

Ответ 2

Спасибо @Voo за исправление. То, что я упомянул ниже, является правильным с точки зрения FileReader#read() v/s BufferedReader#readLine(), но не корректно с точки зрения BufferedReader#read() v/s BufferedReader#readLine(), поэтому я удалил ответ.

Использование метода read() на BufferedReader не является хорошей идеей, это не причинит вам никакого вреда, но, конечно же, это отвлекает цель класса.

Целая жизнь в BufferedReader заключается в уменьшении ввода-вывода путем буферизации содержимого. Вы можете прочитать здесь в учебниках по Java. Вы также можете заметить, что метод read() в BufferedReader фактически унаследован от Reader, а readLine() - BufferedReader собственный метод.

Если вы хотите использовать метод read(), тогда я бы сказал, что лучше использовать FileReader, который предназначен для этой цели. Вы можете прочитать здесь, в учебниках Java.

Итак, Я думаю, что ответ на ваш вопрос очень прост (не вдаваясь в настольную маркировку и все эти объяснения) -

  • Каждый read() обрабатывается базовой ОС и запускает доступ к диску, сетевую активность или некоторые другие операции, которые относительно дороги.
  • Когда вы используете readLine(), вы сохраняете все эти накладные расходы, поэтому readLine() всегда будет быстрее, чем read(), может быть не существенным для небольших данных, но быстрее.

Ответ 3

Не удивительно видеть эту разницу, если вы думаете об этом. Один тест - это повторение строк в текстовом файле, а другое - итерации символов.

Если каждая строка содержит один символ, ожидается, что readLine() будет быстрее, чем метод read() (хотя, как отмечалось выше, это можно утверждать, поскольку BufferedReader буферизует ввод, тогда как чтение физического файла может быть не единственной операцией по выполнению операций)

Если вы действительно хотите проверить разницу между 2, я бы предложил установку, в которой вы выполняете итерацию по каждому символу в обоих тестах. Например. что-то вроде:

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" для сравнения вашего кода. Кроме того, прочитайте как микрофискать код Java.

Ответ 4

Итак, это практический ответ на мой собственный вопрос: вместо использования 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 мс для чтения всего файла char на char эта стратегия значительно быстрее, чем BufferedReader.readLine() (~ 700 мс), не говоря уже о read(). Добавление, если инструкции в цикле для проверки x == '\n' и x == '>' не имеют большого значения. Кроме того, установка StringBuilder для восстановления строк не слишком сильно влияет на время. Так что это очень полезно для меня (по крайней мере пока).

Спасибо @Marco13 за упоминание FileChannel.

Ответ 5

Java JIT оптимизирует пропущенные тела пустого тела, поэтому ваши циклы на самом деле выглядят так:

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

и

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

Я предлагаю вам ознакомиться с бенчмаркингом здесь и оптимизировать циклы здесь.


В связи с тем, что время отличается:

  • Причина одна (это применимо только в том случае, если тела циклов содержат код): В первом примере вы выполняете одну операцию на строку, во втором - делая один за символ. Это добавляет больше строк/символов, которые у вас есть.

    while((c = fa.read()) != -1){
        //One operation per character.
    }
    
    while((line = fa.readLine()) != null){
        //One operation per line.
    }
    
  • Причина вторая: В классе BufferedReader метод readLine() не использует read() за кулисами - он использует свой собственный код. Метод readLine() делает меньше операций для каждого символа для чтения строки, чем требуется для чтения строки с помощью метода read() - вот почему readLine() работает быстрее при чтении всего файла.

  • Причина три:. Для чтения каждого символа требуется больше итераций, чем чтение каждой строки (если только каждый символ не находится в новой строке); read() вызывается больше раз, чем readLine().