Back to Concurrent Collections
Q2Question 2 of 3

HashMap concurrent resize problems?

Key Concepts
  • Java 7 HashMap.resize() uses head insertion, which reverses the linked list in each bucket and can create a circular reference when two threads resize simultaneously.
  • A circular linked list causes get() to loop forever in RUNNABLE state with 100% CPU and no exception thrown.
  • Java 8 switched to tail insertion, eliminating the infinite loop, but concurrent writes still corrupt the node array causing lost entries and wrong values.
  • The JVM is alive but the stuck thread is unresponsive, making this failure extremely hard to detect without a thread dump.
  • jstack reveals the stuck thread as RUNNABLE inside HashMap.get() or HashMap$TreeNode.find(), which is the diagnostic signature.
  • ConcurrentHashMap prevents both problems with per-bucket synchronization during resize and volatile reads for get().
  • Thread dumps are the primary tool for diagnosing HashMap concurrency bugs because the thread state is RUNNABLE, not BLOCKED.

Definition

Concurrent writes to a plain HashMap during resize create a circular linked list in Java 7 (infinite loop, 100% CPU, no exception) or corrupt the node array in Java 8+ (lost entries, wrong results, silent data corruption).
  • Java 7 infinite loop – the HashMap.transfer() method rehashes entries using head insertion, which reverses the linked list in each bucket. Two threads resizing at the same time can create a circular reference where node A points to node B and node B points back to node A. Any get() traversing this bucket follows next pointers forever.
  • Java 8 tail insertion – resize preserves list order so circular lists cannot form. However, concurrent put() calls can still overwrite each other's nodes, lose entries, or corrupt the red-black tree structure used for long chains.
  • No exception thrown – HashMap does not check for concurrent modification during get() or put(). The modCount field is only checked by iterators for fail-fast behavior. A thread stuck in an infinite loop is RUNNABLE, not BLOCKED, and produces no log output.
  • Detection – a thread dump via jstack shows the stuck thread as RUNNABLE at HashMap.get() or HashMap$TreeNode.find(). This is the diagnostic signature.

Imagine two clerks at National Bank of India reorganizing a filing cabinet at the same time. In Java 7, they reverse the order of files in each drawer. Clerk-1 moves file A to point to file B. Clerk-2 reverses B to point back to A. Now anyone looking for a file in that drawer walks from A to B to A to B forever, unable to stop. In Java 8, the files stay in order so the loop cannot happen, but files can still end up in the wrong drawer or fall behind the cabinet entirely. Priya asks for her account statement and the system says it does not exist, even though it was filed correctly yesterday.

Understanding the Concepts

What happens during HashMap resize in Java 7?

When a HashMap exceeds its load factor threshold, it doubles the internal array and rehashes all entries into the new table. In Java 7, the transfer() method iterates each bucket's linked list and reinserts entries into the new table using head insertion. Head insertion reverses the list order.

With a single thread, this is safe. The list A -> B -> null becomes B -> A -> null in the new table. But with two threads resizing simultaneously, the following interleaving creates a circular reference.

DIAGRAM
- Thread-1 reads: A.next is B.
- Thread-2 completes the full transfer: new bucket contains `B -> A -> null`.
- Thread-1 resumes and inserts A into the new bucket, setting A.next to B.
- Thread-1 then processes B, setting B.next to A (because Thread-2 already moved A before B).
- Result: `A -> B -> A -> B -> ...` in an infinite loop.
JAVA
package com.bank.demo;

import java.util.HashMap;
import java.util.Map;

public class InfiniteLoopDemo {
    public static void main(String[] args) {
        // This can hang on Java 7 due to concurrent resize
        Map<String, String> map = new HashMap<>(2, 1.0f);
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                map.put(Thread.currentThread().getName() + "-" + i, "val");
            }
        };
        Thread t1 = new Thread(task, "Rajesh");
        Thread t2 = new Thread(task, "Priya");
        t1.start();
        t2.start();
        // On Java 7: one thread may hang forever in map.get() or map.put()
        // On Java 8+: entries may be silently lost
    }
}

Why does Java 8 still have problems?

Java 8 replaced head insertion with tail insertion during resize, which preserves the original list order and prevents circular references. However, the internal node array is still not protected by any lock. Two threads calling put() concurrently can both see a bucket as empty and both insert their node at index 0, causing one node to overwrite the other.

Additionally, Java 8 converts long chains (8+ nodes) into red-black trees for performance. Concurrent modification of a tree structure without synchronization can corrupt the tree's parent, left, and right pointers, leading to NullPointerException or incorrect traversal results.

  • Two threads both see an empty bucket and both insert: one entry is lost.
  • Tree bin corruption: concurrent treeify operations break the red-black tree.
  • Size counter: non-atomic increment of the size field produces wrong size() results.
  • Corrupted node references may cause NullPointerException during get().

How do you detect a HashMap infinite loop with jstack?

The stuck thread appears as RUNNABLE in the thread dump, which is counterintuitive because most stuck-thread scenarios show BLOCKED or WAITING. The thread is actively executing CPU instructions but making no progress.

TEXT
"http-nio-8080-exec-5" #42 daemon prio=5 RUNNABLE
    at java.util.HashMap$TreeNode.find(HashMap.java:1880)
    at java.util.HashMap$TreeNode.find(HashMap.java:1880)
    at java.util.HashMap.get(HashMap.java:556)
    at com.bank.service.BalanceCacheService.getBalance(BalanceCacheService.java:45)

The key diagnostic clue is seeing HashMap.get() or HashMap$TreeNode.find() in a RUNNABLE thread that is consuming 100% of a CPU core. This never happens under normal operation because HashMap lookups are O(1) or O(log n) and complete in microseconds.

How does ConcurrentHashMap prevent these problems?

ConcurrentHashMap uses per-bucket synchronization during resize. The transfer() method coordinates multiple threads using a shared transferIndex counter, and each bucket is locked individually during migration. For normal put() operations, an empty bucket uses CAS (compare-and-set) to insert the first node, and a non-empty bucket synchronizes on the first node in the chain.

JAVA
package com.bank.demo;

import java.math.BigDecimal;
import java.util.concurrent.ConcurrentHashMap;

public class SafeResizeDemo {
    public static void main(String[] args) throws InterruptedException {
        ConcurrentHashMap<String, BigDecimal> cache = new ConcurrentHashMap<>(2);
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                cache.put("ACCT-" + i, new BigDecimal(i));
            }
        };
        Thread t1 = new Thread(task, "Writer-1");
        Thread t2 = new Thread(task, "Writer-2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Size: " + cache.size());
        // Always correct: no infinite loop, no lost entries
    }
}

How is this different from a deadlock?

A deadlock involves threads in BLOCKED state waiting for each other's locks. The JVM can detect deadlocks automatically and jstack prints "Found Java-level deadlock". A HashMap infinite loop involves a thread in RUNNABLE state that is actively consuming CPU but making no progress. The JVM has no built-in detection for this because the thread appears healthy.

| Aspect | Deadlock | HashMap infinite loop |
|---|---|---|
| Thread state | BLOCKED | RUNNABLE |
| CPU usage | Near zero | 100% on stuck core |
| JVM detection | Automatic (jstack) | None |
| Exception thrown | None | None |
| Progress | Zero | Zero (looping) |
| Fix | Lock ordering, tryLock | Replace with ConcurrentHashMap |

What are the edge cases?

A HashMap shared across threads can fail even if writes are rare. A single resize triggered by a background thread while a request thread is reading can corrupt the structure. The failure is non-deterministic and depends on thread scheduling, making it extremely difficult to reproduce in testing.

JAVA
package com.bank.demo;

import java.util.HashMap;
import java.util.Map;

public class EdgeCaseDemo {
    private static final Map<String, String> config = new HashMap<>();

    public static void main(String[] args) {
        // Background thread updates config once per minute
        new Thread(() -> {
            while (true) {
                config.put("interestRate", "7.5");
                config.put("loanLimit", "5000000");
                try { Thread.sleep(60_000); } catch (InterruptedException e) { return; }
            }
        }, "ConfigReloader").start();

        // Request threads read config constantly
        // Even rare writes can trigger resize and corrupt the structure
        // This is NOT safe even though writes are infrequent
        String rate = config.get("interestRate");
        // May return null, stale value, or cause infinite loop on Java 7
    }
}

What should a senior engineer know?

A senior engineer should be able to diagnose this from a production thread dump without prior context. The signature is a RUNNABLE thread stuck in HashMap.get() with high CPU on that core. The fix is always to replace the HashMap with ConcurrentHashMap and verify that all access paths use the new map. A temporary mitigation is to restart the JVM, but the problem will recur under the same load pattern.

For monitoring, track thread CPU time per thread using JMX ThreadMXBean.getThreadCpuTime(). A thread that consumes steadily increasing CPU time without producing output is likely stuck in an infinite loop.

What are common interview follow-up questions?

  • Can you use Hashtable instead? Technically yes, but Hashtable uses a single global lock like synchronizedMap and is a legacy class. ConcurrentHashMap is always preferred.
  • Does making the HashMap field volatile help? No. Volatile ensures visibility of the reference itself but does not synchronize access to the HashMap's internal structure.
  • Is a read-only HashMap safe to share? Yes, if the HashMap is fully populated before any thread can access it and published through a happens-before edge (e.g., a volatile field, final field in a constructor, or passing through a thread-safe collection).

Visual Overview

DIAGRAM
  Java 7 HashMap Resize: Head Insertion Creates Circular List
  ═══════════════════════════════════════════════════════════

  Before resize (single bucket):
    A ──→ B ──→ null

  Thread-1 reads: A.next = B, pauses.
  Thread-2 completes transfer (reversed):
    New bucket: B ──→ A ──→ null

  Thread-1 resumes:
    Inserts A: A ──→ B
    Inserts B: B ──→ A  (A is already in new bucket!)

  Result: CIRCULAR!
    ┌──→ A ──→ B ──┐
    │               │
    └───────────────┘

  get(key) in this bucket:
    A → B → A → B → A → B → ... FOREVER
    Thread: RUNNABLE, CPU: 100%, Exception: NONE

Key Takeaway

Concurrent writes to a plain HashMap during resize can create a circular linked list in Java 7 that causes get() to loop forever. The thread stays in RUNNABLE state, consuming 100% of a CPU core, with no exception thrown and no error logged. The application appears alive but the stuck thread never completes its request. Java 8 fixed the infinite loop by switching to tail insertion, but concurrent writes still silently corrupt the internal node array, causing lost entries and wrong results.

The diagnostic approach is straightforward: take a thread dump with jstack and look for RUNNABLE threads stuck inside HashMap.get() or HashMap$TreeNode.find(). This pattern never occurs under normal operation because HashMap lookups complete in microseconds. Once identified, the fix is to replace every shared HashMap with ConcurrentHashMap, which prevents both structural corruption during resize (coordinated multi-thread transfer with per-bucket locking) and data races during normal operations (CAS for empty buckets, synchronized on first node for non-empty).

At National Bank of India, four API threads stuck in HashMap.get() at 3 AM left the service alive but unresponsive, returning 504 timeout errors for all users until the JVM was restarted. Replacing the balance cache HashMap with ConcurrentHashMap eliminated the recurrence permanently.

Remember one sentence: a RUNNABLE thread consuming 100% CPU inside HashMap.get() in a thread dump is the signature of a concurrent HashMap corruption bug, and the fix is always ConcurrentHashMap.

---

Share
Use ← → arrow keys to navigate