skip to content

Ahead-of-time compilation of a Java application to a native binary assumes a closed world. What does that assumption mean concretely, and how do you make reflection, dynamic proxies, and resource loading work under it?

level: seniorimportance: must knowfreq 40%

answer

  1. reachable-at-build-time or absent from the binary
  2. string-named lookup is invisible to the analysis
  3. metadata file / tracing agent / framework AOT
  4. failure appears at runtime on the untested path
  5. test the native binary, not just the JVM build

basics

~20 s

Closed world means every class and method reachable at runtime must be known at build time; unreachable code is not in the binary. Anything named by string — reflection, proxies, resources, serialization — is invisible to the analysis and must be declared in build-time metadata, generated by a framework, or recorded by a tracing agent.

solid answer

~60 s

The build performs a whole-program points-to analysis from the entry points and includes only what it proves reachable. That is what makes the binary small and startup instant — and it means the runtime cannot load a class that was not compiled in. Static call graphs cannot see through string-based lookup. `Class.forName(name)`, `getDeclaredMethod`, dynamic proxy interfaces, resource bundles, service files, and serialization all resolve names at runtime, so those classes and members look dead and get dropped. The symptom is a runtime failure such as a class or method not found, only in the native binary. Three remedies, usually combined. Declare the entries in build-time reachability metadata, which lists the classes, methods, fields, resources, and proxy interface sets to keep. Run the application under a tracing agent on the JVM against a representative test suite and let it emit that metadata. Or rely on a framework doing ahead-of-time processing — resolving injection, proxies, and configuration at build time so almost nothing reflective survives to runtime. Coverage is the real risk: metadata from a run that missed a code path fails only on that path in production.

code

java · 6 lines
java
// The analysis sees a String, not a class: Handler is treated as unreachable
Class<?> c = Class.forName(config.get("handler.class"));
Object h = c.getDeclaredConstructor().newInstance();

// The proxy class does not exist at build time; its interface set must be declared
Object p = Proxy.newProxyInstance(loader, new Class<?>[]{ Service.class }, handler);

go deeper

for a junior

Say the build only includes code it can prove is used, so reflection has to be declared up front.

for a middle

List the dynamic features affected and the three ways to supply metadata, and describe the runtime failure symptom.

for a senior

Emphasise coverage risk and pipeline design: run integration tests against the native artifact and prefer build-time framework processing over hand-written metadata.

for a principal

Judge fit before adoption — rule out architectures that depend on runtime discovery or code generation, and weigh the ongoing cost of a second tested artifact against the deployment gains.

## What closed world actually asserts The assumption is that the set of classes, methods, and fields that can ever be used is fully determined at build time. The compiler takes the entry points, follows every call it can prove happens, and keeps only that. Everything else is not merely unoptimized — it is absent from the binary. There is no class loading of new application code afterwards, no defining classes from bytes at runtime, no loading a plugin jar that was not present during the build. This is a strong assumption, and it is precisely what buys the benefits: a small image, no class-loading work at startup, and the freedom to fully resolve and inline across the whole program. ## Where Java breaks it Java's dynamic features are all forms of naming code by data rather than by symbol, which a static analysis cannot follow: - **Reflection** — `Class.forName("com.acme.Handler")`, `getDeclaredMethod("run")`, field access by name, constructor lookup. If the name comes from configuration, the analysis sees a string, not a class. - **Dynamic proxies** — a proxy class is generated at runtime for a set of interfaces; the generated class does not exist at build time, so the exact interface combinations must be declared. - **Resources** — files read via classpath lookup, including message bundles and configuration, are data, not code, and are only embedded if declared. - **Service loading** — provider files map interfaces to implementation class names as text. - **Serialization** — reconstructing objects uses reflective member access and often constructors that no code calls directly. - **JNI** — native code resolves Java classes and members by name from the other side of the boundary. The failure mode is important to describe: the build succeeds, the binary starts, and the feature fails when the path is first exercised, with an error saying the class or method is not present. It is a coverage problem, not a compilation problem. ## The three ways to close the gap **Declarative metadata.** The toolchain reads configuration listing which classes should be registered for reflection, which members, which resource patterns to embed, which proxy interface sets to pregenerate, and which classes are serialized. Modern toolchains consolidate this into a single reachability-metadata file; older ones split it into separate reflection, resource, proxy, JNI, and serialization files. Well-behaved libraries ship their own metadata, and shared community repositories exist so that consumers do not have to write it for third-party code. **Tracing agent.** Run the application on a normal JVM with an agent that records every reflective lookup, resource read, and proxy creation, then emits metadata. Exercise it with the integration test suite, not a smoke test, because whatever path you do not run is whatever fails later. Metadata from multiple runs can be merged. **Build-time framework processing.** The strongest option. Frameworks that do ahead-of-time processing resolve dependency injection graphs, configuration binding, and proxying during the build and emit ordinary generated source or metadata, so the running application performs little or no reflection at all. This removes both the configuration burden and much of the reflection cost even when running on a plain JVM. ## Consequences for how you build and test Because metadata completeness is a coverage property, the pipeline must run the integration test suite *against the native binary*, not only against the JVM build. Teams commonly keep the fast inner loop on the JVM and add a slower native verification stage, accepting that a defect class exists which only that stage can find. It also constrains architecture. Designs built on runtime plugin discovery, user-supplied classes, dynamic bytecode generation, or scripting engines that compile to classes at runtime are a poor fit and should be identified before committing to native compilation. Conversely, a service whose dynamic behaviour is confined to a framework with mature ahead-of-time support is usually straightforward. ## The one-sentence version Closed world means reachability is decided at build time, so anything your program discovers by name at runtime must be declared at build time — and the practical risk is not that this is impossible but that your declarations are incomplete for a path your tests never took.

  • Your native binary throws a class-not-found error for a class that is plainly in the source tree. What happened?
    The class was only ever referenced by name at runtime, so the build-time reachability analysis proved it unreachable and left it out of the image. The fix is to register it in the reachability metadata, or to regenerate metadata by tracing a run that exercises that path, or to remove the reflective indirection so a direct reference makes it reachable.
  • Why is a tracing agent run not sufficient on its own?
    Because it records only what the traced execution actually did. Any conditional branch, error path, or configuration variant not exercised produces no metadata, and the omission is invisible until that path runs in production. It should be driven by the full integration suite and treated as a starting point that library-supplied metadata and explicit declarations complete.
  • How do frameworks with ahead-of-time processing reduce this problem?
    They move dependency injection, configuration binding, and proxy creation from runtime reflection to build-time code generation, so the running program contains ordinary direct calls the analysis can see. That both removes most of the metadata a developer would otherwise write and speeds startup even on a normal JVM.

saying these in an interview costs you the question

  • Believing the native build fails loudly when reflective targets are missing — it usually builds fine and fails at runtime.
  • Assuming a tracing agent run against a smoke test produces complete metadata.
  • Thinking reflection is impossible in a native image rather than requiring declaration.
  • Planning runtime plugin loading or runtime bytecode generation on top of a closed-world binary.

context