Главная > Java Standard Edition > Производительность операций ввода/вывода в Java

Тема Зацепин
268

Java-разработчик 🧩
235
2 минуты

Производительность операций ввода/вывода в Java

1 Введение 2 Временные затраты на открытие файла, его чтение и закрытие 3 Производительность и чтение байтов из файла 4 Подсчет количества строк текстового файла

Добавлено : 2 Mar 2009, 16:13

Содержание

Введение

В єтой статье рассматриваются некоторые вопросы и приемы по улучшению производительности операций ввода/вывода в 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 при помощи команды

javaIODemo1C:\\

вы получите результат, который будет выглядеть примерно так

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 может перевесить в рассуждениях, но подумайте, стоит ли улучшенная производительность увеличения сложности кодирования.

Приведенные выше примеры затронули некоторые основные принципы увеличения скорости операция ввода/вывода. Существуют и другие темы, не затронутые в статье. Среди них:

  • Вопросы производительности сериализации

  • Преобразования байт/символ и символ/байт

  • Преобразование чисел в строки и строк в числа

  • Сжатие

  • Сетевой ввод/вывод

  • Терминальный ввод/вывод

Теги: java i/o

Еще от автора

Применение WeakHashmap для списков слушателей

В статье от 11мая 1999 года Reference Objects были описаны основные идеи применения ссылочных объектов, но не приводилось детального описания. Данная статья позволит вам получить больше сведений, касающихся данной темы. В основном ссылочные объекты применяются для косвенных ссылок на память необходимую объектам. Ссылочные объекты хранятся в очереди (класс ReferenceQueue), в которой отслеживается доступность ссылочных объектов. Исходя из типа ссылочного объекта, сборщик мусора может освобождать память даже тогда, когда обычные ссылки не могут быть освобождены.

Заставки в Mustang

Согласно определению, данному в Wikipedia, заставка - это компьютерный термин, обозначающий рисунок, появляющийся во время загрузки программы или операционной системы. Заставка для пользователя является визуальным отображением инициализации программы. До выхода версии Java SE 6 (кодовое название Mustang) единственной возможностью применения заставки было создание окна, во время запуска метода main, и размещение в нем картинки. Хотя данный способ и работал, но он требовал полной инициализации исполняемой Java среды до появления окна заставки. При инициализации загружались библиотеки AWT и Swing, таким образом, появление заставки задерживалось. В Mustang появился новый аргумент командной строки, значительно облегчающий использование заставок. Этот способ позволяет выводить заставку значительно быстрее до запуска исполняемой Java среды. Окончательное добавление данной функциональности находится на рассмотрении в JCP.

Совмещение изображений

1 Введение 2 Правила визуализации и пример 3 Совмещение изображений в оперативной памяти 4 Постепенное исчезновение изображения 5 Ссылки и дополнительная информация

Еще по теме

Применение WeakHashmap для списков слушателей

В статье от 11мая 1999 года Reference Objects были описаны основные идеи применения ссылочных объектов, но не приводилось детального описания. Данная статья позволит вам получить больше сведений, касающихся данной темы. В основном ссылочные объекты применяются для косвенных ссылок на память необходимую объектам. Ссылочные объекты хранятся в очереди (класс ReferenceQueue), в которой отслеживается доступность ссылочных объектов. Исходя из типа ссылочного объекта, сборщик мусора может освобождать память даже тогда, когда обычные ссылки не могут быть освобождены.

Заставки в Mustang

Согласно определению, данному в Wikipedia, заставка - это компьютерный термин, обозначающий рисунок, появляющийся во время загрузки программы или операционной системы. Заставка для пользователя является визуальным отображением инициализации программы. До выхода версии Java SE 6 (кодовое название Mustang) единственной возможностью применения заставки было создание окна, во время запуска метода main, и размещение в нем картинки. Хотя данный способ и работал, но он требовал полной инициализации исполняемой Java среды до появления окна заставки. При инициализации загружались библиотеки AWT и Swing, таким образом, появление заставки задерживалось. В Mustang появился новый аргумент командной строки, значительно облегчающий использование заставок. Этот способ позволяет выводить заставку значительно быстрее до запуска исполняемой Java среды. Окончательное добавление данной функциональности находится на рассмотрении в JCP.

Использование потоков

1 Введение 2 Работа с выражениями типа Boolean 3 Класс JoptionPane 4 Приложение-счетчик 5 Ссылки

Перехват необрабатываемых исключений

В статье от 16 марта 2004 года Best Practices in Exception Handling были описаны приемы обработки исключений. В данной статье вы изучите новый способ обработки исключений при помощи класса UncaughtExceptionHandler добавленного в J2SE 5.0.

Использование класса LinkedHashMap

1 Введение 2 Сортировка хэш-таблицы 3 Копирование таблицы 4 Сохранение порядка доступа к элементам 5 Ссылки

Сказ про кодировки и java

С кодировками в java плохо. Т.е., наоборот, все идеально хорошо: внутреннее представление строк – Utf16-BE (и поддержка Unicode была с самых первых дней). Все возможные функции умеют преобразовывать строку из маленького регистра в большой, проверять является ли данный символ буквой или цифрой, выполнять поиск в строке (в том числе с регулярными выражениями) и прочее и прочее. Для этих операций не нужно использовать какие-то посторонние библиотеки вроде привычных для php mbstring или iconv. Как говорится, поддержка многоязычных тестов “есть в коробке”. Так откуда берутся проблемы? Проблемы возникают, как только строки текста пытаются “выбраться” из jvm (операции вывода текста различным потребителям) или наоборот пытаются в эту самую jvm “залезть” (операция чтения данных от некоторого поставщика).