1. Проблема: як ефективно обробити багато файлів у каталозі
У сучасних застосунках часто виникає завдання: обробити велику кількість файлів у папці та її підкаталогах. Наприклад:
- Порахувати загальну кількість рядків в усіх ".java"-файлах проєкту.
- Знайти всі файли, змінені за останній місяць.
- Скопіювати або видалити файли за певним критерієм.
Якщо файлів мало, вистачить звичайного циклу. Але за наявності тисяч і десятків тисяч, особливо коли над кожним файлом виконується «важка» операція (читання, парсинг, аналіз), час виконання зростає суттєво.
Питання: як прискорити обробку великої кількості файлів?
Відповідь: використовувати паралелізм — обробляти файли одночасно в кількох потоках.
2. Інструменти для обходу файлової системи
Files.walk()
У Java 8+ з’явився зручний спосіб обходу дерева каталогів — метод Files.walk() із пакета java.nio.file. Він повертає потік Stream<Path> — усі файли та папки, починаючи з вказаної директорії.
Приклад:
import java.nio.file.*;
import java.util.stream.Stream;
Path start = Paths.get("src");
try (Stream<Path> stream = Files.walk(start)) {
stream.forEach(System.out::println);
}
- Files.walk(start) — повертає потік усіх файлів і папок, включно з підкаталогами.
- Можна вказати максимальну глибину обходу: Files.walk(start, 3).
Files.find()
Якщо потрібно одразу фільтрувати за ознакою (наприклад, тільки ".java"-файли), використовуйте Files.find():
import java.nio.file.*;
import java.util.stream.Stream;
Path start = Paths.get("src");
try (Stream<Path> stream = Files.find(
start,
Integer.MAX_VALUE,
(path, attr) -> path.toString().endsWith(".java"))) {
stream.forEach(System.out::println);
}
- Files.find() приймає фільтр (BiPredicate<Path, BasicFileAttributes>), який отримує шлях і атрибути файлу.
3. Паралельна обробка: parallel() і ForkJoinPool
Паралельні стріми: .parallel()
Будь-який Stream має метод parallel(). Якщо його викликати, обробка елементів відбуватиметься у кількох потоках.
Files.walk(start)
.parallel()
.forEach(path -> processFile(path));
Кожен файл оброблятиметься паралельно (за можливості), що особливо ефективно під час «важких» операцій: читання, парсинг, обчислення.
Як це працює всередині? ForkJoinPool
Паралельні стріми використовують спільний пул потоків — ForkJoinPool.commonPool(). Це «розумний» пул, який розподіляє завдання між потоками.
- За замовчуванням кількість потоків дорівнює кількості доступних процесорів: Runtime.getRuntime().availableProcessors().
- Паралельна модель «fork/join» добре підходить для незалежних завдань — як-от обробка окремих файлів.
Коли використовувати .parallel()?
- Коли обробка кожного файлу незалежна від інших.
- Коли операція «важка» (навантажує CPU або довго чекає IO).
- Коли файлів багато (сотні, тисячі).
Не варто використовувати паралельні стріми:
- Якщо файлів мало (накладні витрати на розпаралелювання можуть бути вищими за користь).
- Якщо потрібен суворий порядок або є залежності між елементами.
4. Альтернативи й налаштування паралелізму
Коли краще використовувати ExecutorService?
Паралельні стріми добре підходять для простих випадків. Проте якщо потрібно:
- Контролювати точну кількість потоків (для IO-bound завдань вигідно мати більше потоків, ніж ядер).
- Керувати чергами, скасуванням, повторними спробами, обробкою помилок.
- Будувати складніші конвеїри завдань.
Тоді використовуйте ExecutorService:
import java.nio.file.*;
import java.util.concurrent.*;
ExecutorService executor = Executors.newFixedThreadPool(8);
Files.walk(start)
.filter(Files::isRegularFile)
.forEach(path -> executor.submit(() -> processFile(path)));
executor.shutdown();
Налаштування ForkJoinPool
За замовчуванням спільний пул використовує кількість потоків, що дорівнює кількості процесорів. Це можна змінити через системну властивість (до першого використання паралельних стрімів):
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "16");
- Після виклику всі паралельні стріми використовуватимуть до 16 потоків.
CPU-bound vs IO-bound завдання
- CPU-bound: активно навантажують процесор (математика, парсинг, стиснення). Кількість потоків ≈ кількість ядер.
- IO-bound: багато очікувань диска/мережі. Часто вигідно мати потоків більше, ніж ядер.
Паралельні стріми не завжди оптимальні для IO-bound завдань — частіше виграє власний ExecutorService зі збільшеним пулом.
5. Приклад: паралельний пошук і обробка файлів
Порахуємо загальну кількість рядків у всіх ".java"-файлах проєкту, використовуючи паралельний обхід.
import java.nio.file.*;
import java.util.stream.*;
import java.io.IOException;
public class LineCounter {
public static void main(String[] args) throws IOException {
Path start = Paths.get("src");
long totalLines = Files.walk(start)
.parallel() // паралельна обробка!
.filter(p -> p.toString().endsWith(".java"))
.mapToLong(LineCounter::countLines)
.sum();
System.out.println("Усього рядків коду: " + totalLines);
}
// Метод підрахунку рядків у файлі
private static long countLines(Path path) {
try (Stream<String> lines = Files.lines(path)) {
return lines.count();
} catch (IOException e) {
System.err.println("Помилка читання файлу: " + path);
return 0;
}
}
}
Що відбувається:
- Files.walk(start) — обхід усіх шляхів.
- parallel() — вмикаємо паралельну обробку.
- filter(...) — залишаємо тільки ".java"-файли.
- mapToLong(...) — рахуємо рядки в кожному файлі.
- sum() — підсумовуємо результат.
Переваги: залучаються кілька потоків і водночас код залишається лаконічним.
6. Важливі нюанси та типові помилки
- Не всі завдання прискорюються паралелізмом. Для невеликих наборів файлів або швидких операцій накладні витрати можуть сповільнити програму.
- Закривайте ресурси. Під час роботи з файлами використовуйте try-with-resources — так дескриптори не «витікатимуть». Наприклад, Files.lines(path) у try(...).
- Вкладений паралелізм. Запуск паралельних стрімів усередині інших паралельних завдань (nested parallelism) рідко ефективний і може призводити до деградації продуктивності.
- Побічні ефекти. Уникайте запису в спільні структури/файли без синхронізації. Надавайте перевагу «чистим» операціям над елементами.
7. Схема: як працює паралельний обхід файлів
8. Типові помилки під час паралельної обробки файлів
Помилка № 1: Використання паралельних стрімів для маленьких завдань — накладні витрати більші, ніж виграш.
Помилка № 2: Очікування, що паралельні стріми прискорять IO-bound завдання так само, як CPU-bound. Для IO частіше потрібен ExecutorService із більшим пулом.
Помилка № 3: Необроблені винятки в лямбдах — без обробки IOException потік може обірватися, а результат — виявитися неповним.
Помилка № 4: Гонки під час запису в спільні змінні або файли — синхронізуйте доступ або уникайте побічних ефектів.
Помилка № 5: Забули закрити ресурси — використовуйте try-with-resources для всіх операцій із файлами.
Помилка № 6: Спроба змінити ForkJoinPool.commonPool() після першого використання — налаштування через System.setProperty(...) потрібно робити заздалегідь.
Помилка № 7: Використання паралельних стрімів усередині інших паралельних стрімів — часто призводить до деградації продуктивності.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ