This content originally appeared on DEV Community and was authored by Xuan
Ever stared at your Java application’s memory usage climbing steadily, even after you’ve “fixed” all the obvious leaks? You redeploy a new version, sigh in relief as the memory drops, only to watch it creep up again over hours or days. It’s a frustrating, invisible drain, making your once-robust app feel sluggish and unstable. If this sounds familiar, you’re likely caught in one of Java’s most insidious and often misunderstood traps: the hidden classloader memory leak.
You’re not alone. Many developers, especially those working with application servers, plugin architectures, or hot-reloading scenarios, have battled this phantom menace. It’s not usually a bug in your core business logic; it’s a structural problem with how Java loads and unloads code, and it can leave you scratching your head for weeks. But don’t worry, we’re going to break it down, pinpoint why it happens, and give you concrete strategies to make those leaks disappear for good.
What’s a Memory Leak, Anyway? (The Simple Version)
Before we dive into classloaders, let’s quickly define a memory leak. In Java, we have a wonderful thing called a “Garbage Collector” (GC). Its job is to automatically free up memory occupied by objects that are no longer needed. A memory leak occurs when your application creates objects that are no longer actively used, but they’re still referenced by something else. Because they’re referenced, the GC thinks they’re still important and won’t delete them. Over time, these forgotten objects pile up, eating away at your available memory until your application slows down or, worse, crashes with an OutOfMemoryError
.
The Hidden Hand: Java Classloaders
Now, let’s introduce the star of our show: the Classloader. Think of a classloader as a librarian for your Java code. When your application needs a specific class (like String
or MyCustomClass
), the classloader’s job is to find that class’s .class
file, load it into memory, and make it available to your program.
Java applications don’t just have one classloader; they have a hierarchy. It’s like a family tree:
- The Bootstrap Classloader loads core Java classes (like
java.lang.Object
). - The Extension Classloader loads classes from the
ext
directory. - The Application Classloader loads classes from your application’s classpath.
But here’s where it gets interesting for our problem: many environments, especially web servers like Tomcat or plugin frameworks, create additional classloaders. Each web application deployed on Tomcat, for example, typically gets its own classloader. This is a good thing! It allows different web apps to use different versions of the same library without conflict and makes it possible to “hot-redeploy” one app without restarting the entire server.
The Trap: How Classloaders Get Stuck
The problem arises when an older, parent classloader (like the application server’s main classloader) accidentally holds a reference to an object, a class, or even a thread that was loaded by a newer, child classloader (like your specific web application’s classloader).
Imagine you deploy MyApp_v1
. Its classloader loads all its classes. Then you redeploy MyApp_v2
. The application server tries to get rid of MyApp_v1
‘s classloader and all its associated objects. But if the parent classloader somehow still has a reference to anything from MyApp_v1
‘s world, that entire MyApp_v1
classloader, along with all the classes it loaded and all the objects those classes created, cannot be garbage collected. It’s like a single forgotten book on the top shelf preventing the entire library wing from being demolished.
Common culprits for these sticky references include:
- Static Fields: These are the worst offenders. If your application’s class
MyUtil
has astatic List<MyObject> globalCache
and the parent classloader somehow gets a reference toMyUtil
(e.g., through a globally registered logger, a JDBC driver, or aThreadLocal
), then even after redeploying,MyUtil
‘s class (and thusglobalCache
) remains in memory, holding onto all itsMyObject
instances. -
ThreadLocal
Variables: If your application creates threads, and those threads are reused by the application server (common in thread pools), aThreadLocal
variable set by your old application could persist in that thread, holding a reference to your old application’s classes. - Non-Daemon Threads: If your application starts its own non-daemon threads and doesn’t explicitly shut them down, those threads will continue to run, holding references to your application’s classes, preventing its classloader from being unloaded.
- Globally Registered Resources: JDBC drivers, logging appenders, JMX MBeans, AWT event listeners, or even certain framework-level caches that register themselves with the parent classloader can inadvertently hold onto references from your specific application.
- External Libraries/Frameworks: Sometimes, the issue isn’t directly your code but a third-party library that doesn’t clean up after itself when integrated into a classloader-heavy environment.
Signs You’re Caught in the Trap
- Memory grows after each redeployment: This is the most tell-tale sign. Instead of memory dropping back to baseline, it keeps increasing after every new version of your app.
-
OutOfMemoryError: PermGen space
(Java 7 and earlier) orMetaspace
(Java 8 and later): These errors specifically relate to the area where class definitions are stored. If old classloaders aren’t unloaded, this area will fill up. - Profiling shows multiple instances of your application’s classes: Tools like VisualVM, JProfiler, or YourKit will show several instances of your
MyApplication
class orMyController
class, each associated with a differentWebAppClassLoader
or similar, indicating that old versions are still hanging around.
Escaping the Trap: Solutions and Best Practices
The good news is that once you understand the mechanism, you can put strategies in place to prevent these leaks.
-
Embrace Profiling: This is your number one tool. When you suspect a leak, take a heap dump (e.g.,
jmap -dump:file=heapdump.hprof <pid>
) and analyze it with tools like Eclipse Memory Analyzer (MAT).- Look for multiple instances of your application’s main classes (e.g.,
ServletContext
implementations, specific controllers). - Find the “Path to GC Roots” for these leaked objects. This will show you exactly what is holding onto them, often revealing a static field, a thread, or a registered listener.
- Pay special attention to
ClassLoader
instances. If you see multiple instances of your web application’s classloader (e.g.,WebappClassLoader
), that’s a huge red flag.
- Look for multiple instances of your application’s main classes (e.g.,
-
Clean Up Statics (Aggressively): This is paramount.
-
In Web Applications: Use a
ServletContextListener
. ItscontextDestroyed()
method is called when your application is being shut down/undeployed. In this method, set all static fields in your application that might hold references tonull
.
public class MyCleanupListener implements ServletContextListener { @Override public void contextDestroyed(ServletContextEvent event) { // Example: Nullify a static cache MyUtilityClass.clearStaticCache(); // Any other static resources your app might hold } // ... contextInitialized ... }
-
* Ensure any globally registered objects (like loggers, database drivers, `ThreadFactory` instances) are *deregistered* or *closed* during shutdown. Many frameworks offer specific cleanup hooks.
-
Mind Your
ThreadLocal
s: Always, always callThreadLocal.remove()
when you’re done with aThreadLocal
variable, especially in pooled threads (like those in application servers). It’s good practice to wrapThreadLocal
usage in atry-finally
block to guaranteeremove()
is called.
ThreadLocal<MyObject> myThreadLocal = new ThreadLocal<>(); try { myThreadLocal.set(new MyObject()); // ... use myThreadLocal ... } finally { myThreadLocal.remove(); }
Stop All Non-Daemon Threads: If your application starts any threads (e.g., background workers, scheduled tasks), make sure they are properly shut down when your application is unloaded. Threads that are not marked as
daemon
will prevent the JVM from exiting and can prevent classloaders from being unloaded. Implement proper lifecycle management: start threads incontextInitialized()
and stop them incontextDestroyed()
.-
Deregister Global Resources:
- JDBC Drivers: If you manually register JDBC drivers, deregister them in
contextDestroyed()
. - Loggers: Custom logging appenders might need explicit shutdown.
- JMX MBeans: If you expose MBeans, unregister them.
- AWT/Swing Listeners: Be careful with these in server-side apps; ensure they are detached.
- JDBC Drivers: If you manually register JDBC drivers, deregister them in
Review Third-Party Libraries: Sometimes, the leak isn’t in your code but in a library you use that doesn’t handle classloader isolation well. Look for library-specific cleanup methods or known issues for your particular environment. For example, Apache Commons VFS is a known culprit if not explicitly closed.
Isolate Dependencies (Advanced): For complex plugin architectures, consider using separate, isolated classloaders for each plugin. This makes unloading much cleaner. This is often handled by the framework itself (e.g., OSGi).
Prevention is Better Than Cure
The best defense against classloader leaks is to design your application with graceful shutdown in mind from the very beginning. Assume your application will be reloaded, and explicitly define how all your resources (static fields, threads, listeners) will be cleaned up. Don’t rely on the garbage collector to implicitly understand your application’s lifecycle in complex environments.
Understanding the hidden classloader trap can feel like a deep dive into Java’s internals, but mastering it gives you incredible control over your application’s stability and performance. No more mysterious memory growth, no more OutOfMemoryErrors
after redeployment. Arm yourself with profilers and these cleanup strategies, and you’ll be well on your way to a leak-free Java app.
This content originally appeared on DEV Community and was authored by Xuan