Native Executables with GraalVM/Mandrel

This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against.

Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change.

This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A native executable is what you get when GraalVM’s (or Mandrel’s) native-image tool takes the closed, already-augmented application Quarkus produces and ahead-of-time compiles it straight to machine code, instead of packaging it as a JAR the JVM interprets and JIT-compiles at run time. The payoff — startup measured in milliseconds and a resident memory footprint a fraction of the equivalent JVM process — is real, but it is not free: it depends on the same closed-world assumption Quarkus' augmentation is built on, and it fails loudly for any code path that assumption cannot see through. This page covers how the native build actually gets performed, what commonly breaks and how to fix it, how to debug and honestly benchmark the result, and when paying the native-build cost is actually worth it.

How the native build works

Quarkus' own build-time augmentation, covered in Build-Time Augmentation and the Closed World, already resolves CDI beans, config and routes ahead of time and records the bytecode needed to reproduce them. Building a native executable adds one more closed-world step on top of that: the native-image tool performs its own static reachability analysis over the augmented application — computing the full graph of classes, methods and fields the program can actually reach from its entry points — and ahead-of-time compiles only that graph into the resulting binary. Anything the analysis cannot prove is reachable (an arbitrary Class.forName, an unregistered proxy, a resource never referenced in code) is not there to be called at run time, which is why the failures below are so consistently reflection-, proxy- and resource-shaped: see Build-Time Augmentation and the Closed World for the full explanation of why that assumption exists and what augmentation does to satisfy it.

That analysis is performed by one of two interchangeable builder distributions:

  • GraalVM — the upstream distribution (Oracle GraalVM or GraalVM Community Edition), which bundles native-image alongside the rest of GraalVM’s polyglot and JIT-compiler machinery. Useful when a project needs GraalVM features beyond native-image itself.

  • Mandrel — Red Hat’s downstream distribution, built from a matching upstream OpenJDK with the non-Java polyglot components stripped out, and maintained specifically to build Quarkus native executables predictably. It is what Quarkus' own container builder images use by default, and is the recommended choice when native-image is the only thing being built.

Running the build: local vs. container

The simplest invocation runs the builder against whatever GraalVM/Mandrel installation JAVA_HOME (or GRAALVM_HOME) points at:

./mvnw package -Dnative

This requires a matching GraalVM or Mandrel distribution installed locally, with the native-image component available (gu install native-image on a GraalVM distribution that does not bundle it already). That requirement — and the fact the exact builder version matters — is why most teams instead build inside a container, using the same builder image CI will use, without installing anything locally:

./mvnw package -Dnative -Dquarkus.native.container-build=true

quarkus.native.builder-image pins exactly which container image performs the build (Quarkus defaults to a Mandrel-based image matching the project’s Quarkus version); pin it explicitly in application.properties when a build needs to be reproducible across machines and CI:

quarkus.native.container-build=true
quarkus.native.builder-image=quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:jdk-21

Memory and time, honestly

A native build is not a javac invocation with extra steps — it is a whole-program static analysis followed by ahead-of-time compilation, and both stages are memory- and CPU-hungry. Budget realistically:

  • Memory: non-trivial Quarkus applications commonly need 4-8GB or more of RAM available to the builder. Under-provisioning shows up as the build being killed (OOM) rather than as a clean error. quarkus.native.native-image-xmx caps (or, more usefully, raises) the heap the native-image process itself is allowed, independent of the container’s own memory limit:

    quarkus.native.native-image-xmx=6g
  • Time: a native build of a modest service commonly takes several minutes, growing with the size of the dependency graph the reachability analysis has to walk; this is a real, measurable cost on every CI run that produces a native image, not a one-time tax, and is the main reason native builds are usually a separate, slower CI job rather than the default package target.

What fails under native-image, and how to fix it

Because only what the reachability analysis can prove reachable survives, the failure modes below are the practical, everyday shape the closed-world assumption takes:

Problem Fix

Reflection on a class the analysis never saw a static reference to

Annotate the application’s own class with @RegisterForReflection (io.quarkus.runtime.annotations.RegisterForReflection). For a third-party class you cannot annotate, ship a META-INF/native-image/<groupId>/<artifactId>/native-image.properties file (bundled with the dependency, or added under the application’s own src/main/resources) that points native-image at a reflection configuration, e.g. Args = -H:ReflectionConfigurationFiles=reflect-config.json.

Dynamic proxies for an interface the analysis was never told about

Register the proxy’s interfaces the same way (native-image.properties with -H:DynamicProxyConfigurationFiles=proxy-config.json), or — preferably — rely on a Quarkus extension that already registers them for the library in question; hand-rolled proxy registration is one of the fiddlier cases to get exactly right.

Resources loaded via getResourceAsStream/classpath lookup

Bundle them explicitly with quarkus.native.resources.includes (a comma-separated list of glob patterns, optionally paired with quarkus.native.resources.excludes): + [source,properties] ---- quarkus.native.resources.includes=templates/.txt,i18n/* ----

JNI bindings to native libraries

Register the JNI-accessed classes/methods the same way reflection is registered (a JNI configuration file referenced from native-image.properties); rare in typical Quarkus applications, but shows up with some native database or crypto client libraries.

Java serialization

ObjectInputStream/ObjectOutputStream need every serializable class’s constructor reachable via reflection, which is easy to under-register; prefer a Quarkus-supported format (Jackson, JSON-B) whose extension already handles the necessary registration, rather than relying on native Java serialization in a native image.

Before writing any of the above by hand, check whether the library already has a Quarkus extension or a community-maintained one on Quarkiverse — both cases are covered in more depth, alongside the general "no extension" decision tree, in Build-Time Augmentation and the Closed World.

Build-time vs. run-time class initialization

native-image defaults most classes to run-time initialization — their static initializers run when the executable starts, not when it is built — but Quarkus itself moves a large share of its own and its extensions' classes to build-time initialization for speed, since that state is then baked straight into the image heap. The place this distinction actually bites application code is anywhere a static initializer produces something that must differ per process rather than be frozen once into every binary the build produces: a SecureRandom/RNG seeded in a static initializer, or a native library loaded via System.loadLibrary, must stay run-time-initialized, or every executable built from that image ships with the same frozen seed or the same resolved native-library state. Force run-time initialization for a specific class via quarkus.native.additional-build-args:

quarkus.native.additional-build-args=--initialize-at-run-time=com.example.MyRandomHolder

SSL/TLS in native mode

Quarkus extensions that need TLS (the REST client, gRPC, reactive database drivers) usually get the necessary native-image SSL support included automatically because augmentation can see the need at build time. When nothing in the dependency graph signals it, force it on explicitly:

quarkus.ssl.native=true

Native TLS also depends on the SunEC provider (elliptic-curve cryptography), which has historically needed extra native-image registration and has had narrower algorithm coverage than on the JVM; if a native executable fails or behaves differently on TLS handshakes that work fine in JVM mode, that provider is the first place to look.

Debugging, profiling and testing a native binary

A native executable is not a JVM process, so most of the familiar Java tooling either does not apply or applies only partially:

  • gdb-based debugging: build with quarkus.native.debug.enabled=true to have native-image emit debug symbols, then attach gdb to the resulting binary. It gives source-level debugging of native code, but not the hot-swap or the rich object inspection a JVM debugger gives against a running JVM process.

  • VisualVM/perf and profiling: VisualVM, JFR-based tooling, and heap dumps built around the JVM largely do not apply, because there is no JVM underneath a native executable. Linux perf can profile the binary at the machine-code level, but without the method-name-rich mapping a JVM profiler gives; some JFR event support exists in newer GraalVM/Mandrel releases, but its event coverage remains narrower than on the JVM. Treat native profiling as a lower-resolution instrument than its JVM equivalent, not a drop-in replacement.

  • Compressing the binary with UPX: quarkus.native.compression.level (0 disables compression) trades a slightly slower startup, spent decompressing, for a smaller binary — useful for container image/registry size, not something that helps runtime speed.

Testing the actual native artifact

@QuarkusTest runs your tests in the same JVM process as the application, which says nothing about the native executable actually built from it. io.quarkus.test.junit.QuarkusIntegrationTest instead starts the already built artifact (native executable, or JAR, whichever the build produced) as a separate out-of-process instance and exercises it over the network, giving a test that fails if native-image dropped something reflection needed or a resource never got bundled:

package com.example;

import io.quarkus.test.junit.QuarkusIntegrationTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.Test;

import static org.hamcrest.Matchers.is;

@QuarkusIntegrationTest
class GreetingResourceIT {

    @Test
    void helloEndpointRespondsOverHttp() {
        RestAssured.given()
                .when().get("/hello")
                .then()
                .statusCode(200)
                .body(is("hello"));
    }
}

Run it as part of the verify phase, after the native build has actually produced the artifact it tests against (./mvnw verify -Dnative).

Measuring startup and RSS honestly

Do not report a single anecdotal number from one run. A defensible methodology looks like:

  • Cold start: wrap the process launch with time, but measure to the point the application actually answers a health check or first request — not merely to process exit — and repeat several times, since the very first invocation on a machine also pays one-off OS page-cache warm-up that later runs will not.

  • Resident memory: sample ps -o rss= -p <pid> (or read VmRSS from /proc/<pid>/status on Linux) once shortly after the startup log line, and again after the process has served some real traffic — a snapshot taken at the very first instant tends to understate steady-state RSS.

  • Warm-up: a native executable has no JIT warm-up curve to speak of, but a JVM-mode comparison point absolutely does — comparing a cold-started native process against a JVM process that has already been warmed up (or vice versa) is a common way these numbers get quietly misleading.

The Quarkus performance-measurement guide covers this in more depth, including load-testing tools and the effect of container CPU/memory limits and CPU frequency scaling on the numbers — read it before publishing a benchmark either direction.

Native vs. JVM: the honest trade-off

Dimension Native executable JVM mode

Startup time

Milliseconds — no class loading, no JIT warm-up

Slower cold start; class loading and JIT compilation happen at process start

Memory footprint (RSS)

Low and predictable — no JIT/metaprogramming machinery resident

Higher baseline; the JVM’s own runtime, JIT compiler and class metadata all cost RSS

Peak throughput (after warm-up)

Generally lower than a fully JIT-warmed JVM — the ahead-of-time compiler cannot apply the profile-guided optimizations a warmed-up JIT does

Generally higher once warmed up — the JIT compiler specializes hot paths using real runtime profiles

Build time

Minutes, memory-hungry, needs a dedicated builder (local install or container image)

Ordinary javac/augmentation build time; no separate reachability-analysis step

Tooling and observability maturity

Limited: no JVM-based profilers, partial/no heap dumps, narrower JFR coverage, gdb-only debugging

Full JVM tooling: VisualVM, JFR, heap dumps, attach-based debuggers, mature APM agents

Best fit

Fast cold start and memory density matter more than peak throughput or tooling depth (serverless, scale-to-zero, high replica counts)

Peak throughput, long-running processes, and full observability/debugging tooling matter more than cold-start latency

When native’s build cost or tooling gap outweighs its startup/memory win, a JAR is still the right choice — see Packaging and JVM Modes for the JVM-mode packaging alternatives, including JLink-trimmed runtimes that recover some of native’s memory benefit without leaving the JVM.

The pipeline the builder actually runs, from application classes to the final binary, looks like this:

Native-image build pipeline from application classes through closed-world analysis to native executable

Should this service be native?

flowchart TD A{Is cold-start latency\ncritical? e.g. serverless,\nLambda, scale-to-zero} -->|Yes| Native A -->|No| B{Is memory density\ncritical? e.g. many\nreplicas per node} B -->|Yes| Native B -->|No| C{Can the team afford\nslower builds and a\nseparate native CI job?} C -->|No| JVM C -->|Yes| D{Does the dependency\ngraph fully support native?\nno unsupported reflection-heavy\nlibs without a Quarkiverse extension} D -->|No| JVM D -->|Yes| Native Native[Go native] JVM["Stay on JVM\n(consider JLink -- see\npackaging-and-jvm-modes.adoc)"]