Wrapper Keys Cause IdentityHashMap Serialization Loss
ContainsKey false, duplicated integer keys, identical strings map twice—all from IdentityHashMap's ==.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- IdentityHashMap uses == (reference equality) not equals() for key comparison
- Internally uses open addressing with a flat Object[] — no Entry nodes
- Hash function is System.identityHashCode(key), stable across GC moves
- Faster than HashMap for small maps due to no equals() calls and no per-entry allocation
- Production risk: autoboxed Integer keys outside -128..127 produce multiple entries silently
- Never pass to code expecting standard Map semantics — violates Map contract intentionally
Imagine you have two photocopies of the same driver's license. A normal librarian checks if they look identical — same name, same photo — and treats them as the same card. An IdentityHashMap librarian ignores all that. She only cares whether you're handing her the exact same physical card. Two identical-looking cards are still two different cards to her. That's reference equality: it's not about what something is, it's about which specific object in memory you're pointing to.
Most Java developers reach for HashMap without thinking twice, and 95% of the time that's the right call. But there's a narrow, important category of problems where HashMap's equality semantics actively work against you — and IdentityHashMap was built precisely for those moments. Understanding it isn't just trivia; it reveals how Java's object model actually works at a low level.
The core problem IdentityHashMap solves is this: HashMap uses equals() and hashCode() to compare keys. If two different object instances happen to be equal by those contracts, HashMap considers them the same key. Sometimes that's exactly what you don't want. Think of serialization frameworks tracking which object instances have already been visited, or proxy systems that need to store metadata keyed to a specific object — not to any logically equivalent copy of it. Using == instead of equals() to compare keys is the entire point of this class.
By the end of this article you'll understand how IdentityHashMap stores data without calling equals() or hashCode(), why its internal array layout is unusual, when it's the right tool for production code, and what traps will bite you if you confuse it with a general-purpose map. You'll also have a solid answer ready for the interviewer who asks about it — because it comes up more than people expect.
Why IdentityHashMap Breaks Serialization with Wrapper Keys
IdentityHashMap is a Map implementation that uses reference equality (==) instead of Object.equals() for key comparisons. This means two distinct Integer objects both holding the value 5 are treated as different keys. The core mechanic: hash codes come from System.identityHashCode(), not the key's hashCode() override. This bypasses the contract expected by most Java collections.
When you serialize an IdentityHashMap, the keys are written as ordinary objects. On deserialization, the JVM creates new instances of wrapper types like Integer, Long, or String. Because these new objects have different memory addresses than the originals, IdentityHashMap's reference-equality check fails — every key becomes a miss. The map appears empty or loses entries, even though the serialized data is correct.
Use IdentityHashMap only when you explicitly need reference-based identity, such as tracking object graphs in a serializer, maintaining per-instance metadata, or implementing a debug-level interning cache. Never use it with wrapper keys (Integer, Long, etc.) unless you control serialization and ensure identity is preserved — which standard Java serialization does not. In production, this mistake silently corrupts state.
equals() overhead. After a rolling restart, all sessions were orphaned — the map returned null for every lookup.equals().How IdentityHashMap Works Internally — Not Your Typical Hash Table
Most hash maps use separate chaining or tree bins (HashMap switched to tree bins in Java 8 for large buckets). IdentityHashMap does neither. It uses open addressing with linear probing on a flat array — and that array stores keys and values interleaved at consecutive indices. Key goes in slot 2i, value in slot 2i+1. This is a single Object[] array, not an array of Entry nodes. There are zero node objects. That means less garbage and better cache locality.
The hash function is also intentionally different. Instead of calling key.hashCode(), it uses System.identityHashCode(key) — the JVM-assigned identity hash, which is based on the object's memory address (or a stored snapshot of it before GC moves the object). This means two objects that are .equals() but are different instances will almost certainly land in different buckets.
The table size is always a power of two, and the default capacity is 32 key-value pairs (so the backing array starts at length 64). The load factor is fixed at 2/3 — you can't change it. When the map exceeds 2/3 full, it resizes by doubling. Understanding this layout matters for performance: IdentityHashMap can be faster than HashMap for small maps with frequent put/get because there's no object allocation per entry and no equals() call overhead.
System.identityHashCode() is stable for the object's lifetime — IdentityHashMap doesn't break under GC pressure.When IdentityHashMap Is the Right Tool — Real Production Scenarios
The most common production use case is object graph traversal where you need to track visited nodes. If your nodes override equals() to compare by content (which many domain objects do), a regular HashMap will incorrectly say 'already visited' for two distinct objects that happen to be equal by value. IdentityHashMap solves this cleanly because it truly tracks which specific heap object you've seen.
Java's own serialization mechanism (ObjectOutputStream) uses exactly this pattern internally — it maintains an IdentityHashMap to remember which object references have already been written so it can emit back-references instead of re-serializing the whole object.
Another strong use case is proxy and AOP frameworks. When you wrap objects with dynamic proxies, you often need to store metadata per proxy instance — not per logical equality of the proxied object. A cache keyed on the proxy reference must use reference equality, otherwise two different proxy wrappers around an equivalent target would incorrectly share the same metadata.
A third scenario: weak-reference caches and canonicalization tables. When you're building a system that interns objects (ensuring only one canonical instance exists), you need to look up by reference at certain stages to avoid infinite loops during the canonicalization itself.
Performance Profile, Gotchas, and the Intentional Contract Violation
Here's the elephant in the room: IdentityHashMap intentionally violates the Map contract. The Map interface javadoc states that a map should use equals() to compare keys. IdentityHashMap explicitly documents that it violates this. This is fine — but it means you must never pass an IdentityHashMap to code that expects standard Map semantics. If a library method accepts Map<K,V> and internally calls containsKey with a freshly constructed equal key, it will fail silently and mysteriously.
On the performance side, IdentityHashMap wins on three fronts for small-to-medium maps: no equals() call on get/put (just a single == comparison), no per-entry Node object allocation (entries live directly in the flat array), and better CPU cache utilisation from the interleaved array layout. For large maps with many hash collisions, linear probing can degrade — but in practice IdentityHashMap is used for maps in the dozens-to-hundreds range, not millions.
One subtle gotcha: autoboxing. If you use primitive-wrapper types like Integer as keys, be careful. Integer.valueOf(127) returns a cached instance, so two calls with the value 127 return == the same object. But Integer.valueOf(1000) creates new instances each time. Your map's behaviour changes based on the integer value — a deeply non-obvious bug.
equals() logic, and the identity map won't find them.Thread Safety, Iteration, and Memory Considerations
IdentityHashMap is not thread-safe — just like HashMap. But there's an extra subtlety: because it uses open addressing with linear probing, concurrent modification can corrupt the internal array in ways that silent data loss even more easily than chained maps. A put during an iteration can cause the iterator to skip entries or throw ConcurrentModificationException.
Iteration order is unpredictable — same as HashMap. The iterator returned by entrySet() etc. is fail-fast. For thread-safe access, wrap with Collections.synchronizedMap(), but note that iteration still requires external synchronization. There's no concurrent identity map in the JDK — you'd need to roll your own using striped locks or a custom structure.
Memory-wise, the flat Object[] array grows in powers of two. For a map with N entries, the array length is at least 2 * nextPowerOfTwo(N / 0.666). That can waste space if N is just above a threshold. For example, 33 entries cause a resize to capacity 64, giving an array of 128 slots. If you're memory-constrained, consider initial capacity carefully via the constructor new IdentityHashMap<>(expectedSize). It allocates an array sized to hold expectedSize entries at 2/3 load without immediate resize.
IdentityHashMap vs WeakHashMap vs EnumMap — When to Use Which
Java provides several specialized Map implementations for specific semantics. Choosing wrongly leads to memory leaks, wasted cycles, or just incorrect behaviour.
WeakHashMap uses WeakReference keys — entries are automatically removed when the key is no longer strongly reachable. Perfect for caches where you want keys to be garbage collected. But WeakHashMap uses equals(), not reference equality. If you need reference-equality with weak keys, you must use a third-party library like Guava's Interners or a custom ReferenceIdentityHashMap.
EnumMap is for enum keys — uses ordinal-based array lookup, ridiculously fast and compact. It enforces enum type safety. It uses equals() (but enums are naturally singleton by reference, so == and equals() are equivalent). EnumMap is almost always the best choive for enum keys.
IdentityHashMap is for reference-equality keys only — not for weak references, not for enums. Use it when you must distinguish object instances by identity. For anything else, pick WeakHashMap for weak keys, EnumMap for enum keys, and HashMap for everything else.
- Reference identity (==) → IdentityHashMap
- Logical equality (equals) + general → HashMap (or LinkedHashMap for order)
- Logical equality + weak keys → WeakHashMap
- Enum keys → EnumMap (always prefer over HashMap)
- Concurrent access → ConcurrentHashMap (uses
equals())
The Hierarchy — Why It Inherits HashMap's Problems Without Its Safety
IdentityHashMap extends AbstractMap and implements Map, Serializable, and Cloneable. That looks like HashMap's pedigree. It's not. The class breaks the Map contract deliberately — using reference equality (==) instead of equals() for key comparisons. This means any code that expects Map's standard behavior (like serialization frameworks, ORMs, or caching layers) will silently corrupt your state. Spring Boot's Jackson autoconfiguration, for example, will explode if your IdentityHashMap contains wrapper keys like Integer or String. The serialized output loses entries because two "same" strings with different references become distinct keys on deserialization. Your microservice sends one map, receives a different one. No error. No warning. Just a bug that manifests in production at 3 AM. Know your inheritance — IdentityHashMap inherits from AbstractMap, not HashMap. That distinction matters when your debugger shows a stack trace through AbstractMap.put.
Constructors — Three Ways to Shoot Yourself in the Foot
IdentityHashMap offers three constructors. Default: starts with an expected maximum size of 21. Parameterized: you pick the expected size, but the internal array always resizes to powers of two. Copy constructor: takes a Map and converts it — but here's the trap. The copy constructor uses the source map's size as expected maximum, ignoring the actual identity semantics. Feed a HashMap with 10,000 String keys to IdentityHashMap via copy constructor, and every string becomes a distinct key by reference identity, not equality. That HashMap you thought had duplicates? IdentityHashMap now treats them as unique entries, ballooning memory. Always use the constructor with expectedMaxSize set to N + (N/3) for optimal performance. The default growth factor of 64 entries per resize is fine for small caches, but for anything above 100 keys, specify the size explicitly. That resize penalty kills throughput in tight loops — I've seen 200ms operations balloon to 2 seconds because of 15 resizes in one putAll call.
Operations That Bite — Removing and Accessing by Reference
IdentityHashMap exposes the standard Map API: put, remove, get, keySet, entrySet, values. But each call uses reference identity (==) internally, not equals(). This sounds academic until you debug why a cached object lookup returns null. The remove operation: map.remove(myKey) only works if myKey is the exact same object reference used during put. A deserialized object with identical content will not match. The get operation: same rule. If you use String literals or integers, you're safe because the JVM interns them. But new String("key") creates a different reference every time. In Spring Boot, if you stash request-scoped beans as keys, every request creates new references — your IdentityHashMap grows unboundedly. Iteration: use forEach or entrySet. The internal structure is an Object[] array alternating key, value, key, value — not Node<K,V> objects. This makes entrySet().iterator faster than HashMap for read-heavy workloads. But modifying during iteration throws ConcurrentModificationException same as HashMap. Don't iterate and remove in the same loop without using iterator.remove().
String() as keys in IdentityHashMap creates a memory leak in any loop. Each iteration generates a new reference that stays in the map forever. Always intern strings or retain the original reference to prevent unbounded growth.remove(), containKey() all use reference identity. Store the key reference yourself or use interned literals. New objects = null lookups every time.Serialization Framework Breaks on Deep Object Graphs
serializeReference(Object obj, Map<Object, Boolean> visited) created a new key lookup using SerializationKey.of(obj) — a wrapper that overrides equals() by object content. In a normal Map this works, but IdentityHashMap uses ==, so the freshly created wrapper never matched the original reference already stored. The visited map always returned false, causing infinite recursion until a stack overflow was caught, but the catch block skipped the object.Map<Object, Boolean> but passed the IdentityHashMap. Fix: never widen IdentityHashMap to Map. Use a dedicated wrapper class that enforces reference equality via a private IdentityHashMap inside, or document the reference-equality contract explicitly.- IdentityHashMap intentionally violates the
Map.equals()contract — never treat it as a general-purpose Map. - When a method accepts Map<K,V>, assume it uses
equals()-based semantics unless documented otherwise. - Audit every call site where an IdentityHashMap is passed to external code. A two-minute check saves a two-day debug.
String() creates distinct references. Use System.identityHashCode to compare.System.out.println(System.identityHashCode(putKey) + " vs " + System.identityHashCode(getKey));System.out.println(putKey == getKey); // should be trueKey takeaways
equals() for key comparisonequals()Common mistakes to avoid
4 patternsUsing IdentityHashMap as a drop-in HashMap replacement
Passing IdentityHashMap to library code that expects standard Map semantics
Autoboxing Integer/Long keys without holding the original reference
Assuming String literals behave consistently as IdentityHashMap keys
Interview Questions on This Topic
HashMap and IdentityHashMap both implement Map — what's the fundamental difference in how they determine whether two keys are the same, and what does that mean for the hashCode() contract?
equals() and hashCode() — two objects are the same key if they are logically equal. IdentityHashMap uses == and System.identityHashCode() — two objects are the same key only if they are the exact same heap instance. This means IdentityHashMap doesn't require keys to override hashCode() or equals() at all; it relies on the JVM's identity hash code. The Map interface contract says keys should be compared by equals(), so IdentityHashMap intentionally violates that contract. That's documented but can surprise callers.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Collections. Mark it forged?
9 min read · try the examples if you haven't