-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
Describe the bug
I identified a ClassLoader leak issue in io.micrometer.core.instrument.util.DoubleFormat.
The class uses private static final ThreadLocal fields (DECIMAL_OR_NAN, WHOLE_OR_DECIMAL, DECIMAL) to cache NumberFormat instances for performance. However, there is no mechanism to invoke remove() on these ThreadLocals.
In container environments (like Tomcat/Jetty) where threads are pooled and long-lived:
-
The Container Thread holds a reference to the ThreadLocalMap.
-
The Map Entry holds the NumberFormat instance (Value).
-
The NumberFormat instance holds a reference to its Class/ClassLoader (WebAppClassLoader).
-
Since the ThreadLocal is static and never removed, the ClassLoader cannot be garbage collected upon application reload/undeploy.
-
This leads to java.lang.OutOfMemoryError: Metaspace after multiple redeployments.
Environment
Micrometer version: (Based on source analysis of DoubleFormat.java)
Micrometer registry: Any
OS: Any
Java version: Any
Container: Apache Tomcat / Jetty / Spring Boot DevTools (Hot Reload environments)
To Reproduce
-
Deploy a web application using Micrometer to an external Tomcat container (or use Spring Boot with DevTools).
-
Trigger metrics collection that utilizes DoubleFormat (initializing the ThreadLocals on container threads).
-
Undeploy and redeploy the application multiple times.
-
Observation: Monitor the Metaspace usage. Take a Heap Dump and analyze ClassLoader instances. You will see multiple unreachable WebAppClassLoader instances held by ThreadLocalMap$Entry referencing DoubleFormat.
Expected behavior
Utility classes in a library should avoid using static ThreadLocal without a cleanup hook, as it breaks the lifecycle of the ClassLoader in containerized environments. Consider checking if the performance cost of instantiating DecimalFormat on demand is acceptable on modern JDKs, or implement a proper cleanup strategy.
Additional context
Source Location:
// DoubleFormat.java
private static final ThreadLocal<NumberFormat> DECIMAL_OR_NAN = ThreadLocal.withInitial(() -> ...);
private static final ThreadLocal<DecimalFormat> WHOLE_OR_DECIMAL = ThreadLocal.withInitial(() -> ...);
private static final ThreadLocal<DecimalFormat> DECIMAL = ThreadLocal.withInitial(() -> ...);
// remove() is never called.