Производительность операций ввода/вывода в Java
Содержание
1 Введение
2 Временные затраты на открытие файла, его чтение и закрытие
3 Производительность и чтение байтов из файла
4 Подсчет количества строк текстового файла
Введение
В єтой статье рассматриваются некоторые вопросы и приемы по улучшению производительности операций ввода/вывода в Java. Это сложная тема, поэтому в статье приведены только некоторые ключевые моменты.
Первое, что нужно понимать, - что некоторые факторы, которые могут значительно влиять на производительность ввода/вывода, существуют главным образом вне Java-приложений. Одним из таких факторов является кэширование файлов в операционной системе. При чтении файла с диска операционная система часто сохраняет копию файла в оперативной памяти. Это выполняется для того, чтобы при втором чтении файла не потребовался бы доступ к диску. Этот прием может привести к существенному увеличению быстродействия, и вы должны быть внимательны и делать поправку на этот фактор при анализе производительности ваших Java-приложений. Это один из примеров того, когда добавление дополнительной памяти часто улучшает производительность приложения, даже если приложение не нуждается в памяти для своих внутренних структур данных.
Еще одним фактором производительности является фрагментация файла на диске, когда части файла разбросаны по всему диску. Современные дисковые накопители требуют 10-15 миллисекунд для перемещения из одной части поверхности диска к другой (время поиска). Это длительное время, особенно по сравнению со сверхскоростным процессором. Та же тема возникает при использовании java.io.RandomAccessFile с большой базой данных, которая содержит информацию, распределенную по всему диску. Еще один пример - копирование одного файла в другой, когда файлы размещены друг от друга далеко, так что дисковые головки должны периодически двигаться вперед и назад.
В примере этой статье не будут продемонстрированы вопросы кэширования или времени поиска, но эти темы являются чрезвычайно важными и стоят внимания при разработке Java-приложений.
Рассмотрим некоторый код. Первая тема для демонстрации - стоимость перечисления и открытия файлов. Предположим, что вы пишете приложение, осуществляющее поиск по всем файлам в структуре каталога. Предположим также, что, как часть этого проекта, вам необходимо написать некоторый код, создающий список имен путей, основываясь на стартовой точке каталога. Код может выглядеть примерно так:
import java.io.*;
import java.util.*;
class MyTimer {
private final long start;
public MyTimer() {
start = System.currentTimeMillis();
}
public long getElapsed() {
return System.currentTimeMillis() - start;
}
}
public class IODemo1 {
// рекурсивный метод, который проходит
// по структуре каталога
static void getFileList2(File f, List list) {
if (f.isDirectory()) {
String entries[] = f.list();
int maxlen = (entries == null ? 0 : entries.length);
for (int i = 0; i < maxlen; i++) {
getFileList2(new File(f, entries[i]), list);
}
} else if (f.isFile()) {
list.add(f.getPath());
}
}
// метод верхнего уровня для получения списка названий путей,
// начиная с указанной точки в структуре каталоге
static List getFileList(String fn) {
List list = new ArrayList();
getFileList2(new File(fn), list);
return list;
}
public static void main(String args[]) {
MyTimer mt = new MyTimer();
List list = getFileList(args[0]);
// for (int i = 0; i < list.size(); i++) {
// System.out.println(list.get(i));
// }
System.out.println("number of files = " + list.size());
System.out.println("microseconds per file = " + mt.getElapsed() * 1000
/ list.size());
}
}
Если вы захотите увидеть полученный список названий путей, необходимо раскомментировать три строки в IODemo1, начиная с выражения for (int i = 0; ...). Выполнив эту программу, например, для Windows при помощи команды
java IODemo1 C:\\
вы получите результат, который будет выглядеть примерно так
number of files = 11298
microseconds per file = 525
при первом запуске, и примерно так при втором
number of files = 11298
microseconds per file = 157
Расхождение во времени происходит из-за эффектов кэширования, упомянутых выше.
В результате этого примера указывается, что тратится около 500 микросекунд на каждое название пути для перечисления набора файлов, или около 2000 файлов в секунду. То есть, если вы пишете приложение и просматриваете структуру каталога, состоящего из 100000 файлов, только перечисление файлов займет около 50 секунд. Это время отличается от времени фактического открытия и чтения файлов.
Временные затраты на открытие файла, его чтение и закрытие
Вот еще один пример. В нем измеряется время, требуемое для периодического открытия файла, чтения файла и закрытия файла:
import java.io.*;
class MyTimer {
private final long start;
public MyTimer() {
start = System.currentTimeMillis();
}
public long getElapsed() {
return System.currentTimeMillis() - start;
}
}
public class IODemo2 {
static final int N = 100000;
static final String TESTFILE = "testfile";
public static void main(String args[]) throws IOException {
// создать файл и записать в него один байт
FileOutputStream fos = new FileOutputStream(TESTFILE);
fos.write('A');
fos.close();
// периодически открывать файл и
// читать байт из него.
MyTimer mt = new MyTimer();
for (int i = 1; i <= N; i++) {
FileInputStream fis = new FileInputStream(TESTFILE);
fis.read();
fis.close();
}
System.out.println("microseconds per open/read/close = "
+ mt.getElapsed() * 1000 / N);
}
}
Результат выполнения программы должен выглядеть примерно так:
microseconds per open/read/close = 101
Этот результат означает около 10000 операций открытия/чтения/закрытия в секунду.
Трудно дать простой совет о том, как улучшить производительность примеров IODemo1 и IODemo2. Для примера IODemo1 вы могли бы выполнить перечисление один раз и сохранить результат в кэше. Этот подход может работать в случаях, когда содержимое каталога не меняется. Для примера IODemo2 очевидный подход - держать файлы открытыми вместо периодического выполнения открытий и закрытий.
В действительности операции ввода/вывода больше зависят от изменений в архитектуре, чем от применения приемов повышения производительности. Например, если у вас имеется 100000 маленьких файлов и нужно выполнить поиск в них, почему бы не изменить архитектуру и использовать 1000 больших файлов?
Производительность и чтение байтов из файла
Рассмотрим вопросы производительности, связанные с чтением байтов из файла. Предположим, что вы имеете файл, содержащий последовательность байтов ABABAB..., и вы хотите подсчитать количество байтов "A". Какой самый быстрый способ сделать это? Вот программа, демонстрирующая три подхода:
import java.io.*;
class MyTimer {
private final long start;
public MyTimer() {
start = System.currentTimeMillis();
}
public long getElapsed() {
return System.currentTimeMillis() - start;
}
}
public class IODemo3 {
static final int N = 1000000;
static final String TESTFILE = "testfile";
static int cnt1, cnt2, cnt3;
static void write() throws IOException {
// записать последовательность байтов ABABAB... в файл
FileOutputStream fos = new FileOutputStream(TESTFILE);
boolean flip = false;
for (int i = 1; i <= N; i++) {
fos.write(flip ? 'B' : 'A');
flip = !flip;
}
fos.close();
}
static void read1() throws IOException {
// прочитать байты из файла по одному за один раз
MyTimer mt = new MyTimer();
FileInputStream fis = new FileInputStream(TESTFILE);
cnt1 = 0;
int c;
while ((c = fis.read()) != -1) {
if (c == 'A') {
cnt1++;
}
}
fis.close();
System.out.println("read1 time = " + mt.getElapsed());
}
static void read2() throws IOException {
// прочитать байты из файла по одному за один раз,
// используя буферизацию
MyTimer mt = new MyTimer();
FileInputStream fis = new FileInputStream(TESTFILE);
BufferedInputStream bis = new BufferedInputStream(fis);
cnt2 = 0;
int c;
while ((c = bis.read()) != -1) {
if (c == 'A') {
cnt2++;
}
}
fis.close();
System.out.println("read2 time = " + mt.getElapsed());
}
static void read3() throws IOException {
// прочитать данные из файла, используя буферизацию
// и прямой доступ к буферу
MyTimer mt = new MyTimer();
FileInputStream fis = new FileInputStream(TESTFILE);
cnt3 = 0;
final int BUFSIZE = 1024;
byte buf[] = new byte[BUFSIZE];
int len;
while ((len = fis.read(buf)) != -1) {
for (int i = 0; i < len; i++) {
if (buf[i] == 'A') {
cnt3++;
}
}
}
fis.close();
System.out.println("read3 time = " + mt.getElapsed());
}
public static void main(String args[]) throws IOException {
// записать тестовый файлwrite the test file
write();
// прочитать файл первый раз
read1();
read2();
read3();
// проверка правильности
if (cnt1 != cnt2 || cnt2 != cnt3) {
System.out.println("cnt1/cnt2/cnt3 mismatch");
}
// прочитать файл второй раз
read1();
read2();
read3();
}
}
После выполнения программы вы должны увидеть примерно следующий результат:
read1 time = 4063
read2 time = 140
read3 time = 31
read1 time = 4079
read2 time = 140
read3 time = 16
Программа выполняет все дважды, чтобы убедиться в том, что на ее работу не влияет кэширование файлов операционной системой.
Метод read1 открывает FileInputStream и периодически вызывает метод read. Метод read2 работает так же, но создает буфер для потока ввода. Метод read2 выполняется значительно быстрее метода read1 - в данном случае примерно в 25 раз. Почему? Причина в том, что FileInputStream.read реализован как "родной" метод и вызывает встроенную процедуру для каждого прочитанного байта. Этот процесс может включать в себя такие операции как инициирование системных вызовов и является очень ресурсоемким. Метод read2 использует BufferedInputStream. Этот класс переопределяет чтение и берет байт из буфера. FileInputStream.read вызывается для заполнения буфера только тогда, когда он пуст. Поскольку буфер имеет достаточную длину (в настоящее время 2048 байт), такой вызов происходит не часто.
Метод read3 еще быстрее, чем read2. В нем явно используется буфер для извлечения байтов из файла. Этот подход аналогичен использованию буфера в BufferedInputStream. Но вместо периодического вызова метода read осуществляется прямой доступ к байтам из буфера.
Отличием между read1 и read2 является буферизация. Отличием между read2 и read3 является устранение накладных расходов на вызов метода и обработки, которую BufferedInputStream.read должен выполнять для каждого вызова. Эта обработка включает в себя проверку пустоты буфера. Оба эти приема - использование буферизации и осторожность в использовании байтовых или символьных операций ввода/вывода - являются основными для улучшения производительности ввода/вывода.
Есть вероятность пойти еще дальше в оптимизации такого типа. Например, следующая строка появляется в методе read3 (она используется для итерации по буферу):
for (int i = 0; i < len; i++) {
Если бы строка была такой:
for (int i = 1; i < len; i++) {
она могла бы вызвать ошибку занижения результата на единицу. Если вам нужна максимальная производительность, read3 должен использовать соответствующий подход. Но погружение в низко-уровневые детали может стать источником ошибок кодирования.
Подсчет количества строк текстового файла
Вот последний пример, подсчитывающий количество строк текстового файла:
import java.io.*;
class MyTimer {
private final long start;
public MyTimer() {
start = System.currentTimeMillis();
}
public long getElapsed() {
return System.currentTimeMillis() - start;
}
}
public class IODemo4 {
static final int N = 1000000;
static final String TESTFILE = "testfile";
static int cnt1, cnt2;
static void write() throws IOException {
// записать последовательность строк
// в тестовый файл,
// заканчивающихся символами \r, \n, или \r\n
FileWriter fw = new FileWriter(TESTFILE);
BufferedWriter bw = new BufferedWriter(fw);
String str = "test";
int slen = str.length();
int state = 0;
for (int i = 1; i <= N; i++) {
bw.write(str, 0, slen);
if (state == 0) {
bw.write('\r');
state = 1;
} else if (state == 1) {
bw.write('\n');
state = 2;
} else {
bw.write('\r');
bw.write('\n');
state = 0;
}
}
bw.close();
}
static void read1() throws IOException {
// читать из текстового файла, используя readLine()
MyTimer mt = new MyTimer();
FileReader fr = new FileReader(TESTFILE);
BufferedReader br = new BufferedReader(fr);
cnt1 = 0;
while (br.readLine() != null) {
cnt1++;
}
br.close();
System.out.println("read1 " + mt.getElapsed());
}
static void read2() throws IOException {
// читать из текстового файла, используя буфер
// и читая конкретные символы
MyTimer mt = new MyTimer();
FileReader fr = new FileReader(TESTFILE);
BufferedReader br = new BufferedReader(fr);
cnt2 = 0;
int prev = -1;
final int BUFSIZE = 1024;
char cbuf[] = new char[BUFSIZE];
int currpos = 0;
int maxpos = 0;
while (true) {
if (currpos == maxpos) {
maxpos = br.read(cbuf, 0, BUFSIZE);
if (maxpos == -1) {
break;
}
currpos = 0;
}
int c = cbuf[currpos++];
if (c == '\r' || (c == '\n' && prev != '\r')) {
cnt2++;
}
prev = c;
}
br.close();
System.out.println("read2 " + mt.getElapsed());
}
public static void main(String args[]) throws IOException {
// записать тестовый файл
write();
// прочитать тестовый файл первый раз
read1();
read2();
// проверка правильности
if (cnt1 != cnt2) {
System.out.println("cnt1/cnt2 mismatch");
}
// прочитать тестовый файл второй раз
read1();
read2();
}
}
Результат выполнения программы должен выглядеть примерно так:
read1 1125
read2 578
read1 1093
read2 563
В программе IODemo4 создаются некоторые тестовые данные, состоящие из набора строк. Строка текста заканчивается символами перевода строки, возврата каретки, либо ими обоими. Все эти три случая представлены в данных.
Для подсчета количества строк определяются два метода read. Первый метод вызывает BufferedReader.readLine. Второй использует другой подход, включающий явную буферизацию. Символы читаются из буфера, и подсчитывается каждый конец строки.
Метод read2 выполняется в два раза быстрее, чем read1. Одной из причин такого различия является то, что метод readLine, используемый в read1, возвращает string для каждой прочитанной строки. Эта string должна быть создана и заполнена конкретными символами. В данном приложении string ни для чего не используется. Обработка строк таким способом не эффективна - выполняется лишняя работа.
Но существует другая сторона этого вопроса. В действительности вы можете использовать read1, несмотря на то, что он медленнее. Одной из проблем в read2 является то, что логика обнаружения конца строки не надежна и три возможных вида терминаторов строки явно кодируются в методе read2. Хорошим аргументом является передача таких деталей методу readLine в библиотеке. Кроме того, как насчет двусмысленного случая, когда последняя строка текстового файла не заканчивается терминатором строки? Нужно ли считать ее как строку? В этой ситуации read2 может работать не так как readLine.
Дополнительная скорость read2 может перевесить в рассуждениях, но подумайте, стоит ли улучшенная производительность увеличения сложности кодирования.
Приведенные выше примеры затронули некоторые основные принципы увеличения скорости операция ввода/вывода. Существуют и другие темы, не затронутые в статье. Среди них:
Вопросы производительности сериализации
Преобразования байт/символ и символ/байт
Преобразование чисел в строки и строк в числа
Сжатие
Сетевой ввод/вывод
Терминальный ввод/вывод