Hay una pregunta que aparece cada vez que alguien toca GraalVM o Spring AOT en una reunión técnica: ¿cuánto tarda en arrancar? Es la primera métrica que vuela a la pantalla, el número que cierra el debate en cinco minutos. El problema es que esa pregunta sola no alcanza para tomar ninguna decisión de arquitectura seria, y en 2026 tenemos suficiente evidencia para demostrarlo con un laboratorio reproducible.
Armé JuanTorchia/springboot-jvm-2026 (tag editorial-final-startup-matrix) exactamente con esa hipótesis de trabajo: si solo mirás startup time, estás ignorando la mitad de los costos que importan en producción.
El backend de laboratorio no es un Hello World
Elegir qué medir importa tanto como medir. Un endpoint GET /ping que devuelve {"status":"ok"} no activa el mismo grafo de beans ni el mismo comportamiento de JIT que una aplicación real. Por eso el backend del lab tiene superficie concreta:
POST /api/orderscon Jakarta Validation sobre un recordGET /api/orders/{id}con Spring Data JDBC sobre PostgreSQL 17POST /api/workcon trabajo determinístico (CRC32 iterativo, hasta 5.000 iteraciones)- Flyway para migraciones, Actuator para readiness/liveness
- HikariCP con pool configurado explícitamente en el perfil
benchmark
El WorkService merece un párrafo aparte porque es el único endpoint que mezcla CPU real con una query de base de datos (countOrders()). Eso importa: sin ese endpoint, native y JVM clásica se ven prácticamente iguales en warm latency porque el JIT no tiene nada interesante que optimizar.
// WorkService.java — trabajo determinístico para forzar diferencias reales entre modos
public long calculateScore(String input, int iterations) {
byte[] seed = input.getBytes(StandardCharsets.UTF_8);
long score = 17;
for (int i = 0; i < iterations; i++) {
CRC32 crc = new CRC32();
crc.update(seed);
crc.update(longToBytes(score + i));
// rotación + constante Fibonacci aurea para dispersión
score = Long.rotateLeft(score ^ crc.getValue(), 7) + 0x9E3779B97F4A7C15L;
}
return score & Long.MAX_VALUE;
}El límite de 5_000 iteraciones no es arbitrario: lo validé con WorkServiceTest para que el cap sea predecible y el benchmark no se vuelva una prueba de throughput accidental.
Cuatro modos, cuatro superficies operativas distintas
El lab compara:
jvm:java -jarsobre Eclipse Temurin 21, el baseline de toda empresa que no tocó nadacds: JVM con archivo AppCDS dinámico preparado en una fase separadaaot-jvm: Spring Boot AOT sobre JVM, con-Dspring.aot.enabled=trueverificado en el contenedornative: GraalVM Native Image compilado dentro deghcr.io/graalvm/native-image-community:21
Ese último punto del AOT tiene historia. En la corrida editorial del 17 de mayo de 2026 (17:31–17:44 hora Buenos Aires), los resultados de aot-jvm no tenían sentido hasta que confirmé que el flag estaba llegando al contenedor. Sin spring.aot.enabled=true verificado en el env del runtime, el modo AOT no se diferencia del JVM clásico en startup. El results/environment.json captura eso exactamente para que cualquiera que reproduzca el lab sepa qué estaba corriendo.
El Dockerfile.native hace el build completo adentro del contenedor builder:
# Dockerfile.native — el build de native ocurre dentro del builder, no requiere GraalVM local
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /workspace
RUN microdnf install -y maven && microdnf clean all
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
COPY src/ src/
RUN chmod +x ./mvnw && ./mvnw -Pnative -DskipTests native:compile
FROM ubuntu:24.04
# imagen final sin JRE: solo el binario compilado
COPY --from=builder /workspace/target/startup-lab /workspace/startup-lab
ENTRYPOINT ["/workspace/startup-lab"]Eso significa que el binario startup-lab corre sin JRE en la imagen final. Imagen más chica, startup mucho más rápido, pero el costo se desplazó completamente al build. Esa es la decisión central del modo native: no eliminás trabajo, lo movés de runtime a build time.
Lo que el número de startup no captura
En esta matriz local, native redujo el startup time y el RSS respecto a los modos JVM. Eso es cierto y reproducible en el tag editorial-final-startup-matrix. Pero ese número solo no cuenta la historia completa.
El build time de native es un orden de magnitud mayor que mvn package clásico. Si estás en un pipeline de CI con deploy frecuente, ese costo aparece en cada merge a main. No es un costo de startup: es un costo de ciclo de desarrollo.
La latencia de primer request puede diferir materialmente de la latencia warm. En JVM clásica, el primer request paga el costo de clases no cargadas y JIT frío. En native no hay JIT, así que el primer request y el request número mil tienen perfil similar. Eso puede ser una ventaja o una desventaja dependiendo del perfil de carga real.
El costo de preparación de AppCDS es un tercer momento que aparece solo en el modo cds: hay una fase de dump del archivo que corre antes de que el contenedor esté listo para tráfico. Operativamente eso implica un paso de inicialización que no existe en los otros modos, y que hay que modelar en el pipeline de deploy si CDS es la opción.
La warm latency bajo carga sostenida, el comportamiento del GC en memoria alta, y el scheduling en Kubernetes son dimensiones que este lab no mide intencionalmente. Correr tres iteraciones en Docker Desktop sobre WSL2 en Windows no es producción. Lo que el lab sí garantiza es reproducibilidad local: cualquiera puede clonar el repo y reproducir la matriz con:
# Windows — corrida editorial completa con 3 runs por modo y native habilitado
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-lab.ps1 -Preset editorialLa decisión que el número de startup no puede tomar sola
Mi postura después de armar esto: el startup time es útil como tiebreaker cuando todo lo demás está empatado. Usarlo como métrica primaria para elegir entre JVM clásica, AppCDS, AOT-JVM y native es tomar una decisión de arquitectura con un solo eje.
Lo que sí puedo afirmar con evidencia de esta matriz:
- Si el requisito es startup alrededor de 1,4 segundos y RSS controlado en esta matriz, native entrega eso, pero pagás con build time mayor y pérdida de JIT en warm.
- Si el equipo necesita ciclos de CI rápidos y el startup actual es tolerable, AOT-JVM con
-Dspring.aot.enabled=truemejora el arranque sin cambiar el artefacto de deploy. - AppCDS tiene el menor costo de cambio operativo de todos, pero tiene esa fase de preparación que hay que modelar explícitamente.
- JVM clásica todavía es el baseline correcto para cualquier comparativa. Abandonarla sin medir los otros tres ejes es puro vibes.
No hay un ganador universal. Hay trade-offs que dependen de cuántas veces por hora escala el servicio, qué tan pesado es el pipeline de CI, y si el equipo puede asumir la complejidad operativa adicional de native.
El repo está en JuanTorchia/springboot-jvm-2026, tag editorial-final-startup-matrix. Los resultados raw están en results/raw/*.json y la matriz agregada en results/comparison.md. Si vas a citarlo, usá el wording del README: "In the editorial-final-startup-matrix tag of JuanTorchia/springboot-jvm-2026, measured locally on Windows Docker Desktop/WSL2..." — ese contexto de entorno no es un disclaimer decorativo, es parte del dato.
¿Cuál es la dimensión que más te mueve en la decisión entre estos cuatro modos? ¿Build time, warm latency, o compatibilidad de librerías en native?
Artículos Relacionados
Show HN: Needle distilled Gemini tool calling en 26M parámetros — lectura técnica sin hype
Un modelo de 26M de parámetros entrenado con destilación de Gemini para tool calling apareció en HN y me hizo parar todo. No para celebrar, sino para entender qué problema real señala, dónde están los límites y si vale la pena integrarlo en un stack como el mío.
OpenTelemetry en Spring Boot 3: cuando el log dice OK y el trace muestra el problema
OpenTelemetry no mejora la performance. Mejora la calidad del diagnóstico cuando una request lenta mezcla DB, downstream, N+1 y errores parciales. Este laboratorio reproducible muestra qué señales quedan ocultas si solo tenés logs, y qué aparece cuando mirás el trace.
Prisma vs JDBC: el benchmark que casi me hace culpar al ORM equivocado
Armé un laboratorio reproducible para comparar Prisma 5 contra Spring Boot JdbcTemplate sobre el mismo PostgreSQL 16. Lo que encontré no fue un ganador: fue que el shape de la query y el N+1 explican casi todo, y que culpar al ORM es demasiado fácil.
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.