Spring Boot 3.5 Native Images with GraalVM: A Measured Build, Startup Against the JVM, and a Reflection Failure Fixed with RuntimeHints

Built and run with Spring Boot 3.5.16, GraalVM CE 21.0.2, Native Build Tools 0.10.6, Gradle 8.8 on macOS arm64; JVM comparison on Amazon Corretto 21.0.3; 3 JVM tests plus recorded native runs

Revision note (2026-09-15). The earlier version of this article mixed the retired Spring Native project (org.springframework.experimental:spring-native 0.12.1, @NativeHint, spring.graal.*) with Spring Boot 3, told readers to run gu install native-image (GraalVM for JDK 21 ships no gu), showed a Gradle build with only the Spring Boot plugin and then called ./gradlew nativeBuild, a task that does not exist without the GraalVM Native Build Tools plugin, pointed at native-image-maven-plugin, whose last release was 21.2.0 in 2021, recommended a reflect-config.json for a plain @RestController, which needs none, gave startup figures that were not measured, and cited a Spring blog URL that returns 404. This version is rebuilt around one small project that was compiled twice, run, and timed; every number below comes from those runs.

What was built and on what

A Spring Boot 3.5.16 application with spring-boot-starter-web (Tomcat, Jackson) and two endpoints: GET /hello, and GET /format?formatter=shout&text=hello, which loads a formatter class by name from configuration. The second endpoint is there to fail in the native image.

Environment: macOS on an Apple arm64 laptop; GraalVM CE 21.0.2+13.1 installed with sdk install java 21.0.2-graalce; Gradle 8.8 without wrapper; org.graalvm.buildtools.native 0.10.6, the version spring-boot-dependencies:3.5.16 manages; JVM comparison runs on Amazon Corretto 21.0.3. The lab is examples/spring-boot-native-image in the site repository. The bootBuildImage route (Paketo buildpacks, Docker) was not used.

Build setup

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.16'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'org.graalvm.buildtools.native' version '0.10.6'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

graalvmNative {
    toolchainDetection = false // use the JDK Gradle runs on (JAVA_HOME = GraalVM)
}

There is no Spring Native dependency and no gu step: native-image is a binary inside the GraalVM JDK, and Spring Boot's Gradle plugin, when it sees the Native Build Tools plugin, wires its processAot task in front of nativeCompile. processAot runs the application's context at build time and generates Java source for the bean definitions plus the reflection, resource and proxy hints Spring can infer. The build command is:

$ export JAVA_HOME=~/.sdkman/candidates/java/21.0.2-graalce GRAALVM_HOME=$JAVA_HOME
$ gradle nativeCompile --no-daemon

nativeBuild also exists as an alias once the plugin is applied; without the plugin, the scratch project in the lab answered Task 'nativeBuild' not found in root project.

Build results

GraalVM Native Image: Generating 'native-app' (executable)...
 Java version: 21.0.2+13, vendor version: GraalVM CE 21.0.2+13.1
 Garbage collector: Serial GC (max heap size: 80% of RAM)
[1/8] Initializing... (6.2s @ 0.13GB)
[2/8] Performing analysis... [*****] (20.0s @ 2.22GB)
[3/8] Building universe... (2.5s @ 2.23GB)
[4/8] Parsing methods... [*] (1.8s @ 2.16GB)
[5/8] Inlining methods... [****] (1.3s @ 2.32GB)
[6/8] Compiling methods... [*****] (27.2s @ 3.30GB)
[7/8] Layouting methods... [***] (5.8s @ 4.33GB)
[8/8] Creating image... [***] (9.3s @ 2.03GB)
  37.69MB (48.27%) for code area:    53,795 compilation units
  39.38MB (50.44%) for image heap:  406,457 objects and 263 resources
  78.07MB in total
                       9.0s (12.0% of total time) in 155 GCs | Peak RSS: 3.57GB | CPU load: 5.15
Finished generating 'native-app' in 1m 14s.
BUILD SUCCESSFUL in 1m 20s

So for a web starter with two controllers: 1 minute 14 seconds in native-image, a peak of 3.57 GB of RAM for the compiler, and a 78 MB executable (81,862,472 bytes) next to a 20 MB boot jar (21,275,622 bytes). The laptop was not idle during the build, so read the time as an order of magnitude. Plan CI memory around the peak RSS, not around what the application needs.

Startup and memory against the JVM

A script in the lab starts each variant five times, polls /hello every 20 ms, records the wall time from launch to the first 200, the process RSS right after that response, and Spring Boot's own Started ... in line. The JVM runs use the same jar on Corretto 21.0.3; jvm-aot adds -Dspring.aot.enabled=true, which makes the JVM use the code processAot generated instead of scanning at runtime.

VariantBoot "Started in"First 200 from launchRSS after first requestArtifact
native image, GraalVM CE 21.0.20.028 to 0.036 s0.042 to 0.055 s77 MB78 MB executable
JVM, Corretto 21.0.30.624 to 0.819 s0.923 to 1.167 s193 to 211 MB20 MB jar
JVM with spring.aot.enabled=true0.505 to 0.666 s0.800 to 1.025 s183 to 184 MBsame jar

Raw lines from the run, one per variant:

native     run 5  first-200 after 0.042 s  rss 77 MB  | Started NativeAppApplication in 0.028 seconds (process running for 0.035)
jvm        run 3  first-200 after 0.932 s  rss 193 MB  | Started NativeAppApplication in 0.63 seconds (process running for 0.868)
jvm-aot    run 3  first-200 after 0.800 s  rss 183 MB  | Started NativeAppApplication in 0.514 seconds (process running for 0.743)

What this does and does not say. Startup to first response was roughly 20 times faster in the native binary and idle RSS after one request about 2.5 times lower, for this application, on this laptop, measured once per variant with five repetitions. Nothing here measures throughput, latency under load, or what a warmed-up JIT does after a few minutes; the native image uses GraalVM CE's serial collector and no profile-guided optimization. Those are the comparisons that decide whether a long-running service should go native, and they were not run.

The reflection failure, reproduced

The formatter registry loads implementations by the class name found in configuration:

demo.formatters.plain=com.devdrunk.nativeapp.PlainFormatter
demo.formatters.shout=com.devdrunk.nativeapp.ShoutFormatter
public String format(String name, String text) {
    String className = classNames.get(name);
    try {
        Object instance = Class.forName(className).getDeclaredConstructor().newInstance();
        return ((TextFormatter) instance).format(text);
    } catch (ReflectiveOperationException ex) {
        throw new IllegalStateException("cannot load formatter " + className, ex);
    }
}

Nothing in the code references ShoutFormatter as a type, so the closed-world analysis never sees it. The lab builds the image once with the hints switched off (gradle nativeCompile -Phints=false, which passes a system property to processAot) and runs it:

$ curl -s -w "nHTTP %{http_code}n" http://localhost:8101/hello
{"runtime":"native-image","message":"Hello from Spring Boot"}
HTTP 200
$ curl -s -w "nHTTP %{http_code}n" "http://localhost:8101/format?formatter=shout&text=hello"
{"timestamp":"2026-09-16T01:11:39.311+00:00","status":500,"error":"Internal Server Error","path":"/format"}
HTTP 500
ERROR ... [dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
  [Request processing failed: java.lang.IllegalStateException: cannot load formatter com.devdrunk.nativeapp.ShoutFormatter] with root cause
java.lang.ClassNotFoundException: com.devdrunk.nativeapp.ShoutFormatter

Note what did not fail: /hello works, served by a @RestController with a Map return value and Jackson, without any reflect-config.json. Spring's AOT processing registers what controllers, beans and their signatures need. The earlier version of this article told readers to hand-write a reflection entry for the controller; that entry was never the problem.

The fix: a RuntimeHintsRegistrar

class FormatterHints implements RuntimeHintsRegistrar {

    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection()
            .registerType(PlainFormatter.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)
            .registerType(ShoutFormatter.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
    }
}
@SpringBootApplication
@ImportRuntimeHints(FormatterHints.class)
public class NativeAppApplication { ... }

INVOKE_DECLARED_CONSTRUCTORS is the narrowest category that covers getDeclaredConstructor().newInstance(); registering the class alone would make Class.forName succeed and the constructor lookup fail. The hint is checked by a plain unit test, no native build required:

RuntimeHints hints = new RuntimeHints();
new FormatterHints().registerHints(hints, getClass().getClassLoader());
assertThat(RuntimeHintsPredicates.reflection().onType(ShoutFormatter.class)
    .withMemberCategory(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)).accepts(hints);

The default build includes the hints:

$ curl -s -w "nHTTP %{http_code}n" "http://localhost:8101/format?formatter=shout&text=hello"
{"formatter":"shout","result":"HELLO!"}
HTTP 200

Zero ERROR lines in that run's log. For types that only need to be serialized or deserialized by Jackson, Spring Framework also offers @RegisterReflectionForBinding(MyDto.class), which registers the fields, getters and constructors a binder needs; it was not exercised in this lab, so it is mentioned here only as the documented shortcut. When a third-party library does reflection you cannot predict, the GraalVM tracing agent (-agentlib:native-image-agent) run against the JVM build produces the reachability-metadata.json to start from; that route was also not run here.

What to take from this

  • Spring Boot 3 native support is org.graalvm.buildtools.native plus the Spring Boot plugin; Spring Native, @NativeHint, gu, and native-image-maven-plugin belong to the 2021 to 2022 generation and are gone or archived.
  • Budget the build: about a minute and several gigabytes of RAM for a trivial web app on a laptop.
  • Expect the controller layer to just work; expect anything reached through a string, a Class.forName, a ServiceLoader, or a resource path built at runtime to need a hint. Reproduce the failure in the native binary first, then add the narrowest hint.
  • Test hints with RuntimeHintsPredicates; it takes milliseconds, the native build takes minutes.
  • Startup and idle memory were much better here; throughput and warmed-up latency were not measured and are the numbers that should decide a migration of a long-running service.

Sources

  • Spring Boot 3.5 reference, GraalVM native images: https://docs.spring.io/spring-boot/3.5/reference/packaging/native-image/introducing-graalvm-native-images.html
  • Spring Boot 3.5 reference, advanced native image topics (nested configuration, tracing agent): https://docs.spring.io/spring-boot/3.5/reference/packaging/native-image/advanced-topics.html
  • Spring Boot 3.5 how-to, developing your first native application (Gradle and Maven, buildpacks and Native Build Tools): https://docs.spring.io/spring-boot/3.5/how-to/native-image/developing-your-first-application.html
  • Spring Framework 6.2 reference, AOT and runtime hints: https://docs.spring.io/spring-framework/reference/6.2/core/aot.html#aot.hints
  • GraalVM Native Build Tools 0.10.6, Gradle plugin: https://graalvm.github.io/native-build-tools/0.10.6/gradle-plugin.html
  • GraalVM Native Image reference for JDK 21: https://www.graalvm.org/jdk21/reference-manual/native-image/
  • GraalVM Native Image, reflection: https://www.graalvm.org/latest/reference-manual/native-image/dynamic-features/Reflection/
  • Spring blog, "Spring Boot 3.0 Goes GA" (the earlier version cited a URL for this post that returns 404): https://spring.io/blog/2022/11/24/spring-boot-3-0-goes-ga/
  • Spring blog, "From Spring Native to Spring Boot 3": https://spring.io/blog/2023/02/23/from-spring-native-to-spring-boot-3/
  • Spring Native repository, archived: https://github.com/spring-attic/spring-native