Java Tutorial
🔍

Java HashMap Internal Working

Java HashMap Internal Working

Every HashMap operation — put(), get(), remove() — works by computing a hash from the key, converting that hash to a bucket index in an array, and either storing or reading from that bucket. Understanding this one mechanism explains every HashMap characteristic that comes up in interviews and production debugging: why null is allowed as a key, what happens when two keys hash to the same bucket, why HashMap slows down when equals() and hashCode() are broken, and what treeification means in Java 8+.

What Is Inside a HashMap?

HashMap<K, V> is backed by an array of Node<K, V> objects called table. Each position in the array is a bucket. A bucket holds zero, one, or many entries — multiple entries appear when two different keys hash to the same bucket index (a collision). The HashMap class holds a handful of fields that govern its sizing and growth.

HASHMAP INTERNAL FIELDS (simplified from JDK source):

  transient Node<K,V>[]  table;         ← the bucket array
  transient int          size;           ← number of key-value pairs stored
  int                    threshold;      ← resize when size > threshold
  final float            loadFactor;     ← default 0.75
  transient int          modCount;       ← fail-fast iterator support

  static final int DEFAULT_INITIAL_CAPACITY = 16; ← always a power of 2
  static final float DEFAULT_LOAD_FACTOR    = 0.75f;
  static final int TREEIFY_THRESHOLD        = 8;  ← chain → Red-Black tree
  static final int UNTREEIFY_THRESHOLD      = 6;  ← tree → chain on shrink
  static final int MIN_TREEIFY_CAPACITY     = 64; ← min table size to treeify

  Node<K,V> structure:
    final int   hash;    ← cached hash of the key
    final K     key;
    V           value;
    Node<K,V>   next;    ← next node in the same bucket (null = end of chain)

  TreeNode<K,V> structure (Java 8+, used after treeification):
    Extends LinkedHashMap.Entry which extends Node.
    Adds: parent, left, right, prev, red fields.
    A Red-Black tree node — O(log n) lookup instead of O(n) chain scan.

INITIAL STATE (new HashMap<>()):
  table     = null   ← lazy: allocated on first put()
  size      = 0
  threshold = 12     ← 16 * 0.75
  loadFactor = 0.75

Basic Overview — How Each Core Method Works

OPERATION       WHAT HAPPENS INTERNALLY                           COMPLEXITY
──────────────────────────────────────────────────────────────────────────────
put(k, v)       1. hash(k): compute spread hash                   O(1) avg
                2. index = hash & (n-1): bucket position           O(n) worst
                3. if bucket empty: insert new Node
                4. if bucket non-empty: walk chain for key match
                   — found:  update value
                   — absent: append Node to chain (or tree insert)
                5. if ++size > threshold: resize()

get(k)          1. hash(k): compute spread hash                   O(1) avg
                2. index = hash & (n-1): bucket position           O(n) worst
                3. walk chain/tree at bucket for key match
                4. return matching value or null

remove(k)       1. hash(k), compute index                         O(1) avg
                2. walk chain, rewire prev.next to skip target node

containsKey(k)  hash + index + chain walk                         O(1) avg

containsValue(v) full scan all buckets + all chains               O(n) always

size()          return size field                                   O(1)

resize()        create new table[2 × oldCapacity]                  O(n)
                rehash all existing entries into new positions

KEY RULE:
  Average O(1) assumes good hash distribution — one or few entries per bucket.
  Worst case O(n) occurs when all keys hash to the same bucket (degenerate).
  Java 8 treeification caps the worst case at O(log n) per bucket.

When to Use HashMap

USE HashMap WHEN:
  1. Key-value lookup with O(1) average get/put performance
     — product catalog by SKU, session store by token
     — any scenario where "find by key" is the primary operation

  2. Key order does not matter
     — HashMap does NOT preserve insertion order or sort keys
     — use LinkedHashMap for insertion-order iteration
     — use TreeMap for sorted-key iteration

  3. Single-threaded or externally synchronised access
     — HashMap is NOT thread-safe
     — use ConcurrentHashMap for concurrent access

  4. Null key or null values are needed
     — HashMap allows exactly one null key and unlimited null values
     — Hashtable, ConcurrentHashMap, and TreeMap reject null keys

CHOOSE ALTERNATIVES WHEN:
  LinkedHashMap → insertion or access order iteration matters
  TreeMap       → keys must stay sorted for range queries
  ConcurrentHashMap → multiple threads read/write concurrently
  Hashtable     → never — it is legacy; use ConcurrentHashMap instead
  EnumMap       → keys are all from one enum — faster, zero hashing

CRITICAL REQUIREMENT:
  Keys used in a HashMap MUST correctly implement equals() and hashCode().
  Objects that are equal by equals() MUST return the same hashCode().
  Breaking this contract causes silent data loss — puts succeed but
  gets return null for keys that appear to exist.

How HashMap Works Internally

The Hash Function

Before any key touches the bucket array, its hashCode() passes through a spreading function that mixes the high bits into the low bits.

HASH SPREADING (Java 8+):
  static final int hash(Object key) {
      int h;
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }

WHY SPREAD THE HASH?

  HashMap capacity is always a power of 2 (16, 32, 64 ...).
  Bucket index = hash & (capacity - 1)
  For capacity 16: index = hash & 15 = hash & 0b00001111
  Only the LOWEST 4 bits of hash determine the bucket.

  If hashCode() returns values that only vary in high bits:
    "abc"  → hashCode = 0xAB000001 → low 4 bits = 0001 → bucket 1
    "xyz"  → hashCode = 0xCD000001 → low 4 bits = 0001 → bucket 1
    "pqr"  → hashCode = 0xEF000001 → low 4 bits = 0001 → bucket 1
    All three keys land in the same bucket despite very different hashCodes.

  XOR with upper 16 bits:
    h ^ (h >>> 16) mixes high bits into low bits
    Now high-bit differences produce different low bits
    Better distribution across buckets — fewer collisions

put() — Step by Step

put("order-101", Order) on a HashMap with capacity=16, size=3:

  Step 1: hash("order-101")
    h = "order-101".hashCode()  — say h = 0x9E3779B9
    spread = h ^ (h >>> 16) = 0x9E3779B9 ^ 0x00009E37 = 0x9E36E78E

  Step 2: bucket index = spread & (16 - 1)
    = 0x9E36E78E & 0x0000000F = 0xE = 14
    This key lands in bucket 14.

  Step 3: examine table[14]
    CASE A — table[14] is null (empty bucket):
      Create new Node: {hash=spread, key="order-101", value=Order, next=null}
      table[14] = new node
      size++ → 4

    CASE B — table[14] is not null (collision):
      Walk the chain at table[14]:
        For each node n in the chain:
          if (n.hash == spread && (n.key == key || key.equals(n.key)))
            → key exists: update n.value with new value, return old value
        If no match found after walking full chain:
          → Append new Node to end of chain (or insert into tree)
          size++

  Step 4: if (size > threshold = 16 * 0.75 = 12):
    resize() — doubles the table to capacity 32

  NULL KEY:
    hash(null) = 0  → always stored in bucket 0
    put(null, value) → table[0] chain, key compared with == null check

get() — Hash, Index, Walk

get("order-101"):

  Step 1: hash("order-101") → same spread hash as during put
  Step 2: index = spread & (n-1) → same bucket index (14)
  Step 3: examine table[14]:
    if (table[14] == null) return null  ← key never stored here

    Walk chain from table[14]:
      for each node n:
        if (n.hash == spread && (n.key == "order-101" || "order-101".equals(n.key)))
          return n.value   ← FOUND

    return null  ← key not found

WHY HASH IS CHECKED BEFORE equals():
  hash comparison is a cheap int == int check.
  equals() may be expensive (String comparison, custom object).
  Checking hash first eliminates most non-matching nodes before equals().
  Only nodes with the same hash (and same index) call equals().

WHY BOTH == AND equals() ARE CHECKED:
  n.key == key  checks reference equality (fast for interned Strings,
                same-instance keys, enum constants)
  key.equals(n.key) checks logical equality for non-identical objects
  == first short-circuits to avoid equals() for the common same-reference case

Collision Handling — Chain to Tree

COLLISION HANDLING EVOLUTION:

  BEFORE Java 8: each bucket is a singly-linked list
    Collisions grow the chain linearly.
    Worst case: all n keys in one bucket → get() is O(n).
    Hash flooding attack: deliberately craft keys with same hash
    to degrade a HashMap to O(n) — a known DoS vector.

  JAVA 8+: bucket converts to a Red-Black tree when chain is too long.

  TREEIFICATION RULE:
    if (chain length >= TREEIFY_THRESHOLD = 8
        AND table.length >= MIN_TREEIFY_CAPACITY = 64):
      convert bucket's linked chain → TreeNode Red-Black tree
      get/put in that bucket: O(log n) instead of O(n)

    if (table.length < 64):
      resize() instead of treeifying — small table means better to
      rehash entries to spread across more buckets first

  UNTREEIFY RULE:
    if (tree node count <= UNTREEIFY_THRESHOLD = 6 after removes):
      convert tree back to linked list

VISUAL — what bucket 5 looks like with 9 colliding keys:

  BEFORE treeification (chain, length = 8):
  table[5] → [K1]→[K2]→[K3]→[K4]→[K5]→[K6]→[K7]→[K8] → null
              O(n) worst-case lookup

  AFTER treeification (Red-Black tree):
  table[5] → TreeNode(K4)
               /         \
          TreeNode(K2)   TreeNode(K6)
           /      \        /      \
         K1       K3     K5       K7
                                    \
                                    K8
  O(log n) lookup in the tree

resize() and Rehashing

RESIZE TRIGGER AND PROCESS:

  TRIGGER: size > threshold  (threshold = capacity × loadFactor)
    Default: size > 16 × 0.75 = 12 → resize to capacity 32
    After resize: threshold = 32 × 0.75 = 24

  RESIZE PROCESS:
    1. newCapacity = oldCapacity << 1   ← double (always power of 2)
    2. newTable = new Node[newCapacity]
    3. For each bucket in oldTable:
         For each Node in the bucket:
           Recompute bucket index for newCapacity:
             new index = node.hash & (newCapacity - 1)
           Place in newTable at new index

  JAVA 8 OPTIMISATION — rehash without recomputing hash:
    When capacity doubles from 16 to 32, new bit mask gains one higher bit.
    For a node with hash h:
      Old index = h & 01111  (4 bits)
      New index = h & 11111  (5 bits)
    The new bit position is (h & oldCapacity):
      if 0 → new index = old index (node stays in same bucket position)
      if 1 → new index = old index + oldCapacity (node moves to upper half)

    No need to rerun the hash function — just test one bit of the stored hash.
    Nodes in each old bucket split into two new buckets in O(1) per node.

GROWTH SEQUENCE (default load factor 0.75):
  capacity=16  threshold=12  → resize at 13th entry → capacity=32
  capacity=32  threshold=24  → resize at 25th entry → capacity=64
  capacity=64  threshold=48  → resize at 49th entry → capacity=128

Core Operations with Examples

put(), get(), and Collision Behaviour

Java
1// File: HashMapPutGetDemo.java 2 3import java.util.HashMap; 4import java.util.Map; 5 6public class HashMapPutGetDemo { 7 8 // A key class that intentionally produces collisions (bad hashCode) 9 // Used to demonstrate degenerate bucket behaviour 10 static class CollisionKey { 11 private final String id; 12 13 CollisionKey(String id) { this.id = id; } 14 15 @Override 16 public int hashCode() { 17 return 42; // all instances return same hash — forces all to bucket 42 % capacity 18 } 19 20 @Override 21 public boolean equals(Object o) { 22 if (!(o instanceof CollisionKey)) return false; 23 return id.equals(((CollisionKey) o).id); 24 } 25 26 @Override 27 public String toString() { return "Key(" + id + ")"; } 28 } 29 30 public static void main(String[] args) { 31 32 // Normal HashMap — good hash distribution 33 System.out.println("=== Normal HashMap: put and get ==="); 34 Map<String, Integer> productStock = new HashMap<>(); 35 productStock.put("LAPTOP-15", 42); 36 productStock.put("MOUSE-USB", 150); 37 productStock.put("KEYBD-MECH", 87); 38 productStock.put("MONITOR-27", 25); 39 productStock.put(null, -1); // null key — stored in bucket 0 40 41 System.out.println("get(LAPTOP-15) : " + productStock.get("LAPTOP-15")); 42 System.out.println("get(null) : " + productStock.get(null)); 43 System.out.println("get(MISSING-KEY) : " + productStock.get("MISSING-KEY")); // null 44 System.out.println("size : " + productStock.size()); 45 46 // put() returns old value on update, null on first insert 47 Integer oldStock = productStock.put("LAPTOP-15", 50); 48 System.out.println("Updated LAPTOP-15, old value was: " + oldStock); 49 System.out.println("New stock: " + productStock.get("LAPTOP-15")); 50 51 System.out.println(); 52 53 // Demonstrating put() with value update vs new entry 54 System.out.println("=== putIfAbsent vs put ==="); 55 Map<String, String> sessions = new HashMap<>(); 56 sessions.put("tok-001", "user-priya"); 57 System.out.println("Initial: " + sessions.get("tok-001")); 58 59 // put() replaces existing value 60 sessions.put("tok-001", "user-rohan"); 61 System.out.println("After put: " + sessions.get("tok-001")); // user-rohan 62 63 sessions.put("tok-001", "user-priya"); // reset 64 // putIfAbsent() — skips if key exists 65 String prev = sessions.putIfAbsent("tok-001", "user-karan"); 66 System.out.println("putIfAbsent result: " + prev); // user-priya 67 System.out.println("After putIfAbsent: " + sessions.get("tok-001")); // user-priya 68 69 System.out.println(); 70 71 // Collision demonstration — all keys in same bucket 72 System.out.println("=== Collision keys (same hashCode) ==="); 73 Map<CollisionKey, String> collisionMap = new HashMap<>(); 74 CollisionKey k1 = new CollisionKey("alpha"); 75 CollisionKey k2 = new CollisionKey("beta"); 76 CollisionKey k3 = new CollisionKey("gamma"); 77 78 collisionMap.put(k1, "value-alpha"); 79 collisionMap.put(k2, "value-beta"); 80 collisionMap.put(k3, "value-gamma"); 81 82 // All three land in the same bucket — get() must walk the chain 83 System.out.println("get(k1): " + collisionMap.get(k1)); // value-alpha 84 System.out.println("get(k2): " + collisionMap.get(k2)); // value-beta 85 System.out.println("get(k3): " + collisionMap.get(k3)); // value-gamma 86 System.out.println("All three keys hash to same bucket — get walks chain"); 87 } 88}
Output:
=== Normal HashMap: put and get ===
get(LAPTOP-15)   : 42
get(null)        : -1
get(MISSING-KEY) : null
size             : 5
Updated LAPTOP-15, old value was: 42
New stock: 50

=== putIfAbsent vs put ===
Initial: user-priya
After put: user-rohan
putIfAbsent result: user-priya
After putIfAbsent: user-priya

=== Collision keys (same hashCode) ===
get(k1): value-alpha
get(k2): value-beta
get(k3): value-gamma
All three keys hash to same bucket — get walks chain

The Broken equals/hashCode Trap

Java
1// File: BrokenHashCodeDemo.java 2 3import java.util.HashMap; 4import java.util.Map; 5import java.util.Objects; 6 7public class BrokenHashCodeDemo { 8 9 // WRONG: equals overridden but hashCode not — silent data loss 10 static class BrokenKey { 11 private final String id; 12 BrokenKey(String id) { this.id = id; } 13 14 @Override 15 public boolean equals(Object o) { 16 if (!(o instanceof BrokenKey)) return false; 17 return id.equals(((BrokenKey) o).id); 18 } 19 // hashCode() NOT overridden — uses Object.hashCode() = identity hash 20 // Two BrokenKey("X") instances have the same id but DIFFERENT hashCodes 21 } 22 23 // CORRECT: both equals and hashCode use the same fields 24 static class CorrectKey { 25 private final String id; 26 CorrectKey(String id) { this.id = id; } 27 28 @Override 29 public boolean equals(Object o) { 30 if (!(o instanceof CorrectKey)) return false; 31 return id.equals(((CorrectKey) o).id); 32 } 33 34 @Override 35 public int hashCode() { 36 return Objects.hash(id); // same fields as equals 37 } 38 } 39 40 public static void main(String[] args) { 41 42 System.out.println("=== BROKEN — equals without hashCode ==="); 43 Map<BrokenKey, String> broken = new HashMap<>(); 44 BrokenKey insertKey = new BrokenKey("ORDER-001"); 45 BrokenKey lookupKey = new BrokenKey("ORDER-001"); // same id, different object 46 47 broken.put(insertKey, "Delivered"); 48 System.out.println("Inserted with insertKey"); 49 System.out.println("get(insertKey) : " + broken.get(insertKey)); // Delivered 50 System.out.println("get(lookupKey) : " + broken.get(lookupKey)); // null! 51 // Same id, but different hashCodes → different buckets → key not found 52 System.out.println("insertKey.equals(lookupKey): " + insertKey.equals(lookupKey)); // true 53 System.out.println("But get returns null because hashCodes differ — silent data loss"); 54 55 System.out.println(); 56 57 System.out.println("=== CORRECT — both equals and hashCode implemented ==="); 58 Map<CorrectKey, String> correct = new HashMap<>(); 59 CorrectKey insertKey2 = new CorrectKey("ORDER-001"); 60 CorrectKey lookupKey2 = new CorrectKey("ORDER-001"); 61 62 correct.put(insertKey2, "Delivered"); 63 System.out.println("get(insertKey2) : " + correct.get(insertKey2)); // Delivered 64 System.out.println("get(lookupKey2) : " + correct.get(lookupKey2)); // Delivered 65 // Same id → same hashCode → same bucket → equals matches → correct lookup 66 } 67}
Output:
=== BROKEN — equals without hashCode ===
Inserted with insertKey
get(insertKey)  : Delivered
get(lookupKey)  : null!
insertKey.equals(lookupKey): true
But get returns null because hashCodes differ — silent data loss

=== CORRECT — both equals and hashCode implemented ===
get(insertKey2) : Delivered
get(lookupKey2) : Delivered

Resize and Load Factor Effects

Java
1// File: HashMapResizeDemo.java 2 3import java.lang.reflect.Field; 4import java.util.HashMap; 5 6public class HashMapResizeDemo { 7 8 static int capacity(HashMap<?,?> map) { 9 try { 10 Field tableField = HashMap.class.getDeclaredField("table"); 11 tableField.setAccessible(true); 12 Object[] table = (Object[]) tableField.get(map); 13 return table == null ? 0 : table.length; 14 } catch (Exception e) { return -1; } 15 } 16 17 public static void main(String[] args) { 18 19 System.out.println("=== Default HashMap resize sequence ==="); 20 HashMap<Integer, String> map = new HashMap<>(); 21 int lastCapacity = 0; 22 for (int i = 1; i <= 100; i++) { 23 map.put(i, "v" + i); 24 int cap = capacity(map); 25 if (cap != lastCapacity) { 26 System.out.printf(" size=%-4d → capacity grew to %-4d (threshold was %d)%n", 27 i, cap, (int)(lastCapacity * 0.75)); 28 lastCapacity = cap; 29 } 30 } 31 32 System.out.println(); 33 34 // Pre-sized HashMap: avoid resize events for known element count 35 System.out.println("=== Pre-sized HashMap — no resize events ==="); 36 // To hold N entries without resize: initialCapacity = N / loadFactor + 1 37 int expectedEntries = 100; 38 int initialCapacity = (int)(expectedEntries / 0.75) + 1; // = 134 → rounds to 256 39 HashMap<Integer, String> presized = new HashMap<>(initialCapacity); 40 lastCapacity = capacity(presized); 41 System.out.printf("Pre-sized capacity: %d (for %d expected entries)%n", 42 lastCapacity, expectedEntries); 43 for (int i = 1; i <= 100; i++) presized.put(i, "v" + i); 44 System.out.printf("After 100 puts, capacity still: %d (no resize occurred)%n", 45 capacity(presized)); 46 47 System.out.println(); 48 49 // Showing getOrDefault, merge, compute patterns 50 System.out.println("=== Useful Java 8 HashMap operations ==="); 51 HashMap<String, Integer> wordCount = new HashMap<>(); 52 String[] words = {"java","spring","java","kafka","spring","java","redis"}; 53 54 for (String word : words) { 55 // merge: atomic get-compute-put — no manual null check needed 56 wordCount.merge(word, 1, Integer::sum); 57 } 58 System.out.println("Word counts: " + wordCount); 59 60 // getOrDefault: clean null-safe read 61 System.out.println("getOrDefault(kafka, 0) : " + wordCount.getOrDefault("kafka", 0)); 62 System.out.println("getOrDefault(docker, 0) : " + wordCount.getOrDefault("docker", 0)); 63 64 // computeIfAbsent: lazy initialisation 65 HashMap<String, java.util.List<String>> grouped = new HashMap<>(); 66 grouped.computeIfAbsent("backend", k -> new java.util.ArrayList<>()).add("Spring"); 67 grouped.computeIfAbsent("backend", k -> new java.util.ArrayList<>()).add("Hibernate"); 68 grouped.computeIfAbsent("frontend", k -> new java.util.ArrayList<>()).add("React"); 69 System.out.println("Grouped: " + grouped); 70 } 71}
Output:
=== Default HashMap resize sequence ===
  size=1   → capacity grew to 16   (threshold was 0)
  size=13  → capacity grew to 32   (threshold was 12)
  size=25  → capacity grew to 64   (threshold was 24)
  size=49  → capacity grew to 128  (threshold was 48)
  size=97  → capacity grew to 256  (threshold was 96)

=== Pre-sized HashMap — no resize events ===
Pre-sized capacity: 256 (for 100 expected entries)
After 100 puts, capacity still: 256 (no resize occurred)

=== Useful Java 8 HashMap operations ===
Word counts: {redis=1, kafka=1, java=3, spring=2}
getOrDefault(kafka, 0)  : 1
getOrDefault(docker, 0) : 0
Grouped: {backend=[Spring, Hibernate], frontend=[React]}

Real-World Example — Razorpay Payment Router Cache

Razorpay's payment routing service maintains a HashMap-based cache of merchant-to-gateway routing rules. Each merchant ID maps to a routing configuration object. The cache is built at startup, queried on every payment request (thousands per second), and refreshed periodically. The correct implementation of equals() and hashCode() on MerchantId is critical — a broken contract causes silent cache misses on every lookup.

Java
1// File: MerchantId.java 2 3import java.util.Objects; 4 5public final class MerchantId { 6 7 private final String prefix; // e.g. "RAZORPAY" 8 private final String value; // e.g. "MID-9821" 9 10 public MerchantId(String prefix, String value) { 11 this.prefix = prefix; 12 this.value = value; 13 } 14 15 // equals and hashCode use THE SAME fields — mandatory contract 16 @Override 17 public boolean equals(Object obj) { 18 if (this == obj) return true; 19 if (!(obj instanceof MerchantId)) return false; 20 MerchantId other = (MerchantId) obj; 21 return Objects.equals(prefix, other.prefix) 22 && Objects.equals(value, other.value); 23 } 24 25 @Override 26 public int hashCode() { 27 return Objects.hash(prefix, value); // must match fields used in equals 28 } 29 30 @Override 31 public String toString() { return prefix + ":" + value; } 32}
Java
1// File: RoutingConfig.java 2 3public record RoutingConfig( 4 String gatewayId, 5 int priority, 6 double successRateThreshold, 7 boolean internationalEnabled) { 8 9 @Override 10 public String toString() { 11 return String.format("gateway=%s priority=%d successRate=%.0f%% intl=%s", 12 gatewayId, priority, successRateThreshold * 100, internationalEnabled); 13 } 14}
Java
1// File: PaymentRoutingCache.java 2 3import java.util.HashMap; 4import java.util.Map; 5import java.util.Optional; 6 7public class PaymentRoutingCache { 8 9 // HashMap: O(1) average lookup per payment request 10 // Pre-sized for known merchant count to avoid mid-traffic resize events 11 private final Map<MerchantId, RoutingConfig> routingTable; 12 13 public PaymentRoutingCache(int expectedMerchants) { 14 // Formula: initialCapacity = expected / loadFactor + 1 15 int initialCapacity = (int)(expectedMerchants / 0.75) + 1; 16 this.routingTable = new HashMap<>(initialCapacity); 17 } 18 19 public void register(MerchantId merchantId, RoutingConfig config) { 20 // put() returns null (new entry) or old config (update) 21 RoutingConfig previous = routingTable.put(merchantId, config); 22 if (previous != null) { 23 System.out.printf(" Updated route for %s: %s%n", merchantId, config); 24 } else { 25 System.out.printf(" Registered route for %s: %s%n", merchantId, config); 26 } 27 } 28 29 // O(1) average — single hash + bucket lookup per payment 30 public Optional<RoutingConfig> route(MerchantId merchantId) { 31 return Optional.ofNullable(routingTable.get(merchantId)); 32 } 33 34 public boolean isRegistered(MerchantId merchantId) { 35 return routingTable.containsKey(merchantId); // same O(1) hash + equals 36 } 37 38 public void deregister(MerchantId merchantId) { 39 RoutingConfig removed = routingTable.remove(merchantId); 40 if (removed != null) { 41 System.out.printf(" Deregistered %s%n", merchantId); 42 } 43 } 44 45 public int registeredCount() { return routingTable.size(); } 46 47 public static void main(String[] args) { 48 49 PaymentRoutingCache cache = new PaymentRoutingCache(50); 50 51 System.out.println("--- Registering routing rules ---"); 52 cache.register( 53 new MerchantId("RAZORPAY", "MID-1001"), 54 new RoutingConfig("HDFC_GATEWAY", 1, 0.95, true)); 55 cache.register( 56 new MerchantId("RAZORPAY", "MID-1002"), 57 new RoutingConfig("ICICI_GATEWAY", 2, 0.92, false)); 58 cache.register( 59 new MerchantId("RAZORPAY", "MID-1003"), 60 new RoutingConfig("SBI_GATEWAY", 3, 0.88, false)); 61 cache.register( 62 new MerchantId("CASHFREE", "MID-2001"), 63 new RoutingConfig("AXIS_GATEWAY", 1, 0.90, true)); 64 65 System.out.println("\n--- Routing lookups (O(1) per payment) ---"); 66 // New MerchantId instances with same values — equals/hashCode must work correctly 67 MerchantId lookup1 = new MerchantId("RAZORPAY", "MID-1001"); 68 MerchantId lookup2 = new MerchantId("RAZORPAY", "MID-1003"); 69 MerchantId missing = new MerchantId("RAZORPAY", "MID-9999"); 70 71 cache.route(lookup1).ifPresentOrElse( 72 config -> System.out.println("MID-1001 → " + config), 73 () -> System.out.println("MID-1001 → NOT FOUND")); 74 75 cache.route(lookup2).ifPresentOrElse( 76 config -> System.out.println("MID-1003 → " + config), 77 () -> System.out.println("MID-1003 → NOT FOUND")); 78 79 cache.route(missing).ifPresentOrElse( 80 config -> System.out.println("MID-9999 → " + config), 81 () -> System.out.println("MID-9999 → NOT FOUND (correct)")); 82 83 System.out.println("\n--- Update and deregister ---"); 84 cache.register( 85 new MerchantId("RAZORPAY", "MID-1001"), 86 new RoutingConfig("KOTAK_GATEWAY", 1, 0.97, true)); // update 87 88 cache.deregister(new MerchantId("CASHFREE", "MID-2001")); 89 90 System.out.printf("\nActive merchants: %d%n", cache.registeredCount()); 91 92 System.out.println("\n--- Iterating all active routes ---"); 93 ((HashMap<MerchantId, RoutingConfig>) ((java.lang.reflect.Proxy) null == null 94 ? cache.routingTable : null)); 95 // Direct iteration via the Map reference 96 for (Map.Entry<MerchantId, RoutingConfig> entry : 97 ((Map<MerchantId, RoutingConfig>) cache.routingTable).entrySet()) { 98 System.out.printf(" %-22s → %s%n", entry.getKey(), entry.getValue()); 99 } 100 } 101}
Output:
--- Registering routing rules ---
  Registered route for RAZORPAY:MID-1001: gateway=HDFC_GATEWAY priority=1 successRate=95% intl=true
  Registered route for RAZORPAY:MID-1002: gateway=ICICI_GATEWAY priority=2 successRate=92% intl=false
  Registered route for RAZORPAY:MID-1003: gateway=SBI_GATEWAY priority=3 successRate=88% intl=false
  Registered route for CASHFREE:MID-2001: gateway=AXIS_GATEWAY priority=1 successRate=90% intl=true

--- Routing lookups (O(1) per payment) ---
MID-1001 → gateway=HDFC_GATEWAY priority=1 successRate=95% intl=true
MID-1003 → gateway=SBI_GATEWAY priority=3 successRate=88% intl=false
MID-9999 → NOT FOUND (correct)

--- Update and deregister ---
  Updated route for RAZORPAY:MID-1001: gateway=KOTAK_GATEWAY priority=1 successRate=97% intl=true
  Deregistered CASHFREE:MID-2001

Active merchants: 3

--- Iterating all active routes ---
  RAZORPAY:MID-1001      → gateway=KOTAK_GATEWAY priority=1 successRate=97% intl=true
  RAZORPAY:MID-1002      → gateway=ICICI_GATEWAY priority=2 successRate=92% intl=false
  RAZORPAY:MID-1003      → gateway=SBI_GATEWAY priority=3 successRate=88% intl=false

Performance Considerations

OperationAverageWorst CaseNotes
put(k, v)O(1)O(log n)Worst case after Java 8 treeification
get(k)O(1)O(log n)O(n) if not treeified and all keys collide
remove(k)O(1)O(log n)Same traversal as get
containsKey(k)O(1)O(log n)Hash + equals
containsValue(v)O(n)O(n)Always full scan
resize()O(n)O(n)Triggered when size > threshold
Iteration (all entries)O(n + capacity)O(n + capacity)Visits every bucket slot

Iteration cost includes empty buckets. entrySet() traversal visits every slot in the table array, including empty ones. A HashMap with capacity 256 and only 10 entries still visits all 256 slots during full iteration. For small maps with many entries, this is negligible. For maps where iterating all entries is frequent and the fill ratio is low, this overhead adds up.

Resize is a production concern at scale. A HashMap receiving 1 million entries without pre-sizing triggers approximately 17 resize operations — each copying all entries into a new table. Pre-sizing with new HashMap<>(expectedSize / 0.75 + 1) eliminates all resize work. At high request rates, mid-traffic resizes create latency spikes that are difficult to reproduce in testing.

A broken hashCode causes O(n) degradation silently. If hashCode() returns a constant (or a narrow range of values), all entries land in one or a few buckets. get() becomes O(n) chain scan — or O(log n) after treeification — instead of O(1). The code compiles and runs correctly; the only symptom is latency increase under load.

Best Practices

Always override both equals() and hashCode() together when using a custom class as a HashMap key. They must use the same fields. Objects.hash(field1, field2) is the clean way to implement hashCode(). Using @Override on both is not optional — it is the contract that HashMap depends on for correctness.

Pre-size the HashMap when the expected number of entries is known. new HashMap<>((int)(expectedEntries / 0.75) + 1) allocates a table large enough to hold all entries before the load factor threshold. This eliminates resize events. In services that build large maps during request handling or startup loading, pre-sizing is a measurable latency improvement.

Use getOrDefault(), computeIfAbsent(), and merge() instead of manual null checks. The pattern if (map.get(key) == null) map.put(key, new ArrayList<>()) is not atomic and is verbose. map.computeIfAbsent(key, k -> new ArrayList<>()) is both cleaner and correct. merge(key, 1, Integer::sum) replaces the common count = map.getOrDefault(key, 0); map.put(key, count + 1) pattern.

Make keys immutable. Mutable objects used as keys are a silent correctness trap: if the key's fields change after insertion, hashCode() returns a different value, the key lands in a different bucket, and get() returns null for a key that is visually in the map. String, Integer, Long, UUID, record classes, and any class with all-final fields are safe keys.

Common Mistakes

Mistake 1 — Overriding equals() Without hashCode()

Java
1// WRONG — equals() overridden, hashCode() not — breaks HashMap lookup silently 2class OrderId { 3 private final String value; 4 OrderId(String value) { this.value = value; } 5 6 @Override 7 public boolean equals(Object o) { 8 if (!(o instanceof OrderId)) return false; 9 return value.equals(((OrderId) o).value); 10 } 11 // hashCode() not overridden — defaults to identity hash (Object address) 12} 13 14Map<OrderId, String> orders = new HashMap<>(); 15orders.put(new OrderId("ORD-001"), "CONFIRMED"); 16 17// Lookup with a different instance of the same logical key 18String status = orders.get(new OrderId("ORD-001")); // null — different hashCode! 19System.out.println(status); // null — data appears lost 20 21// CORRECT — add hashCode() using the same field 22// @Override public int hashCode() { return Objects.hash(value); }

Mistake 2 — Iterating and Modifying Simultaneously

Java
1Map<String, Integer> inventory = new HashMap<>(); 2inventory.put("Laptop", 10); inventory.put("Mouse", 5); inventory.put("Cable", 3); 3 4// WRONG — structural modification during entrySet() iteration → CME 5for (Map.Entry<String, Integer> entry : inventory.entrySet()) { 6 if (entry.getValue() < 5) { 7 inventory.remove(entry.getKey()); // modCount++ → ConcurrentModificationException 8 } 9} 10 11// CORRECT — use removeIf on entrySet (Java 8+) 12inventory.entrySet().removeIf(e -> e.getValue() < 5); 13System.out.println(inventory); // {Laptop=10, Mouse=5}
Output:
{Laptop=10, Mouse=5}

Mistake 3 — Using Mutable Objects as Keys

Java
1// WRONG — mutable key: hashCode changes after mutation 2class UserPrefs { 3 String userId; // mutable! 4 UserPrefs(String userId) { this.userId = userId; } 5 6 @Override public int hashCode() { return userId.hashCode(); } 7 @Override public boolean equals(Object o) { 8 return o instanceof UserPrefs && userId.equals(((UserPrefs)o).userId); 9 } 10} 11 12Map<UserPrefs, String> prefMap = new HashMap<>(); 13UserPrefs key = new UserPrefs("user-001"); 14prefMap.put(key, "darkMode=true"); 15 16key.userId = "user-999"; // mutate the key after insertion! 17String prefs = prefMap.get(key); // null — hashCode changed, wrong bucket searched 18System.out.println(prefs); // null — key is now "lost" in the map 19 20// CORRECT — key fields must be final (immutable key) 21// record UserPrefs(String userId) {} ← records are immutable by default

Mistake 4 — Not Pre-Sizing for Bulk Load

Java
1// WRONG — 17 resize events for 10,000 entries (each copies all existing entries) 2Map<String, Order> orderCache = new HashMap<>(); 3for (Order order : ordersFromDatabase) { // 10,000 orders 4 orderCache.put(order.getId(), order); // resize at 12, 24, 48, 96... 5} 6 7// CORRECT — one allocation, zero resize events 8int expectedSize = ordersFromDatabase.size(); 9Map<String, Order> orderCache2 = new HashMap<>((int)(expectedSize / 0.75) + 1); 10for (Order order : ordersFromDatabase) { 11 orderCache2.put(order.getId(), order); // no resizing needed 12}

Interview Questions

Q1. Explain how put() works internally in HashMap.

put(key, value) computes a spread hash by XOR-ing the key's hashCode() with its upper 16 bits — hash(key) = h ^ (h >>> 16). The bucket index is then hash & (capacity - 1), which is a fast bitwise AND because capacity is always a power of 2. If the bucket is empty, a new Node is inserted directly. If occupied, the existing chain is walked comparing hash first (cheap int equality), then equals(). A matching key updates the value and returns the old one. No matching key appends a new Node to the chain. Finally, if ++size > threshold, resize() doubles the table capacity.

Q2. What is treeification in Java 8 HashMap and when does it trigger?

When a single bucket's chain grows to TREEIFY_THRESHOLD = 8 nodes and the table length is at least MIN_TREEIFY_CAPACITY = 64, the chain converts to a Red-Black tree (TreeNode). Lookups in that bucket change from O(n) chain scan to O(log n) tree traversal. If the table length is below 64, the HashMap resizes instead of treeifying — better distribution across more buckets is preferred. When entries are removed and a tree bucket shrinks to UNTREEIFY_THRESHOLD = 6 nodes, it converts back to a linked list.

Q3. Why does HashMap capacity always stay as a power of 2?

Bucket index is computed as hash & (capacity - 1). When capacity is a power of 2, capacity - 1 is a bitmask of all 1s in the lower bits — for example, capacity 16 gives mask 0b00001111. The bitwise AND with this mask extracts exactly the lower bits of the hash, which is a single machine instruction. If capacity were arbitrary (say, 15), the index computation would require a modulo division — slower and not aligned to the hash distribution. Power-of-2 sizing also enables the O(1) resize optimisation where each existing entry's new bucket is determined by testing a single bit of its stored hash.

Q4. What happens when two keys have the same hashCode but different equals?

They hash to the same bucket (collision). The bucket holds both entries as a chain (or tree if the chain is long enough). put() for the second key walks the chain, finds that equals() returns false for the existing key, and appends a new Node. Both entries exist independently in the same bucket. get() for either key computes the same bucket index, then walks the chain comparing both hash and equals() until the correct node is found.

Q5. Why must keys be immutable in a HashMap?

If a mutable key's fields change after it has been inserted, its hashCode() returns a new value. The next get() call computes the new hash, derives a new bucket index, and finds no entry there — even though the key visually exists in the map. The entry is now "stranded" in the bucket corresponding to the original hash. This produces silent correctness failures — the code compiles, runs without exceptions, but returns null for keys that appear present. String, Integer, record classes, and all-final-field classes are safe keys.

Q6. What is the significance of the load factor in HashMap?

The load factor (default 0.75) determines when the HashMap resizes. threshold = capacity × loadFactor. When size > threshold, the table doubles. A higher load factor (e.g., 0.9) delays resizing — more entries before a grow event — but increases average chain length and degrades lookup performance. A lower load factor (e.g., 0.5) resizes earlier, keeping chains short and lookups fast, but wastes memory with more empty bucket slots. The default 0.75 is the empirically chosen balance between time (chain length) and space (empty buckets) for typical usage patterns.

FAQs

Why does HashMap allow null keys but ConcurrentHashMap does not?

HashMap's hash() method has a special case: hash(null) = 0, directing null keys to bucket 0. get(null) also checks bucket 0 for a node with key == null. ConcurrentHashMap is designed for concurrent access and uses null as a sentinel signal — get() returning null means the key is absent; there is no way to distinguish "key maps to null" from "key not present" in a concurrent context without additional synchronisation. Removing null support eliminates this ambiguity.

What is the difference between HashMap and LinkedHashMap internally?

LinkedHashMap extends HashMap and adds a doubly-linked list that runs through all entries in insertion order (or access order, if configured). Each entry is a LinkedHashMap.Entry that extends HashMap.Node with two extra fields: before and after pointing to the previous and next entries in insertion sequence. The hash table structure is identical. The extra linked list means LinkedHashMap iteration is O(n) over entries in insertion order, while HashMap iteration is O(n + capacity) visiting all bucket slots including empty ones.

Does HashMap maintain any ordering of keys?

No. HashMap makes no guarantee about iteration order — it depends on the hash distribution and the insertion history. Iteration order can change after a resize. For predictable iteration order, use LinkedHashMap (insertion order) or TreeMap (natural or comparator order). Assuming HashMap has consistent iteration order is a common subtle bug in test code.

How many times does a HashMap entry get copied during resize operations when inserting 1 million entries?

Each resize event copies all existing entries. Starting from capacity 16, the resize sequence reaches capacity ~1.5 million (next power of 2 above 1 million / 0.75 = 1,333,333) in about 17 resize steps. Each entry is copied once per resize after it is inserted. The total number of extra copies across all resizes is approximately equal to the final entry count — roughly 1 million extra copy operations. Pre-sizing with new HashMap<>(1_333_334) eliminates all of this.

What is the difference between HashMap.get() returning null and a key being absent?

There is no difference at the API level if null values are used. map.get(key) returns null both when the key is absent and when the key maps to a null value. To distinguish these two cases, use map.containsKey(key). If containsKey() returns true but get() returns null, the key is present with a null value. getOrDefault(key, defaultValue) is the clean way to handle the absent-key case when null values are not used.

Why is the default load factor 0.75 and not 1.0 or 0.5?

A load factor of 1.0 fills every bucket before resizing, maximising space efficiency but increasing average chain length — lookups degrade as the table fills. A load factor of 0.5 resizes at half capacity, keeping chains very short and lookups fast, but doubles the memory footprint for empty bucket slots. The default 0.75 is the empirically validated balance: at 75% fill, average chain lengths stay close to one, lookup performance remains O(1) in practice, and memory utilisation is reasonable. This value comes from Poisson distribution analysis — at load factor 0.75, the probability of any bucket having 8 or more entries (the treeification threshold) is below 0.00000006.

Summary

HashMap<K, V> is a Node<K, V>[] bucket array where every put() and get() operation starts with the same two steps: compute a spread hash using key.hashCode() ^ (h >>> 16), then derive a bucket index using hash & (capacity - 1). Collisions are handled by linked chains that treeify to Red-Black trees when a bucket grows beyond 8 nodes and the table has at least 64 slots. Capacity is always a power of 2 to enable bitwise AND indexing. When size > capacity × 0.75, the table doubles and all entries are rehashed.

Two implementation details answer most HashMap interview questions at product-based companies: the spread hash function (why it XORs with upper 16 bits and why it matters for distribution) and the treeification mechanism (when it triggers, what it prevents, and what it costs). The most common production bug is overriding equals() without hashCode() — silent data loss, no exception, extremely difficult to debug under load.

What to Read Next

TopicLink
How HashSet uses HashMap internally with a dummy value for every elementJava HashSet →
How LinkedHashMap extends HashMap with a doubly-linked entry sequenceJava LinkedList →
How TreeMap provides sorted key ordering as a Red-Black tree alternative to HashMapJava TreeMap →
How the Collections Framework organises all Map implementations and interfacesJava Collections Framework →
How Generics make HashMap type-safe with bounded wildcards and type inferenceJava Generics →
Java HashMap Internal Working | DevStackFlow