Benchmark Real com PostgreSQL, HikariCP e k6

Introdução
Desde o lançamento das Virtual Threads no Java 21 através da JEP 444, muita gente começou a repetir a mesma frase:
"Virtual Threads deixam sua aplicação mais rápida."
Mas será que isso é verdade?
A resposta curta é:
- depende do workload
- depende do gargalo
- e principalmente do que você está medindo
Para entender isso de forma prática, montei um benchmark real comparando:
- Platform Threads
- Virtual Threads
Usando:
- Java 25
- PostgreSQL
- HikariCP
- k6
- workload HTTP + acesso real ao banco
O objetivo foi medir:
- throughput
- latência
- tail latency (p95)
- comportamento sob saturação
Repositório do projeto: HttpServer JDK built-in
E se você gostou, me deixe uma estrelinha ⭐
Ambiente do benchmark
Stack utilizada
| Tecnologia | Versão |
|---|---|
| Java | 25 |
| PostgreSQL | Local |
| HikariCP | Pool limitado |
| k6 | Load testing |
| HttpServer | JDK built-in |
Arquitetura do teste
O servidor foi implementado usando o HttpServer da própria JDK.
A ideia foi manter:
- mínimo overhead
- foco total no modelo de threading
Project Loom] C --> E[Platform Threads
Fixed Thread Pool] D --> F[PostgreSQL] E --> F F --> G[HikariCP
Limited Pool]
Configuração do servidor
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">private</span></span> <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">static</span></span> <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">void</span></span> <span class=<span class="hljs-string">"hljs-title function_"</span>>startWebServer</span><span class=<span class="hljs-string">"hljs-params"</span>>(<span class=<span class="hljs-string">"hljs-type"</span>><span class="hljs-type">boolean</span></span> virtual, <span class=<span class="hljs-string">"hljs-type"</span>><span class="hljs-type">boolean</span></span> withLock)</span> <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">throws</span></span> IOException {
<span class=<span class="hljs-string">"hljs-type"</span>>HttpServer</span> <span class=<span class="hljs-string">"hljs-variable"</span>>httpServer</span> <span class=<span class="hljs-string">"hljs-operator"</span>>=</span> HttpServer.create(<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">new</span></span> <span class=<span class="hljs-string">"hljs-title class_"</span>>InetSocketAddress</span>(<span class=<span class="hljs-string">"hljs-number"</span>><span class="hljs-number">8001</span></span>), <span class=<span class="hljs-string">"hljs-number"</span>><span class="hljs-number">0</span></span>);
httpServer.createContext(<span class=<span class="hljs-string">"hljs-string"</span>>&quot;/caramelo&quot;</span>, <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">new</span></span> <span class=<span class="hljs-string">"hljs-title class_"</span>>WebServerHandler</span>(withLock));
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">if</span></span> (virtual) {
httpServer.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
} <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">else</span></span> {
httpServer.setExecutor(Executors.newFixedThreadPool(<span class=<span class="hljs-string">"hljs-number"</span>><span class="hljs-number">200</span></span>));
}
httpServer.start();
}
Simulação de workload real
O benchmark não utilizou apenas Thread.sleep().
Foi utilizado acesso real ao PostgreSQL via JDBC.
Database Service
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">public</span></span> <span class=<span class="hljs-string">"hljs-keyword"</span>>class</span> <span class=<span class="hljs-string">"hljs-title class_"</span>>DatabaseService</span> {
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">private</span></span> <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">static</span></span> <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">final</span></span> HikariDataSource dataSource;
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">static</span></span> {
<span class=<span class="hljs-string">"hljs-type"</span>><span class="hljs-keyword">var</span></span> <span class=<span class="hljs-string">"hljs-variable"</span>>config</span> <span class=<span class="hljs-string">"hljs-operator"</span>>=</span> <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">new</span></span> <span class=<span class="hljs-string">"hljs-title class_"</span>>HikariConfig</span>();
config.setJdbcUrl(<span class=<span class="hljs-string">"hljs-string"</span>>&quot;jdbc:postgresql:<span class="hljs-comment">//localhost:5432/test&quot;</span>);</span>
config.setUsername(<span class=<span class="hljs-string">"hljs-string"</span>>&quot;postgres&quot;</span>);
config.setPassword(<span class=<span class="hljs-string">"hljs-string"</span>>&quot;password&quot;</span>);
<span class=<span class="hljs-string">"hljs-comment"</span>><span class="hljs-comment">// gargalo controlado</span></span>
config.setMaximumPoolSize(<span class=<span class="hljs-string">"hljs-number"</span>><span class="hljs-number">100</span></span>);
dataSource = <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">new</span></span> <span class=<span class="hljs-string">"hljs-title class_"</span>>HikariDataSource</span>(config);
}
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">public</span></span> <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">static</span></span> String <span class=<span class="hljs-string">"hljs-title function_"</span>>query</span><span class=<span class="hljs-string">"hljs-params"</span>>()</span> {
<span class=<span class="hljs-string">"hljs-comment"</span>><span class="hljs-comment">// Você pode testar de forma deterministica ou real, direto em uma tabela do Postgres</span></span>
<span class=<span class="hljs-string">"hljs-type"</span>><span class="hljs-type">boolean</span></span> <span class=<span class="hljs-string">"hljs-variable"</span>>deterministic</span> <span class=<span class="hljs-string">"hljs-operator"</span>>=</span> <span class=<span class="hljs-string">"hljs-literal"</span>><span class="hljs-literal">true</span></span>;
<span class=<span class="hljs-string">"hljs-type"</span>>String</span> <span class=<span class="hljs-string">"hljs-variable"</span>>sql</span> <span class=<span class="hljs-string">"hljs-operator"</span>>=</span> deterministic
? <span class=<span class="hljs-string">"hljs-string"</span>>&quot;SELECT <span class="hljs-title function_">pg_sleep</span><span class="hljs-params">(<span class="hljs-number">0.2</span>)</span>&quot;</span>
: <span class=<span class="hljs-string">"hljs-string"</span>>&quot;SELECT * FROM carro&quot;</span>;
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">try</span></span> (<span class=<span class="hljs-string">"hljs-type"</span>>Connection</span> <span class=<span class="hljs-string">"hljs-variable"</span>>conn</span> <span class=<span class="hljs-string">"hljs-operator"</span>>=</span> dataSource.getConnection();
<span class=<span class="hljs-string">"hljs-type"</span>><span class="hljs-keyword">var</span></span> <span class=<span class="hljs-string">"hljs-variable"</span>>stmt</span> <span class=<span class="hljs-string">"hljs-operator"</span>>=</span> conn.createStatement()) {
<span class=<span class="hljs-string">"hljs-type"</span>><span class="hljs-keyword">var</span></span> <span class=<span class="hljs-string">"hljs-variable"</span>>rs</span> <span class=<span class="hljs-string">"hljs-operator"</span>>=</span> stmt.executeQuery(sql);
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">if</span></span> (rs.next()) {
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">return</span></span> <span class=<span class="hljs-string">"hljs-string"</span>>&quot;OK_DB&quot;</span>;
}
} <span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">catch</span></span> (Exception e) {
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">return</span></span> <span class=<span class="hljs-string">"hljs-string"</span>>&quot;ERROR&quot;</span>;
}
<span class=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">return</span></span> <span class=<span class="hljs-string">"hljs-string"</span>>&quot;OK_DB&quot;</span>;
}
}
Por que limitar o HikariCP?
Essa foi a parte mais importante do benchmark.
Sem um recurso limitado, o teste ficaria artificial.
O pool de conexões foi limitado para:
100 conexões
Isso criou um gargalo real.
Ferramenta de carga
O benchmark foi executado usando k6.
Script utilizado
<span <span class="hljs-keyword">class</span>=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">import</span><<span class="hljs-regexp">/span> http <span class="hljs-keyword">from</</span>span> <span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-string"</span>></span><span class="hljs-symbol">&#x27;</span>k6/http<span class="hljs-symbol">&#x27;</span><span class="hljs-tag"></<span class="hljs-name">span</span>></span></span>;
<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-keyword"</span>></span>import<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span> { check, sleep } <span <span class="hljs-keyword">class</span>=<span class="hljs-string">"hljs-keyword"</span>><span class="hljs-keyword">from</span><<span class="hljs-regexp">/span> <span class="hljs-string">&#x27;k6&#x27;</</span>span>;
<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-keyword"</span>></span>export<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span> <span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-keyword"</span>></span>const<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span> options = {
<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-attr"</span>></span>vus<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span>: <span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-number"</span>></span>500<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span>,
<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-attr"</span>></span>duration<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span>: <span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-string"</span>></span><span class="hljs-symbol">&#x27;</span>30s<span class="hljs-symbol">&#x27;</span><span class="hljs-tag"></<span class="hljs-name">span</span>></span></span>,
};
<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-keyword"</span>></span>export<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span> <span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-keyword"</span>></span>default<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span> <span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-keyword"</span>></span>function<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span> (<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-params"</span>></span><span class="hljs-tag"></<span class="hljs-name">span</span>></span></span>) {
<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-keyword"</span>></span>const<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span> res = http.<span <span class="hljs-keyword">class</span>=<span class="hljs-string">"hljs-title function_"</span>>get<<span class="hljs-regexp">/span>(<span class="hljs-string">&#x27;http:/</span><span class="hljs-regexp">/localhost:8001/</span>caramelo&#x27;</span>);
<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-title function_"</span>></span>check<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span>(res, {
<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-string"</span>></span><span class="hljs-symbol">&#x27;</span>status is 200<span class="hljs-symbol">&#x27;</span><span class="hljs-tag"></<span class="hljs-name">span</span>></span></span>: <span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-function"</span>></span>(<span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-params"</span>></span>r<span class="hljs-tag"></<span class="hljs-name">span</span>></span>) =<span class="hljs-symbol">&gt;</span><span class="hljs-tag"></<span class="hljs-name">span</span>></span></span> r.<span <span class="hljs-keyword">class</span>=<span class="hljs-string">"hljs-property"</span>>status<<span class="hljs-regexp">/span> === <span class="hljs-number">200</</span>span>,
});
<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-title function_"</span>></span>sleep<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span>(<span class="language-xml"><span class="hljs-tag"><<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hljs-number"</span>></span>0.1<span class="hljs-tag"></<span class="hljs-name">span</span>></span></span>);
}
O que foi medido
Para cada cenário:
- throughput
- p95
- estabilidade
- comportamento sob concorrência
Os testes foram executados com:
| VUs |
|---|
| 50 |
| 100 |
| 200 |
| 300 |
| 400 |
| 500 |
Resultados consolidados
| Model | VUs | p95 | Throughput |
|---|---|---|---|
| Virtual Threads | 50 | 219ms | 158 req/s |
| Virtual Threads | 100 | 220ms | 317 req/s |
| Virtual Threads | 200 | 345ms | 460 req/s |
| Virtual Threads | 300 | 566ms | 461 req/s |
| Virtual Threads | 400 | 841ms | 458 req/s |
| Virtual Threads | 500 | 996ms | 460 req/s |
| Platform Threads | 50 | 344ms | 139 req/s |
| Platform Threads | 100 | 217ms | 317 req/s |
| Platform Threads | 200 | 350ms | 458 req/s |
| Platform Threads | 300 | 1.43s | 472 req/s |
| Platform Threads | 400 | 1.78s | 467 req/s |
| Platform Threads | 500 | 2s | 467 req/s |
O que os números mostram
1. Throughput ficou praticamente igual
Esse foi o primeiro insight importante.
Mesmo usando Virtual Threads:
o throughput não aumentou drasticamente
Por quê?
Porque o gargalo principal continuou sendo:
- PostgreSQL
- HikariCP
- I/O externo
Ou seja:
Virtual Threads não fazem milagre quando o recurso externo continua limitado.
2. A diferença apareceu na latência de cauda
A partir de ~300 VUs, o comportamento mudou completamente.
Virtual Threads
| VUs | p95 |
|---|---|
| 300 | 566ms |
| 400 | 841ms |
| 500 | 996ms |
Crescimento:
- gradual
- previsível
- controlado
Platform Threads
| VUs | p95 |
|---|---|
| 300 | 1.43s |
| 400 | 1.78s |
| 500 | 2s |
Aqui a cauda começou a explodir.
3. O comportamento sob saturação
Até ~200 VUs:
os dois modelos se comportaram de forma muito parecida
Isso mostra que:
abaixo da saturação, o modelo de threading importa pouco
Após ~300 VUs
O sistema começou a entrar em fila.
E foi exatamente nesse momento que:
- scheduler (agendar execução das threads, em Platform Threads o scheduler principal é do: sistema operacional)
- fairness (justiça na distribuição de execução, ou seja, as requisições recebem tempo de execução de forma equilibrada?)
- modelo de execução
passaram a importar.
Resumo técnico simples:
| Conceito | Significado |
|---|---|
| Scheduler | decide quem executa |
| Fairness | quão equilibrada é a distribuição |
| Starvation | threads esquecidas esperando demais |
O principal resultado do benchmark
As Virtual Threads não aumentaram significativamente o throughput.
Mas elas:
- reduziram tail latency
- reduziram extremos
- mantiveram maior estabilidade
- degradaram de forma muito mais previsível
Por que isso acontece?
Platform Threads dependem diretamente de threads do sistema operacional.
Sob alta concorrência:
- context switch aumenta
- starvation aparece
- algumas requisições ficam presas por muito tempo
Resultado:
- spikes
- cauda longa
- latência imprevisível
Já as Virtual Threads
Como são extremamente leves:
- podem estacionar facilmente
- liberam carrier threads
- reduzem contenção pesada do scheduler do SO
Resultado:
- menor p95
- menor tail latency
- distribuição mais homogênea
O benchmark confirmou exatamente a JEP 444
A proposta das Virtual Threads nunca foi:
&quot;fazer CPU ficar mais rápida&quot;
A proposta sempre foi:
melhorar concorrência e escalabilidade de workloads bloqueantes
E foi exatamente isso que apareceu no benchmark.
Conclusão
O benchmark demonstrou algo muito importante:
Virtual Threads não removem gargalos externos.
Se o banco continua limitado:
- throughput continuará limitado
Mas elas melhoram significativamente:
- estabilidade
- fairness
- tail latency
- comportamento sob saturação
Resultado final
| Característica | Virtual Threads | Platform Threads |
|---|---|---|
| Throughput | Similar | Similar |
| Escalabilidade | Melhor | Pior |
| Tail latency | Muito melhor | Muito pior |
| Estabilidade | Alta | Média |
| Degradação sob saturação | Suave | Agressiva |
Considerações finais
O mais interessante desse benchmark é que ele foi executado em um cenário relativamente simples.
Mesmo assim:
- o comportamento emergente ficou muito claro
- a saturação apareceu de forma natural
- e o impacto do modelo de threading ficou evidente
Em workloads:
- I/O bound
- bloqueantes
- altamente concorrentes
Virtual Threads entregam exatamente o que o Loom prometeu:
concorrência massiva com menor custo operacional
Referências
- JEP 444 - Virtual Threads
- Java 21/25
- Project Loom
- HikariCP
- PostgreSQL
- k6
Publicado por: Guilherme Gomes - 10/05/2026 15:08
Caramelo.dev