JavaRush /Курси /JAVA 25 SELF /Паралельні обходи ФС: Files.walk() + parallel() та ForkJo...

Паралельні обходи ФС: Files.walk() + parallel() та ForkJoin

JAVA 25 SELF
Рівень 59 , Лекція 2
Відкрита

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. Схема: як працює паралельний обхід файлів

flowchart TD A["Files.walk(start)"] --> B["Stream<Path>"] B --> C{".parallel()?"} C -- Ні --> D[Звичайний forEach] C -- Так --> E["Паралельний forEach (ForkJoinPool)"] E --> F[Обробка файлів у кількох потоках]

8. Типові помилки під час паралельної обробки файлів

Помилка № 1: Використання паралельних стрімів для маленьких завдань — накладні витрати більші, ніж виграш.

Помилка № 2: Очікування, що паралельні стріми прискорять IO-bound завдання так само, як CPU-bound. Для IO частіше потрібен ExecutorService із більшим пулом.

Помилка № 3: Необроблені винятки в лямбдах — без обробки IOException потік може обірватися, а результат — виявитися неповним.

Помилка № 4: Гонки під час запису в спільні змінні або файли — синхронізуйте доступ або уникайте побічних ефектів.

Помилка № 5: Забули закрити ресурси — використовуйте try-with-resources для всіх операцій із файлами.

Помилка № 6: Спроба змінити ForkJoinPool.commonPool() після першого використання — налаштування через System.setProperty(...) потрібно робити заздалегідь.

Помилка № 7: Використання паралельних стрімів усередині інших паралельних стрімів — часто призводить до деградації продуктивності.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ