1. Чтение файла по частям: ByteBuffer и кодировка
Сегодня мы редко имеем дело с маленькими текстовыми файлами. Обычно это огромные логи серверов, отчёты, CSV-файлы или гигабайтные дампы данных. Поэтому важно не просто читать файл, а делать это эффективно и без «замирания» приложения.
Асинхронный подход помогает именно в этом: он не блокирует основной поток — будь то интерфейс или серверная логика, — позволяет читать и записывать большие объёмы данных параллельно и делает приложение масштабируемым, когда нужно работать сразу с несколькими файлами.
Главное понимать: асинхронный ввод-вывод не ускоряет сам диск — чудес не бывает. Он просто позволяет вашей программе не скучать в ожидании, пока диск выполняет операцию, и заниматься другими делами в это время.
Как работает асинхронное чтение?
Асинхронный канал (AsynchronousFileChannel) читает не строки, а блоки байтов в объект ByteBuffer. Это как если бы вы таскали коробки с буквами, а не отдельные слова. После чтения вам нужно превратить эти байты в строки — с учётом кодировки!
Пример: асинхронное чтение файла блоками
Давайте напишем простейший пример асинхронного чтения файла блоками по 4096 байт и вывода содержимого в консоль.
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncTextReadExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("bigfile.txt");
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(4096);
int position = 0;
Future<Integer> future = channel.read(buffer, position);
while (future.get() > 0) {
buffer.flip();
// Преобразуем байты в строку (UTF-8)
String chunk = StandardCharsets.UTF_8.decode(buffer).toString();
System.out.print(chunk);
buffer.clear();
position += chunk.getBytes(StandardCharsets.UTF_8).length;
future = channel.read(buffer, position);
}
}
}
}
Важные моменты:
- Мы читаем файл по частям (буферу), а не целиком.
- После чтения байты декодируются в строку с помощью Charset.
- Не забываем про buffer.clear() — иначе следующий read не сработает!
Почему просто декодировать байты недостаточно?
Беда в том, что строка может «разорваться» между двумя блоками, особенно если используется многобайтовая кодировка (например, "UTF-8"). Если последний байт в буфере — это половина символа, то следующий блок начнётся с «остатка» символа. Без специальной обработки вы получите «кракозябры» или даже ошибку декодирования.
2. Преобразование байтов в строки: обработка разрывов
Проблема разрыва строк
Допустим, у вас строка "Привет\nМир\n", а буфер закончился на "Прив", а "ет\nМир\n" попало в следующий блок. Если просто склеивать строки, можно потерять символы или получить некорректную строку.
Решение: использовать CharsetDecoder
Java предоставляет класс CharsetDecoder, который умеет корректно обрабатывать такие случаи. Он «запоминает» недекодированные байты и корректно восстанавливает символы на стыке блоков.
Пример использования CharsetDecoder
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.CharBuffer;
import java.nio.ByteBuffer;
CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();
ByteBuffer buffer = ... // ваши байты
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// Теперь charBuffer содержит корректно декодированные символы
В реальной задаче вы будете хранить «остаток» между чтениями и декодировать с учётом этого остатка.
3. Асинхронная запись текстовых файлов
Чтение — это только половина дела. Запись также выполняется блоками байт, которые нужно сначала получить из строк (кодировать).
Пример: асинхронная запись строки в файл
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
public class AsyncTextWriteExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("output.txt");
String text = "Привет, мир!\n";
ByteBuffer buffer = ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8));
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
Future<Integer> future = channel.write(buffer, 0);
// Для демонстрации подождём завершения (обычно делать не надо!)
future.get();
System.out.println("Данные записаны асинхронно.");
}
}
}
Комментарий: В реальных асинхронных сценариях не стоит вызывать future.get() в основном потоке — это превращает асинхронный код в синхронный. Лучше использовать CompletionHandler (см. предыдущую лекцию).
4. Практика: асинхронное чтение большого текстового файла и подсчёт строк
Давайте реализуем практическую задачу: асинхронно прочитать большой текстовый файл и посчитать количество строк ("\n"). Результат — вывести количество строк в консоль.
Пример с использованием CompletionHandler
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.CharBuffer;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicLong;
public class AsyncLineCounter {
public static void main(String[] args) throws IOException {
Path path = Path.of("bigfile.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(4096);
AtomicLong position = new AtomicLong(0);
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
StringBuilder leftover = new StringBuilder();
AtomicLong lines = new AtomicLong(0);
channel.read(buffer, position.get(), null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer result, Object attachment) {
if (result == -1) {
// Файл прочитан до конца
if (leftover.length() > 0) lines.incrementAndGet();
System.out.println("Строк в файле: " + lines.get());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
return;
}
buffer.flip();
CharBuffer charBuffer = CharBuffer.allocate(buffer.remaining());
decoder.decode(buffer, charBuffer, false);
charBuffer.flip();
String chunk = leftover.toString() + charBuffer.toString();
leftover.setLength(0);
// Считаем строки
int last = 0;
int idx;
while ((idx = chunk.indexOf('\n', last)) != -1) {
lines.incrementAndGet();
last = idx + 1;
}
// Остаток (часть строки после последнего \n)
if (last < chunk.length()) {
leftover.append(chunk.substring(last));
}
buffer.clear();
position.addAndGet(result);
channel.read(buffer, position.get(), null, this);
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("Ошибка чтения: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
// Чтобы программа не завершилась раньше времени (только для примера!)
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
}
- Мы используем CompletionHandler для truly async-кода.
- После каждого чтения буфер декодируется с помощью CharsetDecoder.
- Остаток строки, не закончившейся на "\n", переносится в следующий блок.
- После окончания файла, если что-то осталось в leftover, это тоже считается строкой.
- Для простоты пример «засыпает» на 2000 мс, чтобы асинхронная операция завершилась (в реальных приложениях это не нужно — обычно есть главный цикл или UI).
5. Асинхронная запись результатов в файл
Допустим, мы хотим записать результат (например, количество строк) в новый файл — тоже асинхронно.
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncWriteResult {
public static void main(String[] args) throws IOException {
String result = "Строк в файле: 12345\n";
ByteBuffer buffer = ByteBuffer.wrap(result.getBytes(StandardCharsets.UTF_8));
Path path = Path.of("result.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
path, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
channel.write(buffer, 0, null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer written, Object attachment) {
System.out.println("Результат записан асинхронно!");
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("Ошибка записи: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
}
6. Советы по обработке частичных данных и кодировок
Частичные строки между блоками
Если строка разбита между двумя блоками, не пытайтесь «склеить» байты вручную! Используйте CharsetDecoder, который аккуратно обработает недостающие байты и не потеряет ни одного символа.
Работа с разными кодировками
"UTF-8" — стандарт для современных приложений, но если файл в другой кодировке (например, "Windows-1251" или "UTF-16"), используйте соответствующий Charset:
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
Charset charset = Charset.forName("Windows-1251");
CharsetDecoder decoder = charset.newDecoder();
Использование CharsetDecoder и CharsetEncoder
Когда вы читаете или записываете данные по частям, важно правильно работать с кодировкой. Символ может «порваться» между блоками, и тогда без дополнительной обработки получится каша из байтов.
Чтобы этого избежать, используются CharsetDecoder и CharsetEncoder.
При чтении вызывается decode(ByteBuffer, CharBuffer, endOfInput), а при записи — encode(CharBuffer, ByteBuffer, endOfInput).
Они заботятся о том, чтобы даже если символ оказался разделён между двумя блоками, он всё равно был собран и обработан правильно.
7. Типичные ошибки при асинхронной обработке текстовых файлов
Ошибка №1: Игнорирование остатков строки. Если не хранить «хвост» строки между блоками, то часть строк может потеряться или быть некорректно декодирована.
Ошибка №2: Неправильная работа с буфером. Забыли вызвать buffer.clear() после обработки — следующий read не сработает или данные будут некорректны.
Ошибка №3: Использование неподходящей кодировки. Если байты декодируются не тем Charset, что был при записи файла, возможны «кракозябры» или даже ошибки.
Ошибка №4: Блокировка основного потока. Если вы вызываете future.get() или Thread.sleep() в UI-потоке, вы теряете смысл асинхронности. Используйте CompletionHandler и реактивные подходы.
Ошибка №5: Не закрыт канал после завершения. Всегда закрывайте канал (channel.close()) после окончания всех операций, даже если была ошибка.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ