1. Коли багатопоточність допомагає
Багатопоточність потрібна, коли є багато роботи, яку можна виконувати одночасно. Наприклад, якщо треба обробити десятки файлів — скопіювати їх, перелічити або проаналізувати — простіше доручити різні частини різним потокам, ніж робити все послідовно. Це якби замість одного друга, який перебирає ваш архів фотографій, ви покликали одразу п’ятьох: робота піде швидше й веселіше.
Вона особливо корисна, коли ви маєте справу з пакетною обробкою файлів, завантаженням або копіюванням великих файлів частинами або коли після читання даних потрібно паралельно щось обчислити для різних фрагментів.
Але багатопоточність не завжди допомагає. Якщо у вас один маленький файл, запускати заради нього десяток потоків безглуздо. Якщо диск або мережа вже завантажені вщерть, нові потоки лише сповільнять процес. А якщо кілька потоків одночасно пишуть в один і той самий файл без синхронізації — можна отримати справжній хаос із пошкодженими даними.
Простіше кажучи, багатопоточність — це інструмент. Як молоток: ним можна забити цвях, а можна й по пальцю вдарити. Головне — знати, коли й як його використовувати.
2. Інструменти Java для багатопотокового I/O
Ви вже знаєте, що в Java є кілька способів запускати завдання паралельно:
- Класичний Thread — ручне створення потоків.
- Пул потоків через ExecutorService — сучасний, гнучкий і зручний спосіб.
- CompletableFuture та паралельні стріми (Stream API) — для просунутих завдань (детальніше розглянемо їх у наступних лекціях).
Почнемо з найпростішого: обробка кількох файлів у різних потоках.
Приклад 1: Класичний Thread
public class FileCopyTask extends Thread {
private final Path source;
private final Path target;
public FileCopyTask(Path source, Path target) {
this.source = source;
this.target = target;
}
@Override
public void run() {
try {
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("Файл скопійовано: " + source);
} catch (IOException e) {
System.err.println("Помилка копіювання " + source + ": " + e.getMessage());
}
}
}
// Запуск кількох копій в окремих потоках
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
new FileCopyTask(file, Path.of("backup_" + file.getFileName())).start();
}
Плюси: Просто й зрозуміло.
Мінуси: Керувати великою кількістю потоків вручну незручно, немає контролю над кількістю потоків, що працюють одночасно.
Приклад 2: ExecutorService — пул потоків
ExecutorService дозволяє делегувати завдання пулу потоків, який сам вирішить, скільки потоків використовувати одночасно.
import java.util.concurrent.*;
public class MultiFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4); // максимум 4 потоки
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Files.copy(file, Path.of("backup_" + file.getFileName()), StandardCopyOption.REPLACE_EXISTING);
System.out.println("Скопійовано: " + file);
} catch (IOException e) {
System.err.println("Помилка: " + file + " " + e.getMessage());
}
});
}
executor.shutdown(); // більше не приймаємо завдання
executor.awaitTermination(1, TimeUnit.MINUTES); // чекаємо завершення всіх завдань
}
}
Плюси:
- Легко масштабувати (можна задати потрібну кількість потоків).
- Зручно контролювати завершення завдань (методи shutdown(), awaitTermination(...)).
- Підходить для обробки сотень і тисяч файлів.
3. Проблеми та обмеження багатопотокового I/O
Конкуренція за ресурси
Якщо ви спробуєте одночасно читати або писати один і той самий файл із кількох потоків без синхронізації — результат буде непередбачуваним. Це якби двоє людей одночасно писали на одній і тій самій сторінці книжки: вийде каша. Для координації використовуйте, наприклад, synchronized, явні блокування або окремий потік запису.
Обмеження файлової системи та ОС
- Не всі файлові системи добре підтримують одночасний запис в один файл.
- Операційна система може обмежувати кількість одночасно відкритих файлів.
- Жорсткий диск (особливо HDD) погано працює за великої кількості випадкових звернень.
Синхронізація під час запису у спільний ресурс
Якщо кілька потоків пишуть в один файл (наприклад, лог), обов’язково використовуйте синхронізацію (наприклад, через synchronized, блокування або спеціальні потоки запису).
Неефективність на малих файлах
Для невеликих файлів накладні витрати на створення потоків і перемикання між ними можуть бути більшими, ніж виграш від паралелізму.
4. Практичні приклади
Паралельне копіювання файлів
Припустімо, у нас є папка з купою логів, які потрібно скопіювати в архівний каталог.
import java.nio.file.*;
import java.util.List;
import java.util.concurrent.*;
public class ParallelFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4);
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
// ... додайте скільки завгодно файлів
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Path target = Path.of("archive", file.getFileName().toString());
Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("Скопійовано: " + file);
} catch (IOException e) {
System.err.println("Помилка: " + file + " " + e.getMessage());
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
}
}
Коментар:
- Ми використовуємо пул із 4 потоків — цього зазвичай достатньо, щоб завантажити диск, але не перевантажити систему.
- Для 1000 файлів можна збільшити пул до 8, але не варто робити його надто великим.
Паралельна обробка рядків файлу за допомогою Stream API
Починаючи з Java 8 можна використовувати паралельні стріми для обробки вмісту файлу:
import java.nio.file.*;
import java.io.IOException;
public class ParallelLineProcessing {
public static void main(String[] args) throws IOException {
Path path = Path.of("biglog.txt");
// Files.lines повертає Stream<String> — потік рядків файлу
Files.lines(path)
.parallel() // перетворюємо на паралельний потік
.filter(line -> line.contains("ERROR"))
.forEach(line -> System.out.println("Помилка: " + line));
}
}
Важливо:
- Паралельні стріми пришвидшують обробку, якщо вона потребує значних обчислень (CPU-bound), а не сам процес читання (I/O-bound).
- Якщо обробка рядка проста (наприклад, просто друк через System.out.println), приросту швидкості може не бути.
Читання/запис різних частин одного великого файлу
Java дозволяє читати або писати різні ділянки одного файлу одночасно за допомогою FileChannel і методів позиціювання. Це вже просунутий рівень, але принцип простий: кожен потік працює зі своїм фрагментом файлу.
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.io.*;
import java.nio.ByteBuffer;
public class FileChunkReader implements Runnable {
private final Path path;
private final long position;
private final long size;
public FileChunkReader(Path path, long position, long size) {
this.path = path;
this.position = position;
this.size = size;
}
@Override
public void run() {
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate((int) size);
channel.read(buffer, position);
System.out.println("Прочитано фрагмент із позиції " + position + " розміром " + size);
// Тут можна обробити buffer
} catch (IOException e) {
System.err.println("Помилка читання фрагмента: " + e.getMessage());
}
}
}
// Приклад запуску: читаємо файл по 1 МБ у 4 потоки
Path file = Path.of("bigdata.bin");
long fileSize = Files.size(file);
long chunkSize = 1024 * 1024; // 1 МБ
int chunks = (int) Math.ceil((double) fileSize / chunkSize);
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < chunks; i++) {
long position = i * chunkSize;
long size = Math.min(chunkSize, fileSize - position);
executor.submit(new FileChunkReader(file, position, size));
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
Коментар:
- Кожен потік читає свій фрагмент файлу, не заважаючи іншим.
- Такий підхід використовується, наприклад, у торентах і завантажувачах.
Синхронізація під час запису у спільний файл
Якщо кілька потоків пишуть в один і той самий файл (наприклад, лог), потрібно синхронізувати доступ, щоб не отримати «вінегрет» із рядків:
import java.io.*;
public class SafeLogger {
private final Writer writer;
public SafeLogger(String filename) throws IOException {
this.writer = new BufferedWriter(new FileWriter(filename, true));
}
public synchronized void log(String message) throws IOException {
writer.write(message);
writer.write(System.lineSeparator());
writer.flush();
}
public void close() throws IOException {
writer.close();
}
}
Коментар:
- Метод log позначено як synchronized, щоб лише один потік писав у файл у цей момент.
- Це працює, але за великої кількості потоків може стати вузьким місцем — краще писати в різні файли, а потім об’єднувати.
5. Коли не варто використовувати багатопоточність
Багатопоточність вабить до себе: більше потоків — значить, усе має літати! Але на практиці це не завжди так. Якщо ви обробляєте кілька невеликих файлів, то простіше й надійніше зробити це послідовно. Час, який ви витратите на запуск потоків і координацію між ними, просто не окупиться.
Іноді проблема взагалі не в диску, а в мережі — тоді додавання потоків нічого не пришвидшить, тому що «вузьке місце» (bottleneck) перебуває зовсім в іншому місці. Ще одна пастка — паралельний запис в один і той самий файл. Якщо у вас мало досвіду із синхронізацією, краще навіть не пробувати: великий шанс отримати зіпсовані дані.
І нарешті, якщо ваш диск або файлова система не люблять, коли до них одночасно звертаються десятки потоків, — багатопоточність не врятує, а лише погіршить ситуацію.
Простіше кажучи, якщо здається, що «чим більше потоків, тим краще», — найчастіше це не так. Інколи один спокійний потік робить роботу чистіше, швидше й надійніше, ніж десяток квапливих.
6. Коротке знайомство з FileChannel для просунутих завдань
FileChannel із пакета java.nio.channels — це інструмент для роботи з файлами на низькому рівні, який дозволяє читати й писати дані за довільними позиціями. Це дозволяє реалізувати, наприклад, паралельне завантаження або обробку великих файлів частинами.
Приклад:
try (FileChannel channel = FileChannel.open(Path.of("bigfile.bin"), StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
long position = 0;
int bytesRead = channel.read(buffer, position); // читаємо 1024 байти з позиції 0
// обробка буфера
}
Важливо:
- FileChannel не синхронізований — якщо кілька потоків працюють з одним каналом, синхронізацію потрібно реалізувати самостійно.
- Для паралельної роботи простіше відкривати окремий канал для кожного потоку.
7. Типові помилки при багатопоточному I/O
Помилка № 1: Несинхронізований доступ до одного файлу на запис.
У результаті — пошкоджені дані, дивні символи, інколи файл взагалі не читається. Завжди синхронізуйте доступ або пишіть в окремі файли.
Помилка № 2: Надто багато потоків.
Якщо ви відкриваєте 1000 потоків заради копіювання 1000 файлів, ваш комп’ютер може образитися (OutOfMemoryError, гальма, падіння). Використовуйте пул потоків (ExecutorService) і обмежуйте їх кількість.
Помилка № 3: Потоки/файли не закриваються.
Кожен відкритий потік — це ресурс ОС. Якщо їх не закривати, можна отримати помилку «Too many open files». Використовуйте try-with-resources або не забувайте викликати close().
Помилка № 4: Передчасне завершення програми.
Якщо не дочекатися завершення всіх потоків (наприклад, не викликати executor.awaitTermination(...)), програма може завершитися раніше, ніж усі файли скопіюються.
Помилка № 5: Паралельний запис в одну й ту саму ділянку файлу без урахування позицій.
Якщо кілька потоків пишуть в одну й ту саму область файлу — дані перемішаються. Для запису за позиціями використовуйте канали й чіткий поділ діапазонів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ