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 ObjectOutputStream tracks handles by identity so a shared LineItem is 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.

RuleBehavior
Key equality==, not equals
HashSystem.identityHashCode
Null keyallowed (one); null values allowed
Thread-safeno — same story as HashMap
Iteration orderunspecified
equals / hashCode of the mapIdentity of keys, not key equals
Fail-fast iteratorsyes (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 / ctorJob
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 / containsKeyIdentity
keySet / values / entrySetLive 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

RuleBehavior
Key equalityequals / hashCode of the key (while it lives)
Key refsWeak; entry dropped after GC when no other strong refs
ValuesStrong — must not point back at the key
Null keyallowed (not weakly referenced; it cannot be collected)
Null valuesallowed
Thread-safeno
Iteratorsfail-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 / ctorJob
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 / containsKeyOrdinary equality, then maybe expunge stale keys
keySet / values / entrySetLive 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.

IdentityHashMapWeakHashMap
Why it exists== is the equality you meanThe GC should drop unused keys
Key test==equals / hashCode
HashidentityHashCodethe key’s hashCode
GCKeys are strongKeys are weak
Good default cache?nono
Replaces HashMap?nono

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

QuestionHonest 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

QuestionHonest 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 IdentityHashMap only when == is the business rule.
  • Keep WeakHashMap values from retaining their keys.
  • Use LinkedHashMap (or a cache) when you mean LRU.

Don’t:

  • Replace HashMap<Order, V> with IdentityHashMap because two records “should be distinct objects.” If they should be distinct values, give them distinct ids. If they should be one value, HashMap is correct.
  • Store map.put(order, wrapperThatHolds(order)) in a WeakHashMap and 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.

Next optional step in the series Unmodifiable copies without wrapping a live ArrayList. Collection Factories: List.of and Map.copyOf Without the Mutable Default