Two Order records with the same fields are equals. Sometimes that is the wrong question: you need this instance, not this value. Sometimes the question is the opposite of a cache: drop the entry when nobody else still holds the key.
IdentityHashMap and WeakHashMap are two unusual maps with two different jobs. Neither is a general HashMap replacement. The Collections roadmap owns ordinary equals / hashCode keys, fail-fast, and why Map is not a Collection. The language contract is equals and hashCode. This post is the two types that break those habits on purpose.
The lab types are the series’ checkout records:
public record Order(
String id,
String customerEmail,
List<LineItem> items,
BigDecimal total,
boolean active) {}
public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}
IdentityHashMap: this instance, not this value
A graph walker hits the same Order object twice through two lists and must not charge it twice. A serializer sees a cycle: this LineItem object was already written. The question is not “same id and total.” The question is is it this object?
IdentityHashMap answers with == and System.identityHashCode. It never calls the key’s equals or hashCode.
LineItem widget = new LineItem("SKU-100", 2, new BigDecimal("9.99"));
Order a = new Order("o-1", "ada@ex.com", List.of(widget), new BigDecimal("19.98"), true);
Order b = new Order("o-1", "ada@ex.com", List.of(widget), new BigDecimal("19.98"), true);
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true — records compare components
Map<Order, String> byValue = new HashMap<>();
byValue.put(a, "first");
System.out.println(byValue.get(b)); // first — equals/hashCode
Map<Order, String> byRef = new IdentityHashMap<>();
byRef.put(a, "first");
System.out.println(byRef.get(b)); // null — different instance
System.out.println(byRef.get(a)); // first
That is the whole type. Records make the contrast loud because record equals / hashCode are derived from every component. Two new Order(...) with the same fields are equal and still two identities. A HashMap merges them. An IdentityHashMap does not.
identityHashCode, not hashCode
Object.hashCode may be overridden. Records override it. IdentityHashMap ignores that and uses System.identityHashCode(key) — the identity-based code even when hashCode() is customized — plus == in the bucket.
System.out.println(a.hashCode() == b.hashCode()); // true (same components)
System.out.println(System.identityHashCode(a)
== System.identityHashCode(b)); // usually false
System.out.println(System.identityHashCode(a)
== System.identityHashCode(a)); // true
identityHashCode(x) is stable for the life of the object and does not change if you never override anything — it is what HashMap would have used if Order had not overridden hashCode. After a record (or any value type) overrides it, only identityHashCode still names the instance.
Expected use, not a faster HashMap
Reach for IdentityHashMap when the domain sentence is “this instance”:
- Topology / cycle detection. Walk an object graph; the already-visited set must be identity or a DAG with value-equal nodes is treated as a cycle — or a cycle is treated as two nodes.
- Serialization / cloning. JDK
ObjectOutputStreamtracks handles by identity so a sharedLineItemis written once. Your copier should too. - “This instance, not this value.” A listener object, a lock, a node you already parented. Value equality would merge two listeners that happen to look alike.
It is not a general Map replacement and not a performance trick. Skipping equals can look cheaper. The contract is the reason to pick it. If Order.id is the key you mean, use HashMap<String, Order>. If two records with the same fields should be one entry, you wanted HashMap / HashSet.
Contract, nulls, threads
The Map interface says keys are compared with equals. IdentityHashMap violates that on purpose. Document the field: this map is identity-keyed. equals between two maps is also identity-based on the keys, so an IdentityHashMap and a HashMap with “the same” record keys will not compare equal. That surprise is the type working.
| Rule | Behavior |
|---|---|
| Key equality | ==, not equals |
| Hash | System.identityHashCode |
| Null key | allowed (one); null values allowed |
| Thread-safe | no — same story as HashMap |
| Iteration order | unspecified |
equals / hashCode of the map | Identity of keys, not key equals |
| Fail-fast iterators | yes (hub) |
Map<Order, Integer> seen = new IdentityHashMap<>();
seen.put(a, 1);
seen.put(b, 1);
seen.put(null, 0); // ok
System.out.println(seen.size()); // 3 — a, b, and null
System.out.println(seen.containsKey(b)); // true
Layout trivia, not a reason to pick it: OpenJDK uses open addressing (linear probe), unlike HashMap’s bins. The hash table post notes that. You still program to Map.
API you actually call
Same Map surface. The constructors are the tell.
| Method / ctor | Job |
|---|---|
IdentityHashMap() | Empty identity map |
IdentityHashMap(int expectedMaxSize) | Size for the expected number of mappings, not a bit mask you compute |
IdentityHashMap(Map) | Copy; keys still compared with == after the copy |
put / get / containsKey | Identity |
keySet / values / entrySet | Live views; iteration order unspecified |
A topology pass over checkout objects:
void markVisited(Order order, IdentityHashMap<Object, Boolean> seen) {
if (seen.put(order, Boolean.TRUE) != null) {
return; // this instance already walked
}
for (LineItem item : order.items()) {
if (seen.put(item, Boolean.TRUE) == null) {
// first time this LineItem object
}
}
}
IdentityHashMap<Object, Boolean> seen = new IdentityHashMap<>();
markVisited(a, seen);
markVisited(a, seen); // no-op — same instance
markVisited(b, seen); // walks b; b.equals(a) does not matter
put returns the previous value, or null if the key was absent — same as HashMap, different notion of “absent.”
Copying does not change the rule. new IdentityHashMap<>(hashMap) still compares with == after the copy. A HashMap that already collapsed a and b into one entry copies one entry; putting b afterward adds a second.
Map<Order, String> hashed = new HashMap<>();
hashed.put(a, "first");
hashed.put(b, "second"); // overwrites — a.equals(b)
System.out.println(hashed.size()); // 1
Map<Order, String> ident = new IdentityHashMap<>(hashed);
ident.put(b, "second");
System.out.println(ident.size()); // 2 — a from the copy, b by ==
Note: Identity does not forgive a mutable key you rehash in a HashMap. == still finds the same instance after you change a field. That is not a reason to use IdentityHashMap as a HashMap with “safe mutation.” Stop mutating keys. Use a record or an id string.
WeakHashMap: keys the GC may drop
You attach a note to an Order the checkout service does not own. When that Order instance is otherwise unreachable, the note should vanish with it. You do not want a map that pins every order that ever passed through for the life of the JVM.
WeakHashMap holds keys as weak references. After a GC, if nothing else strongly references a key, the entry is cleared. Values are ordinary strong references.
Map<Order, String> notes = new WeakHashMap<>();
Order ephemeral = new Order("o-9", "ada@ex.com", List.of(), BigDecimal.ZERO, true);
notes.put(ephemeral, "hold at dock");
System.out.println(notes.get(ephemeral)); // hold at dock
ephemeral = null;
// After a GC that collects the Order, notes no longer contains that entry.
// Timing is the collector’s, not yours. size() can change with no remove() call.
Lookup still uses the key’s equals and hashCode while the key is reachable. This is not an identity map. Two equal records are still one WeakHashMap key if both are strongly reachable. The unusual part is only the weak key and the silent removal.
Do not write a unit test that asserts notes.isEmpty() after System.gc(). The collector is allowed to ignore you. The contract is: if the key is collected, the entry is gone by the next map operation that drains the reference queue (typically get, put, size, iteration).
The value→key leak
Values are strong. If a value points at its own key — directly, or through a graph the value holds — the key is still strongly reachable. The weak reference never clears. The entry never drops. That is the classic leak, and it looks like the map “is not working.”
final class HoldTicket {
final Order order; // strong ref back to the key
final String note;
HoldTicket(Order order, String note) {
this.order = order;
this.note = note;
}
}
Map<Order, HoldTicket> holds = new WeakHashMap<>();
Order order = new Order("o-1", "ada@ex.com", List.of(), new BigDecimal("19.98"), true);
holds.put(order, new HoldTicket(order, "hold at dock"));
order = null;
// The HoldTicket still points at the Order. The entry stays until holds is cleared
// or the HoldTicket is dropped. WeakHashMap did not get a chance.
The same pin happens if the value is the key (map.put(order, order)), a list that contains the order, or a lambda that captured it. Store a value that does not retain the key: an id string, a BigDecimal, a flag. If the note must mention the order, keep a weak ref inside the value too — or do not use WeakHashMap.
Not a cache
Wrong answer: “WeakHashMap is the JDK’s LRU cache.”
It has no max size. It has no access-order. It has no eviction policy beyond “the GC collected this key.” Entries can live forever if keys stay reachable (a pool of interned strings, a static Order, a cache of Class objects you also load). Entries can vanish under memory pressure on a request you still needed. That is not a cache you can reason about.
For a bounded cache, prefer an access-order LinkedHashMap LRU (removeEldestEntry) or a real cache library with size, time, and stats. WeakHashMap is a canonicalizing map or a side table for objects you do not own, not “just cache it.”
String literals and interned SKUs never become weakly collectable. The JVM holds them. A WeakHashMap<String, LineItem> keyed by "SKU-100" is a HashMap that happens to use weak refs you will never see fire.
Map<String, LineItem> bySku = new WeakHashMap<>();
LineItem widget = new LineItem("SKU-100", 2, new BigDecimal("9.99"));
bySku.put("SKU-100", widget); // interned literal — the key will not be collected
Class keys have the same shape until the classloader itself is collected. If you meant “metadata per class that dies with the class,” ClassValue is the API. If you meant “at most 100 SKUs,” you meant LRU.
Contract, nulls, fail-fast
| Rule | Behavior |
|---|---|
| Key equality | equals / hashCode of the key (while it lives) |
| Key refs | Weak; entry dropped after GC when no other strong refs |
| Values | Strong — must not point back at the key |
| Null key | allowed (not weakly referenced; it cannot be collected) |
| Null values | allowed |
| Thread-safe | no |
| Iterators | fail-fast |
size() / isEmpty() | May change without your remove — a GC ran |
Map<String, LineItem> bySku = new WeakHashMap<>();
bySku.put(null, new LineItem("SKU-100", 1, BigDecimal.ONE)); // ok
bySku.put("SKU-200", null); // ok
A null key never disappears via GC. Treat it as a normal entry you must remove yourself.
Because the collector can delete entries at any time, several Map invariants are soft here: size() after a put of a now-unreachable key may already be smaller; a get of a key you still hold is fine; a get of a key you dropped is a race with GC. Document that. Do not use WeakHashMap as a concurrent map — wrap or don’t share.
API you actually call
| Method / ctor | Job |
|---|---|
WeakHashMap() | Empty weak-key map |
WeakHashMap(int initialCapacity) | Hash capacity, same idea as HashMap |
WeakHashMap(int, float) | Capacity and load factor |
WeakHashMap(Map) | Copy into weak keys |
put / get / containsKey | Ordinary equality, then maybe expunge stale keys |
keySet / values / entrySet | Live views; stale entries may vanish during iteration |
A side table that does not pin checkout objects:
Map<Order, String> dockNotes = new WeakHashMap<>();
void annotate(Order order, String note) {
dockNotes.put(order, note); // note must not wrap order
}
String noteOf(Order order) {
return dockNotes.get(order);
}
When the caller drops order and the GC runs, annotate’s entry is gone. The checkout service never stored a strong map from id to order inside this table. If you also keep Map<String, Order> byId with strong values, those orders will never be weak-cleared from dockNotes either — the strong map is the pin. Weak keys only help when you are not the one retaining the instance.
Two maps, not one idea
People fuse them: “identity, so the GC can collect.” That sentence names neither type correctly.
IdentityHashMap | WeakHashMap | |
|---|---|---|
| Why it exists | == is the equality you mean | The GC should drop unused keys |
| Key test | == | equals / hashCode |
| Hash | identityHashCode | the key’s hashCode |
| GC | Keys are strong | Keys are weak |
| Good default cache? | no | no |
Replaces HashMap? | no | no |
You can combine them only by accident of wrapping. A WeakHashMap whose keys happen to use identity equals (plain Object) still hashes with Object.hashCode, not with IdentityHashMap’s table. If you need both “this instance” and “drop when nobody holds it,” that is a specialized structure (weak identity map), not either JDK class used as a default.
Interview lens
Two types, two question sets. Mixing them (“identity so the GC can collect”) is the fail.
IdentityHashMap
| Question | Honest answer |
|---|---|
When is == the point? | Graph topology, serialization handles, “this listener / this node,” not value equality. |
vs HashMap with records? | Records with the same components are equals and a HashMap merges them. IdentityHashMap keeps both instances. |
| What hash does it use? | System.identityHashCode, not key.hashCode(). |
| General-purpose map? | No. It deliberately violates Map’s equals contract. Document it. |
| Null / threads / order? | Null key allowed; not thread-safe; iteration unspecified. |
They may hand you two new String("SKU-100") and ask get. Identity misses; HashMap hits (String equals). Prefer the record example — it is the same fact without intern folklore.
WeakHashMap
| Question | Honest answer |
|---|---|
| When does the entry disappear? | After GC, when no strong refs to the key remain. Not on a timer. Not at a max size. |
| The value→key leak? | Strong values that point at the key pin the entry. put(k, k) never weakly clears. |
| Why is it a bad “just cache it” default? | No size bound, no LRU, unpredictable GC, keys you still use (interned strings, Class) never drop. Use LinkedHashMap LRU or a real cache. |
| Null key? | Allowed; not a weak ref; you must remove it. |
| Iterators? | Fail-fast. Not a concurrent map. size() can change without remove. |
Wrong answer: “WeakHashMap is the JDK’s LRU cache.” LRU is access order plus a size policy. Weak is “the collector ate the key.”
Cheat sheet
IdentityHashMap
keys == and identityHashCode; ignores equals/hashCode
use topology, serialization, this instance not this value
not a HashMap substitute; violates Map.equals on purpose
null key yes threads no iteration unspecified
WeakHashMap
keys weak refs; equals/hashCode while reachable
drop GC + no other strong refs to the key
values strong — must not point back at the key
not an LRU / size-bounded cache
null key yes (not weak) threads no iterators fail-fast
size() may shrink without remove
Do IdentityHashMap for already-visited object identity
WeakHashMap for a side table of objects you do not retain
Don't IdentityHashMap because records allocate
WeakHashMap as the default cache
Do:
- Pick
IdentityHashMaponly when==is the business rule. - Keep
WeakHashMapvalues from retaining their keys. - Use LinkedHashMap (or a cache) when you mean LRU.
Don’t:
- Replace
HashMap<Order, V>withIdentityHashMapbecause two records “should be distinct objects.” If they should be distinct values, give them distinctids. If they should be one value,HashMapis correct. - Store
map.put(order, wrapperThatHolds(order))in aWeakHashMapand blame the GC. - Treat either type as thread-safe.
Wrap-up
IdentityHashMap compares keys with == and hashes with identityHashCode. Use it for topology and serialization, not as a faster HashMap. WeakHashMap weakly references keys so the GC can drop entries you are not otherwise retaining. Values are strong; a value that points at its key is a leak. It is not the JDK LRU cache.
Ordinary maps still start mutable and grow. The next post is how to skip that default when the collection should have been unmodifiable from the first line.