1. Nem tudo que pode ser paralelizado precisa ser paralelizado
Em Java há muitas maneiras de paralelizar tarefas. Mas “paralelismo = sempre mais rápido” é como pensar que, se adicionar mais sal à sopa, ela ficará mais saborosa: até certo ponto — sim; depois disso — é melhor não tentar.
ExecutorService é uma ótima opção quando você tem tarefas explícitas que precisam ser iniciadas e controladas: processamento de requisições, carregamento assíncrono de dados, cálculos independentes. Você decide quantas threads haverá no pool e gerencia o ciclo de vida das tarefas.
parallelStream — uma forma rápida de paralelizar o processamento de coleções quando as operações são independentes e sem efeitos colaterais. Vale a pena para coleções “pesadas” (dezenas de milhares de elementos ou mais).
ForkJoinPool — a escolha para tarefas que se dividem bem em subtarefas (divide & conquer): ordenação, busca, agregação de grandes arrays. É usado por dentro de parallelStream, mas você também pode controlá-lo diretamente.
Não use paralelismo “por via das dúvidas”. Se a tarefa for pequena, a sobrecarga de agendamento, troca de contexto e sincronização pode consumir todo o ganho.
Exemplo: quando o paralelismo não é necessário
List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
.mapToInt(x -> x)
.sum(); // Paralelizar por 5 números é exagero!
2. Thread-safety: evite dados compartilhados mutáveis
No mundo paralelo, a principal ameaça são as condições de corrida (race conditions). Se várias threads alteram a mesma variável, o resultado pode ser inesperado.
- Evite variáveis compartilhadas mutáveis. Até uma expressão como counter++ não é atômica.
- Use coleções thread-safe e operações atômicas. Por exemplo, ConcurrentHashMap, CopyOnWriteArrayList, AtomicInteger, AtomicLong.
- Sem efeitos colaterais em streams paralelos. Não modifique estruturas externas a partir de parallelStream.
Exemplo de código ruim
List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // PERIGOSO!
Aqui, result.add() não é thread-safe. Resultado: elementos perdidos ou exceções.
Como fazer corretamente?
List<Integer> result = numbers.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList());
3. Desempenho: mais threads nem sempre é melhor
Tarefas pequenas não valem a paralelização. Se o trabalho leva milissegundos, a execução paralela muitas vezes só vai desacelerar por causa da sobrecarga.
Meça o desempenho. Para medições rápidas, use System.nanoTime():
long start = System.nanoTime();
// ... seu código ...
long end = System.nanoTime();
System.out.println("Tempo de execução: " + (end - start) + " ns");
Para microbenchmarks sérios, use JMH (Java Microbenchmark Harness).
Exemplo: comparação entre stream sequencial e paralelo
List<Integer> bigList = IntStream.range(0, 1_000_000)
.boxed().collect(Collectors.toList());
long t1 = System.nanoTime();
long sum1 = bigList.stream().mapToLong(x -> x).sum();
long t2 = System.nanoTime();
long sum2 = bigList.parallelStream().mapToLong(x -> x).sum();
long t3 = System.nanoTime();
System.out.println("Sequencial: " + (t2 - t1) / 1_000_000 + " ms");
System.out.println("Paralelo: " + (t3 - t2) / 1_000_000 + " ms");
Tente no seu computador — o ganho é perceptível em coleções realmente grandes e em operações pesadas.
4. Tratamento de erros: não ignore exceções em threads
Future e tratamento de exceções
Se você iniciou a tarefa via ExecutorService.submit(), as exceções não “vazam” automaticamente — é necessário tratá-las com Future.get():
Future<Integer> future = executor.submit(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Ops!");
return 42;
});
try {
Integer result = future.get(); // pode lançar ExecutionException
} catch (ExecutionException e) {
System.err.println("Erro na tarefa: " + e.getCause());
}
ForkJoin e tratamento de erros
No ForkJoinPool, as exceções são “empacotadas” na tarefa. Ao chamar join()/get(), elas emergem:
ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
int result = pool.invoke(task);
} catch (Exception e) {
System.err.println("Erro no ForkJoin: " + e);
}
Não se esqueça de tratar InterruptedException
Muitos métodos (por exemplo, Future.get(), Thread.sleep()) podem lançar InterruptedException. Não “engula” a exceção — reaja corretamente: restaure o status de interrupção ou finalize a tarefa.
5. Depuração e testes de código paralelo
Logging e debug
Bugs paralelos são traiçoeiros e costumam se manifestar de forma instável. Registre com indicação da thread: Thread.currentThread().getName(). Isso ajuda a entender quem e quando executa o código.
Em casos complexos, use um depurador com suporte a multithreading (por exemplo, IntelliJ IDEA). Thread.sleep() temporários às vezes ajudam a “capturar” uma corrida de dados rara.
Testando cenários multithread
Separe testes específicos para operações paralelas e use utilitários de espera de condições, por exemplo Awaitility. Execute esses testes repetidamente: alguns problemas só aparecem na 100ª ou 1000ª execução.
6. Dicas e nuances úteis
Legibilidade e manutenção: escreva código paralelo compreensível
- Documente. Comente trechos complexos e a escolha das ferramentas.
- Use abstrações de alto nível. Prefira ExecutorService, parallelStream, ForkJoinPool em vez de gerenciamento manual de threads.
- Evite “mágica”. Não complique a sincronização se for possível fazer de forma mais simples.
Tabela: quando usar cada ferramenta
| Cenário | Ferramenta recomendada |
|---|---|
| Muitas tarefas independentes | |
| Processamento de uma coleção grande | parallelStream ou ForkJoin |
| Tarefa de “dividir para conquistar” | |
| Tarefa assíncrona simples | |
| Muitas tarefas pequenas | Stream sequencial |
| Tarefas com efeitos colaterais | Apenas coleções thread-safe! |
“Mandamentos” do programador paralelo
- Não tenha variáveis compartilhadas mutáveis — a menos que tenha certeza de que são thread-safe.
- Não paralelize por paralelizar: avalie o ganho potencial.
- Não se esqueça de encerrar os pools: shutdown()/shutdownNow().
- Não use parallelStream para operações com efeitos colaterais.
- Não se esqueça de tratar exceções de Future e ForkJoinTask.
- Não “engula” InterruptedException — finalize a tarefa corretamente.
7. Erros comuns em programação paralela
Erro nº 1: paralelização de tarefas pequenas. Muitas vezes iniciantes paralelizam tudo, mesmo quando o trabalho leva microssegundos. Resultado — mais lento por causa da sobrecarga.
Erro nº 2: efeitos colaterais em streams. Em parallelStream não se deve modificar variáveis ou coleções externas — você terá condições de corrida e bugs imprevisíveis.
Erro nº 3: ignorar exceções. Se você não tratar erros de Future.get() ou ForkJoinTask, não saberá por que a tarefa não foi concluída.
Erro nº 4: esquecer shutdown() no ExecutorService. Sem finalização explícita, o aplicativo pode “travar” ao encerrar.
Erro nº 5: uso de coleções não thread-safe. Escrever de várias threads em um ArrayList comum é um caminho certo para erros.
Erro nº 6: “engolir” InterruptedException. Se a thread foi interrompida — respeite isso e finalize o trabalho corretamente.
Erro nº 7: lógica de sincronização complexa demais. Blocos synchronized em excesso levam a deadlock/livelock. Prefira abstrações de alto nível.
GO TO FULL VERSION