Java nativo: cuándo sí, cuándo no y qué conviene en cloud native
Por Dorian Chávez · fundador de Hábil y arquitecto de integración ·
GraalVM, Quarkus, Spring Boot, caché de arranque de Java 25 y CRaC: qué gana y qué cuesta cada opción, y cuál conviene según su servicio y su nube.
En casi todas las conversaciones sobre microservicios en Java aparece la misma pregunta: ¿los compilamos nativos? La promesa suena irresistible: servicios que arrancan en una fracción del tiempo y usan una fracción de la memoria. Y es cierta, con una letra chica que casi nunca se lee: lo que se gana en arranque y memoria se paga en capacidad de procesamiento, en tiempo de compilación y en complejidad para operar.
Este artículo es para quien decide cómo se construyen y se corren los servicios Java de un banco, una aseguradora, una cadena de retail o cualquier empresa regulada. Responde tres preguntas: cuándo conviene el nativo y cuándo no, qué hacer si su servicio es nuevo o si ya está en Spring Boot, y qué conviene en una arquitectura cloud native.
Primero, qué significa «nativo»
Un servicio Java normal corre sobre la JVM, la máquina virtual de Java. Arranca, carga sus clases y, mientras atiende peticiones, un compilador interno (el JIT) va optimizando el código que más se usa. Por eso un servicio Java tarda en «calentar»: los primeros segundos es lento y después es muy rápido.
Compilar a nativo —con GraalVM Native Image— hace todo ese trabajo antes, al construir. El resultado es un ejecutable que ya no necesita la JVM: arranca mucho más rápido y usa mucha menos memoria. A cambio, el compilador tiene que conocer de antemano todo lo que el programa puede hacer (a eso le llaman «mundo cerrado»), y pierde la capacidad de seguir optimizando con la carga real.
Entre esos dos extremos hay opciones intermedias que conviene comparar antes de decidir:
- El caché de arranque de Java (proyecto Leyden). Java 24 introdujo la carga y el enlace anticipados de clases y Java 25 sumó la ergonomía y los perfiles; con ellos la JVM puede guardar en un archivo el trabajo de carga y enlace de clases y los perfiles de una corrida de entrenamiento, y reutilizarlo al arrancar. El caché depende de la aplicación, el JDK, el sistema operativo y la arquitectura. Sigue siendo la JVM de siempre, con su JIT; arranca más rápido y, en el laboratorio de Quarkus, también usó menos memoria [1][2][3].
- Guardar y restaurar un proceso ya caliente. CRaC, un proyecto de OpenJDK disponible en algunas distribuciones de Java, y AWS Lambda SnapStart toman una «foto» de la aplicación ya iniciada y la restauran. CRaC puede restaurar muy rápido; SnapStart puede bajar el arranque en frío de Lambda a menos de un segundo en escenarios favorables (cifra de AWS; depende de aplicación, plataforma y carga), con condiciones sobre el estado, las conexiones y las credenciales [4][5].
Lo que dicen las mediciones
La comparación pública más completa de las tres modalidades la publica el propio proyecto Quarkus en su guía oficial, con un servicio de prueba [6]. Es un laboratorio de un proveedor y con una carga concreta, así que se lee como orden de magnitud, no como promesa:
| Modalidad | Tiempo hasta la primera petición | Rendimiento pico (peticiones por segundo) | Memoria residente (RSS) |
|---|---|---|---|
| JVM normal | ~4.4 s | ~13,300 | ~304 MiB |
| JVM con caché de arranque (Leyden) | ~1.9 s | ~12,400 | ~240 MiB |
| Nativo (GraalVM) | ~0.6 s | ~5,400 | ~95 MiB |
Cifras del laboratorio del proyecto Quarkus (proveedor): corrida del 21-abr-2026 con Quarkus 3.34.3, JDK 25.0.2, GraalVM 25.0.2, 4 CPU y `-Xmx512m`. La memoria residente es la RAM que el proceso mantiene ocupada; el rendimiento pico es la capacidad máxima bajo esa carga, no la de su producción; y el tiempo a la primera petición no es el tiempo de despliegue ni la latencia que siente su cliente.
Tres lecturas que importan más que los números:
- En ese laboratorio, el nativo llegó a la primera petición unas siete veces antes y usó cerca de un tercio de la memoria.
- Pero procesó alrededor del 40 % de las peticiones por segundo que la JVM. En un servicio de larga vida con carga constante, la JVM puede dar más rendimiento sostenido, porque su JIT optimiza con lo que de verdad pasa en producción; Oracle GraalVM ofrece optimización guiada por perfiles (PGO) para acortar esa brecha, pero no está disponible en la edición Community: confirme edición y licencia, pues este laboratorio no la midió [6][7]. El resultado depende de la carga, la CPU, el recolector y la configuración.
- El caché de arranque se queda con buena parte de la ventaja pagando poco: arranque más de dos veces más rápido, casi el mismo rendimiento y, aquí, la memoria bajó de 304 a 240 MiB (el efecto puede variar y el caché suma unos 198 MB al artefacto). OpenJDK usa la aplicación de ejemplo de Spring (PetClinic) como caso de estudio del caché; ningún caso de estudio sustituye medir su servicio [1][3].
Y un hueco que conviene decir: no encontramos ninguna medición pública e independiente que compare las tres modalidades en la latencia del peor caso (el p99: la latencia bajo la cual cae el 99 % de las peticiones; muestra la cola, no el máximo absoluto). Las que existen son de proveedores, con sus cargas. La única forma seria de decidir es medir con la suya.
Los tiempos que importan: compilar, arrancar, estar listo y calentar
Cuando se habla de «rápido» se mezclan cuatro tiempos distintos, y conviene separarlos:
| Tiempo | JVM normal | JVM con caché de arranque | Nativo |
|---|---|---|---|
| Compilar el servicio | segundos (unos 30 s en el ejemplo de Quarkus) | segundos, más una corrida de entrenamiento para generar el caché | de 3 a 10 minutos y de 4 a 8 GB de memoria [6] (cifra de proveedor; depende de aplicación, plataforma y carga) |
| Arrancar hasta atender la primera petición | ~4.4 s | ~1.9 s | ~0.6 s [6] |
| Estar listo para recibir tráfico | lo que tarde en arrancar y conectar con sus dependencias | igual, pero arranca antes | igual, pero arranca antes |
| Calentar hasta su mejor rendimiento | mientras el JIT optimiza con la carga real | parte del calentamiento ya viene en el caché [2] | no hay calentamiento: llega directo a su rendimiento, que en este laboratorio fue menor [6] |
Dos consecuencias prácticas. El tiempo de compilación lo paga su equipo en cada cambio y cada parche, no el cliente; y «estar listo» casi nunca depende solo del arranque de Java: conectar con la base de datos, el broker de mensajes o el proveedor de identidad suele pesar igual o más.
Las sondas de Kubernetes: donde el arranque se vuelve un problema real
Kubernetes decide si un servicio vive y si puede recibir tráfico con tres sondas [17]:
- Startup (¿ya terminó de arrancar?). Mientras no pase, las otras dos no se evalúan. El tiempo que se le tolera al arranque es el número de intentos por el intervalo entre ellos (
failureThreshold × periodSeconds). Esta sonda existe justo para los servicios que tardan en arrancar. - Liveness (¿el proceso sigue vivo?). Si falla, Kubernetes reinicia el contenedor.
- Readiness (¿puede recibir tráfico ahora?). Si falla, Kubernetes deja de mandarle peticiones, sin reiniciarlo.
Con la extensión SmallRye Health, Quarkus las expone en /q/health/live, /q/health/ready y /q/health/started [19]; Spring Boot, en /actuator/health/liveness y /actuator/health/readiness, que habilita automáticamente al desplegarse en Kubernetes [20].
Un ejemplo ilustrativo para un servicio en JVM que tarda unos 5 segundos en arrancar:
startupProbe:
httpGet: { path: /q/health/started, port: 8080 }
periodSeconds: 2
failureThreshold: 30 # le tolera hasta 60 s de arranque
livenessProbe:
httpGet: { path: /q/health/live, port: 8080 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /q/health/ready, port: 8080 }
periodSeconds: 5En nativo, con medio segundo de arranque, la sonda de startup casi no espera; con JVM, es la que evita que el clúster mate al servicio a media subida. Un servicio lento para arrancar no necesita nativo para sobrevivir al despliegue: necesita una sonda de startup bien puesta. Esa sonda evita reinicios prematuros, pero no acorta el tiempo hasta estar listo: si su objetivo de escalamiento exige estar listo antes, el caché de arranque o el nativo siguen siendo opciones a medir.
Los dos errores que más vemos:
- Una sonda de liveness que revisa la base de datos o un servicio externo. Si esa dependencia se cae, Kubernetes reinicia todas las instancias a la vez y convierte un problema externo en una caída propia. La documentación de Spring Boot lo advierte con esas palabras [20]. La liveness solo debe responder «el proceso vive».
- No poner sonda de startup y compensar con un retraso fijo en la liveness. Si un día el arranque tarda más —un nodo cargado, una dependencia lenta—, el servicio entra en un ciclo de reinicios.
La readiness sí puede considerar dependencias, con criterio: si sin la base de datos el servicio no puede responder nada útil, conviene sacarlo del tráfico; si puede responder en parte, es mejor dejarlo y manejar el error más arriba [20].
Cuánta memoria pide un servicio Java en un contenedor, y por qué
Es común ver servicios Spring Boot que piden cerca de 500 MB por contenedor, aunque su lógica sea pequeña. No es un defecto de Spring: es la suma de lo que necesita la JVM para vivir:
- El heap, donde viven los objetos. Si no se configura, la JVM toma como máximo una cuarta parte de la memoria que detecta [22], y dentro de un contenedor detecta el límite del contenedor [23].
- Las clases cargadas (metaspace). Un framework con muchas funciones carga miles de clases.
- El código ya optimizado por el JIT (code cache).
- Un stack por cada hilo. Un servidor web con un pool grande de hilos suma memoria aunque esos hilos estén esperando.
- Búferes y el propio recolector de memoria.
Por eso lo que importa en Kubernetes no es el tamaño del heap, sino la memoria total del proceso (lo que el sistema ve como RSS), y el límite del contenedor tiene que cubrirla con margen. Si no, Kubernetes mata el contenedor por falta de memoria aunque el heap se vea sano.
Antes de irse a nativo, hay ajustes que bajan la memoria sin cambiar de modelo: fijar el porcentaje de memoria que toma el heap, dimensionar los pools de hilos a lo que el servicio de verdad atiende y elegir el recolector según el tamaño del contenedor. El caché de arranque, en cambio, acelera el arranque y, en el laboratorio de Quarkus, bajó la memoria de 304 a 240 MiB (~21 %); el efecto puede variar y el caché aumenta el tamaño del artefacto [24].
Donde el nativo sí cambia las cuentas es en la densidad. Una cuenta ilustrativa: en un nodo con 16 GB disponibles para servicios caben unos 32 contenedores de 500 MB, o unos 160 de 100 MB. Si su factura de nube o sus nodos en casa los define la memoria, y tiene decenas de servicios chicos, esa diferencia paga la compilación nativa. Si tiene pocos servicios grandes con carga constante, no.
Lo que cuesta el nativo y casi nadie pone en la propuesta
- Construir tarda y pesa. La guía de Quarkus estima de 3 a 10 minutos y de 4 a 8 GB de memoria para una compilación nativa (cifra de proveedor; depende de aplicación, plataforma y carga), contra unos segundos de la normal [6]. Eso se repite en cada cambio, cada parche de Java y cada dependencia nueva, y se nota en el pipeline.
- El «mundo cerrado» rompe cosas en silencio. Lo que el programa descubre en tiempo de ejecución —reflexión, proxies, serialización, carga dinámica de clases, recursos— tiene que declararse. Si no, el binario puede compilar y fallar al ejecutar una ruta dinámica que nadie probó; por eso un piloto nativo tiene que incluir pruebas de integración del binario, no solo del código [8].
- En Spring Boot, cuando se habilita el procesamiento AOT o el nativo, algunas decisiones se congelan al compilar. Los perfiles y las propiedades que cambian qué componentes se crean dejan de poder cambiarse al arrancar; las credenciales y direcciones sí [9].
- Diagnosticar es distinto. JFR, la grabación de eventos que un equipo usa a diario en la JVM, existe en nativo, pero viene apagada y hay que incluirla al compilar [10]; lo mismo pasa con otras herramientas de diagnóstico. Valide antes que su operación tenga lo que necesita para investigar un incidente.
- En una empresa regulada, el nativo agrega cosas que gobernar: el compilador y su versión, la metadata, el inventario de componentes (SBOM) y las pruebas del binario, todo trazable por versión. Y ante una vulnerabilidad en una dependencia o en el JDK, no basta con actualizar: hay que recompilar, volver a probar y volver a liberar el binario. No reduce el trabajo de seguridad; lo cambia de lugar.
Si su servicio es nuevo: ¿Quarkus?
Para un servicio nuevo, la pregunta correcta no es «¿Quarkus o Spring?», sino «¿qué tipo de servicio es?».
- Quarkus nació para esto. Resuelve casi todo al compilar y sus extensiones declaran si son compatibles con nativo [11]; aun así, la compatibilidad se revisa dependencia por dependencia. Si el servicio es pequeño, va a escalar a cero o vive con poca memoria, Quarkus nativo puede reducir arranque y memoria cuando las extensiones requeridas son compatibles; la guía de Quarkus recomienda partir de JVM y pasar a nativo ante una necesidad concreta. Y si después resulta que conviene la JVM, Quarkus también corre muy bien en ella.
- Spring Boot sigue siendo una gran opción cuando el equipo ya lo domina, cuando el servicio depende de librerías de su ecosistema o cuando la lógica es compleja y de vida larga. Desde la versión 3 soporta nativo de forma oficial, y hoy ofrece también el caché de arranque de Java 25 [9][12].
- Micronaut y Helidon también ofrecen rutas nativas; la compatibilidad se revisa por dependencia y caso de uso [13][14].
Si su equipo sabe Spring Boot: ¿cuándo le conviene entrarle a Quarkus?
Es la pregunta más común, y la respuesta honesta empieza por lo que no se ve en un comparativo: el costo de tener dos frameworks. Dos formas de configurar, de probar, de monitorear y de contratar. Un equipo que domina Spring Boot ya es productivo, y esa productividad vale más que unos segundos de arranque en un servicio que vive semanas sin reiniciarse.
Quédese en Spring Boot cuando:
- El servicio vive mucho tiempo con carga constante: ahí la JVM puede rendir mejor y el arranque pesa poco; valídelo con su perfil de tráfico.
- Depende de librerías del ecosistema Spring que no tienen equivalente directo.
- El caché de arranque de Java 25 ya le resuelve el problema de tiempos [12].
Entrarle a Quarkus conviene cuando:
- Hay servicios nuevos que van a escalar a cero, correr como funciones o vivir con muy poca memoria, y el nativo sí paga su costo.
- La densidad del clúster es un problema real de costo: muchos servicios chicos que no caben en los nodos.
- El equipo tiene espacio para aprender, y se empieza con un servicio piloto, no con una migración.
Lo que hay que reaprender, dicho desde la experiencia. Pasarse a Quarkus no es cambiar anotaciones. Cambia la forma de inyectar dependencias (CDI en lugar del contenedor de Spring), la forma de acceder a datos (Panache, con su propio estilo de entidades y repositorios, en lugar de Spring Data), la capa REST, la configuración y el ciclo de desarrollo. A nosotros nos tocó reaprender buena parte de lo que dábamos por sabido. Vale la pena cuando el tipo de servicio lo pide; no vale la pena para todo.
Lo que facilita el cambio, y lo que no. Quarkus trae una capa de compatibilidad con las anotaciones de Spring más usadas —inyección de dependencias, controladores web, Spring Data JPA, propiedades, transacciones— para que un equipo de Spring sea productivo desde el primer día [21]. Pero es un puente, no una copia: no soporta @Conditional ni @ComponentScan, porque Quarkus resuelve las dependencias al compilar, y la propia guía recomienda pasar con el tiempo a las anotaciones estándar de CDI [21]. Un servicio Spring grande no se «convierte» a Quarkus; se reescribe con calma o se queda donde está.
La regla que usamos: no se cambia de framework por moda ni por un benchmark. Se cambia cuando un tipo de servicio concreto lo justifica con números, y se empieza por uno.
Si ya tiene Spring Boot: no salte directo al nativo
Saltar directo a nativo un servicio Spring que funciona, «porque arranca más rápido», agrega riesgo cuando no se ha validado la compatibilidad de sus dependencias. El orden que recomendamos:
- Actualice primero. Java 25 LTS —o una versión posterior que su organización haya validado— y la versión vigente de Spring Boot pueden traer mejoras de arranque y de memoria; valídelo con pruebas funcionales y operativas.
- Active el caché de arranque. Spring Boot lo documenta como su opción recomendada para Java 25 en adelante [12]. Requiere un flujo de entrenamiento y validación del artefacto (misma aplicación y misma versión de Java). Es un cambio de menor riesgo que el nativo y, en muchos servicios, puede alcanzar.
- Evalúe CRaC solo si su plataforma lo soporta y su equipo puede cuidar lo que exige: cerrar y reabrir conexiones, refrescar credenciales y no dejar secretos dentro de la foto [4][15].
- Vaya a nativo solo con un piloto medido en el servicio donde la memoria o el arranque de verdad cuestan dinero.
Qué conviene en cloud native
«Cloud native» no significa «nativo». Significa diseñar servicios que escalen, se recuperen y se desplieguen de forma automática. Lo que conviene depende de cómo vive cada servicio:
| Tipo de servicio | Lo que conviene | Por qué |
|---|---|---|
| Funciones y servicios que escalan a cero | Nativo, o SnapStart si opera en AWS Lambda y valida sus restricciones | El arranque se paga en cada petición fría [5][16] |
| Servicios de larga vida con carga constante | JVM, con caché de arranque | El JIT entrega más rendimiento sostenido [6] |
| Picos de tráfico impredecibles | Nativo para las instancias que se agregan en el pico | Pueden reducir el tiempo hasta estar listas; mídalo incluyendo conexiones y dependencias |
| Muchos servicios pequeños en un clúster | Nativo si la memoria es lo que limita la densidad | Caben más servicios por nodo |
| Herramientas de línea de comandos y procesos cortos | Nativo | No hay tiempo para calentar |
En Kubernetes hay un detalle práctico: un servicio que tarda en arrancar necesita una sonda de inicio con tiempo suficiente; si no, el clúster lo reinicia antes de que termine de levantar [17]. El caché de arranque y el nativo reducen ese problema; una sonda de startup que cubra el peor arranque evita los reinicios, aunque no convierte un servicio lento en uno listo.
Lo que no se sostiene
- «El nativo siempre es más rápido.» Arranca antes; con carga sostenida, la JVM suele procesar más.
- «El nativo resuelve la latencia del peor caso.» La cola la siguen definiendo el recolector de memoria, la red, los pools de conexiones y las dependencias externas.
- «Compila sin cambios.» Solo si todas sus dependencias ya están preparadas para el mundo cerrado.
- «Menos memoria es menos costo.» Hay que sumar la CPU por petición, el pipeline, el tamaño del artefacto y la operación.
- «Leyden ya reemplaza al nativo.» Todavía no: conserva la JVM, y la compilación anticipada de código (JEP 544) está en estado de candidata, no disponible en una versión liberada del JDK [18].
Por dónde empezar
- Clasifique sus servicios por cómo viven: funciones, servicios de larga vida, picos, herramientas.
- Mida antes de decidir: arranque, memoria, peticiones por segundo y latencia del peor caso, con su carga real.
- Pruebe primero lo barato: actualizar Java y activar el caché de arranque.
- Haga un piloto nativo solo donde el arranque o la memoria cuesten dinero, con sus dependencias reales y su pipeline.
- Defina el criterio de salida antes de empezar: si el piloto no mejora la métrica que le duele (memoria, arranque o costo) lo suficiente para pagar su complejidad, el servicio se queda en la JVM.
- Decida con los números en la mano, y deje escrito por qué.
El diagnóstico no empieza migrando. Clasificamos los servicios candidatos, medimos arranque, memoria, capacidad y latencia del peor caso con su carga, revisamos dependencias y controles de operación, y le entregamos una decisión trazable por servicio: dónde conviene la JVM, el caché de arranque, CRaC o el nativo, qué riesgo queda y cuál sería el piloto mínimo. Así decide con evidencia antes de comprometer su plataforma.
Referencias
- OpenJDK, JEP 483, Ahead-of-Time Class Loading & Linking (JDK 24). https://openjdk.org/jeps/483
- OpenJDK, JEP 514 y JEP 515 (JDK 25). https://openjdk.org/jeps/514 · https://openjdk.org/jeps/515
- OpenJDK, Project Leyden. https://openjdk.org/projects/leyden/
- OpenJDK, CRaC. https://github.com/openjdk/crac
- AWS, Lambda SnapStart. https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html
- Quarkus, guía Building a Native Executable (versión 3.40), comparativo JVM, caché AOT y nativo del laboratorio del proyecto. https://quarkus.io/version/3.40/guides/building-native-image/
- GraalVM, Optimizations and Performance. https://www.graalvm.org/jdk25/reference-manual/native-image/optimizations-and-performance/
- GraalVM, Dynamic Features (reflexión, proxies, recursos). https://www.graalvm.org/latest/reference-manual/native-image/dynamic-features/
- Spring Boot, Ahead-of-Time Processing y GraalVM Native Images. https://docs.spring.io/spring-boot/reference/packaging/aot.html · https://docs.spring.io/spring-boot/reference/packaging/native-image/introducing-graalvm-native-images.html
- GraalVM, JFR in Native Image. https://www.graalvm.org/latest/reference-manual/native-image/debugging-and-diagnostics/JFR/
- Quarkus, versiones y soporte (3.40 LTS). https://quarkus.io/blog/quarkus-3-40-released/
- Spring Boot, AOT Cache. https://docs.spring.io/spring-boot/reference/packaging/aot-cache.html
- Micronaut, documentación (5.2). https://docs.micronaut.io/5.2.x/core/
- Helidon, Native Image. https://helidon.io/docs/v4/mp/guides/native-image
- Spring Boot, Checkpoint and Restore. https://docs.spring.io/spring-boot/reference/packaging/checkpoint-restore.html
- AWS, Reducing Java cold starts on AWS Lambda functions with SnapStart. https://aws.amazon.com/blogs/compute/reducing-java-cold-starts-on-aws-lambda-functions-with-snapstart/
- Kubernetes, Configure Liveness, Readiness and Startup Probes. https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
- OpenJDK, JEP 544, Ahead-of-Time Code Compilation (propuesto). https://openjdk.org/jeps/544
- Quarkus, SmallRye Health. https://quarkus.io/guides/smallrye-health
- Spring Boot, Actuator: Kubernetes Probes. https://docs.spring.io/spring-boot/reference/actuator/endpoints.html
- Quarkus, Quarkus Extension for Spring DI API y guías de compatibilidad con Spring. https://quarkus.io/guides/spring-di
- Oracle, Java SE 25 GC Tuning Guide: Ergonomics (heap máximo por defecto, 1/4 de la memoria física). https://docs.oracle.com/en/java/javase/25/gctuning/ergonomics.html
- Oracle, The java Command (detección de contenedores,
UseContainerSupport). https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html - Quarkus Performance Lab, corridas JVM, caché AOT y nativo (memoria de 304 a 240 MiB con caché AOT); ver [6].
- Nube e infraestructura →
- Modernizar el core sin frenar el negocio →
- Liberar sin miedo en su propia infraestructura →
¿Su operación tiene estos desafíos?
¿Prefiere correo? Escríbanos a hola@habil.mx