platform-java uses isolated ClassLoaders to provide application isolation. While powerful, ClassLoaders can cause memory leaks if applications don't follow best practices. This document outlines common leak sources and how to avoid them.
A ClassLoader leak occurs when the JVM cannot garbage collect a ClassLoader after an application is undeployed because:
- Some code still holds a reference to a class loaded by that ClassLoader
- That class holds a reference back to its ClassLoader
- The ClassLoader holds references to all classes it loaded
- This prevents garbage collection of the entire application
Result: Memory leak - the application's memory is never released even after undeploy.
Problem: ThreadLocals store per-thread data but are never automatically cleaned up.
Bad Example:
public class MyApp implements Application {
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();
@Override
public void start(ApplicationContext context) {
connectionHolder.set(createConnection()); // Creates leak!
}
@Override
public void stop() {
// ThreadLocal not cleaned up!
}
}Good Example:
public class MyApp implements Application {
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();
@Override
public void start(ApplicationContext context) {
connectionHolder.set(createConnection());
}
@Override
public void stop() {
// Clean up ThreadLocal before stopping
Connection conn = connectionHolder.get();
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
// Log error
}
}
connectionHolder.remove(); // ✅ Remove ThreadLocal value
}
}Best Practice: Always call threadLocal.remove() in your stop() method.
Problem: Static fields are stored in the Class object, which is loaded by your ClassLoader. They prevent the ClassLoader from being garbage collected.
Bad Example:
public class MyApp implements Application {
private static List<String> globalCache = new ArrayList<>(); // Leak!
private static final Logger LOG = LoggerFactory.getLogger(MyApp.class); // OK
@Override
public void start(ApplicationContext context) {
globalCache.add("data");
}
}Good Example:
public class MyApp implements Application {
// ✅ Use instance fields instead of static
private List<String> cache = new ArrayList<>();
// ✅ Static constants for primitives/Strings are OK
private static final String APP_NAME = "MyApp";
// ✅ Static loggers are OK (SLF4J is in parent classloader)
private static final Logger LOG = LoggerFactory.getLogger(MyApp.class);
@Override
public void start(ApplicationContext context) {
cache.add("data");
}
@Override
public void stop() {
cache.clear(); // Cleanup
}
}Best Practice:
- Avoid static fields holding mutable data
- Use instance fields instead
- Static fields for primitives, Strings, and platform classes are OK
Problem: JDBC drivers registered with DriverManager are never automatically deregistered.
Bad Example:
public class MyApp implements Application {
@Override
public void start(ApplicationContext context) {
// Driver auto-registers with DriverManager on class load
Class.forName("org.postgresql.Driver"); // Leak!
Connection conn = DriverManager.getConnection("jdbc:postgresql://...");
}
}Good Example:
public class MyApp implements Application {
private Driver driver;
@Override
public void start(ApplicationContext context) throws Exception {
// Manually register driver so we can deregister it
driver = new org.postgresql.Driver();
DriverManager.registerDriver(driver);
Connection conn = DriverManager.getConnection("jdbc:postgresql://...");
}
@Override
public void stop() throws Exception {
// ✅ Deregister driver
if (driver != null) {
DriverManager.deregisterDriver(driver);
}
}
}Note: platform-java's ClassLoaderCleanupUtil automatically deregisters JDBC drivers, but it's still best practice to do it manually.
Best Practice: Explicitly register and deregister JDBC drivers.
Problem: Threads hold references to their context ClassLoader.
Bad Example:
public class MyApp implements Application {
@Override
public void start(ApplicationContext context) {
// Creates thread that runs forever
new Thread(() -> {
while (true) {
// Do work
}
}).start(); // Leak - thread never stops!
}
}Good Example:
public class MyApp implements Application {
private volatile boolean running = true;
private Thread worker;
@Override
public void start(ApplicationContext context) {
worker = new Thread(() -> {
while (running) {
// Do work
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
worker.start();
}
@Override
public void stop() {
// ✅ Stop thread gracefully
running = false;
if (worker != null) {
worker.interrupt();
try {
worker.join(5000); // Wait up to 5 seconds
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}Better: Use the platform's thread pool instead:
public class MyApp implements Application {
@Override
public void start(ApplicationContext context) {
// ✅ Use platform thread pool (managed by platform-java)
context.getThreadPool().submit(() -> {
// Do work
});
}
}Best Practice: Use platform thread pools or ensure threads are stopped in stop().
Problem: Shutdown hooks registered with Runtime.addShutdownHook() hold thread references.
Bad Example:
public class MyApp implements Application {
@Override
public void start(ApplicationContext context) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
// Cleanup
})); // Leak if not removed!
}
}Good Example:
public class MyApp implements Application {
private Thread shutdownHook;
@Override
public void start(ApplicationContext context) {
shutdownHook = new Thread(() -> {
// Cleanup
});
Runtime.getRuntime().addShutdownHook(shutdownHook);
}
@Override
public void stop() {
// ✅ Remove shutdown hook
if (shutdownHook != null) {
try {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
} catch (IllegalStateException e) {
// Shutdown in progress, ignore
}
}
}
}Best Practice: Remove shutdown hooks in stop() method.
Problem: MBeans registered with MBeanServer hold references.
Bad Example:
public class MyApp implements Application {
@Override
public void start(ApplicationContext context) throws Exception {
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
ObjectName name = new ObjectName("com.myapp:type=Stats");
mbs.registerMBean(new MyStats(), name); // Leak if not unregistered!
}
}Good Example:
public class MyApp implements Application {
private ObjectName mbeanName;
@Override
public void start(ApplicationContext context) throws Exception {
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
mbeanName = new ObjectName("com.myapp:type=Stats");
mbs.registerMBean(new MyStats(), mbeanName);
}
@Override
public void stop() throws Exception {
// ✅ Unregister MBean
if (mbeanName != null) {
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
if (mbs.isRegistered(mbeanName)) {
mbs.unregisterMBean(mbeanName);
}
}
}
}Best Practice: Unregister MBeans in stop() method.
Problem: Some logging frameworks (Logback, Log4j) cache loggers by ClassLoader.
Best Practice: Use SLF4J API (provided by platform). platform-java handles the cleanup automatically.
// ✅ Good - use SLF4J
private static final Logger logger = LoggerFactory.getLogger(MyApp.class);
// ❌ Bad - don't use Logback/Log4j directly
private static final ch.qos.logback.classic.Logger logger = ...;Run platform-java with leak detection enabled:
java -Dplatform-java.debug.detectLeaks=true -jar platform-java-launcher.jarWhen you undeploy an application, platform-java will:
- Trigger garbage collection
- Check if the ClassLoader was collected
- Log a warning if a leak is detected
// In your application's stop() method, verify cleanup
@Override
public void stop() {
// Your cleanup code
// Verify no threads are running
Thread.getAllStackTraces().keySet().stream()
.filter(t -> t.getName().contains("myapp"))
.forEach(t -> {
System.err.println("WARNING: Thread still running: " + t.getName());
});
}Use VisualVM, JProfiler, or YourKit to find ClassLoader leaks:
- Deploy and start your application
- Take a heap dump
- Undeploy your application
- Trigger GC (
System.gc()) - Take another heap dump
- Compare dumps - your application's ClassLoader should be gone
If it's still present, the profiler will show the reference chain keeping it alive.
Before deploying your application to platform-java, verify:
- No static fields holding mutable data
- All ThreadLocals are cleaned up in
stop() - All threads are stopped or use platform thread pool
- JDBC drivers are deregistered in
stop() - Shutdown hooks are removed in
stop() - JMX MBeans are unregistered in
stop() - No infinite loops in background threads
- All resources (files, sockets) are closed in
stop() - Tested with leak detection enabled
| Pattern | Risk Level | Solution |
|---|---|---|
| Static mutable fields | HIGH | Use instance fields |
| ThreadLocal | HIGH | Call .remove() in stop() |
| JDBC drivers | HIGH | Deregister in stop() |
| Custom threads | MEDIUM | Stop gracefully or use platform pool |
| Shutdown hooks | MEDIUM | Remove in stop() |
| JMX MBeans | MEDIUM | Unregister in stop() |
| Logging | LOW | Use SLF4J API |
Golden Rule: If you allocate it in start(), clean it up in stop().