Back to Blog
memory leaks Javaheap memorygarbage collectionOutOfMemoryErrorSpring Boot memory optimization

Memory Leaks in Java: Why Developers Get Them Wrong

Master Java memory leaks: learn why they happen, common misconceptions, detection strategies, and how to fix them before they crash your production system

J

JOptimize Team

April 25, 2026· 5 min read

Memory Leaks in Java: Why Developers Get Them Wrong

"Java has garbage collection, so we don't have memory leaks."

If you've said this, you're not alone. But you're also completely wrong.

Java's garbage collector is powerful, but it's not magic. Memory leaks in Java are just as real as in C++—they're just harder to see until your production system crashes at 3 AM.

The difference? In Java, memory leaks are invisible until they explode.


The Misconception: "Garbage Collection Solves Everything"

Here's the truth: Garbage collection only frees memory when objects become unreachable.

If an object is still referenced—even if you never use it again—the GC can't touch it.

This is where Java memory leaks come from.

The Classic Example

@Service public class UserCache { // This Map holds references forever private static Map<Long, User> cache = new HashMap<>(); public User getUser(Long id) { if (!cache.containsKey(id)) { User user = userRepository.findById(id).orElse(null); cache.put(id, user); // ← Object is cached indefinitely } return cache.get(id); } }

The problem:

  • User is added to cache
  • Even if that user is deleted from database, the cache still holds a reference
  • GC can't free the memory because the reference still exists
  • After 1 million users, your heap is 2GB of dead references
  • Application crashes: java.lang.OutOfMemoryError: Java heap space Result: Your application grinds to a halt. Not because of bad code—because of a reference that should have been garbage-collected.

Why Memory Leaks Happen in Java (5 Common Causes)

1. Static Collections (The Most Common)

// WRONG: Static Map that grows forever public class ConnectionManager { private static Map<String, Connection> pool = new HashMap<>(); public static void addConnection(String key, Connection conn) { pool.put(key, conn); // ← Reference never released } }

Why it leaks:

  • Static variables live for the entire JVM lifetime
  • References in static collections are never garbage-collected
  • Even after connection closes, the reference stays Fix: Use time-based or size-limited cache:
// CORRECT: Use Caffeine (bounded cache with expiration) private static LoadingCache<String, Connection> pool = Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) .maximumSize(1000) .build(key -> openConnection(key));

2. Listeners & Event Subscribers (Forgotten Unsubscribe)

@Component public class UserEventListener { @PostConstruct public void init() { // Register listener eventBus.subscribe(this); // ← Reference held by eventBus } // NEVER unsubscribe! @EventListener public void onUserCreated(UserCreatedEvent event) { // Handle event } }

Why it leaks:

  • eventBus holds a reference to this listener
  • When bean is destroyed, listener reference remains in eventBus
  • Event bus keeps collecting listeners = memory leak Fix: Unsubscribe on destruction:
@Component public class UserEventListener { @PostConstruct public void init() { eventBus.subscribe(this); } @PreDestroy public void cleanup() { eventBus.unsubscribe(this); // ← Release reference } }

3. Circular References Between Objects

public class Parent { private Child child; public Parent(Child child) { this.child = child; child.setParent(this); // ← Circular reference } } public class Child { private Parent parent; public void setParent(Parent parent) { this.parent = parent; } }

Why it leaks:

  • Parent references Child, Child references Parent
  • Even if both become unreachable, they reference each other
  • Modern GC handles this, but WeakReferences can help Fix: Use WeakReference:
public class Child { private WeakReference<Parent> parent; public void setParent(Parent parent) { this.parent = new WeakReference<>(parent); } public Parent getParent() { return parent != null ? parent.get() : null; } }

4. Thread Local Variables Never Cleared

public class RequestContext { private static ThreadLocal<User> currentUser = new ThreadLocal<>(); public static void setUser(User user) { currentUser.set(user); // ← Reference in thread } public static User getUser() { return currentUser.get(); } // Missing cleanup! }

Why it leaks:

  • ThreadLocal holds reference per thread
  • Thread pool reuses threads (doesn't create new ones)
  • Old thread still has reference to User
  • Request ends, but User is never released = memory leak Fix: Always clean up ThreadLocal:
@Component public class RequestContextFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws Exception { try { RequestContext.setUser(user); chain.doFilter(request, response); } finally { RequestContext.clear(); // ← CRITICAL } } } public class RequestContext { private static ThreadLocal<User> currentUser = new ThreadLocal<>(); public static void clear() { currentUser.remove(); // ← Release reference } }

5. Resource Leaks (Files, Connections, Streams)

public class FileProcessor { public void processFile(String filename) throws IOException { FileInputStream fis = new FileInputStream(filename); BufferedReader reader = new BufferedReader(new InputStreamReader(fis)); String line; while ((line = reader.readLine()) != null) { processLine(line); } // Missing close() - file handle leaked! } }

Why it leaks:

  • File handles are system resources
  • Not closing them exhausts OS file descriptor limit
  • Eventually: java.io.IOException: Too many open files Fix: Use try-with-resources:
public void processFile(String filename) throws IOException { try (FileInputStream fis = new FileInputStream(filename); BufferedReader reader = new BufferedReader(new InputStreamReader(fis))) { String line; while ((line = reader.readLine()) != null) { processLine(line); } } // ← Automatically closes resources }

How Memory Leaks Kill Your Production System

The Timeline

Day 1: Application deployed
  - Heap usage: 500MB / 2GB available
  - No issues
 
Day 7: Heap usage growing slowly
  - Heap usage: 900MB
  - Response time: Still normal
  - No alarms
 
Day 14: Heap usage accelerating
  - Heap usage: 1.5GB
  - Response time: Slight increase (100ms → 150ms)
  - Garbage collector running constantly
 
Day 21: CRISIS
  - Heap usage: 1.9GB / 2GB
  - GC pauses: 2-3 seconds every few requests
  - Response time: 1000ms+
  - Users complain: "Application is slow"
  - Ops team pages you
 
Day 22: System outages
  - Heap full: 2GB
  - OutOfMemoryError thrown
  - Application crashes
  - 1,000+ users affected
  - Database connections pile up
  - Cascading failure
 
Day 23: Emergency redeployment
  - Restart application
  - Heap resets to 500MB
  - System works again
  - But nobody knows WHY it happened
  - Happens again in 3 weeks

This cycle repeats until you fix the leak.


Detecting Memory Leaks: Tools & Techniques

Technique 1: Heap Dump Analysis (Manual)

# Force heap dump jmap -dump:live,format=b,file=heap.bin <PID> # Analyze with JProfiler or Eclipse MAT # Look for: # - Growing collections # - Duplicate objects # - Unreachable references # - Static field references # Time: 2-4 hours # Accuracy: 80% (requires expertise)

Technique 2: Monitoring (Continuous)

# Monitor heap over time jconsole # Built-in Java monitoring visualvm # Better visualization

Watch for:

  • Heap usage climbing steadily
  • GC not recovering memory after garbage collection
  • Sawtooth pattern that keeps climbing

Technique 3: JVM Flags (Automatic Leak Detection)

# Run with verbose GC logging java -Xmx2g \ -Xms512m \ -XX:+PrintGCDetails \ -XX:+PrintGCDateStamps \ -Xloggc:gc.log \ -jar app.jar # Analyze gc.log for: # - Heap not shrinking after Full GC # - Full GC happening more frequently # - Young generation collection time increasing

Technique 4: JOptimize Dynamic Monitoring (Automated)

1. Deploy JOptimize JVM agent
2. Live profiling monitors:
   - Heap growth rate
   - Object allocation patterns
   - GC frequency & pause times
3. Detects memory leak signatures
4. Alerts: "Potential leak detected in UserCache"
5. Get actionable recommendations
 
Time: Real-time monitoring
Accuracy: 95%+

Real Example: Fixing a Memory Leak in Production

The Symptom

2024-12-10 14:32:15 WARN  [GC] Full GC took 2341ms
2024-12-10 14:32:20 ERROR OutOfMemoryError: Java heap space
2024-12-10 14:32:21 FATAL Application crash

Step 1: Get heap dump from production

jmap -dump:live,format=b,file=prod-heap.bin 12345 # Transfer to local machine

Step 2: Analyze with Eclipse MAT

1. Open prod-heap.bin in Eclipse MAT
2. Generate "Leak Suspects" report
3. See: "UserCache holds 1.2GB in 500k User objects"
4. Find code: UserCache.cache.put() in UserService

Step 3: Identify the leak

@Service public class UserService { private static Map<Long, User> cache = new HashMap<>(); public User getUser(Long id) { if (!cache.containsKey(id)) { User user = userRepository.findById(id).orElse(null); cache.put(id, user); // ← NEVER EVICTS } return cache.get(id); } }

The leak: After 500k user queries, cache holds 500k User objects. Database deletes users, but cache keeps references.

Step 4: Fix

@Service public class UserService { // Use bounded cache with TTL private final LoadingCache<Long, User> cache = Caffeine.newBuilder() .maximumSize(10_000) // Max 10k users in cache .expireAfterWrite(5, TimeUnit.MINUTES) // Auto-expire .build(id -> userRepository.findById(id).orElse(null)); public User getUser(Long id) { return cache.get(id); } // When user is deleted in database public void deleteUser(Long id) { userRepository.deleteById(id); cache.invalidate(id); // ← Explicit eviction } }

Step 5: Deploy & verify

# Deploy fix # Monitor heap for 7 days # Heap stays stable at 500MB instead of climbing to 2GB # Problem solved!

Memory Leak Prevention Checklist

Code Level

  • ☐ Never use unbounded static collections
  • ☐ Always unsubscribe from event listeners
  • ☐ Use try-with-resources for I/O
  • ☐ Clear ThreadLocal in finally blocks
  • ☐ Use WeakReference for circular references
  • ☐ Use bounded caches (Caffeine, Guava)

Architecture Level

  • ☐ Avoid large static fields
  • ☐ Use dependency injection instead of singletons
  • ☐ Monitor heap growth in staging environment
  • ☐ Set appropriate JVM heap limits (-Xmx)
  • ☐ Enable GC logging in production

Monitoring Level

  • ☐ Track heap usage over time
  • ☐ Alert on GC pause times > 1 second
  • ☐ Alert on Full GC > 5 times per hour
  • ☐ Monitor thread count (growing threads = leak)
  • ☐ Take periodic heap dumps for analysis

Key Takeaways

  1. Java has memory leaks. They're just invisible until they crash your system.
  2. Most leaks come from references you forgot about. Static collections, listeners, ThreadLocals.
  3. Detection requires tools. Don't guess—use jmap, jconsole, or JOptimize.
  4. Prevention is easier than debugging. Use bounded caches, try-with-resources, and proper cleanup.
  5. Monitor in production. Heap dumps from staging won't catch real-world leaks.

Detect Memory Leaks: Use JOptimize CLI

npm install -g @joptimize/cli joptimize auth YOUR_API_KEY joptimize analyze .

That's it. You get:

  • Memory leak patterns (static collections, ThreadLocal, listeners)
  • Performance issues (N+1 queries, tight coupling)
  • Security vulnerabilities (OWASP Top 10, Spring Security)
  • Architecture problems (circular dependencies, God Classes) All from your terminal. All in 2 minutes.


Have you experienced memory leaks in Java? Share your story in the comments—we read every one.

Share this article: Twitter | LinkedIn

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.