ClassValue<T>, the Cache That Dies With the Class
Why static Map<Class<?>, T> Leaks The Whole ClassLoader
Caching something per class is one of the most ordinary things many libraries do.
And a Class<?> looks like the perfect map key: there’s exactly one instance per class per loader, its identity hash is stable, and equals is identity comparison.
So, we often choose the most straightforward solution:
private static final Map<Class<?>, ClassMeta> CACHE = new ConcurrentHashMap<>();It’s simple, and in a plain application, that’s fine. Forever.
In anything that reloads classes, though, it’s a slow leak with a quite ugly ending.
Table of Contents
The (Quite Small) Problem
I’m writing this article because of a small tracing library I built for our Apache Tapestry apps. A homespun library that might be better replaced with OpenTelemetry, but it was a nice learning exercise and requires no dependencies.
It records method calls per request and formats them into a trace afterward. For every recorded call, it needs two things about the declaring class: the package name and the simple name.
If this only happened once per finished trace, performance wouldn’t matter much.
But once per recorded call, on a request that touches a few thousand of them, we’d rather not redo the same String work over and over.
That’s why we cache a small, unchangeable record for each class:
record ClassMeta(String packageName, String simpleName) { }Before optimizing this, it’s worth knowing that
Classalready caches some of it for us.getPackageName()memoizes its result into a privatepackageNamefield, andgetSimpleName()stores its result inReflectionData, which theClassholds behind aSoftReference(both easy to confirm withjavap -p java.lang.Class). That’s a cache we don’t control, though. Under memory pressure, the soft reference can be cleared, and the work happens again.
And the reflex for filling our own cache is computeIfAbsent:
public static ClassMeta of(Class<?> c) {
return CACHE.computeIfAbsent(c, k -> new ClassMeta(k.getPackageName(),
k.getSimpleName()));
}Thanks to the ConcurrentHashMap from the intro, it’s thread-safe, it’s fast, and it’s correct.
For most codebases, this is the right approach, and there’s genuinely nothing to fix.
But that’s exactly why this kind of problem can slip into production unnoticed.
Who Is Holding What?
Let’s look at how the references work, rather than just focusing on the API.
The map is static, so it’s rooted in the class that declares it, which is rooted in that class’s loader.
For a library sitting on the container’s classpath, that’s the application loader, the one that lives for the entire lifetime of the JVM.
The map’s keys are Class objects, a Class strongly references its ClassLoader, and a ClassLoader strongly references every class it ever defined.
Together, these three facts create a long chain of references:
AppClassLoader (lives for the whole JVM)
-> ClassMeta.CACHE (static)
-> key: OrderService.class
-> OrderService.class.getClassLoader()
= WebAppClassLoader #4 <== redeployed 3 versions ago
-> every class it ever defined
-> their static fields
-> ...Just one cached key pins an entire application version in memory.
Not one class, and not one small record.
The whole loader, every class it defined, every static field those classes hold, and whatever those fields point at.
Why It Hides In Plain Sight
This issue is genuinely hard to spot, which is another reason I wanted to write about it.
The chain of references survives a full GC, because nothing here is actual garbage.
The retained set is verifiably reachable from a live static field, so no collector will ever touch it, no matter how aggressively we tune.
It also doesn’t look like a leak in the metrics we usually watch. Most of the memory being used is for class metadata, which is stored in Metaspace. This is native memory, so heap metrics don’t report it at all. Heap usage does creep up, but slowly and unevenly enough that it usually reads like normal growth.
The failure mode isn’t a graph bending upwards.
It’s a JVM that stays healthy through redeploy after redeploy, and then dies of OutOfMemoryError: Metaspace on one deployment too many.
Anything that keeps minting class loaders gets there eventually, whether that’s container redeploys, regenerated proxies, or a scripting engine.
To make matters worse, the cause might be code shipped several deployments ago that hasn’t changed since.
This is the classic app-server redeploy leak, and the shape is always the same.
Whether the culprit is a ThreadLocal, a JDBC driver registered in a static registry, or a well-meaning cache…
something long-lived is holding a reference to something that should be short-lived.
The Ugly Ending
Let’s demonstrate the issue in a smaller environment. Every number in this article comes from running Temurin 25.0.3 on my M5 Pro, and I’ll link to the code as we go.
Define the same class 20,000 times, each in a fresh loader, which is what a container does across a long series of redeploys.
Limit the JVM’s Metaspace with -XX:MaxMetaspaceSize=32m so the problem shows up in seconds instead of weeks or months.
Then let the static Map see every one of those classes on the way past:
cache: static Map<Class<?>, ClassMeta>
deploy used committed class space
2000 2.9 MB 12.6 MB 1.1 MB
4000 5.3 MB 24.4 MB 2.1 MB
5308 6.7 MB 32.0 MB 2.7 MB <== OutOfMemoryError: MetaspaceIt climbs in a straight line and dies at redeploy 5,308, not much more than a quarter of the way through. Not one of those 5,308 classes was ever unloaded, because not one of them was ever unreachable.
The used column also shows why this problem is hard to catch early.
It reads a reasonable 6.7 MB right when the JVM crashes at the 32 MB cap, because the cap limits committed memory, not used.
Metaspace is allocated to class loaders in chunks, so thousands of loaders, each with a mostly-empty chunk, end up committing four to five times as much memory as they actually use.
Careful with these two numbers if you go looking yourself. HotSpot’s
Metaspacememory pool already includes the compressed class space, so adding the two pools together double-counts. The class-space column above is reported separately for exactly that reason.
The Fixes That Don’t Work
If holding on to something short-lived is the issue, a first reflex might be reaching for WeakHashMap<Class<?>, ClassMeta>.
The key becomes weak, so surely the entry disappears once the class is otherwise unreachable?
Well… only if nothing else keeps that key alive, and the value frequently does.
Any cached value that references its own class, or anything else loaded by the same loader, forms a strong path from the entry back to its own key, and the entry then keeps itself alive indefinitely.
Our ClassMeta happens to hold nothing but two strings, but a cached serializer, a MethodHandle, or a resolved annotation model tends to reference its own class.
Give the record a single Class<?> owner field, which is the most natural thing in the world to do, and the weak key stops helping entirely:
cache implementation: WeakHashMap, value references its key
cached value: ClassMetaWeak[packageName=shop, simpleName=OrderService]
weak cache entries: 1 (a cleared weak key would have removed it)
after undeploy + GC: STILL REACHABLE -> class shop.OrderService via WebAppClassLoader #1The entry is still there, the key was never cleared, and the loader is still pinned.
One field did that.
This is precisely what WeakHashMap’s Javadoc warns about: value objects must not strongly refer to their own keys, directly or indirectly.
Getting that right means WeakReference values, a ReferenceQueue, and a cleanup pass, which is quite a lot of machinery for caching two strings.
A second reflex is reaching for Collections.synchronizedMap(new WeakHashMap<>()), because WeakHashMap isn’t thread-safe.
Now, every lookup on the hot path requires a global lock, which removes the concurrency benefits that made ConcurrentHashMap appealing in the first place.
We’re now three data structures deep, slower than when we started, and the loader is still pinned.
We wanted a cache entry per class. What we got is a GC root per class.
The JDK Already Has A Solution
There’s a class in java.lang for exactly the described issue, and it has been there since Java 7: ClassValue<T>.
record ClassMeta(String packageName, String simpleName) {
private static final ClassValue<ClassMeta> META = new ClassValue<>() {
@Override
protected ClassMeta computeValue(Class<?> c) {
return new ClassMeta(c.getPackageName(), c.getSimpleName());
}
};
public static ClassMeta of(Class<?> c) {
return META.get(c);
}
}That’s the whole thing, no extra map needed. The record stays the same, and there’s no need for any cleanup code.
But the interesting part isn’t the API surface, it’s how it differs from using a Map:
- A
Mapstores key to value, and therefore owns its keys. - A
ClassValuestores the value on the class, and theClassValueinstance is merely the lookup key inside a per-class map.
Think of it as a Map-less map.
We can store values directly on the class itself, and the JDK provides basic map-like behavior for free, without causing ownership problems:
BEFORE: the map owns the class
AppClassLoader
-> ClassMeta.CACHE (static)
-> key: OrderService.class <== the reference that shouldn't exist
-> WebAppClassLoader #4
-> every class it defined
AFTER: the class owns the value
AppClassLoader
-> ClassMeta.META (static ClassValue)
(references no classes at all)
WebAppClassLoader #4
-> OrderService.class
-> ClassMeta["shop", "OrderService"]When the ClassLoader becomes unreachable, its classes go, and the cached values go with them, because they were never stored anywhere else.
No reference queue, no cleanup pass, no eviction policy, and no shutdown hook that somebody forgets to register.
The Class lifetime is the eviction policy.
The Ending That Doesn’t Come
Same 20,000 redeploys, same 32 MB cap, and the only line that does anything differently is the one that fills the cache.
cache: ClassValue<ClassMeta>
deploy used committed class space
2000 2.9 MB 12.6 MB 1.1 MB
4000 5.3 MB 24.4 MB 2.1 MB
6000 1.6 MB 5.0 MB 0.4 MB
8000 3.8 MB 16.8 MB 1.4 MB
10000 6.1 MB 28.4 MB 2.4 MB
12000 2.4 MB 9.1 MB 0.8 MB
14000 4.6 MB 20.8 MB 1.8 MB
16000 1.0 MB 1.6 MB 0.1 MB
18000 3.2 MB 13.3 MB 1.1 MB
20000 5.4 MB 25.0 MB 2.1 MB
survived 20000 redeploys, usage rose and fell throughoutThe two runs are indistinguishable up to redeploy 4,000, down to the last reported megabyte.
After that point, the Map version keeps using more memory and crashes after 1,308 more redeploys, while the ClassValue version drops to 5.0 MB and continues running.
Each drop in memory usage shows a batch of classes being unloaded, which is what the garbage collector couldn’t do with the Map approach.
No special tuning was needed, and no manual cache clearing was done.
The classes simply became garbage, just as they should have from the start.
That sawtooth is the whole article in one column of numbers.
static Map run climbs to the 32 MB cap and dies at redeploy 5,308, the ClassValue run sawtooths between 1.6 and 28.4 MB and survives all 20,000The mechanism the JDK uses might be an implementation detail, but a nice one to have seen at least once.
java.lang.Classcarries a package-privatetransient ClassValue.ClassValueMap classValueMapfield, andClassValueMapis aWeakHashMapkeyed by theClassValue’s own identity object, with a probe-based cache array in front of it for fast lookups. Both are visible viajavap -p java.lang.Classandjavap -p 'java.lang.ClassValue$ClassValueMap'. Don’t build on the field, though. Do rely on the ownership direction, which is the part the API actually promises.
Where It Comes From
If ClassValue is such a neat solution to such an ordinary problem, why have so few people actually heard of it?
Because it wasn’t created for us.
ClassValue arrived in Java 7 as part of JSR 292, the invokedynamic work that most modern Java features rely on, as infrastructure for caching per-class data at call sites.
It lives in java.lang because the MethodHandle machinery needed it there, not because it was ever pitched to application developers.
That’s also why its Javadoc reads more technical than many other Java types, and why the genuinely interesting property is so easy to miss.
“Lazily associate a computed value with any Class object” sounds like a memoization helper.
The word that actually matters in my opinion, lifetime, doesn’t appear at all.
Neither do “unload”, “garbage”, or “collect”.
There Are Caveats, Though
ClassValue<T> is a specialized tool, and that specialization comes with trade-offs.
It is not meaningfully faster.
get()is a specialized per-class lookup that the JIT handles well, but aConcurrentHashMaphit is already very cheap, and the first lookup for a class takes a lock on that class’s internal map to record the result. UseClassValuefor lifetime reasons, not for better throughput. The numbers for that claim are below.computeValuecan run more than once for the same class.
Even though only one result ever gets published, two threads racing on the same class can both compute one.remove()does the same thing deliberately: it drops the association, and the nextget()recomputes from scratch. So,computeValuemust be idempotent and have no side effects. Fine for an immutable record built from two strings, definitely not fine for anything that registers, counts, or opens something.The key has to be a
Class.
No composite keys, noMethodkeys, no “per class, per configuration profile”. My tracing tool caches per-method timing data keyed byjava.lang.reflect.Method, and those caches can’t useClassValue<T>at all.No size bound, no TTL, and
remove()is awkward.
Since the lifetime is the eviction policy, there is no other one. If entries need to expire on a schedule, or the cache needs an upper bound, this is definitely the wrong tool and a real cache library will suit you better.It’s a footgun for large values.
A value stored withClassValuelives exactly as long as its class. Attach something big to a class loaded by the application loader, and it’s effectively permanent, which is the same lifetime bug pointing the other way.
Repository:
ComputeCount.javacounts thecomputeValuecalls across aremove().
About That Throughput Claim
“Don’t switch for speed” deserves some numbers to back up that claim.
A plain loop over ten warm keys, one thread, so treat these as an order of magnitude and nothing more:
round map.get computeIfAbsent ClassValue.get
1 1.64 ns 6.16 ns 1.49 ns
2 1.63 ns 6.18 ns 1.49 ns
3 1.64 ns 6.13 ns 1.49 ns
4 1.63 ns 6.12 ns 1.49 ns
5 1.71 ns 6.21 ns 1.50 nsThis is a loop with a warmup, not JMH. One thread, one JVM, no forks, no statistics. If a real decision depends on the answer, measure it properly, because a microbenchmark like this one is only trustworthy for the crude conclusion it’s being used for here.
ClassValue.get() does come out ahead of Map.get(), but the gap is 0.15 ns, which is almost nothing.
Technically, the number is smaller, but it’s not enough to justify making any changes.
Even at a million lookups per second, the total difference is just 0.15 milliseconds, which is lost in the noise of the rest of the code.
And that gap has been shrinking.
On JDK 21, the previous LTS, the same loop reports 1.22 ns for ClassValue.get(), so it got slower on the way to 25, not faster.
The cause is JDK-8351996, which reworked remove() so that stale values stop escaping from computeValue, and the 1-9% regression that came with it wasn’t fixed until JDK 26.
A correctness fix was allowed to reduce performance for an entire release cycle.
To me, this shows clearly what ClassValue is for: no one is optimizing it for speed, because lookup speed was never its main purpose.
The middle column in the table is actually more interesting.
Map.computeIfAbsent on every lookup is about 3.7 times slower than a simple Map.get(), because it does extra work even when the value is already present.
In this case, that’s a real inefficiency, unlike the small difference between get methods.
But again, this isn’t the main issue here.
Fixing it by switching to ClassValue would be fixing the wrong thing for the wrong reason, and swapping in a Map.get() fast path would have bought the same nanoseconds while keeping the leak.
Lifetime is the reason to switch.
Speed never was.
Making the Leak Visible
The redeploy loops above expose the problem, but running 5,308 iterations isn’t practical for debugging. For that, we want a single cycle, and one cycle is small enough to fake.
Load a class in a throwaway URLClassLoader, cache something derived from it, then drop the loader and every reference we hold ourselves.
A WeakReference<Class<?>> tells us whether the class survived, and -Xlog:class+unload=info gives us the same answer from the JVM’s side:
cache implementation: static Map
cached value: ClassMetaMap[packageName=shop, simpleName=OrderService]
after undeploy + GC: STILL REACHABLE -> class shop.OrderService via WebAppClassLoader #1
cache implementation: ClassValue
cached value: ClassMetaValue[packageName=shop, simpleName=OrderService]
[0.029s][info][class,unload] unloading class shop.OrderService 0x0000080001041000
after undeploy + GC: unloadedOnly one of the two runs produces an unload message. That’s the whole difference between the two caching methods, and it only takes 40 lines of code to demonstrate.
That shape is worth keeping as a test.
Using a WeakReference to a Class and calling System.gc() isn’t pretty, but it will fail as soon as someone adds a static Map<Class<?>, ...> to a library that needs to support reloading.
Every other tool below only reports the leak once it’s running somewhere, and this one reports it in CI.
In a real process, the loaders are the thing to count:
jcmd <pid> VM.classloadersandjcmd <pid> VM.classloader_statslist the loaders and their metadata footprint. TwoWebAppClassLoaderinstances after a single redeploy is the smoking gun, andjmap -clstats <pid>does the same job on older JDKs.-Xlog:class+unload=infologs classes as they unload. A redeploy that produces no unload entries at all means something is still holding on.-XX:MaxMetaspaceSize=64min a test environment turns “mysterious growth over a week in production” into “fails on the third redeploy of the integration suite”.-XX:+HeapDumpOnOutOfMemoryError, followed by a “path to GC root” query on any staleClassin the dump. That path walks straight to the offendingstaticfield, and it will name the field.
Building a habit of using that last one is worthwhile. It doesn’t just confirm the leak, it shows the exact line of code that caused it.
Who Owns Whose Lifetime?
ConcurrentHashMap is not the villain in this story.
It did precisely what a Map is supposed to do: it held on to its keys until told otherwise, and it did so quickly and thread-safely.
This isn’t a suggestion to replace every Map<Class<?>, T> in your codebase.
In an application that never reloads a class, and that’s most of them, the “leak” has no consequence whatsoever, and ClassValue buys us nothing but an anonymous subclass and an unfamiliar type in a code review.
However, if we’re working with a container, framework, agent, or plugin host, using ClassValue makes the entire problem go away.
The real issue wasn’t the data structure itself, but choosing one without considering which object should control another’s lifetime. In any system that loads code dynamically, this question is central to the design.
So, the next time you write static Map<Class<?>, ...>, you’ll know what to consider first, and that the JDK quietly provided an answer back in 2011.
Resources
ClassValueJavadoc: the API, minus the reason it exists.WeakHashMapJavadoc: the warning about values referring to their own keys.JSR 292: Supporting Dynamically Typed Languages on the Java Platform
Code examples: No build tool required,
./run.shtakes about three seconds.