Question 7 · Section 3

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.

Language versions: English Russian Ukrainian

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

  1. ThreadLocal.remove() in finally always
  2. Static collections → limit + eviction policy
  3. Inner classes — make nested class static if it does NOT need access to outer class fields. If access is needed — keep non-static, but control lifetime.
  4. try-with-resources for all Closeable
  5. Unsubscribe from listeners on destruction
  6. Cache proxies and compiled scripts
  7. MaxDirectMemorySize for Native Memory control
  8. 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() in finally is mandatory
  • Non-static inner class implicitly holds reference to Outer.this → make static if 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:MaxDirectMemorySize for 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.this field; 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]]