Home Java IdentityHashMap in Java: Reference Equality, Internals & Real Use Cases

IdentityHashMap in Java: Reference Equality, Internals & Real Use Cases

In Plain English 🔥
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.
⚡ Quick Answer
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.

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.

IdentityHashMapInternals.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243
import java.util.IdentityHashMap;
import java.util.Map;

public class IdentityHashMapInternals {

    public static void main(String[] args) {

        // Two String objects with IDENTICAL content but different references.
        // 'new String(...)' deliberately bypasses the string pool so we get
        // two physically separate objects in the heap.
        String keyA = new String("session");
        String keyB = new String("session");

        System.out.println("--- Equality checks ---");
        System.out.println("keyA.equals(keyB) : " + keyA.equals(keyB));  // true  — same content
        System.out.println("keyA == keyB       : " + (keyA == keyB));    // false — different objects
        System.out.println("identityHashCode A : " + System.identityHashCode(keyA));
        System.out.println("identityHashCode B : " + System.identityHashCode(keyB));

        // ------- IdentityHashMap behaviour -------
        IdentityHashMap<String, String> identityMap = new IdentityHashMap<>();

        identityMap.put(keyA, "value-for-A");
        identityMap.put(keyB, "value-for-B");  // keyB != keyA by ==, so this is a SECOND entry

        // Two entries exist even though the keys look identical!
        System.out.println("\n--- IdentityHashMap ---");
        System.out.println("Size (expect 2) : " + identityMap.size());
        System.out.println("Get with keyA   : " + identityMap.get(keyA));  // value-for-A
        System.out.println("Get with keyB   : " + identityMap.get(keyB));  // value-for-B

        // ------- HashMap behaviour for contrast -------
        Map<String, String> hashMap = new java.util.HashMap<>();

        hashMap.put(keyA, "value-for-A");
        hashMap.put(keyB, "value-for-B");  // keyB.equals(keyA) == true, so this OVERWRITES

        System.out.println("\n--- HashMap (for comparison) ---");
        System.out.println("Size (expect 1) : " + hashMap.size());  // only 1 entry survives
        System.out.println("Get with keyA   : " + hashMap.get(keyA)); // value-for-B (overwritten)
        System.out.println("Get with keyB   : " + hashMap.get(keyB)); // value-for-B
    }
}
▶ Output
--- Equality checks ---
keyA.equals(keyB) : true
keyA == keyB : false
identityHashCode A : 1829164700
identityHashCode B : 2018699554

--- IdentityHashMap ---
Size (expect 2) : 2
Get with keyA : value-for-A
Get with keyB : value-for-B

--- HashMap (for comparison) ---
Size (expect 1) : 1
Get with keyA : value-for-B
Get with keyB : value-for-B
🔥
Why identityHashCode doesn't change after GC moves the objectThe JVM computes the identity hash code on first request and caches it in the object's mark word. Even when the garbage collector moves the object to a new memory address during compaction, the cached hash stays the same. So 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.

ObjectGraphVisitor.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
import java.util.IdentityHashMap;
import java.util.ArrayList;
import java.util.List;

/**
 * Demonstrates IdentityHashMap as a 'visited' tracker during
 * recursive object graph traversal. This is the pattern Java's
 * own serialization engine uses internally.
 */
public class ObjectGraphVisitor {

    // A simple node that deliberately overrides equals() by content.
    // This is the key: two nodes with the same label are 'equal' by value
    // but may be two physically separate objects in a cyclic graph.
    static class GraphNode {
        String label;
        List<GraphNode> neighbours = new ArrayList<>();

        GraphNode(String label) { this.label = label; }

        // equals() and hashCode() are based on label content — NOT reference
        @Override public boolean equals(Object other) {
            if (!(other instanceof GraphNode)) return false;
            return this.label.equals(((GraphNode) other).label);
        }
        @Override public int hashCode() { return label.hashCode(); }
        @Override public String toString() { return "Node(" + label + ")"; }
    }

    // IdentityHashMap as a visited-set. Value is irrelevant; we use Boolean.TRUE
    // as a lightweight placeholder — the key IS the visited tracking mechanism.
    private final IdentityHashMap<GraphNode, Boolean> visited = new IdentityHashMap<>();

    public void traverse(GraphNode startNode) {
        // containsKey uses == internally — correct for cycle detection
        if (visited.containsKey(startNode)) {
            System.out.println("Already visited (by reference): " + startNode);
            return;
        }

        visited.put(startNode, Boolean.TRUE);  // mark this exact object as seen
        System.out.println("Visiting: " + startNode);

        for (GraphNode neighbour : startNode.neighbours) {
            traverse(neighbour);  // recurse into neighbours
        }
    }

    public static void main(String[] args) {
        GraphNode nodeA = new GraphNode("auth-service");
        GraphNode nodeB = new GraphNode("user-service");
        GraphNode nodeC = new GraphNode("auth-service");  // SAME label as nodeA, different object!

        // Build a graph: A -> B -> C -> A (cycle back to A, not C)
        nodeA.neighbours.add(nodeB);
        nodeB.neighbours.add(nodeC);
        nodeC.neighbours.add(nodeA);  // points back to the ORIGINAL nodeA instance

        System.out.println("nodeA.equals(nodeC) = " + nodeA.equals(nodeC)); // true — same label!
        System.out.println("nodeA == nodeC      = " + (nodeA == nodeC));    // false — different objects
        System.out.println();

        // If we used a HashSet<GraphNode> or HashMap<GraphNode,...> for 'visited',
        // visiting nodeC would INCORRECTLY appear as 'already visited' because
        // nodeC.equals(nodeA) == true. IdentityHashMap prevents that false positive.
        new ObjectGraphVisitor().traverse(nodeA);
    }
}
▶ Output
nodeA.equals(nodeC) = true
nodeA == nodeC = false

Visiting: Node(auth-service)
Visiting: Node(user-service)
Visiting: Node(auth-service)
Already visited (by reference): Node(auth-service)
⚠️
Use Collections.newSetFromMap() to get an IdentityHashSetJava has no built-in IdentityHashSet, but you can create one in one line: Set identitySet = Collections.newSetFromMap(new IdentityHashMap<>()). It delegates set semantics to the map's key-equality rules, giving you a fully working reference-equality Set without rolling your own.

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 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.

AutoboxingTrap.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
import java.util.IdentityHashMap;

/**
 * Demonstrates the autoboxing trap in IdentityHashMap.
 * This is one of the most surprising bugs you can hit in production
 * when using IdentityHashMap with Integer keys.
 */
public class AutoboxingTrap {

    public static void main(String[] args) {

        IdentityHashMap<Integer, String> sessionMap = new IdentityHashMap<>();

        // Integer.valueOf() caches values from -128 to 127.
        // Both of these calls return THE EXACT SAME cached Integer object.
        Integer sessionIdSmall1 = Integer.valueOf(42);   // returns cached instance
        Integer sessionIdSmall2 = Integer.valueOf(42);   // returns the SAME cached instance

        System.out.println("--- Small Integer (within cache range) ---");
        System.out.println("sessionIdSmall1 == sessionIdSmall2 : " + (sessionIdSmall1 == sessionIdSmall2)); // true!

        sessionMap.put(sessionIdSmall1, "user-alice");
        sessionMap.put(sessionIdSmall2, "user-bob");   // same reference! OVERWRITES, size stays 1

        System.out.println("Map size (expect 1 due to cache): " + sessionMap.size()); // 1
        System.out.println("Value: " + sessionMap.get(Integer.valueOf(42)));           // user-bob

        sessionMap.clear();

        // Integer.valueOf(1000) is OUTSIDE the cache range.
        // Each call creates a brand new Integer object on the heap.
        Integer sessionIdLarge1 = Integer.valueOf(1000); // new object
        Integer sessionIdLarge2 = Integer.valueOf(1000); // another new object

        System.out.println("\n--- Large Integer (outside cache range) ---");
        System.out.println("sessionIdLarge1 == sessionIdLarge2 : " + (sessionIdLarge1 == sessionIdLarge2)); // false!

        sessionMap.put(sessionIdLarge1, "user-charlie");
        sessionMap.put(sessionIdLarge2, "user-diana");  // different reference — TWO entries!

        System.out.println("Map size (expect 2, surprise!): " + sessionMap.size()); // 2

        // Trying to retrieve with a THIRD Integer(1000) won't find anything
        // because it's yet another new reference, unknown to the map.
        Integer lookupAttempt = Integer.valueOf(1000);
        System.out.println("Lookup with new Integer(1000): " + sessionMap.get(lookupAttempt)); // null!

        // FIX: Always hold onto the exact reference you put in, and use it to retrieve.
        System.out.println("Lookup with original ref     : " + sessionMap.get(sessionIdLarge1)); // user-charlie
    }
}
▶ Output
--- Small Integer (within cache range) ---
sessionIdSmall1 == sessionIdSmall2 : true
Map size (expect 1 due to cache): 1
Value: user-bob

--- Large Integer (outside cache range) ---
sessionIdLarge1 == sessionIdLarge2 : false
Map size (expect 2, surprise!): 2
Lookup with new Integer(1000): null
Lookup with original ref : user-charlie
⚠️
Watch Out: Never use String literals or interned Strings as IdentityHashMap keysString literals are interned by the JVM — 'String s = "hello"' always returns the same pooled object. This means two literal 'hello' keys always share the same reference, which makes them behave like a normal map. That might look correct but it's accidental and fragile. The moment someone passes a non-interned String (e.g. from user input or a database), the reference equality breaks down and lookups return null. Always use new String(...) in tests to validate your IdentityHashMap behaves the way you intend.
Feature / AspectHashMapIdentityHashMap
Key equality checkkey.equals(otherKey)key == otherKey (reference only)
Hash function usedkey.hashCode() + spreadSystem.identityHashCode(key)
Internal structureArray of Node linked lists / tree binsSingle flat Object[] with interleaved k/v
Per-entry object allocationYes — one Node per entryNo — entries live directly in the array
Null keys allowedYes (one null key)Yes (one null key)
Default capacity16 buckets32 key-value pairs (array length 64)
Configurable load factorYes (default 0.75)No — fixed at 2/3
Map contract complianceFull complianceIntentionally violates equals() contract
Thread safetyNot thread-safeNot thread-safe
Iteration orderUnpredictableUnpredictable
Best forGeneral-purpose key-value storageObject graph tracking, proxies, serialization
Risk with autoboxed keysLow — equals() still works correctlyHigh — cached vs non-cached instances behave differently

🎯 Key Takeaways

  • IdentityHashMap uses == not equals() for key comparison — two objects that look identical but are different heap instances are treated as completely separate keys, which is the entire point of the class.
  • Its internal structure is a single flat Object[] with keys and values interleaved — no Entry node allocation, which gives better cache locality and less GC pressure for maps in the dozens-to-hundreds of entries range.
  • Autoboxing with Integer/Long keys outside the -128..127 cache range creates new object instances on every valueOf() call — lookups with a freshly constructed equal Integer will always return null because the reference doesn't match.
  • IdentityHashMap intentionally violates the Map interface contract around equals() — never pass it to library or framework code that assumes standard Map semantics, or you'll get silent, maddening bugs.

⚠ Common Mistakes to Avoid

  • Mistake 1: Using IdentityHashMap as a drop-in HashMap replacement — Symptom: lookups return null even though you 'put' the key, because you're retrieving with a different object instance that happens to be equal by value. Fix: Only use IdentityHashMap when you explicitly need reference equality; always retrieve with the exact same reference you inserted.
  • Mistake 2: Passing IdentityHashMap to library code that expects standard Map semantics — Symptom: silent incorrect behaviour, like library code failing to find keys it just inserted, or treating equal objects as distinct entries in ways the library never anticipated. Fix: Never widen an IdentityHashMap to Map and hand it off to third-party or framework code unless you've audited exactly how that code uses the map. Use a local variable typed as IdentityHashMap to make the intent visible.
  • Mistake 3: Autoboxing Integer/Long keys without holding the original reference — Symptom: Integer values outside -128..127 produce two separate entries for the 'same' number, and subsequent lookups return null because the lookup creates yet another new Integer instance. Fix: Store the boxed reference in a variable immediately after construction (Integer key = Integer.valueOf(largeNumber)), always use that variable for both put and get, and never reconstruct the Integer inline at the call site.

Interview Questions on This Topic

  • QHashMap 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?
  • QCan you describe a concrete scenario in production code where using HashMap instead of IdentityHashMap would introduce a bug that's very hard to find? Walk me through why it fails.
  • QIdentityHashMap uses a flat Object array instead of Entry nodes — what are the memory and cache-locality implications of that design, and why does it fix the load factor at 2/3 rather than letting you configure it?

Frequently Asked Questions

What is the difference between IdentityHashMap and HashMap in Java?

HashMap compares keys using equals() and hashCode(), so two different objects with the same logical value count as the same key. IdentityHashMap compares keys using == (reference equality) and System.identityHashCode(), so only the exact same heap object counts as a match. This makes IdentityHashMap ideal for tracking specific object instances rather than logically equal values.

Does IdentityHashMap allow null keys and null values?

Yes. IdentityHashMap allows one null key and any number of null values, the same as HashMap. The null key is handled as a special case internally since System.identityHashCode(null) returns 0.

Is IdentityHashMap thread-safe?

No — IdentityHashMap is not thread-safe and must be externally synchronized if accessed from multiple threads concurrently. You can wrap it with Collections.synchronizedMap(), though for most concurrent use cases you'd want ConcurrentHashMap instead — which uses equals() semantics. There is no concurrent identity-map in the standard library, so you'd need to synchronize manually or use a lock-striped approach.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousText Blocks in Java 15Next →Java Agent and Instrumentation
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged