Quarkus and GraalVM Native Image: Compiling Ultra-Fast Java Binaries

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

The JVM Warm-Up Tax in Modern Cloud Workloads

For decades, enterprise Java developers accepted a familiar deal: write once, rely on the HotSpot Virtual Machine, and let the Just-In-Time (JIT) compiler optimize bytecode at runtime. That trade-off works brilliantly for long-lived monoliths with steady traffic. It falls apart in modern cloud environments like Kubernetes clusters, scale-to-zero serverless platforms, or edge nodes.

Consider the baseline footprint. A standard Spring Boot service on OpenJDK 21 easily swallows 250MB to 450MB of Resident Set Size (RSS) memory before serving its first HTTP call. Cold starts usually take anywhere from 3 to 8 seconds. The JVM spends that initial window scanning classpaths, evaluating annotations, and generating runtime proxies. When a sudden traffic spike triggers your Horizontal Pod Autoscaler, incoming requests queue up or time out while new pods crawl through their boot phase.

Ahead-of-time (AOT) compilation fixes this. By pairing GraalVM with Quarkus, you shift the expensive analysis and code generation out of production and straight into your CI/CD build step.

Core Concepts: Ahead-of-Time (AOT) and Build-Time Metadata

Switching from dynamic bytecode to a compiled binary requires a fundamental shift in how you reason about the runtime.

The Closed-World Assumption

Standard HotSpot assumes an open world. It can load new classes or generate dynamic bytecode on the fly at any point during execution. GraalVM Native Image does the exact opposite: it operates under the closed-world assumption.

During compilation, GraalVM traces reachable code paths starting from the main() entry point. It inspects your classes, third-party JARs, and JDK internals. Any class, method, or field never directly touched during this static analysis gets stripped out. The result is a lean, self-contained binary containing only the machine code your application actually calls.

How Quarkus Simplifies Native Builds

Running native image builds against raw Java applications used to be painful. You had to manually map every reflective lookup, dynamic proxy, and resource bundle into verbose reflect-config.json files. Miss one entry, and the compiled binary crashes in production with a ClassNotFoundException.

Quarkus redesigns the framework layer to eliminate this friction:

  • Build-Time Metaprogramming: Arc (Quarkus's CDI container) resolves dependency injection, parses YAML configs, and indexes JPA entities during the Maven or Gradle build. By the time the code hits native compilation, all injection wiring is already plain Java code.
  • Automatic Flag Generation: Quarkus extensions register reflection metadata and JNI hooks on your behalf, sparing you from writing JSON descriptors by hand.
  • Substrate VM: The executable embeds Substrate VM instead of a full HotSpot runtime. This stripped-down engine handles thread management and memory allocation directly through operating system primitives, using a simple Serial Garbage Collector by default.

Hands-On Practice: Building and Benchmarking Native Binaries

Let's build a production-style service, package it into a native binary, and measure its actual runtime metrics.

1. Bootstrapping a Lean Quarkus Service

Scaffold a minimal project using the Quarkus Maven plugin. We will use the reactive HTTP layer backed by Jackson for JSON processing:

mvn io.quarkus.platform:quarkus-maven-plugin:3.15.1:create \
    -DprojectGroupId=com.itfromzero.nativeboot \
    -DprojectArtifactId=quarkus-native-demo \
    -DclassName="com.itfromzero.nativeboot.ProductResource" \
    -Dpath="/api/products" \
    -Dextensions="resteasy-reactive-jackson"

Define a domain record and an endpoint that returns a list of items:

package com.itfromzero.nativeboot;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;

@Path("/api/products")
public class ProductResource {

    public record Product(String sku, String name, double price) {}

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<Product> listProducts() {
        return List.of(
            new Product("HW-101", "Mechanical Keyboard", 89.99),
            new Product("HW-204", "Wireless Ergonomic Mouse", 49.50)
        );
    }
}

2. Producing the Native Executable

Skip installing a local GraalVM SDK. Quarkus can spin up an isolated Mandrel container (Red Hat's OpenJDK-focused GraalVM distribution) to handle the build:

# Run the build inside a Mandrel container
./mvnw package -Dnative -Dquarkus.native.container-build=true

# Verify the standalone executable
ls -lh target/quarkus-native-demo-1.0.0-SNAPSHOT-runner

You end up with a standalone Linux ELF binary roughly 38MB in size. It runs directly on the host without an installed JRE or JDK package.

3. Handling Reflection Without Blind Spots

What happens when you import an unmanaged third-party library? If that library parses JSON or marshals DTOs via reflection without a dedicated Quarkus extension, GraalVM might omit its fields during dead-code elimination. The application will compile cleanly, then blow up at runtime when a payload arrives.

Tag external DTOs with @RegisterForReflection to force the compiler to keep their accessors and fields:

package com.itfromzero.nativeboot;

import io.quarkus.runtime.annotations.RegisterForReflection;

@RegisterForReflection
public class ExternalAuditPayload {
    private String trackingId;
    private long timestamp;

    public String getTrackingId() { return trackingId; }
    public void setTrackingId(String trackingId) { this.trackingId = trackingId; }
    public long getTimestamp() { return timestamp; }
    public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
}

4. Measuring Startup Speed and Memory Usage

Launch the compiled binary directly from your shell:

./target/quarkus-native-demo-1.0.0-SNAPSHOT-runner

The console output shows immediate initialization:

2026-11-02 11:15:02,108 INFO  [io.quarkus] (main) quarkus-native-demo 1.0.0-SNAPSHOT native (powered by Quarkus 3.15.1) started in 0.018s. Listening on: http://0.0.0.0:8080

The operational gap between native and JVM mode is stark:

  • Cold Start: 18 milliseconds on native versus 1.8 seconds on JVM mode—a 100x improvement.
  • Idle RSS Memory: 22MB compared to roughly 185MB on standard OpenJDK.
  • Final Image Size: 48MB when dropped into a stripped-down container layer, versus 220MB+ for an OpenJDK base image.

5. Building a Minimal Kubernetes Container

Package the binary into a Red Hat Universal Base Image (UBI) minimal container. Running as a non-root user keeps the container compliant with locked-down Kubernetes security policies:

FROM registry.access.redhat.com/ubi9/ubi-minimal:9.4
WORKDIR /work/
RUN chown 1001 /work \
    && chmod "g+rwX" /work \
    && chown 1001:root /work
COPY --chown=1001:root target/*-runner /work/application

EXPOSE 8080
USER 1001

ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]

Evaluating the Trade-Offs

Native compilation is not a silver bullet. Build steps are resource-heavy; compiling a moderate service can consume 6GB to 8GB of RAM and take 3 to 5 minutes on a 4-core CI runner. Debugging also becomes harder when you need to inspect core dumps or run thread diagnostics across native code.

Throughput characteristics differ, too. Standard HotSpot uses runtime profiling to recompile hot execution paths, often matching or beating Substrate VM on steady-state CPU workloads. However, when you need sub-second autoscaling, small container footprints, or minimal cloud bills on AWS Fargate and Knative, native images are tough to beat.

Share: