DEV Community

Cover image for parallelStream() Aninhado: Mais Concorrência, Menos Performance
Hugo Marques
Hugo Marques

Posted on

parallelStream() Aninhado: Mais Concorrência, Menos Performance

.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)
    )
);
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

🕳️ 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"
Enter fullscreen mode Exit fullscreen mode

▶️ Como rodar o benchmark

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

./gradlew run
Enter fullscreen mode Exit fullscreen mode

Ou rodar diretamente com:

./gradlew jmh
Enter fullscreen mode Exit fullscreen mode

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!


Top comments (5)

Collapse
 
gusoliveiira profile image
gus •

Baita artigo! Também não sabia que o nested parallel causava essas issues.. interessante. Tem algum cenário em que você enxerga que isso possa ser interessante?

Collapse
 
hugaomarques profile image
Hugo Marques •

Você quer dizer em qual cenário vale a penar usar parallel? Ou em qual cenário tem que ter cuidado com nestedParallels?

Collapse
 
gusoliveiira profile image
gus •

Em qual cenário vale a pena usar.

Thread Thread
 
hugaomarques profile image
Hugo Marques •

Sempre que vc tem tarefas que podem ser quebradas em microtarefas independentes. Por exemplo, se vc processa countries x usuarios. Você consegue processar cada país em isolado? Se sim, vale à pena. Também é bom se atentar se seu processamento é CPU ou IO. Se for IO, não vale. Se for CPU, aí sim.

Collapse
 
marcelmfa profile image
marcelmfa •

Coloquei umas perguntas nos comentários no vídeo que vc gravou com o Carlos, mas acho que vc não deve ser notificado, então vai aqui também:

  1. Chegou a verificar o uso de ThreadLocal ao invés de ThreadContext ???
  2. Chegou a explorar a solução do Spring Batch para esse tipo de aplicação??? Acho que nessas suas abordagens vc usou o Spring + Java puro, certo?
  3. Com quanto de memória (xms, xmx, etc) vc rodou essa aplicação??? Só o default, acho que são 64Mb, né???
  4. Como vc fez o profiling e monitoramento da aplicação??? profiling com VisualVM ou outra coisa??? Monitoramento com Grafana e Prometheus ou outra coisa???