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: Использование параллельных стримов внутри других параллельных стримов — часто приводит к деградации производительности.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ