skip to content

During a native-image migration, an eagerly-created singleton in a build-time-initialized class ends up holding stale/empty config in production. How do you diagnose and prevent this class of bug fleet-wide?

level: principalimportance: should knowfreq 25%

answer

  1. singleton built on build machine -> frozen in image heap
  2. captures build env config, never re-reads production
  3. class-init report to prove/trace build-time init
  4. fix: runtime Spring bean + @ConfigurationProperties
  5. prevent: ban env/clock/random in static init + native binary tests

basics

~20 s

The singleton was constructed at build time, so it captured build-machine config and got frozen into the binary. Fix by moving the class to run-time init (or making it a runtime-created Spring bean) and prevent recurrence with policy: no environment-reading state in static holders, plus behavioral tests on the actual native binary.

solid answer

~50 s

Root cause: the class was initialized at build time, so its eager singleton was constructed on the build machine — capturing whatever config/env existed there (usually empty or placeholder) — and that object was snapshotted into the image heap, never rebuilt at startup. Diagnose by (1) confirming the value is build-machine-specific, (2) checking GraalVM class-initialization reports to prove the class is build-time-init, and (3) tracing why (often a transitive dependency dragged it earlier). Fix by forcing `--initialize-at-run-time` on that class, or better, removing the static singleton entirely and letting Spring create it as a runtime bean that reads config via `@ConfigurationProperties`/`Environment` at startup. Prevent fleet-wide with: a coding standard banning env/secret/clock/random reads in static initializers, CI that runs behavioral smoke tests against the built native binary (not just the JVM), and review of any `--initialize-at-build-time` addition.

code

java · 19 lines
java
// BEFORE: static singleton frozen at build time with build-machine config
public final class PaymentGateway {
    static final PaymentGateway INSTANCE = new PaymentGateway();
    private final String endpoint = System.getenv("PAY_URL"); // build-time value!
    public static PaymentGateway get() { return INSTANCE; }
}

// AFTER: runtime Spring bean, config bound at startup against real env
@ConfigurationProperties("payment")
record PaymentProps(String url) { }

@Configuration
@EnableConfigurationProperties(PaymentProps.class)
class PaymentConfig {
    @Bean
    PaymentGateway paymentGateway(PaymentProps props) {
        return new PaymentGateway(props.url()); // created at runtime
    }
}

go deeper

for a junior

Understand that a singleton built at build time keeps the build machine's config forever.

for a middle

Diagnose by comparing to build-machine values and fix by forcing run-time init on the class.

for a senior

Prefer converting to a runtime Spring bean with @ConfigurationProperties and use class-init reports to trace how it became build-time.

for a principal

Establish fleet-wide prevention: coding standards banning env/clock/random in static initializers, native-binary behavioral tests in CI, class-init report diffing, and reviewed exceptions for any build-time-init addition.

## Why this happens (mechanism) Under `--initialize-at-build-time`, a class's static initializer runs on the **build machine** and the resulting object graph is serialized into the **image heap** of the binary. An **eagerly-created singleton** — e.g. `static final Service INSTANCE = new Service();` where `Service`'s constructor reads config — is therefore built once, at build time, with the build environment's config, and that exact frozen instance is shared by every production process. Because the initializer never re-runs, production config/env is never consulted. Symptoms: null/empty/placeholder values, stale feature flags, wrong endpoints, or a shared mutable singleton behaving identically (and wrongly) across all replicas. ## How the class got build-time-initialized Usually one of: - Someone added `--initialize-at-build-time` for a package that swept the class in. - A **transitive dependency**: a class explicitly build-time-init referenced this class in its own initializer, forcing it earlier (GraalVM propagates build-time init to dependencies referenced during build-time init). - A library shipped metadata that opted it in. ## Diagnosis playbook 1. **Confirm it's a build-time freeze**: compare the bad value against the build machine's env/config — a match (e.g. the CI runner's empty var) is the tell. 2. **Prove the init timing**: enable GraalVM's class-initialization diagnostics/report during the build to list which classes are build-time vs run-time, and trace *why* a class was moved to build-time (the report shows the initializing chain). 3. **Reproduce in a test**: a behavioral test on the **native binary** that asserts the config value — it will pass on the JVM and fail on native, isolating the AOT-specific defect. ## Fixes (in order of preference) 1. **Eliminate the static singleton.** Make it a Spring-managed bean created at runtime; inject config with `@ConfigurationProperties` or `Environment`. Runtime beans are initialized when the context refreshes at startup, so they read real production config. This is the durable fix. 2. **Force run-time init** for the class: `--initialize-at-run-time=com.example.Service` in `native-image.properties`/build args. Re-run the initializer at startup so it reads production env. 3. **Break the transitive pull**: if a build-time-init class dragged it in, reclassify the parent to run-time too, or restructure so the parent doesn't touch the config-reading class during its own initialization. ## Preventing the whole class of bug (principal-level) - **Coding standard**: no environment-, secret-, clock-, file-, socket-, thread-, or randomness-dependent work in `static {}` blocks or static field initializers. Such state belongs in runtime beans. Enforce via review and, where possible, static analysis (e.g. ArchUnit rules flagging `System.getenv`/`Instant.now` in static initializers). - **Default to run-time init** for all application classes; treat every `--initialize-at-build-time` addition as a reviewed exception with a written justification (must be pure/deterministic). - **Test the artifact you ship**: CI must run **native binary** smoke/behavioral tests, not only JVM tests. A green build proves nothing about frozen state — only running the binary and asserting fresh timestamps, real config, and per-instance-distinct tokens does. - **Class-init audit in CI**: capture the class-initialization report as a build artifact and diff it; a new class appearing under build-time init should trigger review. - **Fleet-wide sweep**: grep for static singletons and static initializers touching env/secrets/clock/random across services; convert them to runtime beans. ## Spring-specific guidance Prefer `@Configuration` + `@Bean` / `@ConfigurationProperties` over hand-rolled static holders. Spring Boot's AOT engine already runs the context ahead of time to generate bean registrations, but **bean instances themselves are created at runtime** during context startup — so configuration binding happens against the live environment. The danger zone is **non-Spring static state** you introduced; that's where the audit focuses. ## Key mental model 'Compiles and boots' is not 'correct.' Build-time init can silently substitute build-machine reality for production reality. Design so that all environment-dependent state is created at runtime, and validate on the real binary.

  • Why doesn't a successful native build catch this bug?
    The build succeeds because a frozen singleton is a valid image-heap object; only running the binary reveals it holds build-machine config. That's why CI must run behavioral tests against the native executable, not just JVM tests.
  • The class was never explicitly marked build-time — why is it initialized at build time?
    A class explicitly marked build-time-init referenced it during its own static initialization, and GraalVM propagates build-time init to classes it must initialize to complete that. The class-init report shows the chain; fix by reclassifying the parent or breaking the reference.
  • What ArchUnit-style rule would prevent recurrence?
    A rule flagging static initializers / static field initializers that call System.getenv, read files, instantiate Random/SecureRandom, or call now()/currentTimeMillis — forcing that state into runtime beans instead.

saying these in an interview costs you the question

  • Concluding the bug is a config-loading issue unrelated to AOT
  • Fixing only the one class instead of banning env-reading static state
  • Trusting JVM tests to catch native-only frozen-state defects
  • Adding --initialize-at-build-time to 'speed things up' without justification

context