CWE-401: Missing Release of Memory after Effective Lifetime - Java
Overview
Memory leaks in Java occur when objects are unintentionally retained in memory, preventing garbage collection. Java's automatic memory management does not cover operating-system and pool resources: unclosed files, connections and streams exhaust file descriptors and connection pools, and objects held past their useful life exhaust the heap.
Primary Defence: Use try-with-resources for all AutoCloseable resources, implement bounded caches with LRU eviction, unregister event listeners when components are destroyed, avoid static collections that grow unbounded, and use soft references for cache values that shouldn't survive memory pressure.
Common Vulnerable Patterns
Unclosed File Resources
// VULNERABLE - Unclosed File Resources
public String readFile(String path) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(path));
String line = reader.readLine();
return line;
// No close() - file handle leaked!
}
// Called repeatedly
for (int i = 0; i < 10000; i++) {
readFile("data_" + i + ".txt");
// Each call leaks a file descriptor
}
Why this is vulnerable: Every call to readFile() opens a file and creates a BufferedReader, consuming a file descriptor from the operating system. When the method returns without calling reader.close(), the handle stays open even though it is no longer reachable. File descriptors are a limited resource (typically 1024-4096 per process on Linux), so a web server handling 1000 requests/second exhausts them within seconds and starts failing with "Too many open files". The JVM's garbage collector will eventually finalize the Reader and close the file, but it does so non-deterministically and far too slowly to keep a high-throughput application alive. Open handles also prevent file deletion and hold file locks on some systems.
Database Connection Leaks
// VULNERABLE - Database Connection Leaks
public List<User> getUsers() throws SQLException {
Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM users");
List<User> users = new ArrayList<>();
while (rs.next()) {
users.add(new User(rs.getString("name")));
}
return users;
// No close() for rs, stmt, or conn - all leaked!
}
Why this is vulnerable: Database connections are expensive resources backed by network sockets, memory buffers, and server-side state. Connection pools typically limit connections to 10-50 per application. This method leaks all three resources (ResultSet, Statement, Connection), so each invocation permanently consumes a connection from the pool: even one request per second drains it in under a minute, after which new requests block waiting for a free connection that no other request will hand back. The leaked ResultSet and Statement hold memory on both client and database server, and the database keeps transaction state alive. Unlike file handles, connections won't be reclaimed until garbage collection (unpredictable) or the database times out the idle connection (minutes to hours).
Static Collection Leaks
public class SessionManager {
// Static - lives for entire JVM lifetime
private static List<UserSession> allSessions = new ArrayList<>();
public static void createSession(User user) {
UserSession session = new UserSession(user);
allSessions.add(session);
// Session added but NEVER removed - infinite growth!
}
public static UserSession getSession(String sessionId) {
return allSessions.stream()
.filter(s -> s.getId().equals(sessionId))
.findFirst()
.orElse(null);
}
// No method to remove expired sessions!
}
// Web application handling user logins
// After 1 million logins over weeks/months, heap exhausted
Why this is vulnerable: Static fields are GC roots, so allSessions and everything it holds live for the lifetime of the JVM. Every call to createSession() adds a UserSession that is never removed, not when the session expires and not when the user logs out, so the list grows with every login. After weeks or months of operation a busy web application accumulates millions of expired sessions and dies with OutOfMemoryError. The leak is gradual, so it rarely appears in testing and only manifests in long-running production systems. The linear search in getSession() also gets slower as the list grows.
Secure Patterns
Try-With-Resources
public String readFile(String path) throws IOException {
// try-with-resources: automatically closes reader
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
return reader.readLine();
}
// reader.close() called automatically, even if exception thrown
}
public List<User> getUsers() throws SQLException {
String sql = "SELECT * FROM users";
List<User> users = new ArrayList<>();
// Multiple resources: all closed in reverse order
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
users.add(new User(rs.getString("name")));
}
}
return users;
// rs.close(), stmt.close(), conn.close() all called automatically
}
Why this works: Try-with-resources guarantees deterministic resource cleanup by automatically calling close() on all AutoCloseable resources when the try block exits, whether normally or via exception. Resources are closed in reverse order of declaration, ensuring proper cleanup of dependent resources (ResultSet before Statement before Connection). That removes the usual source of resource leaks: forgetting to close on some code path, normally an error path. The compiler generates the finally block, so an exception thrown during close() is recorded as a suppressed exception (available via getSuppressed()) instead of masking the original - one of the cases hand-written finally blocks get wrong, along with null checks and multiple resources. Any class implementing AutoCloseable works with this pattern, which makes it the standard Java idiom for resource management.
Bounded Cache with LRU Eviction
import java.util.LinkedHashMap;
import java.util.Map;
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize) {
// accessOrder=true: maintain access order for LRU
super(maxSize + 1, 0.75f, true);
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize; // Evict oldest when size exceeded
}
}
// Usage
public class UserCache {
private final LRUCache<String, User> cache = new LRUCache<>(1000);
public User getUser(String userId) {
return cache.computeIfAbsent(userId, id -> {
return database.fetchUser(id); // Cache miss: fetch from DB
});
}
// Cache never exceeds 1000 entries
// Least recently used entries automatically evicted
}
Why this works: An LRU (Least Recently Used) cache with a maximum size evicts the oldest entries once the limit is reached, capping memory usage at a predictable level (maxSize x average entry size) instead of letting the map grow until the heap runs out. LinkedHashMap maintains insertion or access order efficiently, and removeEldestEntry is called after each insertion to decide whether to evict. The access-order mode (third constructor parameter) keeps recently used entries in the cache, so hot data stays cached and cold data is discarded - what a long-running application needs when its working set exceeds available memory. For thread-safe caching, wrap with Collections.synchronizedMap() or use libraries like Guava's CacheBuilder or Caffeine with more sophisticated eviction policies (size-based, time-based, reference-based).
Soft References for Cache-Like Structures
import java.lang.ref.SoftReference;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class ImageCache {
// SoftReference values: cleared only when the heap is under pressure.
// The map itself is a plain ConcurrentHashMap - the keys are Strings the
// caller supplied, and their lifetime is not the question here.
private final Map<String, SoftReference<Image>> cache = new ConcurrentHashMap<>();
public Image getImage(String path) {
SoftReference<Image> ref = cache.get(path);
if (ref != null) {
Image img = ref.get();
if (img != null) {
return img; // Cache hit: image still in memory
}
cache.remove(path, ref); // reference was cleared: drop the dead entry
}
// Cache miss or image was reclaimed: reload
Image img = loadImageFromDisk(path);
cache.put(path, new SoftReference<>(img));
return img;
}
// Cache grows/shrinks with available heap; the GC decides when to evict
}
Why this works: A SoftReference is cleared only when the collector needs the memory, which is what "a cache that shrinks under memory pressure" actually requires. This prevents OutOfMemoryError while still serving hits: if the JVM has spare heap, cached objects survive; if memory is tight, they are reclaimed and the next call reloads. Removing the dead entry on a cleared reference keeps the map from accumulating SoftReference husks, which is the one cost this pattern has over a plain map.
WeakReference is the wrong reference type here, and the failure is silent. A weak reference is cleared as soon as its referent stops being strongly reachable, with no regard for how much heap is free - so a WeakReference<Image> in a cache is cleared the moment the caller drops the image it was handed, and the cache never returns a hit for anything not already in use somewhere else. Measured on JDK 26 with a WeakHashMap<String, WeakReference<Image>>: 100 lookups over 20 distinct paths produced 100 disk loads. The identical access pattern with SoftReference produced 20. A cache that never caches passes every correctness test, leaks nothing, and shows up as unexplained I/O rather than as a memory problem.
WeakHashMap is a separate mistake stacked on the same example. It holds its keys weakly, so it answers "drop the entry when nobody else is using this key object" - useful for attaching data to objects you do not own, and meaningless for String paths, where a literal is interned and never collected while a freshly built key is collected as soon as the caller drops it. Pick the reference type by asking which of the two lifetimes you want to track, the key's or the value's, and use a plain map for the other one.
Session Management with Expiration
import java.util.concurrent.*;
import java.time.Instant;
public class SessionManager {
private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor();
public SessionManager() {
// Cleanup expired sessions every 5 minutes
cleanupExecutor.scheduleAtFixedRate(
this::removeExpiredSessions,
5, 5, TimeUnit.MINUTES
);
}
public void createSession(String sessionId, User user) {
Session session = new Session(user, Instant.now().plusSeconds(3600));
sessions.put(sessionId, session);
}
public Session getSession(String sessionId) {
Session session = sessions.get(sessionId);
if (session != null && session.isExpired()) {
sessions.remove(sessionId);
return null;
}
return session;
}
private void removeExpiredSessions() {
sessions.entrySet().removeIf(entry -> entry.getValue().isExpired());
}
public void shutdown() {
cleanupExecutor.shutdown();
}
}
Why this works: Expired sessions are removed rather than left to accumulate, so memory stays bounded at roughly the number of sessions actually live. The ScheduledExecutorService sweeps expired entries every five minutes, and checking expiration on access (getSession) clears frequently accessed entries immediately, leaving the sweep to catch the ones nobody touches. ConcurrentHashMap keeps both paths safe when request threads run them concurrently. The cleanup interval trades memory efficiency (sweep more often) against CPU overhead (sweep less often).
Detecting Leaks
A Java leak shows up as heap that grows across load and never returns to its earlier level after a full GC, so a short test run will not reveal it. Drive the suspect path repeatedly, then compare heap dumps taken at the start and the end:
jcmd <pid> GC.heap_dump before.hprof
# apply sustained load against the suspect endpoint
jcmd <pid> GC.run
jcmd <pid> GC.heap_dump after.hprof
Open both in VisualVM (bundled with most JDK distributions), YourKit or JProfiler and compare instance counts. A class whose count rises with load and never falls is the leak; the tool's reference path shows what is still holding it.
One source deserves specific attention because pooling hides it:
ThreadLocal values set during a request stay attached to the thread when it
returns to the pool. In a servlet container or executor the thread is reused,
so the value survives indefinitely and accumulates per pooled thread. Clear
these in a finally block, not at the end of the happy path.