How can a memory leak occur in Java?
A memory leak occurs when a reference to an object remains even though the object is no longer needed.
Junior Level
A memory leak occurs when a reference to an object remains even though the object is no longer needed.
Most common causes:
1. Static collections:
// ❌ Collection grows infinitely
static List<String> log = new ArrayList<>();
log.add("message"); // Never cleaned!
2. ThreadLocal without cleanup:
// ❌ Forgot to remove
ThreadLocal<User> user = new ThreadLocal<>();
user.set(currentUser);
// In thread pool, thread is reused → old User remains!
3. Unclosed resources:
// ❌ Forgot close()
FileInputStream fis = new FileInputStream("file.txt");
// Buffers remain in memory
How to avoid:
- Clean collections
- Call
ThreadLocal.remove() - Use try-with-resources
Middle Level
1. ThreadLocal in Thread Pools
// ❌ Problem: threads are reused
public class UserContext {
private static ThreadLocal<User> context = new ThreadLocal<>();
public static void set(User user) {
context.set(user);
}
}
// Request 1: User A
UserContext.set(userA);
// ... processing ...
// Forgot UserContext.remove()!
// Request 2 (same thread): User B
// context.get() → returns userA! ← Bug + leak
Solution:
// ✅ Always remove in finally
try {
context.set(user);
// processing
} finally {
context.remove(); // Mandatory!
}
2. Inner Classes (implicit references)
// ❌ Non-static inner class
public class Outer {
private byte[] data = new byte[1_000_000]; // 1 MB
class Inner { // Implicitly holds reference to Outer.this
void doWork() { }
}
public void leak() {
Inner inner = new Inner();
executor.submit(inner::doWork); // Inner passed to another thread
// Outer (1 MB) won't be deleted while Inner is alive!
}
}
// ✅ Static nested class
static class Inner { // No reference to Outer
void doWork() { }
}
3. Static Collections
// ❌ Unbounded cache
static Map<String, Data> cache = new HashMap<>();
public Data getData(String key) {
if (!cache.containsKey(key)) {
Data data = loadFromDb(key);
cache.put(key, data); // Grows infinitely!
}
return cache.get(key);
}
// ✅ Bounded cache (Caffeine)
static Cache<String, Data> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
4. String.intern() Abuse
// ❌ Interning millions of unique strings
while (readingLogs) {
String uniqueLog = readLine();
uniqueLog.intern(); // In Java 7+, strings from StringTable can be GC'd (if the ClassLoader that loaded them is collected).
// But with intensive intern(), the table grows faster than it's cleaned.
}
// StringTable in Heap → fills up → OOM
String.intern() is useful for deduplicating a known set of repeating strings
(enum-like values, keys). Harmful for unique strings (logs, UUIDs).
5. Listeners and Observers
// ❌ Subscribed, but unsubscribed
eventBus.subscribe(listener);
// listener object won't be deleted while eventBus is alive
Senior Level
ClassLoader Leak (Metaspace)
Dynamic class generation:
Spring → proxies via CGLIB
Hibernate → proxies for Entity
Groovy/JavaScript → script compilation
Each generated class → Metaspace
Classes are cleaned only with ClassLoader
If ClassLoader is not GC'd:
→ All classes remain
→ Metaspace grows → OOM
Causes of ClassLoader leak:
1. ThreadLocal holds object from application ClassLoader
2. DriverManager holds reference to JDBC driver
3. LogManager holds reference to application Logger
4. java.beans.Introspector caches BeanInfo
Diagnostics:
# Check Metaspace
jcmd <pid> VM.metaspace
# Track class loading
-Xlog:class+load=info
-XX:+TraceClassLoading
-XX:+TraceClassUnloading
# If more classes loaded than unloaded → leak!
Off-Heap Memory Leaks
// DirectByteBuffer — memory outside Heap
ByteBuffer buf = ByteBuffer.allocateDirect(100 * 1024 * 1024);
// 100 MB outside -Xmx!
// Phantom Reference cleans buffer when ByteBuffer is GC'd
// But if GC doesn't run (Heap empty) → Native Memory grows
// NIO Cleaner works asynchronously
→ Delay between "object dead" and "memory freed"
// Solution:
// 1. Limit: -XX:MaxDirectMemorySize=2g
// 2. Manual cleanup: ((DirectBuffer) buf).cleaner().clean();
// ⚠️ Warning: cleaner().clean() is internal JDK API, not guaranteed
// between versions. Use only as last resort.
// 3. Monitoring: NMT
Dynamic Proxies and Metaspace
// Each proxy generation → new class
for (int i = 0; i < 1_000_000; i++) {
// New class for each call!
Object proxy = Proxy.newProxyInstance(
classLoader,
new Class<?>[] { Interface.class },
handler
);
}
// 1M classes → Metaspace OOM!
// Solution: cache proxies
Production Experience
Real scenario #1: Groovy scripts
- Application compiles Groovy scripts on the fly
- Each script → new ClassLoader → new classes
- In 2 weeks: Metaspace 256 MB → 4 GB → OOM
- Solution: cache compiled scripts
Real scenario #2: Netty Direct Buffers
- Netty allocates DirectByteBuffer per connection
- 10,000 connections × 1 MB = 10 GB Native Memory
- Heap empty (data sent immediately) → GC doesn’t run
- OOM Killer kills process
- Solution:
-XX:MaxDirectMemorySize+ buffer pool
Best Practices
- ThreadLocal.remove() in
finallyalways - Static collections → limit + eviction policy
- Inner classes — make nested class
staticif it does NOT need access to outer class fields. If access is needed — keep non-static, but control lifetime. - try-with-resources for all Closeable
- Unsubscribe from listeners on destruction
- Cache proxies and compiled scripts
- MaxDirectMemorySize for Native Memory control
- NMT for off-heap monitoring
Senior Summary
- ThreadLocal — cause #1 in web applications
- Inner classes — implicit reference to outer
- ClassLoader leaks — Metaspace grows unnoticed
- DirectByteBuffer — leak outside Heap (need NMT)
- Dynamic proxies — cache them!
- String.intern() — be careful with unique strings
- Listeners — always unsubscribe
- Native Memory — can kill even with empty Heap
Interview Cheat Sheet
Must know:
- ThreadLocal in thread pool: threads are reused →
.remove()infinallyis mandatory - Non-static inner class implicitly holds reference to
Outer.this→ makestaticif outer access not needed - Static collections without limit — infinite growth → use Caffeine/Guava with
maximumSize String.intern()for unique strings (logs, UUIDs) fills StringTable → OOM- ClassLoader Leak: dynamic class generation (Spring/CGLIB/Groovy) → Metaspace grows
- DirectByteBuffer — memory outside Heap (
-XX:MaxDirectMemorySizefor control) - Listeners/Observers: subscribed → unsubscribe on destruction
Common follow-up questions:
- Why does Inner Class cause a leak? — Every non-static inner class has an implicit
Outer.thisfield; if inner is passed to another thread — entire outer won’t be deleted - How to prevent ClassLoader Leak? — Cache proxies and compiled scripts; check ThreadLocal, DriverManager, Introspector
- Why is DirectByteBuffer dangerous? — Memory outside
-Xmx; GC may not run on empty Heap → Native Memory grows → OOM Killer kills process - When is
String.intern()useful? — For deduplicating a known set of repeating strings (enum-like values); harmful for unique ones
Red flags (DO NOT say):
- “ThreadLocal is safe in Tomcat” — without
.remove()it’s the #1 cause of leaks and security breaches cleaner().clean()for DirectByteBuffer — this is internal JDK API, not guaranteed between versions- “Inner class — just syntax, no leak” — implicit reference to outer exists
Related topics:
- [[6. What is a memory leak in Java]]
- [[11. What is Metaspace (or PermGen)]]
- [[21. What is a memory leak and how to detect it]]
- [[2. What is stored in Heap]]
- [[18. What are -Xms and -Xmx parameters]]