GraalVM Native Image Guide: Master Java Reflection, Startup Speed, and Container Builds Without the Guesswork

GraalVM Native Image guide: fix reflection, resource, and proxy errors, cut Java cold starts to milliseconds, and ship smaller, faster containers.

GraalVM Native Image Guide: Master Java Reflection, Startup Speed, and Container Builds Without the Guesswork

The first time I watched a Java function cold start on a serverless platform, I sat there counting. One second. Two. Three. Then finally, the JSON came back. The request itself took forty milliseconds. The rest was the JVM waking up, loading thousands of classes, warming up the just-in-time compiler, and only then deciding it was ready to do the actual work. That gap between what the machine was doing and what I needed it to do is what pushed me into GraalVM Native Image, and I want to walk you through every pattern I picked up along the way.

Native Image is not a different Java. It is the same Java you already write, compiled ahead of time into a standalone executable. Instead of shipping a jar plus a JVM, you ship one binary. No class loading at startup. No JIT warmup. The compiler already looked at your code during the build and decided what to keep, what to inline, and what to throw away entirely.

That last part is the trade. The compiler can only see what is reachable from your main method at build time. Anything that shows up dynamically at runtime — a class loaded by name, a resource read from a path string, a proxy generated on the fly — is invisible to it unless you tell it otherwise. This is called the closed-world assumption, and almost every problem you will hit comes from violating it by accident.

Let me start where everyone starts. Install the toolchain. I use SDKMAN because it keeps versions tidy.

sdk install java 21.0.2-graalce
sdk use java 21.0.2-graalce
native-image --version

If that last command prints a version, you are ready. If it complains, you installed a normal JDK and the native-image tool is not there. On macOS with Homebrew it is brew install --cask graalvm-jdk. On Linux, download the tarball and set JAVA_HOME and GRAALVM_HOME to the same directory.

Now the smallest possible build. Take a plain class, compile it with javac, and feed it to the compiler.

public class Hello {
    public static void main(String[] args) {
        System.out.println("built at " + System.currentTimeMillis());
    }
}
javac Hello.java
native-image Hello
./hello

That produces a binary around ten megabytes on my machine. It starts in about nine milliseconds. I remember staring at that number and laughing, because I had spent years assuming JVM startup was just the price of admission.

The real work begins the moment you add a library. Take Jackson, which every Java service I have ever written depends on. Here is the sort of code that works perfectly on HotSpot and dies instantly in a native image.

ObjectMapper mapper = new ObjectMapper();
User user = mapper.readValue(json, User.class);

You will get something like com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class com.example.User. The compiler stripped User because nothing referenced its fields directly. Jackson wanted to reach them by reflection, and reflection metadata is not carried into the image unless you ask.

The first pattern is the blunt one. Annotate the class.

import com.oracle.svm.core.annotate.RegisterForReflection;

@RegisterForReflection
public record User(String name, String email) {}

Recompile, rebuild, and it works. For a handful of DTOs, this is fine. For a hundred, you want the configuration file approach, because it keeps the metadata out of your domain code.

Create src/main/resources/META-INF/native-image/reflect-config.json and list what needs to survive.

[
  {
    "name": "com.example.User",
    "allDeclaredConstructors": true,
    "allPublicConstructors": true,
    "allDeclaredMethods": true,
    "allPublicMethods": true,
    "allDeclaredFields": true
  }
]

The build picks that file up automatically as long as it sits under META-INF/native-image on the classpath. I keep one file per library, named after the package, so I can delete them cleanly when a library starts shipping its own metadata.

Jackson has a subtlety worth knowing. Records do not need setters, but they do need their canonical constructor registered, and Jackson also wants access to any @JsonCreator factory. If you generate the config by hand and miss the constructor, you get a different error at runtime and it will confuse you for an hour.

Hibernate Validator has the same problem in a slightly different shape. Constraints are discovered reflectively, so you need the validator internals registered, not just your DTOs. Most modern versions ship their own reachability metadata now, which the build downloads and applies automatically. If yours does not, the fix looks like this.

[
  {
    "name": "com.example.User",
    "allDeclaredFields": true,
    "allDeclaredMethods": true
  },
  {
    "name": "org.hibernate.validator.internal.engine.ValidatorImpl",
    "allDeclaredConstructors": true
  }
]

JPA providers are worse, and I want to be honest about that. Hibernate ORM needs entity classes, proxies for lazy loading, and bytecode-generated enhancers. It works, but it needs the Hibernate-specific reachability metadata and a few build-time flags. My advice, from painful experience, is to lean on a framework here. Writing the metadata yourself for a full JPA stack is a weekend you do not get back.

Resource loading is the next wall. Any call to getResourceAsStream with a path is a string at build time, so the compiler cannot know what file you want. The fix goes in resource-config.json.

{
  "resources": {
    "includes": [
      { "pattern": "\\Qapplication.properties\\E" },
      { "pattern": ".*\\.json$" },
      { "pattern": "templates/.*" }
    ],
    "excludes": [
      { "pattern": ".*\\.tmp$" }
    ]
  },
  "bundles": [
    { "name": "messages" }
  ]
}

I limit includes aggressively. Every file you include is embedded in the binary, and I once shipped a native image that was ninety megabytes because I wrote a pattern that matched a folder of test fixtures. Keep the wildcards narrow and check the binary size after each change.

Dynamic proxies are the third wall, and they show up in frameworks more than in your own code. Spring’s @Transactional, JPA lazy loading, and gRPC all create proxy classes at runtime. In a native image you must declare the interface combinations up front.

[
  {
    "interfaces": [
      "com.example.UserRepository",
      "org.springframework.data.repository.CrudRepository"
    ]
  }
]

Notice that the proxy is described by the set of interfaces, not by a single class name. If your code calls Proxy.newProxyInstance with a variable list, you need one entry per combination that can actually occur. In practice you find them by running the native image agent.

Serialization is a fourth wall and a quieter one. Java’s built-in serialization is reflective by nature, so anything you expect to serialize needs an entry in serialization-config.json.

{
  "types": [
    { "name": "com.example.SessionToken" }
  ],
  "lambdaCapturingTypes": [],
  "proxies": []
}

Honestly, I avoid Java serialization entirely in native services. JSON is easier to control and easier to debug.

Now, the biggest single helper in this whole workflow is the native image agent. It watches a normal JVM run and writes the configuration files for you. Start your app with the agent attached, exercise every code path you care about, then shut down.

java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
     -jar target/app.jar

While it runs, you click through the application. Hit every endpoint, trigger every scheduled job, exercise error paths. When you stop the process, the agent writes reflect-config.json, resource-config.json, proxy-config.json, and serialization-config.json into that directory. Then you rebuild the native image with those files on the classpath.

You should run the agent in merge mode the second time, so new findings add to the existing files instead of overwriting them.

java -agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image \
     -jar target/app.jar

I do this once per major release and just commit the resulting files. They are part of the source now, and they should be reviewed like any other code.

Build-time initialization is where I lost the most time early on. GraalVM runs static initializers during the build by default, which is great for a HashMap constant and terrible for anything that opens a socket. If a class initializes a connection pool in a static block, the build will either fail with a hostname resolution error or, worse, bake a stale connection into the binary.

The rule I follow is simple. Pure computation in a static initializer is fine. Anything that touches the filesystem, the network, the clock, or a random number generator must run at runtime.

native-image \
  --initialize-at-run-time=com.example.config \
  --initialize-at-build-time=com.example.util \
  -jar target/app.jar

The error message when you get this wrong is usually a ClassNotFoundException or a SocketException with a stack trace pointing at a class you did not write. Keep the flags in your build script so the next person does not have to rediscover them.

Records and class hierarchies need one more detail. If a framework allocates your type without calling a constructor — Jackson sometimes does, and so do some ORMs — you need "unsafeAllocated": true on the entry.

[
  {
    "name": "com.example.Event",
    "allDeclaredConstructors": true,
    "allDeclaredFields": true,
    "unsafeAllocated": true
  }
]

For sealed hierarchies, register the parent and each permitted subtype. For enums, register the enum class with allPublicFields so valueOf works.

At this point you have seen the raw patterns, and I want to talk about frameworks, because most of us are not writing bare Jackson services. We are writing Spring Boot or Quarkus, and both of them have solved a lot of this for you.

Spring Boot 3 ships a native profile. When you build with -Pnative, the Maven plugin runs a separate AOT phase that inspects your beans, your controllers, and your configuration, and generates hints automatically. For everything it misses, you write a RuntimeHintsRegistrar.

import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.ImportRuntimeHints;

public class MyHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader loader) {
        hints.reflection()
             .registerType(User.class,
                 MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                 MemberCategory.INVOKE_PUBLIC_METHODS);
        hints.resources()
             .registerPattern("templates/*.html");
    }
}

Then attach it to a configuration class.

@Configuration
@ImportRuntimeHints(MyHints.class)
public class AppConfig {}

For the common case of returning objects from a controller, there is a shorter path.

@RestController
@RegisterReflectionForBinding(User.class)
public class UserController {
    @GetMapping("/users/{id}")
    public User get(@PathVariable String id) {
        return service.find(id);
    }
}

That one annotation covers serialization and deserialization for the whole class, and it replaces a hundred lines of JSON config. I wish I had known about it a year earlier.

Quarkus takes a different approach. Its extensions carry their own reflection metadata, generated when the extension was built. That means adding quarkus-hibernate-orm-panache gives you native support without writing anything. You only register your own types.

@RegisterForReflection
public class PaymentRequest {
    public String id;
    public BigDecimal amount;
}

Building is a single command.

./mvnw package -Pnative

Or, if you do not have GraalVM locally, build inside a container.

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

Quarkus is faster to iterate on in my experience because the extension ecosystem handles more of the sharp edges. Spring Boot gives you more control because the hints system is explicit. Neither is wrong. Pick whichever your team already knows.

Now containers. The whole point of native image in a cloud setting is a tiny deployment artifact.

FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /build
COPY pom.xml .
COPY src ./src
RUN ./mvnw -Pnative native:compile -DskipTests

FROM gcr.io/distroless/base-debian12
COPY --from=builder /build/target/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]

That is a multi-stage build. The first stage has the whole JDK and the compiler, and it is hundreds of megabytes. The second stage has a distroless base and your binary, and it lands around forty megabytes. The final image has no shell, no package manager, and no JVM, which is both smaller and safer.

If your binary links against glibc dynamically, distroless with a Debian base works. If you want a truly static binary, build with musl and you can use scratch as the base.

native-image --static --libc=musl -jar target/app.jar
FROM scratch
COPY --from=builder /build/target/app /app
ENTRYPOINT ["/app"]

A scratch image is around twenty megabytes. It starts in under fifty milliseconds. It has no operating system at all. I have run these in production and the deployment feels almost fake.

The performance story is where I want to be careful, because native image is not a strict upgrade. It is a trade. Startup goes from seconds to milliseconds. Memory footprint drops by roughly five times. But peak throughput on a long-running service is often ten to thirty percent lower, because HotSpot’s JIT has spent minutes watching your real traffic and can inline and specialize in ways the AOT compiler cannot.

That means native image wins for serverless functions, command line tools, short batch jobs, and anything that scales to zero and back. A JVM deployment still wins for a long-lived service that handles steady traffic, because the JIT eventually catches up and surpasses what AOT can do.

I keep both builds in my projects. mvn package for the jar, mvn -Pnative package for the binary, and I choose per deployment target rather than per project.

When something feels slow in a native image, perf is the tool. HotSpot has -XX:+FlightRecorder and a decade of tooling. Native images do not, so you profile the operating system instead.

perf record -g ./target/app
perf report

That gives you a flame graph of the machine code the AOT compiler produced. In my experience the hot spots are usually a reflection call happening in a loop, or a pattern where the AOT compiler could not devirtualize a method call. Moving the reflection lookup outside the loop often fixes it.

You can also ask the compiler to profile your code and optimize for it. Build once with instrumentation, run under real load, then build again with the profile.

native-image --pgo-instrument -jar target/app.jar
./app   # run under load
native-image --pgo=default.iprof -jar target/app.jar

That closes some of the throughput gap, often a good chunk of it, at the cost of an extra build step. I use it for batch jobs where I know the access pattern is stable.

Testing native images is the part that trips teams up in CI. You cannot test the native binary with JUnit the same way you test the jar. You need to run the binary and hit it over the network. Quarkus has @QuarkusIntegrationTest for exactly this. Spring Boot has @SpringBootTest(webEnvironment = RANDOM_PORT) combined with a build profile that runs the native executable.

The pattern I use is two test tiers. Unit tests and slice tests run on the JVM, fast, with full tooling. Integration tests run against the native binary, slow, and they catch the reflection errors that only appear when the closed-world assumption bites.

mvn test                     # JVM tier
mvn verify -Pnative          # native tier

When a native integration test fails with ClassNotFoundException, that is almost always a missing reflection entry. Add the class to reflect-config.json, or annotate it, and rebuild. If it fails with NoSuchMethodError, it is usually a constructor that was stripped. Register the declared constructors.

Build times are the last thing. A native image build for a small service takes around ninety seconds. A large Spring Boot service can take five minutes or more, which is painful in CI. Two patterns help.

The first is Docker layer caching. Put your dependency files first so that the dependency download layer does not invalidate when only your source changes. For Maven, that means copying pom.xml and running dependency:go-offline before copying src.

COPY pom.xml .
RUN ./mvnw dependency:go-offline
COPY src ./src
RUN ./mvnw -Pnative native:compile -DskipTests

Now a source-only change reuses the cached dependency layer and saves close to a minute.

The second is to bump the compiler’s memory and parallelism. The default heap is conservative, and on a build machine with sixteen cores you can give it more.

native-image \
  -J-Xmx8g \
  --parallelism=8 \
  -jar target/app.jar

I also reach for incremental builds when I am iterating on config. GraalVM caches some intermediate state under ~/.cache/graalvm, and a warm cache turns a five-minute build into a two-minute one. Keep that directory mounted in your CI runners.

I want to close with the thing I wish someone had told me at the very beginning. Native image is not a magic switch. It is a contract. You are telling the compiler, at build time, exactly what your program can do. Everything it can reach is in the binary. Everything else is gone. That contract is what gives you the fast startup and the small memory footprint, and it is also what makes the errors so cryptic when you violate it.

So the workflow I have settled on is this. Build the JVM version first. Make it work. Add the reflection and resource config as you go, testing with the native build at least once a week. Run the agent after every major dependency upgrade to refresh the metadata. Keep both build profiles in the project so you can switch per environment. And measure both, because the answer to “JVM or native” depends on your workload and not on anyone’s opinion, including mine.

The first native binary you build will probably fail. The second one will too. By the fifth, you will be reading reflect-config.json the way you read pom.xml, and the four-second cold start will feel like something from a different era.


// Keep Reading

Similar Articles