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 (4)

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.