Why does reflection often break when you compile a Spring application to a GraalVM native image?
answer
- closed-world = frozen at build time
- static analysis can't follow runtime strings
- class dropped or metadata stripped
- declare in reflect-config.json / hints
- fails only in native binary, not JVM
basics
~20 sGraalVM native image builds a closed world: it only includes code it can see is used at build time. Reflection resolves types/methods at runtime, so unless that reflective access is declared up front, the elements are dropped and calls fail.
solid answer
~40 sA GraalVM native image is produced by ahead-of-time compilation under a closed-world assumption: the builder statically analyses reachable code and includes only what it can prove is used. Reflection (Class.forName, getDeclaredMethod, newInstance) resolves targets dynamically at runtime, which the static analysis usually cannot follow. Any class, constructor, field, or method reached only reflectively is considered unreachable and is either omitted from the image or has its metadata stripped, so at runtime you get ClassNotFoundException, NoSuchMethodException, or a missing-registration error. To keep it working you must declare the reflective access ahead of time (reflect-config.json or reachability hints) so the builder includes those elements and their metadata. Spring's AOT engine generates most of these hints for you, but reflection your own code performs may still need explicit registration.
code
java · 8 lines// Works on the JVM, breaks in a native image unless declared:
String name = env.getProperty("handler.class"); // runtime string
Class<?> type = Class.forName(name); // analysis can't resolve 'name'
Object handler = type.getDeclaredConstructor() // constructor metadata stripped
.newInstance(); // -> MissingReflectionRegistrationError
// On native image the builder never saw which class 'name' is,
// so the class + its no-arg constructor were not included.go deeper
Must grasp the one-liner: native image freezes reachable code at build time; reflection resolves at runtime, so it breaks unless declared.
Should name the closed-world assumption and know reflect-config.json / hints declare the access.
Should explain that both the class AND its metadata can be stripped, and that Spring AOT auto-generates most hints.
Frames it as a build-time reachability contract and drives a testing/observability strategy so reflective gaps surface before production.
## The core problem When you run a normal Spring app on the JVM, the whole classpath is present and the JIT compiler and classloader resolve everything lazily at runtime. **Reflection** — the Java API that lets code inspect and invoke types by name at runtime (`Class.forName("com.acme.Foo")`, `clazz.getDeclaredMethod("bar")`, `constructor.newInstance()`) — works fine because every class is available and its metadata (list of methods, fields, constructors) is retained. **GraalVM native image** is different. It performs **ahead-of-time (AOT) compilation**: at *build time* a static analysis ("points-to analysis") walks the call graph starting from `main`, determines every method and class that is *reachable*, and compiles only those into a standalone native executable. Everything it cannot prove is used is discarded. This is the **closed-world assumption** — the set of classes and members is frozen at build time; nothing new can be loaded or discovered later. ## Why reflection specifically breaks Static analysis follows ordinary method calls and field accesses. But a reflective call like `Class.forName(name)` takes a *runtime string* — the analysis generally has no idea which class `name` refers to, so it cannot mark that class as reachable. The same applies to `getDeclaredMethod("x")`, `getField("y")`, and dynamic proxies. As a result: - The reflectively-referenced class may be **omitted entirely** from the image. - Even if the class is included for other reasons, its **reflection metadata** (the enumerable list of declared methods/fields/constructors) is **stripped** by default, so `getDeclaredMethods()` returns an incomplete or empty set and `newInstance()` fails. At runtime you then see `ClassNotFoundException`, `NoSuchMethodException`, `NoSuchFieldException`, or a GraalVM `MissingReflectionRegistrationError`. ## The fix: declare the access ahead of time Because the builder cannot *discover* reflective targets, you must *tell* it. The classic mechanism is a **`reflect-config.json`** file (placed under `META-INF/native-image/...`) that lists each class and which members need reflective access, e.g. `allDeclaredConstructors`, `methods`, `fields`. The native-image builder reads this file and keeps those elements plus their metadata. In a Spring context you rarely hand-write this JSON. Spring's **AOT processing** analyses your beans, configuration, and common patterns (JSON binding, JPA entities, `@ConfigurationProperties`, etc.) and emits the equivalent reachability metadata during the build. *How* Spring generates those hints is a separate concern (reachability metadata / `RuntimeHints`). The point of this topic is simply: **reflective access is unreachable unless it is declared** — the native image cannot invent it. ## Common Spring gotchas - Libraries that reflect over your classes (Jackson deserialization, Hibernate, validation) need the target classes registered — an entity with no reflection metadata deserializes to garbage or throws. - Code that builds a class name from a string (plugin lookups, `Class.forName(prop)`) is invisible to analysis and must be registered explicitly. - Reflection that *works on the JVM* gives you no compile-time warning; the failure surfaces only when you run the native binary — so native image tests are essential. ## When to care Only when targeting native image (`spring-boot:build-image` / `native` profile / GraalVM). On the plain JVM none of this applies. If you never build native, reflection behaves normally.
- Does this affect a normal JVM deployment?No. On the HotSpot JVM the whole classpath is loaded lazily and all reflection metadata is present, so reflection works with no configuration. The breakage is specific to AOT-compiled native images with their closed-world assumption.
- If reflection breaks silently, how do you find the problem?You run the native executable (or its tests) — the failure only appears there, as ClassNotFoundException / NoSuchMethodException / MissingReflectionRegistrationError. GraalVM's tracing agent can also record reflective accesses on a JVM run to help generate the needed config.
saying these in an interview costs you the question
- Thinking reflection works the same in native image as on the JVM
- Believing the native-image builder can 'figure out' Class.forName targets by itself
- Assuming a passing JVM test means the native image will also work