Java nativo: quando sim, quando não e o que convém em cloud native
Por Dorian Chávez · fundador da Hábil e arquiteto de integração ·
GraalVM, Quarkus, Spring Boot, cache de inicialização do Java 25 e CRaC: o que cada opção ganha e custa, e qual convém ao serviço e à nuvem.
Em quase todas as conversas sobre microsserviços em Java aparece a mesma pergunta: vamos compilá-los como nativos? A promessa soa irresistível: serviços que iniciam em uma fração do tempo e usam uma fração da memória. E é verdadeira, com uma letra miúda que quase nunca se lê: o que se ganha em inicialização e memória se paga em capacidade de processamento, em tempo de compilação e em complexidade de operação.
Este artigo é para quem decide como os serviços Java de um banco, de uma seguradora, de uma rede de varejo ou de qualquer empresa regulada são construídos e executados. Responde a três perguntas: quando o nativo convém e quando não, o que fazer se o serviço é novo ou se já está em Spring Boot, e o que convém em uma arquitetura cloud native.
Primeiro, o que significa «nativo»
Um serviço Java comum roda sobre a JVM, a máquina virtual do Java. Inicia, carrega as classes e, enquanto atende requisições, um compilador interno (o JIT) vai otimizando o código mais usado. Por isso um serviço Java demora a «aquecer»: nos primeiros segundos é lento e depois é muito rápido.
Compilar para nativo —com GraalVM Native Image— faz todo esse trabalho antes, na construção. O resultado é um executável que já não precisa da JVM: inicia muito mais rápido e usa muito menos memória. Em troca, o compilador precisa conhecer de antemão tudo o que o programa pode fazer (o chamado «mundo fechado») e perde a capacidade de continuar otimizando com a carga real.
Entre esses dois extremos há opções intermediárias que convém comparar antes de decidir:
- O cache de inicialização do Java (projeto Leyden). O Java 24 introduziu o carregamento e a vinculação antecipados de classes e o Java 25 acrescentou a ergonomia e os perfis; com eles a JVM pode guardar em um arquivo o trabalho de carga e vinculação de classes e os perfis de uma execução de treinamento, e reutilizá-lo ao iniciar. O cache depende da aplicação, do JDK, do sistema operacional e da arquitetura. Continua sendo a mesma JVM, com o JIT de sempre; inicia mais rápido e, no laboratório do Quarkus, também usou menos memória [1][2][3].
- Guardar e restaurar um processo já aquecido. O CRaC, um projeto do OpenJDK disponível em algumas distribuições de Java, e o AWS Lambda SnapStart tiram uma «foto» da aplicação já iniciada e a restauram. O CRaC pode restaurar muito rápido; o SnapStart pode reduzir o cold start do Lambda a menos de um segundo em cenários favoráveis (número da AWS; depende da aplicação, da plataforma e da carga), com condições sobre estado, conexões e credenciais [4][5].
O que dizem as medições
A comparação pública mais completa das três modalidades é publicada pelo próprio projeto Quarkus no guia oficial, com um serviço de teste [6]. É um laboratório de um fornecedor e com uma carga específica, portanto lê-se como ordem de grandeza, não como promessa:
| Modalidade | Tempo até a primeira requisição | Desempenho de pico (requisições por segundo) | Memória residente (RSS) |
|---|---|---|---|
| JVM comum | ~4,4 s | ~13.300 | ~304 MiB |
| JVM com cache de inicialização (Leyden) | ~1,9 s | ~12.400 | ~240 MiB |
| Nativo (GraalVM) | ~0,6 s | ~5.400 | ~95 MiB |
Números do laboratório do projeto Quarkus (fornecedor): execução de 21 de abril de 2026 com Quarkus 3.34.3, JDK 25.0.2, GraalVM 25.0.2, 4 CPUs e `-Xmx512m`. A memória residente é a RAM que o processo mantém ocupada; o desempenho de pico é a capacidade máxima sob essa carga, não a da produção real; e o tempo até a primeira requisição não é o tempo de implantação nem a latência que o cliente sente.
Três leituras que importam mais que os números:
- Nesse laboratório, o nativo chegou à primeira requisição cerca de sete vezes antes e usou perto de um terço da memória.
- Mas processou cerca de 40 % das requisições por segundo da JVM. Em um serviço de longa vida com carga constante, a JVM pode dar mais desempenho sustentado, porque o JIT otimiza com o que de fato acontece em produção; o Oracle GraalVM oferece otimização guiada por perfis (PGO) para encurtar essa diferença, mas ela não está disponível na edição Community: convém confirmar edição e licença, pois este laboratório não a mediu [6][7]. O resultado depende da carga, da CPU, do coletor e da configuração.
- O cache de inicialização fica com boa parte da vantagem pagando pouco: inicialização mais de duas vezes mais rápida, quase o mesmo desempenho e, aqui, memória de 304 para 240 MiB (o efeito pode variar e o cache soma cerca de 198 MB ao artefato). O OpenJDK usa a aplicação de exemplo do Spring (PetClinic) como estudo de caso do cache; nenhum estudo de caso substitui medir o serviço real [1][3].
E uma lacuna que convém dizer: não encontramos nenhuma medição pública e independente que compare as três modalidades na latência do pior caso (o p99: a latência abaixo da qual ficam 99 % das requisições; mostra a cauda, não o máximo absoluto). As que existem são de fornecedores, com as cargas deles. A única forma séria de decidir é medir com a carga real.
Os tempos que importam: compilar, iniciar, estar pronto e aquecer
Quando se fala em «rápido», misturam-se quatro tempos distintos, e convém separá-los:
| Tempo | JVM comum | JVM com cache de inicialização | Nativo |
|---|---|---|---|
| Compilar o serviço | segundos (cerca de 30 s no exemplo do Quarkus) | segundos, mais uma execução de treinamento para gerar o cache | de 3 a 10 minutos e de 4 a 8 GB de memória [6] (número de fornecedor; depende da aplicação, da plataforma e da carga) |
| Iniciar até atender a primeira requisição | ~4,4 s | ~1,9 s | ~0,6 s [6] |
| Estar pronto para receber tráfego | o tempo que levar para iniciar e conectar com as dependências | igual, mas inicia antes | igual, mas inicia antes |
| Aquecer até o melhor desempenho | enquanto o JIT otimiza com a carga real | parte do aquecimento já vem no cache [2] | não há aquecimento: chega direto ao desempenho dele, que neste laboratório foi menor [6] |
Duas consequências práticas. O tempo de compilação é pago pela equipe a cada mudança e a cada patch, não pelo cliente; e «estar pronto» quase nunca depende só da inicialização do Java: conectar com o banco de dados, o broker de mensagens ou o provedor de identidade costuma pesar igual ou mais.
As probes do Kubernetes: onde a inicialização vira um problema real
O Kubernetes decide se um serviço está vivo e se pode receber tráfego com três probes [17]:
- Startup (já terminou de iniciar?). Enquanto não passar, as outras duas não são avaliadas. O tempo tolerado para a inicialização é o número de tentativas vezes o intervalo entre elas (
failureThreshold × periodSeconds). Esta probe existe justamente para os serviços que demoram a iniciar. - Liveness (o processo continua vivo?). Se falhar, o Kubernetes reinicia o contêiner.
- Readiness (pode receber tráfego agora?). Se falhar, o Kubernetes deixa de enviar requisições, sem reiniciá-lo.
Com a extensão SmallRye Health, o Quarkus as expõe em /q/health/live, /q/health/ready e /q/health/started [19]; o Spring Boot, em /actuator/health/liveness e /actuator/health/readiness, que ele habilita automaticamente ao ser implantado no Kubernetes [20].
Um exemplo ilustrativo para um serviço em JVM que leva cerca de 5 segundos para iniciar:
startupProbe:
httpGet: { path: /q/health/started, port: 8080 }
periodSeconds: 2
failureThreshold: 30 # tolera até 60 s de inicialização
livenessProbe:
httpGet: { path: /q/health/live, port: 8080 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /q/health/ready, port: 8080 }
periodSeconds: 5No nativo, com meio segundo de inicialização, a startup probe quase não espera; com JVM, é ela que evita que o cluster mate o serviço no meio da subida. Um serviço lento para iniciar não precisa de nativo para sobreviver à implantação: precisa de uma startup probe bem configurada. Essa probe evita reinícios prematuros, mas não encurta o tempo até ficar pronto: se a meta de escalabilidade exige ficar pronto antes, o cache de inicialização ou o nativo continuam sendo opções a medir.
Os dois erros que mais vemos:
- Uma liveness probe que verifica o banco de dados ou um serviço externo. Se essa dependência cai, o Kubernetes reinicia todas as instâncias ao mesmo tempo e transforma um problema externo em uma queda própria. A documentação do Spring Boot alerta sobre isso com essas palavras [20]. A liveness deve responder apenas «o processo está vivo».
- Não configurar startup probe e compensar com um atraso fixo na liveness. Se um dia a inicialização demora mais —um nó sobrecarregado, uma dependência lenta—, o serviço entra em um ciclo de reinícios.
A readiness pode, sim, considerar dependências, com critério: se sem o banco de dados o serviço não consegue responder nada útil, convém tirá-lo do tráfego; se consegue responder em parte, é melhor deixá-lo e tratar o erro mais acima [20].
Quanta memória um serviço Java pede em um contêiner, e por quê
É comum ver serviços Spring Boot que pedem cerca de 500 MB por contêiner, mesmo com lógica pequena. Não é um defeito do Spring: é a soma do que a JVM precisa para viver:
- O heap, onde vivem os objetos. Se não for configurado, a JVM toma no máximo um quarto da memória que detecta [22], e dentro de um contêiner detecta o limite do contêiner [23].
- As classes carregadas (metaspace). Um framework com muitas funções carrega milhares de classes.
- O código já otimizado pelo JIT (code cache).
- Um stack para cada thread. Um servidor web com um pool grande de threads soma memória mesmo que essas threads estejam esperando.
- Buffers e o próprio coletor de memória.
Por isso, o que importa no Kubernetes não é o tamanho do heap, mas a memória total do processo (o que o sistema vê como RSS), e o limite do contêiner precisa cobri-la com folga. Do contrário, o Kubernetes mata o contêiner por falta de memória mesmo que o heap pareça saudável.
Antes de ir para o nativo, há ajustes que reduzem a memória sem trocar de modelo: fixar a porcentagem de memória que o heap toma, dimensionar os pools de threads para o que o serviço de fato atende e escolher o coletor conforme o tamanho do contêiner. O cache de inicialização, por outro lado, acelera a inicialização e, no laboratório do Quarkus, baixou a memória de 304 para 240 MiB (~21 %); o efeito pode variar e o cache aumenta o tamanho do artefato [24].
Onde o nativo de fato muda as contas é na densidade. Uma conta ilustrativa: em um nó com 16 GB disponíveis para serviços cabem uns 32 contêineres de 500 MB, ou uns 160 de 100 MB. Se a fatura de nuvem ou os nós próprios são definidos pela memória, e há dezenas de serviços pequenos, essa diferença paga a compilação nativa. Se há poucos serviços grandes com carga constante, não.
O que o nativo custa e quase ninguém coloca na proposta
- Construir demora e pesa. O guia do Quarkus estima de 3 a 10 minutos e de 4 a 8 GB de memória para uma compilação nativa (número de fornecedor; depende da aplicação, da plataforma e da carga), contra uns segundos da comum [6]. Isso se repete a cada mudança, a cada patch de Java e a cada dependência nova, e se sente no pipeline.
- O «mundo fechado» quebra coisas em silêncio. O que o programa descobre em tempo de execução —reflexão, proxies, serialização, carga dinâmica de classes, recursos— precisa ser declarado. Se não for, o binário pode compilar e falhar ao executar um caminho dinâmico que ninguém testou; por isso um piloto nativo precisa incluir testes de integração do binário, não só do código [8].
- No Spring Boot, quando o processamento AOT ou o nativo é habilitado, algumas decisões ficam congeladas na compilação. Os perfis e as propriedades que mudam quais componentes são criados deixam de poder ser alterados na inicialização; as credenciais e os endereços, sim [9].
- Diagnosticar é diferente. O JFR, a gravação de eventos que uma equipe usa diariamente na JVM, existe no nativo, mas vem desligado e é preciso incluí-lo na compilação [10]; o mesmo vale para outras ferramentas de diagnóstico. Convém validar antes se a operação dispõe do que precisa para investigar um incidente.
- Em uma empresa regulada, o nativo acrescenta coisas a governar: o compilador e a versão dele, os metadados, o inventário de componentes (SBOM) e os testes do binário, tudo rastreável por versão. E diante de uma vulnerabilidade em uma dependência ou no JDK, não basta atualizar: é preciso recompilar, testar de novo e liberar de novo o binário. Não reduz o trabalho de segurança; muda-o de lugar.
Se o serviço é novo: Quarkus?
Para um serviço novo, a pergunta certa não é «Quarkus ou Spring?», mas «que tipo de serviço é?».
- O Quarkus nasceu para isso. Resolve quase tudo na compilação e as extensões dele declaram se são compatíveis com o nativo [11]; mesmo assim, a compatibilidade se verifica dependência por dependência. Se o serviço é pequeno, vai escalar a zero ou vive com pouca memória, o Quarkus nativo pode reduzir inicialização e memória quando as extensões necessárias são compatíveis; o guia do Quarkus recomenda partir da JVM e migrar para o nativo diante de uma necessidade concreta. E se depois a JVM se mostrar mais conveniente, o Quarkus também roda muito bem nela.
- O Spring Boot continua sendo uma ótima opção quando a equipe já o domina, quando o serviço depende de bibliotecas do ecossistema dele ou quando a lógica é complexa e de longa vida. Desde a versão 3 suporta nativo oficialmente, e hoje oferece também o cache de inicialização do Java 25 [9][12].
- Micronaut e Helidon também oferecem caminhos nativos; a compatibilidade se verifica por dependência e caso de uso [13][14].
Se a equipe sabe Spring Boot: quando convém entrar no Quarkus?
É a pergunta mais comum, e a resposta honesta começa pelo que não aparece em um comparativo: o custo de ter dois frameworks. Duas formas de configurar, de testar, de monitorar e de contratar. Uma equipe que domina o Spring Boot já é produtiva, e essa produtividade vale mais que alguns segundos de inicialização em um serviço que vive semanas sem reiniciar.
Permanecer no Spring Boot quando:
- O serviço vive muito tempo com carga constante: aí a JVM pode render mais e a inicialização pesa pouco; convém validar com o perfil de tráfego.
- Depende de bibliotecas do ecossistema Spring que não têm equivalente direto.
- O cache de inicialização do Java 25 já resolve o problema de tempos [12].
Entrar no Quarkus convém quando:
- Há serviços novos que vão escalar a zero, rodar como funções ou viver com muito pouca memória, e o nativo de fato paga o custo dele.
- A densidade do cluster é um problema real de custo: muitos serviços pequenos que não cabem nos nós.
- A equipe tem espaço para aprender, e se começa com um serviço piloto, não com uma migração.
O que é preciso reaprender, dito a partir da experiência. Passar para o Quarkus não é trocar anotações. Muda a forma de injetar dependências (CDI em vez do contêiner do Spring), a forma de acessar dados (Panache, com estilo próprio de entidades e repositórios, em vez do Spring Data), a camada REST, a configuração e o ciclo de desenvolvimento. A nós coube reaprender boa parte do que dávamos por sabido. Vale a pena quando o tipo de serviço pede; não vale a pena para tudo.
O que facilita a mudança, e o que não. O Quarkus traz uma camada de compatibilidade com as anotações do Spring mais usadas —injeção de dependências, controladores web, Spring Data JPA, propriedades, transações— para que uma equipe de Spring seja produtiva desde o primeiro dia [21]. Mas é uma ponte, não uma cópia: não suporta @Conditional nem @ComponentScan, porque o Quarkus resolve as dependências na compilação, e o próprio guia recomenda migrar com o tempo para as anotações padrão do CDI [21]. Um serviço Spring grande não se «converte» para Quarkus; reescreve-se com calma ou permanece onde está.
A regra que usamos: não se troca de framework por moda nem por um benchmark. Troca-se quando um tipo de serviço concreto justifica com números, e começa-se por um.
Se já há Spring Boot: não saltar direto para o nativo
Saltar direto para o nativo em um serviço Spring que funciona, «porque inicia mais rápido», acrescenta risco quando não se validou a compatibilidade das dependências. A ordem recomendada:
- Atualizar primeiro. O Java 25 LTS —ou uma versão posterior que a organização tenha validado— e a versão vigente do Spring Boot podem trazer melhorias de inicialização e de memória; convém validar com testes funcionais e operacionais.
- Ativar o cache de inicialização. O Spring Boot o documenta como a opção recomendada a partir do Java 25 [12]. Exige um fluxo de treinamento e validação do artefato (mesma aplicação e mesma versão do Java). É uma mudança de menor risco que o nativo e, em muitos serviços, pode bastar.
- Avaliar o CRaC somente se a plataforma o suporta e a equipe consegue cuidar do que ele exige: fechar e reabrir conexões, renovar credenciais e não deixar segredos dentro da foto [4][15].
- Ir para o nativo somente com um piloto medido no serviço em que a memória ou a inicialização de fato custam dinheiro.
O que convém em cloud native
«Cloud native» não significa «nativo». Significa desenhar serviços que escalem, se recuperem e sejam implantados de forma automática. O que convém depende de como cada serviço vive:
| Tipo de serviço | O que convém | Por quê |
|---|---|---|
| Funções e serviços que escalam a zero | Nativo, ou SnapStart se opera no AWS Lambda e valida as restrições | A inicialização é paga a cada requisição fria [5][16] |
| Serviços de longa vida com carga constante | JVM, com cache de inicialização | O JIT entrega mais desempenho sustentado [6] |
| Picos de tráfego imprevisíveis | Nativo para as instâncias adicionadas no pico | Podem reduzir o tempo até ficarem prontas; medir incluindo conexões e dependências |
| Muitos serviços pequenos em um cluster | Nativo se a memória é o que limita a densidade | Cabem mais serviços por nó |
| Ferramentas de linha de comando e processos curtos | Nativo | Não há tempo para aquecer |
No Kubernetes há um detalhe prático: um serviço que demora a iniciar precisa de uma probe de início com tempo suficiente; se não, o cluster o reinicia antes de ele terminar de subir [17]. O cache de inicialização e o nativo reduzem esse problema; uma startup probe que cubra a pior inicialização evita os reinícios, embora não transforme um serviço lento em um serviço pronto.
O que não se sustenta
- «O nativo é sempre mais rápido.» Inicia antes; com carga sustentada, a JVM costuma processar mais.
- «O nativo resolve a latência do pior caso.» A cauda continua sendo definida pelo coletor de memória, pela rede, pelos pools de conexões e pelas dependências externas.
- «Compila sem mudanças.» Só se todas as dependências já estão preparadas para o mundo fechado.
- «Menos memória é menos custo.» É preciso somar a CPU por requisição, o pipeline, o tamanho do artefato e a operação.
- «O Leyden já substitui o nativo.» Ainda não: conserva a JVM, e a compilação antecipada de código (JEP 544) está em estado de candidata, não disponível em uma versão liberada do JDK [18].
Por onde começar
- Classificar os serviços por como vivem: funções, serviços de longa vida, picos, ferramentas.
- Medir antes de decidir: inicialização, memória, requisições por segundo e latência do pior caso, com a carga real.
- Testar primeiro o barato: atualizar o Java e ativar o cache de inicialização.
- Fazer um piloto nativo somente onde a inicialização ou a memória custam dinheiro, com as dependências reais e o pipeline.
- Definir o critério de saída antes de começar: se o piloto não melhora a métrica que dói (memória, inicialização ou custo) o suficiente para pagar a complexidade, o serviço permanece na JVM.
- Decidir com os números na mão, e deixar por escrito o porquê.
O diagnóstico não começa migrando. Classificamos os serviços candidatos, medimos inicialização, memória, capacidade e latência do pior caso com a carga real, revisamos dependências e controles de operação, e entregamos uma decisão rastreável por serviço: onde convém a JVM, o cache de inicialização, o CRaC ou o nativo, que risco permanece e qual seria o piloto mínimo. Assim se decide com evidência antes de comprometer a plataforma.
Referências
- OpenJDK, JEP 483, Ahead-of-Time Class Loading & Linking (JDK 24). https://openjdk.org/jeps/483
- OpenJDK, JEP 514 e 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, guia Building a Native Executable (versão 3.40), comparativo de JVM, cache AOT e nativo do laboratório do projeto. 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 (reflexão, proxies, recursos). https://www.graalvm.org/latest/reference-manual/native-image/dynamic-features/
- Spring Boot, Ahead-of-Time Processing e 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, versões e suporte (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, documentação (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 (proposto). 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 e guias de compatibilidade com o Spring. https://quarkus.io/guides/spring-di
- Oracle, Java SE 25 GC Tuning Guide: Ergonomics (heap máximo padrão, 1/4 da memória física). https://docs.oracle.com/en/java/javase/25/gctuning/ergonomics.html
- Oracle, The java Command (detecção de contêineres,
UseContainerSupport). https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html - Quarkus Performance Lab, execuções de JVM, cache AOT e nativo (memória de 304 para 240 MiB com cache AOT); ver [6].
- Nuvem e infraestrutura →
- Modernizar o core sem frear o negócio →
- Liberar sem medo na infraestrutura própria →
A operação da empresa enfrenta esses desafios?
Prefere e-mail? Escreva para hola@habil.mx