1. Problema: como processar muitos arquivos em um diretório de forma eficiente
Em aplicativos modernos, é comum surgir a tarefa: processar uma grande quantidade de arquivos em uma pasta e seus subdiretórios. Por exemplo:
- Calcular o número total de linhas em todos os arquivos ".java" do projeto.
- Encontrar todos os arquivos modificados no último mês.
- Copiar ou excluir arquivos por um determinado critério.
Se houver poucos arquivos, um laço simples basta. Mas com milhares e dezenas de milhares, especialmente quando cada arquivo envolve uma operação “pesada” (leitura, parsing, análise), o tempo cresce significativamente.
Pergunta: como acelerar o processamento de uma grande quantidade de arquivos?
Resposta: usar paralelismo — processar os arquivos simultaneamente em várias threads.
2. Ferramentas para percorrer o sistema de arquivos
Files.walk()
No Java 8+ surgiu uma maneira prática de percorrer a árvore de diretórios — o método Files.walk() do pacote java.nio.file. Ele retorna um stream Stream<Path> — todos os arquivos e pastas, a partir do diretório especificado.
Exemplo:
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) — retorna um stream de todos os arquivos e pastas, incluindo subdiretórios.
- Você pode especificar a profundidade máxima do percurso: Files.walk(start, 3).
Files.find()
Se você precisa filtrar imediatamente por um critério (por exemplo, apenas arquivos ".java"), use 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() aceita um filtro (BiPredicate<Path, BasicFileAttributes>) que recebe o caminho e os atributos do arquivo.
3. Processamento paralelo: parallel() e ForkJoinPool
Streams paralelos: .parallel()
Todo Stream possui o método parallel(). Ao chamá-lo, o processamento dos elementos ocorrerá em várias threads.
Files.walk(start)
.parallel()
.forEach(path -> processFile(path));
Cada arquivo será processado em paralelo (quando possível), o que é especialmente eficaz em operações “pesadas”: leitura, parsing e cálculos.
Como isso funciona por dentro? ForkJoinPool
Streams paralelos usam o pool comum de threads — ForkJoinPool.commonPool(). É um pool “inteligente” que distribui tarefas entre as threads.
- Por padrão, o número de threads é igual ao de processadores disponíveis: Runtime.getRuntime().availableProcessors().
- O modelo paralelo “fork/join” é adequado para tarefas independentes — como o processamento de arquivos individuais.
Quando usar .parallel()?
- Quando o processamento de cada arquivo é independente dos demais.
- Quando a operação é “pesada” (carrega a CPU ou passa muito tempo aguardando I/O).
- Quando há muitos arquivos (centenas, milhares).
Evite usar streams paralelos:
- Se houver poucos arquivos (a sobrecarga de paralelização pode superar o ganho).
- Se for necessário ordem estrita ou houver dependências entre os elementos.
4. Alternativas e ajuste do paralelismo
Quando é melhor usar ExecutorService?
Streams paralelos são bons para casos simples. Mas se você precisa:
- Controlar o número exato de threads (para tarefas IO-bound pode ser vantajoso ter mais threads do que núcleos).
- Gerenciar filas, cancelamentos, tentativas (retries) e tratamento de erros.
- Construir pipelines de tarefas mais complexos.
Então use 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();
Ajuste do ForkJoinPool
Por padrão, o pool comum usa um número de threads igual ao número de processadores. É possível alterar isso via propriedade do sistema (antes do primeiro uso de streams paralelos):
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "16");
- Após essa configuração, todos os streams paralelos usarão até 16 threads.
CPU-bound vs IO-bound tarefas
- CPU-bound: carregam ativamente o processador (matemática, parsing, compressão). Número de threads ≈ número de núcleos.
- IO-bound: passam muito tempo esperando disco/rede. Frequentemente é vantajoso ter mais threads do que núcleos.
Streams paralelos nem sempre são ideais para tarefas IO-bound — muitas vezes um ExecutorService próprio, com pool ampliado, apresenta melhor desempenho.
5. Exemplo: busca e processamento paralelos de arquivos
Vamos calcular o número total de linhas em todos os arquivos ".java" do projeto usando percurso paralelo.
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() // processamento paralelo!
.filter(p -> p.toString().endsWith(".java"))
.mapToLong(LineCounter::countLines)
.sum();
System.out.println("Total de linhas de código: " + totalLines);
}
// Método para contar as linhas em um arquivo
private static long countLines(Path path) {
try (Stream<String> lines = Files.lines(path)) {
return lines.count();
} catch (IOException e) {
System.err.println("Erro ao ler o arquivo: " + path);
return 0;
}
}
}
O que está acontecendo:
- Files.walk(start) — percorre todos os caminhos.
- parallel() — ativamos o processamento paralelo.
- filter(...) — mantemos apenas os arquivos ".java".
- mapToLong(...) — contamos as linhas em cada arquivo.
- sum() — somamos o resultado.
Vantagens: várias threads são utilizadas e, ainda assim, o código permanece conciso.
6. Nuances importantes e erros comuns
- Nem toda tarefa fica mais rápida com paralelismo. Para conjuntos pequenos de arquivos ou operações rápidas, a sobrecarga pode tornar o programa mais lento.
- Feche os recursos. Ao trabalhar com arquivos, use try-with-resources — assim os descritores não “vazam”. Por exemplo, Files.lines(path) em try(...).
- Paralelismo aninhado. Executar streams paralelos dentro de outras tarefas paralelas (nested parallelism) raramente é eficaz e pode levar à degradação de desempenho.
- Efeitos colaterais. Evite escrever em estruturas/arquivos compartilhados sem sincronização. Prefira operações “puras” sobre os elementos.
7. Esquema: como funciona o percurso paralelo de arquivos
flowchart TD
A["Files.walk(start)"] --> B["Stream<Path>"]
B --> C{".parallel()?"}
C -- Não --> D[forEach sequencial]
C -- Sim --> E["forEach paralelo (ForkJoinPool)"]
E --> F[Processamento de arquivos em várias threads]
8. Erros típicos no processamento paralelo de arquivos
Erro nº 1: Usar streams paralelos para tarefas pequenas — a sobrecarga é maior que o benefício.
Erro nº 2: Esperar que streams paralelos acelerem IO-bound tanto quanto CPU-bound. Para I/O, geralmente é necessário um ExecutorService com pool maior.
Erro nº 3: Exceções não tratadas em lambdas — sem tratar IOException, o stream pode ser interrompido e o resultado acabar incompleto.
Erro nº 4: Condições de corrida ao escrever em variáveis ou arquivos compartilhados — sincronize o acesso ou evite efeitos colaterais.
Erro nº 5: Esquecer de fechar recursos — use try-with-resources para todas as operações com arquivos.
Erro nº 6: Tentar alterar ForkJoinPool.commonPool() após o primeiro uso — a configuração via System.setProperty(...) deve ser feita com antecedência.
Erro nº 7: Usar streams paralelos dentro de outros streams paralelos — frequentemente leva à degradação de desempenho.
GO TO FULL VERSION