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

Параллельные обходы ФС: 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: Использование параллельных стримов внутри других параллельных стримов — часто приводит к деградации производительности.

1
Задача
JAVA 25 SELF, 59 уровень, 2 лекция
Недоступна
Подсчёт общего количества файлов в директории и поддиректориях
Подсчёт общего количества файлов в директории и поддиректориях
1
Задача
JAVA 25 SELF, 59 уровень, 2 лекция
Недоступна
Параллельный поиск файлов по дате изменения
Параллельный поиск файлов по дате изменения
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ