.parallelStream() em todos os lugares? Nem sempre é uma boa ideia.

Você já pensou:

“E se eu colocar .parallelStream() em todas as camadas do meu código? Vai ficar tudo mais rápido, né?”

Pois é. Eu pensei isso. Spoiler: não ficou. 😅

Recentemente otimizando o processamento e transformações de milhões de objetos em memória (haja CPU) eu me peguei com alguns níveis aninhados de parallelStreams. Quando eu olhei minhas métricas, a CPU estava derretendo. A minha hipótese é que as tasks competem pelo mesmo thread pool, o common fork join pool nesse caso.

Se você assim como eu ficou curioso pra entender o porquê disso, continue lendo. Neste artigo, vamos olhar os resultados de alguns benchmarks que mostram o que acontece quando você exagera no uso de .parallelStream().

Também mostro qual abordagem funciona melhor e por quê.


Um problema complexo: muitas camadas, muito paralelismo

Imagine um código com múltiplas camadas:

  • 10 grupos externos (ex: regiões)
  • 100 grupos médios (ex: armazéns)
  • 100 itens finais (ex: produtos)

Cada item executa um cálculo pesado em CPU, e pode alocar memória no processo.

A primeira ideia foi paralelizar tudo:

outer.parallelStream().forEach(o ->
    middle.parallelStream().forEach(m ->
        inner.parallelStream().forEach(this::processar)
    )
);

Parece ideal. Mas quando rodei benchmarks sérios...

🚨 O desempenho caiu. E a variabilidade aumentou.


⚙️ Como o experimento foi feito

Pra medir corretamente, eu usei o JMH (Java Microbenchmark Harness) — a ferramenta padrão da comunidade Java pra benchmarks de alta confiança.

Simulei uma hierarquia de dados:

  • outerSize: regiões
  • middleSize: armazéns
  • innerSize: produtos

Cada combinação gera uma tarefa com carga de CPU + alocação de memória.


📊 O que estamos comparando

Implementei três variações do mesmo processamento:

Técnica Descrição
nestedParallelStreams() Paraleliza todas as camadas (exagerado)
flattenedParallelStream() Só a camada externa é paralela
singleParallelStream() Cria uma lista plana e paraleliza uma vez

🧪 Como simulamos a carga

Cada tarefa executa:

  • Operações com Math.sqrt() (CPU)
  • Concatenação de strings
  • Criação de listas intermediárias (pra adicionar uma pressãozinha na heap)
record ComplexObject(String name, int value, byte[] payload) {
    ComplexObject(String name, int value) {
        this(name, value, new byte[1024]); // Simula peso na memória
    }
}

🕳️ Por que usamos Blackhole?

Essa técnica foi nova pra mim. O JMH fornece o objeto Blackhole pra evitar que o compilador JIT otimize fora o código de benchmark.

Sem o Blackhole, o compilador pode notar que você não usa o resultado de uma função e simplesmente eliminar a execução dela — o que estraga o nosso experimento.

blackhole.consume(results); // Garante que os resultados sejam "usados"

▶️ Como rodar o benchmark

Você pode rodar com o próprio main() incluído na classe:

./gradlew run

Ou rodar diretamente com:

./gradlew jmh

Os parâmetros (outerSize, middleSize, etc.) são controlados por @Param e podem ser ajustados com argumentos de linha de comando ou diretamente no código.


5 horas de exeução depois...

Image description


🔍 O que os resultados mostraram?

✅ 1. Paralelismo único é mais eficiente

Configuração: (100, 50, 5) → 25.000 tarefas

Técnica Tempo médio (ms)
singleParallelStream 3.233 ms
flattenedParallelStream 3.546 ms
nestedParallelStreams 3.972 ms

💡 A paralelização profunda não ajudou. Só causou mais overhead.


⚠️ 2. Em escala, o nested vira caos

Configuração: (500, 100, 10) → 500.000 tarefas

Técnica Tempo médio (ms) Desvio
nestedParallelStreams 69.486 ms ±106.638 ms 😱
flattenedParallelStream 78.037 ms ±44.430 ms
singleParallelStream 75.201 ms ±63.081 ms

💣 O nested parecia “mais rápido” em uma das execuções, mas o desvio padrão gigantesco mostra que o sistema ficou instável — provavelmente por conta do GC ou contenção de threads.


✅ Conclusão: paralelize com cuidado

O que aprendemos:

  • 🔹 Paralelize uma vez, na camada mais externa
  • 🔹 Evite .parallelStream() aninhado
  • 🔹 Benchmarks revelam o que "parece rápido", mas não é
  • 🔹 Mais .parallelStream() ≠ mais performance

✌️ Bônus: minha lição pessoal

“Achei que estava otimizando. Estava só confundindo o escalonador.”

A minha hipótese estava correta. E se você ficou curioso, dá pra fazer uns experimentos menores que o resultado é o mesmo. Eu rodei um código bem mais simples e ainda assim é notável que o nestedParallelStreams adiciona overheard em toda a operação.


🔗 Código completo

O código está disponível aqui.
Se lembre que você precisa adicionar a dependência do jmh.

Clone, rode, brinque com os parâmetros — e veja por si mesmo!