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 для справді асинхронного коду.
- Після кожного читання буфер декодується за допомогою 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()) після завершення всіх операцій, навіть якщо була помилка.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ