Contents
- WeakHashMap — Keys Held by Weak References
- GC Behavior and Entry Eviction
- Use Cases — Caches and Listeners
- WeakReference Explained
- IdentityHashMap — == Instead of equals()
- IdentityHashMap Use Cases
- Comparison Table
In a regular HashMap, the map holds a strong reference to each key, preventing garbage collection. WeakHashMap wraps each key in a WeakReference instead — once no other strong reference to the key exists, the GC can collect it and the map entry disappears automatically:
import java.util.*;
// WeakHashMap — keys are stored as WeakReferences
// When a key has no other strong references, the GC can collect it
// and the entry is automatically removed from the map
WeakHashMap<Object, String> weakMap = new WeakHashMap<>();
Object key1 = new Object();
Object key2 = new Object();
weakMap.put(key1, "value for key1");
weakMap.put(key2, "value for key2");
System.out.println(weakMap.size()); // 2
// Releasing the strong reference — key2 can now be GC'd
key2 = null;
// Force a GC for demonstration (not reliable in production)
System.gc();
// After GC, the entry for the nulled key may be gone
// (not guaranteed immediately, but will happen eventually)
System.out.println("After GC, size: " + weakMap.size()); // 1 or 2 depending on GC
// key1 is still strongly referenced — its entry stays
System.out.println(weakMap.containsValue("value for key1")); // true
// String literals are interned and held by the JVM — they are NEVER collected
// Use WeakHashMap only with non-interned objects as keys
WeakHashMap<String, String> badIdea = new WeakHashMap<>();
badIdea.put("literal", "metadata"); // "literal" is interned; entry never evicted
String dynamic = new String("dynamic"); // new String() — not interned
badIdea.put(dynamic, "metadata2");
dynamic = null; // now collectible
System.gc();
// After GC: "dynamic" entry may be gone, "literal" stays
WeakHashMap entries can disappear at any time — even between two consecutive lines of code — because GC can run at any point. Never rely on an entry still being present after a GC-eligible key was released.
Eviction is lazy: entries are not removed immediately when a key is collected. Internally, WeakHashMap uses a ReferenceQueue — when a key is GC'd, its WeakReference is enqueued, and the next call to any map method triggers a sweep that removes those stale entries:
import java.lang.ref.*;
import java.util.*;
// How WeakHashMap eviction works internally:
// 1. Keys are wrapped in WeakReference objects backed by a ReferenceQueue
// 2. When the GC collects a key, the WeakReference is enqueued
// 3. On the next map operation (get/put/size/etc.), the map polls the queue
// and removes entries whose keys were collected
// Demonstrating the timing of eviction
WeakHashMap<Object, String> map = new WeakHashMap<>();
for (int i = 0; i < 5; i++) {
map.put(new Object(), "value-" + i); // all keys immediately eligible for GC
}
System.out.println("Before GC: " + map.size()); // 5 (keys still in scope)
// The keys created in the loop have no strong references — they can be GC'd now
System.gc();
Thread.sleep(100); // give GC a chance to run
// Trigger eviction by calling any map method
int sizeAfterGc = map.size(); // this polls the internal ReferenceQueue
System.out.println("After GC: " + sizeAfterGc); // likely 0
// Practical rule: never use WeakHashMap if keys need to stay alive
// Do use it when keys are objects whose lifetime is managed elsewhere
// Example: canonical map pattern (intern pool)
// Maps each object to a canonical representative — GC removes stale entries
Map<String, WeakReference<String>> internPool = new WeakHashMap<>();
String s1 = new String("hello");
internPool.put(s1, new WeakReference<>(s1));
WeakReference<String> ref = internPool.get(s1);
System.out.println(ref != null ? ref.get() : "evicted"); // hello
// WeakHashMap does NOT support null values cleanly for eviction detection
// because null is also the return value for a missing key; use a sentinel object
final Object PRESENT = new Object();
WeakHashMap<Object, Object> weakSet = new WeakHashMap<>();
The two canonical use cases are metadata caches (attach computed properties to objects without preventing their collection) and weak listener registries (avoid the classic listener-leak where an event source's strong reference to a listener prevents it from being GC'd):
import java.util.*;
import java.util.function.*;
// --- Use Case 1: Metadata / attribute cache ---
// Attach computed metadata to objects without preventing their GC
WeakHashMap<Object, Map<String, Object>> metadataCache = new WeakHashMap<>();
class ImageProcessor {
private static final WeakHashMap<byte[], Map<String, Object>> cache =
new WeakHashMap<>();
static Map<String, Object> analyze(byte[] imageData) {
return cache.computeIfAbsent(imageData, data -> {
// Expensive analysis — cached per image data reference
Map<String, Object> result = new HashMap<>();
result.put("width", 100);
result.put("height", 200);
result.put("size", data.length);
return result;
});
}
}
byte[] img = new byte[1024];
Map<String, Object> info = ImageProcessor.analyze(img);
System.out.println(info); // {width=100, height=200, size=1024}
// When 'img' is no longer referenced elsewhere, the cache entry is automatically evicted
// No manual cache.remove(img) needed
// --- Use Case 2: Weak listener registry ---
// Listeners registered here are auto-removed when the listener object is GC'd
// Prevents the classic "listener leak" pattern
class EventBus {
private final WeakHashMap<Object, Runnable> listeners = new WeakHashMap<>();
public void register(Object owner, Runnable listener) {
listeners.put(owner, listener);
}
public void fire() {
// Iterate a snapshot to avoid ConcurrentModificationException during GC eviction
new ArrayList<>(listeners.values()).forEach(Runnable::run);
}
}
EventBus bus = new EventBus();
Object componentA = new Object();
bus.register(componentA, () -> System.out.println("ComponentA handling event"));
Object componentB = new Object();
bus.register(componentB, () -> System.out.println("ComponentB handling event"));
bus.fire(); // both listeners fire
componentB = null; // componentB is released
System.gc();
Thread.sleep(50);
bus.fire(); // only componentA's listener fires (componentB was GC'd)
The listener-leak problem — where objects that register callbacks are never garbage collected because the event source holds a strong reference to them — is one of the most common memory leaks in Java GUI and observer-pattern code. WeakHashMap solves it elegantly when the listener's identity is used as the map key.
Java provides four reference strengths. WeakReference is collected on the next GC cycle once no strong or soft reference exists; SoftReference is collected only under memory pressure; PhantomReference is used for post-collection cleanup via a ReferenceQueue:
import java.lang.ref.*;
// Java has four reference strengths:
// Strong — normal references (Object o = new Object()) — GC never collects
// Soft — SoftReference — GC collects only under memory pressure
// Weak — WeakReference — GC collects as soon as no strong/soft reference exists
// Phantom — PhantomReference — notification after collection, no .get() possible
// WeakReference basics
Object strongRef = new Object();
WeakReference<Object> weakRef = new WeakReference<>(strongRef);
System.out.println(weakRef.get() != null); // true — strongly reachable
strongRef = null; // release strong reference
System.gc();
// weakRef.get() MAY now return null
System.out.println(weakRef.get()); // null (likely after GC)
// ReferenceQueue — notified when a reference is cleared
ReferenceQueue<Object> refQueue = new ReferenceQueue<>();
Object tracked = new Object();
WeakReference<Object> trackedRef = new WeakReference<>(tracked, refQueue);
tracked = null;
System.gc();
Reference<?> polled = refQueue.poll(); // non-blocking poll
if (polled != null) {
System.out.println("Object was collected, reference enqueued");
}
// SoftReference — useful for memory-sensitive caches
// Kept as long as JVM has enough heap; cleared before OutOfMemoryError
SoftReference<byte[]> softCache = new SoftReference<>(new byte[1024 * 1024]);
byte[] cached = softCache.get();
if (cached == null) {
// Cache miss — memory was reclaimed under pressure
cached = new byte[1024 * 1024]; // reload
softCache = new SoftReference<>(cached);
}
// Reference strength summary (what triggers GC collection):
// Strong → never collected while reference is in scope
// Soft → collected only when JVM needs memory (good for caches)
// Weak → collected on any GC cycle (good for canonicalizing, metadata)
// Phantom → collected; .get() always null; used for cleanup tracking
IdentityHashMap intentionally violates the Map contract by using == (reference equality) instead of equals() for key comparison. Two distinct objects with the same content are treated as different keys, making it the right tool whenever object identity — not logical equality — is the correct semantic:
import java.util.*;
// IdentityHashMap uses System.identityHashCode() and == for key comparison
// NOT equals() and hashCode() — this violates Map's general contract intentionally
IdentityHashMap<String, String> idMap = new IdentityHashMap<>();
String s1 = new String("key");
String s2 = new String("key"); // different object, same content
// HashMap: s1 and s2 are the same key (equals() returns true)
HashMap<String, String> hashMap = new HashMap<>();
hashMap.put(s1, "value1");
hashMap.put(s2, "value2"); // overwrites s1's entry
System.out.println(hashMap.size()); // 1
// IdentityHashMap: s1 and s2 are DIFFERENT keys (s1 != s2)
idMap.put(s1, "value1");
idMap.put(s2, "value2"); // separate entry
System.out.println(idMap.size()); // 2
System.out.println(idMap.get(s1)); // value1
System.out.println(idMap.get(s2)); // value2
// Interned strings: "hello" == "hello" (same reference in string pool)
String interned1 = "hello";
String interned2 = "hello"; // same reference
idMap.put(interned1, "first");
idMap.put(interned2, "second"); // overwrites — same reference
System.out.println(idMap.size()); // 2 (only s1,s2 + 1 interned)
// Practical: tracking visited nodes in a graph
record Node(int id, List<Node> neighbors) {}
Node a = new Node(1, new ArrayList<>());
Node b = new Node(2, new ArrayList<>());
Node c = new Node(3, new ArrayList<>());
a.neighbors().addAll(List.of(b, c));
b.neighbors().add(a); // cycle
// Use IdentityHashMap for visited set — tracks object identity, not logical equality
void dfs(Node start) {
IdentityHashMap<Node, Boolean> visited = new IdentityHashMap<>();
Deque<Node> stack = new ArrayDeque<>();
stack.push(start);
while (!stack.isEmpty()) {
Node current = stack.pop();
if (visited.containsKey(current)) continue; // identity check — no infinite loop
visited.put(current, true);
System.out.println("Visiting node " + current.id());
current.neighbors().forEach(stack::push);
}
}
dfs(a);
// Visiting node 1
// Visiting node 3
// Visiting node 2
The most common applications are cycle detection during deep-copy or serialization (tracking which exact object instances have been visited), proxy registries (mapping each target object to its proxy by reference), and class-keyed dispatch tables (where class literals are effectively singletons):
import java.util.*;
// --- Use Case 1: Serialization / deep-copy cycle detection ---
// Track objects already serialized to handle circular references
class DeepCopy {
private final IdentityHashMap<Object, Object> copies = new IdentityHashMap<>();
@SuppressWarnings("unchecked")
public <T> T copy(T original) {
if (original == null) return null;
// If already copied this exact object instance, return the copy
Object existing = copies.get(original);
if (existing != null) return (T) existing;
// Simulate creating a copy (real impl would use reflection)
Object clone = createShallowCopy(original);
copies.put(original, clone); // track by identity before recursing
// ... recursively copy fields ...
return (T) clone;
}
private Object createShallowCopy(Object o) { return new Object(); /* simplified */ }
}
// --- Use Case 2: Proxy / instrumentation frameworks ---
// Map original objects to their proxy counterparts by identity
class ProxyRegistry {
private final IdentityHashMap<Object, Object> proxies = new IdentityHashMap<>();
public <T> T getOrCreateProxy(T target, Class<T> iface) {
return iface.cast(proxies.computeIfAbsent(target, t -> {
// In real code: java.lang.reflect.Proxy.newProxyInstance(...)
System.out.println("Creating proxy for " + System.identityHashCode(t));
return t; // simplified — just return same object
}));
}
}
ProxyRegistry registry = new ProxyRegistry();
List<String> list1 = new ArrayList<>();
List<String> list2 = new ArrayList<>(); // different object
registry.getOrCreateProxy(list1, List.class); // creates proxy
registry.getOrCreateProxy(list1, List.class); // returns cached proxy (same identity)
registry.getOrCreateProxy(list2, List.class); // creates new proxy (different identity)
// --- Use Case 3: Class-keyed maps (classes are singletons per ClassLoader) ---
// class literals are effectively interned; HashMap and IdentityHashMap behave the same
// but IdentityHashMap is faster because it skips hashCode() computation
IdentityHashMap<Class<?>, String> typeLabels = new IdentityHashMap<>();
typeLabels.put(Integer.class, "int");
typeLabels.put(String.class, "str");
typeLabels.put(Double.class, "dbl");
Object value = 42;
System.out.println(typeLabels.get(value.getClass())); // int
IdentityHashMap is intentionally designed to violate the Map contract (which requires equals()-based key comparison). Its Javadoc explicitly warns: "This class is not a general-purpose Map implementation." Use it only when reference identity is the correct semantic for your keys.
The three maps differ on two axes: how keys are compared (equals() vs ==) and how keys are referenced (strong vs weak). Use this table to pick the right one for your access pattern:
// | Property | HashMap | WeakHashMap | IdentityHashMap |
// |-----------------------|-------------------|----------------------|-----------------------|
// | Key comparison | equals() | equals() | == (reference) |
// | Key hashing | hashCode() | hashCode() | System.identityHash.. |
// | Key reference type | Strong | Weak | Strong |
// | GC-driven eviction | No | Yes (when key GC'd) | No |
// | Null keys | 1 null allowed | 1 null allowed | 1 null allowed |
// | Null values | Yes | Yes | Yes |
// | Thread-safe | No | No | No |
// | Iteration order | Unspecified | Unspecified | Unspecified |
// | Typical use | General purpose | Caches, listeners | Proxy, cycle detect |
// | Performance | O(1) | O(1)* | O(1) (no equals call) |
// | When key is String | Careful: interned | Careful: interned | Two "a" may differ |
// * get/put, but iteration may trigger GC cleanup
// Thread-safe wrappers for single-threaded scenarios where you want synchronized access:
Map<Object, String> syncWeak = Collections.synchronizedMap(new WeakHashMap<>());
// Note: iteration still requires external synchronization:
// synchronized (syncWeak) { syncWeak.forEach(...); }
// Key point recap:
// WeakHashMap → "I want entries to disappear when the key is no longer used"
// IdentityHashMap → "I want two objects with equal content to be separate keys"
// HashMap → "Normal key-value storage with logical equality"
// Verify IdentityHashMap semantics
IdentityHashMap<Object, Integer> idm = new IdentityHashMap<>();
Object x = new Object();
Object y = new Object();
Object z = x; // z and x are the SAME object
idm.put(x, 1);
idm.put(y, 2);
idm.put(z, 3); // z == x → overwrites x's entry
System.out.println(idm.size()); // 2 (x/z counted once, y once)
System.out.println(idm.get(x)); // 3 (overwritten by z)
System.out.println(idm.get(z)); // 3 (z is x)
System.out.println(idm.get(y)); // 2