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);
long position = 0;
int bytesRead;
Future<Integer> future = channel.read(buffer, position);
while ((bytesRead = future.get()) > 0) {
buffer.flip();
// Перетворюємо байти на рядок (UTF-8)
String chunk = StandardCharsets.UTF_8.decode(buffer).toString();
System.out.print(chunk);
buffer.clear();
position += bytesRead;
future = channel.read(buffer, position);
}
}
}
}
Важливі моменти:
- Ми читаємо файл частинами (буфером), а не цілком.
- Після читання байти декодуються в рядок за допомогою Charset.
- Після обробки звільняємо буфер: buffer.clear(), якщо всі байти вже декодовано, або buffer.compact(), якщо в кінці блоку могли залишитися байти неповного символу.
- Цей приклад ламається на багатобайтних символах: Charset.decode() замінить «розірваний» на стику блоків символ на «�». Чому так і як це виправити — нижче.
Чому просто декодувати байти недостатньо?
Проблема в тому, що символ може «розірватися» між двома блоками, особливо якщо використовується багатобайтне кодування (наприклад, "UTF-8"). Якщо останній байт у буфері — це половина символу, то наступний блок почнеться з «залишку» символу. Без спеціальної обробки ви отримаєте «абракадабру» або навіть помилку декодування.
2. Перетворення байтів на рядки: обробка розривів
Проблема розриву рядків
Припустімо, у вас рядок "Привіт\nСвіт\n", а буфер закінчився на "Прив", а "іт\nСвіт\n" потрапило в наступний блок. Якщо просто склеювати рядки, можна втратити символи або отримати некоректний рядок.
Рішення: використовувати CharsetDecoder
Java надає клас CharsetDecoder, який уміє коректно обробляти такі випадки. Сам він байтів не зберігає: якщо в кінці буфера опинився початок багатобайтного символу, decode() залишить ці байти в ByteBuffer — позиція буфера зупиниться перед ними. Ваше завдання — їх не втратити: після декодування викликайте buffer.compact(), а не buffer.clear(). Метод compact() перенесе недекодовані байти на початок буфера, і наступне читання допише нові одразу за ними.
Приклад використання 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 містить коректно декодовані символи
Окремо зберігати «залишок» байтів не потрібно — його зберігає сам ByteBuffer після compact(). Коли вхідні дані закінчилися, востаннє викличте decode з endOfInput = true, а потім decoder.flush(charBuffer) — цього вимагає контракт CharsetDecoder. І перевіряйте результат decode: якщо байти пошкоджені, він поверне помилку, а не просунеться далі.
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.CharacterCodingException;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CoderResult;
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) {
boolean endOfInput = (result == -1); // -1: файл прочитано до кінця
buffer.flip();
// +1 — запас під символ, який декодер міг «притримати» до кінця вхідних даних
CharBuffer charBuffer = CharBuffer.allocate(buffer.remaining() + 1);
CoderResult cr = decoder.decode(buffer, charBuffer, endOfInput);
if (!cr.isError() && endOfInput) {
cr = decoder.flush(charBuffer); // після останнього decode(..., true)
}
if (cr.isError()) {
// Пошкоджені байти: без цієї перевірки цикл «compact → read → decode» не просунеться
try { cr.throwException(); } catch (CharacterCodingException e) { failed(e, attachment); }
return;
}
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));
}
if (endOfInput) {
// Останній рядок без '\n' теж вважається рядком
if (leftover.length() > 0) lines.incrementAndGet();
System.out.println("Рядків у файлі: " + lines.get());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
return;
}
// Недекодовані байти (початок «розірваного» символу) залишилися в buffer.
// compact() переносить їх на початок буфера, наступне читання допише байти одразу за ними.
buffer.compact();
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 для справді асинхронного коду.
- Після кожного читання буфер декодується за допомогою CharsetDecoder.
- Залишок рядка, що не закінчився на "\n", переноситься в наступний блок.
- Недекодовані байти в кінці блоку залишаються в buffer, і compact() зберігає їх для наступного читання. А leftover — це хвіст рядка (уже символи): він потрібен лише для підрахунку рядків.
- Коли read повернув -1, робимо останній виклик decode(..., true) і flush(). Якщо байти пошкоджені, decode поверне помилку — передаємо її у failed(), інакше цикл читання зациклиться.
- Після завершення файлу, якщо щось залишилося в 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 зупиниться перед неповним символом, а buffer.compact() збереже його байти до наступного читання.
Робота з різними кодуваннями
"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).
Декодер зупиняється перед неповним символом і залишає його байти у вхідному буфері. Зібрати символ вийде, лише якщо зберегти ці байти (compact()) і передати їх у наступний виклик decode.
7. Типові помилки під час асинхронної обробки текстових файлів
Помилка № 1: Втрата недекодованих байтів між блоками. Якщо після decode викликати buffer.clear(), початок «розірваного» багатобайтного символу пропаде: наступний блок почнеться із середини символу, і декодер поверне помилку або «абракадабру». Викликайте buffer.compact().
Помилка № 2: Неправильна робота з буфером. Забули викликати buffer.clear() або buffer.compact() після обробки — наступний read не спрацює або дані будуть некоректні.
Помилка № 3: Використання невідповідного кодування. Якщо байти декодуються не тим Charset, що був під час запису файлу, можливі «абракадабра» або навіть помилки.
Помилка № 4: Блокування основного потоку. Якщо ви викликаєте future.get() або Thread.sleep() в UI-потоці, ви втрачаєте сенс асинхронності. Використовуйте CompletionHandler і реактивні підходи.
Помилка № 5: Канал не закрито після завершення. Завжди закривайте канал (channel.close()) після завершення всіх операцій, навіть якщо була помилка.