Skip to content

ClassLoader leak (Metaspace OOM) caused by static ThreadLocals in DoubleFormat #7184

@QiuYucheng2003

Description

@QiuYucheng2003

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:

  1. The Container Thread holds a reference to the ThreadLocalMap.

  2. The Map Entry holds the NumberFormat instance (Value).

  3. The NumberFormat instance holds a reference to its Class/ClassLoader (WebAppClassLoader).

  4. Since the ThreadLocal is static and never removed, the ClassLoader cannot be garbage collected upon application reload/undeploy.

  5. 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

  1. Deploy a web application using Micrometer to an external Tomcat container (or use Spring Boot with DevTools).

  2. Trigger metrics collection that utilizes DoubleFormat (initializing the ThreadLocals on container threads).

  3. Undeploy and redeploy the application multiple times.

  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    waiting for teamAn issue we need members of the team to review

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions