findStaticVarHandle returns the superclass's static field when the subclass field is not registered for reflection
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:
// 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 presentreflect-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.
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-boundExpected 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 = trueConsequences:
findStaticVarHandle(SSMSW.class, "FACTORY", ...)returns the value ofSSMS.FACTORY(the registered superclass field) instead of throwing or finding the subclass field.- Caffeine therefore silently instantiates the non-expiring implementation (
SSMS) for everyexpireAfterWrite + maximumSizecache — no exception, no warning. - 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-image25.0.3), linux amd64 - Reproduced with the default glibc build; the original report is from a
--static --libc=muslproduction 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
findStaticVarHandlefast 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.
Source: oracle/graal