skip to content

Closed-World Assumption

Native image analyses the whole program at build time and compiles only what it proves reachable — no JIT, no class loading at runtime. Understanding this single assumption explains every other native limitation.

part ofSpring Frameworkoverview, primer and where to startread it →
on this pageshow

explore

questions

5

What is the closed-world assumption in GraalVM native image, and what does it mean for a Spring application?

level: juniorimportance: must knowfreq 62%

answer

  1. reachable set fixed at build
  2. points-to analysis from entry points
  3. no JIT, no runtime class loading
  4. reflection/proxies/resources need hints
  5. Spring AOT + RuntimeHints

basics

~20 s

GraalVM analyzes your whole app at build time and includes only the code it can prove is reachable. Nothing new can be loaded at runtime, so the native executable is a fixed, self-contained set of classes and methods.

solid answer

~40 s

The closed-world assumption means GraalVM's native-image builder decides the entire set of classes, methods, and fields your program can ever use at build time, not at runtime. A static points-to analysis starts from your entry points and follows every call, computing the reachable set. Only that reachable code is AOT-compiled into a standalone native executable. At runtime there is no JIT compiler and no dynamic class loading, so anything the analysis could not see, most notably reflection, proxies, and resource loading, must be declared ahead of time via reachability metadata. For Spring this is why the framework ships AOT processing (BeanFactoryInitializationAotProcessor, RuntimeHints) that pre-computes bean definitions and registers hints so the closed world is complete before you run.

code

java · 23 lines
java
// Declaring reachability metadata so the closed world includes reflection
// that the points-to analysis cannot discover on its own.
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;

@Configuration
@ImportRuntimeHints(MyApp.Hints.class)
public class MyApp {

    static class Hints implements RuntimeHintsRegistrar {
        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            // Without this, Class.forName("com.acme.Widget") + reflective
            // instantiation would fail at runtime in the native image.
            hints.reflection().registerType(com.acme.Widget.class,
                    MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
            hints.resources().registerPattern("schema/*.sql");
        }
    }
}

go deeper

for a junior

Know the one-liner: whole app analyzed at build, only reachable code included, nothing new loaded at runtime.

for a middle

Should name the consequences: no JIT, no dynamic class loading, and that reflection/proxies/resources need metadata.

for a senior

Connect to Spring AOT: bean definitions pre-computed, RuntimeHints registered, GraalVM build tools plugin.

for a principal

Frame trade-offs: startup/memory vs runtime dynamism and peak throughput; when native is the wrong choice.

## The core idea A normal JVM operates under an **open-world assumption**: at any moment your program can load a new class (Class.forName), generate a proxy, read a classpath resource, or JIT-compile a hot method. The JVM does not need to know the full universe of code in advance. GraalVM **native image** flips this to a **closed-world assumption**. The `native-image` build tool takes your application plus all dependencies and, starting from the declared **entry points** (typically the `main` method and configured reflection/JNI roots), runs a **points-to static analysis**. This analysis answers: which classes can actually be instantiated, which methods can actually be invoked, which fields can actually be read/written. The transitive closure of that is the **reachable set**. ## What happens to unreachable code Everything the analysis cannot prove reachable is **discarded** (dead-code elimination). The reachable set is then **AOT-compiled** (Ahead-Of-Time) directly to native machine code and linked into a single **standalone executable** together with a minimal runtime called **Substrate VM** (memory management, a garbage collector, thread support). There is **no bytecode interpreter shipped for your code and no JIT compiler**, and there is **no runtime class loading** of new application classes. ## Why this matters (the payoff) - **Fast startup** (tens of milliseconds) because there is no class loading, no bytecode verification, and no JIT warm-up. - **Low memory footprint** because there is no JIT, no profiling data, and dead code is removed. - **Smaller attack surface** and instant peak performance. ## The cost (why it is hard) Any behavior that is **dynamic** breaks the analysis because the builder cannot see it statically: - **Reflection** (`Class.forName`, `Method.invoke`) — target is a runtime string. - **Dynamic proxies** (`java.lang.reflect.Proxy`). - **JNI**, **serialization**, and **resource loading** (`getResourceAsStream`). These must be declared as **reachability metadata** so the builder includes them in the closed world. In modern GraalVM this is JSON under `META-INF/native-image/` (e.g. `reflect-config.json`, `resource-config.json`), or produced programmatically. ## How Spring cooperates Spring Boot 3 / Spring Framework 6 embrace the closed world through **Spring AOT**: - At **build time** an AOT processing phase runs (`org.springframework.context.aot`), executing `BeanFactoryInitializationAotProcessor` and `BeanRegistrationAotProcessor` implementations. It **pre-computes bean definitions** and generates Java source so the context does not need to scan/reflect at runtime. - It contributes **`RuntimeHints`** (via `RuntimeHintsRegistrar` / `@ImportRuntimeHints` / `@RegisterReflectionForBinding`) so the reflection, resources, and proxies Spring needs are declared to the builder. - The **GraalVM Native Build Tools** Gradle/Maven plugin (`org.graalvm.buildtools.native`) wires the whole thing, and `./gradlew nativeCompile` produces the executable. ## Gotchas - **Conditional / profile-based beans**: whatever branch is not exercised at build-time AOT still needs its hints; missing hints cause `ClassNotFoundException` / `MissingReflectionRegistrationError` **at runtime**, not build time. - **Configuration is frozen**: `@Profile` and property-driven bean selection are largely decided during AOT, so a native image is less dynamic than a JVM run. - **No agent = missing metadata**: for code the analysis cannot reach, GraalVM offers a **tracing agent** (`-agentlib:native-image-agent`) that records reflection/resource use during a JVM run and emits the config. ## When to use Use native image for **serverless / scale-to-zero / CLI / high-density** workloads where startup and memory dominate. Stay on the JVM when you rely heavily on runtime dynamism, need max long-running throughput (JIT can beat AOT peak), or your build/test loop can't absorb the slow native compile.

  • Name three kinds of runtime behavior that the closed-world analysis cannot see on its own.
    Reflection (Class.forName / Method.invoke), dynamic JDK proxies, and resource/classpath loading (getResourceAsStream); also serialization and JNI. Each must be declared as reachability metadata (RuntimeHints or META-INF/native-image JSON).
  • If a needed class is missing from the reachable set, when do you find out?
    At runtime, not build time — you get a ClassNotFoundException or MissingReflectionRegistrationError when the code path executes, because the class was dead-code-eliminated from the image.

saying these in an interview costs you the question

  • Thinking native image still has a JIT that warms up over time
  • Believing you can Class.forName any class at runtime like on the JVM
  • Assuming Spring auto-discovers all reflection with no hints needed
  • Confusing AOT compilation with just bytecode caching

context

open as a page

How does the points-to static analysis compute the reachable set, and what starts it?

level: middleimportance: should knowfreq 40%

basics

~20 s

It starts from entry points like main and registered roots, then follows every possible call and field access transitively until nothing new is discovered. Whatever it reaches is kept and compiled; the rest is removed.

open as a page

A Spring native image builds successfully but throws MissingReflectionRegistrationError at runtime. What happened and how do you fix it?

level: middleimportance: should knowfreq 30%

basics

~20 s

The points-to analysis couldn't see a reflective call, so the target's metadata was left out of the closed world. The build still succeeds; the failure appears when that code runs. Fix it by registering a reflection hint.

open as a page

Native images have no JIT compiler. What are the practical performance consequences versus a warmed-up JVM?

level: seniorimportance: should knowfreq 34%

basics

~20 s

Native images start instantly and use less memory because everything is compiled ahead of time. But without a JIT there is no runtime profiling, so long-running peak throughput can be lower than a warmed-up JVM.

open as a page

The closed world forbids runtime class loading. As a principal engineer, how do you decide whether a Spring service should be a native image, and how do you handle dynamic requirements?

level: principalimportance: should knowfreq 22%

basics

~20 s

Go native when startup and memory dominate (serverless, high density) and the app's dynamism is bounded and known at build time. Handle dynamic needs with reachability metadata, Spring AOT, and the tracing agent — or keep genuinely dynamic services on the JVM.

open as a page