#14346·graal

findStaticVarHandle returns the superclass's static field when the subclass field is not registered for reflection

Author: power721Created Aug 31, 2026Updated Sep 4, 2026
Labelsbugnative-image

Describe the bug

MethodHandles.Lookup.findStaticVarHandle(refc, name, type) in a native image resolves name to a static field declared in a superclass of refc when the field declared in refc itself is not registered for reflection, silently returning the superclass's field value.

On the JVM the same call resolves refc's own field. When the field cannot be found at all, the expected behavior per the Lookup contract is NoSuchFieldException.

Steps to reproduce

Minimal reproducer (3 files, no framework): https://gist.github.com/power721/fac60fa2eb2292f75fb4ccf4a6ec8c80

In short, using Caffeine 3.2.4 whose LocalCacheFactory exercises exactly this pattern — SSMSW extends SSMS, both declare static final LocalCacheFactory FACTORY, and the factory lookup reads it via findStaticVarHandle as a fast path:

java
// Step 1: build a maximumSize-only cache (impl class SSMS) -> initializes SSMS,
// populates LocalCacheFactory.FACTORIES["SSMS"]
Cache<String, Boolean> sizeOnly = Caffeine.newBuilder().maximumSize(100).build();

// Step 2: build a maximumSize + expireAfterWrite cache (impl class SSMSW extends SSMS)
Cache<String, Boolean> expiring = Caffeine.newBuilder()
        .expireAfterWrite(Duration.ofSeconds(1)).maximumSize(100).build();
expiring.put("k", Boolean.TRUE);
Thread.sleep(2000);
// JVM: entry expired; native image: still present

reflect-config.json registers the parent SSMS fully (fields included) but the subclass SSMSW only with allDeclaredConstructors — i.e. the subclass is reachable, its own FACTORY field is not registered. This mirrors a real Spring Boot native build where the parent class was explicitly listed in reflect-config while the subclass only became reachable through AOT analysis.

bash
javac -cp caffeine-3.2.4.jar Repro.java -d .
java -cp .:caffeine-3.2.4.jar Repro     # correct
native-image -cp .:caffeine-3.2.4.jar \
  -H:ReflectionConfigurationFiles=reflect-config.json Repro repro
./repro                                 # mis-bound

Expected behavior

FACTORIES.get("SSMSW") holds the factory lambda declared by SSMSW itself, same as on the JVM. If a field is not registered and cannot be resolved on the exact class, findStaticVarHandle should throw NoSuchFieldException (Caffeine has a correct slow-path fallback for that case), not fall back to an inherited same-name static field.

Actual behavior

$ java -cp .:caffeine-3.2.4.jar Repro
t+2.0s hit=null
FACTORIES.get("SSMSW") = ...SSMSW$$Lambda/...
SSMSW.FACTORY (declared) = ...SSMSW$$Lambda/...   (same instance)
mis-bound = false

$ ./repro
t+2.0s hit=true                                    <-- 1s TTL entry did NOT expire
FACTORIES.get("SSMSW") = ...SSMS$$Lambda/...       <-- parent's factory was returned
SSMSW.FACTORY (declared) = ...SSMSW$$Lambda/...    <-- correct value still readable via classic Field reflection
mis-bound = true

Consequences:

  1. findStaticVarHandle(SSMSW.class, "FACTORY", ...) returns the value of SSMS.FACTORY (the registered superclass field) instead of throwing or finding the subclass field.
  2. Caffeine therefore silently instantiates the non-expiring implementation (SSMS) for every expireAfterWrite + maximumSize cache — no exception, no warning.
  3. In the real-world Spring Boot application where we found this, every TTL-based Caffeine cache in the process (login rate limiter, bot command cooldowns, API response caches) never evicted; entries only went away on process restart.

Note the field itself is intact: classic Field reflection reads the correct per-class values (see output above). Only the findStaticVarHandle lookup picks the wrong declaring class.

Workaround

Register the static field for reflection explicitly (e.g. allDeclaredFields or a fields entry for SSMSW), or bypass the fast path by pre-populating the factory registry through classic reflection before the first cache is built.

Environment

  • Oracle GraalVM for JDK 25.0.3+9.1 (native-image 25.0.3), linux amd64
  • Reproduced with the default glibc build; the original report is from a --static --libc=musl production build of a Spring Boot 4.0.7 app, so it is not libc-specific
  • Caffeine 3.2.4 (also present in 3.1.8, which has the same findStaticVarHandle fast path)

Additional details

The same shadowing pattern (static final field with identical name declared along a subclass chain) is used elsewhere in Caffeine (NodeFactory families), so the blast radius is not limited to SS* classes — any library that reads a static final field via findStaticVarHandle on a class whose superclass declares the same field is at risk when only the superclass field is registered.