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
private static void startWebServer(boolean virtual, boolean withLock) throws IOException {
HttpServer httpServer = HttpServer.create(new InetSocketAddress(8001), 0);
httpServer.createContext("/caramelo", new WebServerHandler(withLock));
if (virtual) {
httpServer.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
} else {
httpServer.setExecutor(Executors.newFixedThreadPool(200));
}
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
public class DatabaseService {
private static final HikariDataSource dataSource;
static {
var config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/test");
config.setUsername("postgres");
config.setPassword("password");
// gargalo controlado
config.setMaximumPoolSize(100);
dataSource = new HikariDataSource(config);
}
public static String query() {
// Você pode testar de forma deterministica ou real, direto em uma tabela do Postgres
boolean deterministic = true;
String sql = deterministic
? "SELECT pg_sleep(0.2)"
: "SELECT * FROM carro";
try (Connection conn = dataSource.getConnection();
var stmt = conn.createStatement()) {
var rs = stmt.executeQuery(sql);
if (rs.next()) {
return "OK_DB";
}
} catch (Exception e) {
return "ERROR";
}
return "OK_DB";
}
}
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
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 500,
duration: '30s',
};
export default function () {
const res = http.get('http://localhost:8001/caramelo');
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(0.1);
}
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:
"fazer CPU ficar mais rápida"
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
Caramelo