Back to Concurrent Collections
Q1Question 1 of 3

What concurrency issues does HashMap cause?

Key Concepts
  • A plain HashMap used by multiple threads causes infinite loops in Java 7 due to circular linked lists created during concurrent resize.
  • In Java 8 and later, the infinite loop is fixed but concurrent writes still cause silent data corruption and lost updates.
  • There is no happens-before relationship for HashMap operations, so threads may read stale values from CPU caches.
  • ConcurrentHashMap is the correct replacement: it uses per-bucket locking for writes and volatile reads for gets.
  • Collections.synchronizedMap wraps a HashMap with a single coarse lock, which is correct but slow under contention.
  • Atomic compound operations like computeIfAbsent on ConcurrentHashMap prevent cache stampede without external synchronization.
  • Never use a plain HashMap in any multi-threaded code path, even for read-mostly workloads, because safe publication is not guaranteed.

Definition

A plain HashMap used as a shared cache in multi-threaded code causes infinite loops (Java 7), silent data corruption (Java 8+), and stale reads because HashMap has no synchronization or visibility guarantees.
  • HashMap is not thread-safe – it has no internal synchronization, no volatile fields, and no happens-before guarantees for concurrent access.
  • Infinite loop (Java 7) – concurrent resize reverses linked lists with head insertion, and two threads resizing simultaneously can create a circular linked list that makes get() loop forever at 100% CPU.
  • Lost updates (Java 8+) – two threads calling put() on the same bucket can overwrite each other's entries because the internal node array is not protected by any lock.
  • Stale reads – without volatile or synchronized, a thread may read an old cached value from its CPU register and never see the update written by another thread.
  • ConcurrentModificationException – iterating a HashMap while another thread modifies it throws a fail-fast exception.
  • ConcurrentHashMap is the correct fix: per-bucket CAS for empty buckets, synchronized(firstNode) for non-empty buckets, and volatile reads for get() with zero lock overhead.

Think of a shared whiteboard at National Bank of India where tellers write account balances. A plain HashMap is a whiteboard with no rules. If Rajesh and Priya write to the same line at the same time, one entry overwrites the other and a customer's balance disappears. Worse, in Java 7, two tellers reorganizing the whiteboard simultaneously could create a loop in the layout, and anyone reading that section walks in circles forever. ConcurrentHashMap is the same whiteboard but with small section locks: two tellers writing to different sections proceed in parallel, and if they need the same section, one waits briefly while the other finishes.

Understanding the Concepts

What concurrency issues does HashMap cause?

A plain HashMap is designed for single-threaded use. It has no internal locks, no volatile fields, and no memory visibility guarantees. When multiple threads read and write a HashMap concurrently, three categories of failure can occur: structural corruption, data loss, and stale reads.

The most dramatic failure happened in Java 7, where the resize() method reversed the linked list in each bucket using head insertion. When two threads triggered a resize at the same time, they could create a circular reference in a bucket's linked list. Any subsequent call to get() that landed in that bucket would follow the next pointers in an infinite loop, consuming 100% CPU on that thread without ever throwing an exception.

Java 8 replaced head insertion with tail insertion, which eliminated the circular-list problem. However, concurrent writes still corrupt the internal data structure: two threads can both see a bucket as empty and both insert, causing one entry to be silently lost.

JAVA
package com.bank.cache;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;

public class UnsafeCacheDemo {
    // WRONG: plain HashMap shared across threads
    private static final Map<String, BigDecimal> balanceCache = new HashMap<>();

    public static void main(String[] args) throws InterruptedException {
        Runnable writer = () -> {
            for (int i = 0; i < 100_000; i++) {
                balanceCache.put("SAV2001", new BigDecimal(i));
            }
        };
        Thread t1 = new Thread(writer, "Writer-1");
        Thread t2 = new Thread(writer, "Writer-2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // Result is unpredictable: lost updates, possible infinite loop on Java 7
        System.out.println("Final size: " + balanceCache.size());
    }
}

Why does HashMap cause an infinite loop in Java 7?

In Java 7, the HashMap.transfer() method rehashes entries from the old table to the new table using head insertion. Head insertion reverses the order of entries in each bucket. When two threads resize the same HashMap simultaneously, the following sequence can occur.

Thread-1 reads that node A points to node B. Thread-2 completes the transfer first and reverses the list so that B now points to A. Thread-1 resumes and sets A's next pointer to B, but B already points back to A. The result is a circular linked list: A points to B, B points to A. Any get() call that hashes to this bucket follows the next pointers forever.

The thread appears as RUNNABLE in a thread dump, not BLOCKED or WAITING, because it is actively executing CPU instructions. No exception is thrown. The application appears alive but the thread never completes.

  • Thread state is RUNNABLE, not BLOCKED, making it hard to detect.
  • CPU usage spikes to 100% on that core.
  • No exception is thrown and no error is logged.
  • Java 8 fixed this by using tail insertion, which preserves list order during resize.

How do you fix HashMap concurrency issues?

The primary fix is to replace HashMap with ConcurrentHashMap. It provides thread safety with fine-grained locking and lock-free reads.

JAVA
package com.bank.cache;

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

public class SafeCacheDemo {
    // CORRECT: ConcurrentHashMap with per-bucket synchronization
    private static final ConcurrentHashMap<String, BigDecimal> balanceCache =
            new ConcurrentHashMap<>();

    public static void main(String[] args) throws InterruptedException {
        Runnable writer = () -> {
            for (int i = 0; i < 100_000; i++) {
                balanceCache.put("SAV2001", new BigDecimal(i));
            }
        };
        Thread t1 = new Thread(writer, "Writer-1");
        Thread t2 = new Thread(writer, "Writer-2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Final value: " + balanceCache.get("SAV2001"));
        // Always consistent, no corruption, no infinite loop
    }
}

How is ConcurrentHashMap different from Collections.synchronizedMap?

Collections.synchronizedMap() wraps every operation with a single global lock. Only one thread can read or write at a time. ConcurrentHashMap uses fine-grained per-bucket locking for writes and volatile reads for gets, allowing much higher concurrency.

| Feature | ConcurrentHashMap | synchronizedMap |
|---|---|---|
| Lock granularity | Per-bucket (fine) | Entire map (coarse) |
| Read locking | Lock-free volatile read | Full lock on every read |
| Write locking | CAS or synchronized(firstNode) | Full lock on every write |
| Concurrent readers | Unlimited | One at a time |
| Atomic compound ops | computeIfAbsent, merge | None built in |
| Iterator behavior | Weakly consistent | Fail-fast (needs external sync) |
| Null keys/values | Not allowed | Allowed |

JAVA
package com.bank.cache;

import java.math.BigDecimal;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class MapComparisonDemo {
    public static void main(String[] args) {
        // Option 1: synchronizedMap (simple but slow)
        Map<String, BigDecimal> syncMap =
                Collections.synchronizedMap(new HashMap<>());
        syncMap.put("SAV2001", new BigDecimal("50000.00"));
        // Every get() and put() acquires the SAME lock

        // Option 2: ConcurrentHashMap (fast, production-grade)
        ConcurrentHashMap<String, BigDecimal> concMap = new ConcurrentHashMap<>();
        concMap.put("SAV2001", new BigDecimal("50000.00"));
        // get() uses volatile read with zero lock overhead
        // put() locks only the affected bucket
    }
}

What are the atomic compound operations on ConcurrentHashMap?

ConcurrentHashMap provides atomic compound operations that eliminate the need for external synchronization. The most important is computeIfAbsent, which loads a value exactly once even when many threads request the same missing key simultaneously.

JAVA
package com.bank.cache;

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

public class AtomicOpsDemo {
    private static final ConcurrentHashMap<String, BigDecimal> cache =
            new ConcurrentHashMap<>();

    static BigDecimal loadBalanceFromDB(String accountId) {
        System.out.println(Thread.currentThread().getName()
                + " loading from DB: " + accountId);
        return new BigDecimal("75000.00");
    }

    public static void main(String[] args) {
        // computeIfAbsent: atomic check-and-load
        BigDecimal balance = cache.computeIfAbsent("SAV2001",
                AtomicOpsDemo::loadBalanceFromDB);
        // Only ONE thread loads from DB, even with 100 concurrent requests

        // merge: atomic read-modify-write
        cache.merge("SAV2001", new BigDecimal("1000.00"), BigDecimal::add);
        // Atomically adds 1000 to the existing balance

        // compute: atomic update with access to old value
        cache.compute("SAV2001", (key, oldVal) ->
                oldVal == null ? BigDecimal.ZERO : oldVal.subtract(new BigDecimal("500.00")));

        System.out.println("Final balance: " + cache.get("SAV2001"));
    }
}
TEXT
main loading from DB: SAV2001
Final balance: 75500.00

What are the common mistakes when using HashMap in concurrent code?

The most common mistake is assuming that read-only access is safe. Even if only one thread writes, readers without a happens-before relationship may see stale or partially constructed values.

JAVA
package com.bank.cache;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;

public class CommonMistakesDemo {
    // WRONG: "only one writer, so it's safe" is FALSE
    private static final Map<String, BigDecimal> cache = new HashMap<>();

    public static void main(String[] args) {
        // Writer thread
        new Thread(() -> {
            cache.put("SAV2001", new BigDecimal("100000.00"));
        }, "Writer").start();

        // Reader thread: may NEVER see the write due to no happens-before
        new Thread(() -> {
            while (cache.get("SAV2001") == null) {
                // JIT may hoist this check out of the loop
                // Thread spins forever seeing null
            }
            System.out.println("Balance: " + cache.get("SAV2001"));
        }, "Reader").start();
    }
}
JAVA
package com.bank.cache;

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

public class CorrectPatternDemo {
    // CORRECT: ConcurrentHashMap provides visibility guarantees
    private static final ConcurrentHashMap<String, BigDecimal> cache =
            new ConcurrentHashMap<>();

    public static void main(String[] args) {
        new Thread(() -> {
            cache.put("SAV2001", new BigDecimal("100000.00"));
        }, "Writer").start();

        new Thread(() -> {
            BigDecimal balance;
            while ((balance = cache.get("SAV2001")) == null) {
                Thread.onSpinWait();
            }
            System.out.println("Balance: " + balance);
        }, "Reader").start();
    }
}

What about production caching with TTL and eviction?

For production caches that need time-to-live expiration, size-based eviction, and cache statistics, use a dedicated caching library built on top of ConcurrentHashMap.

JAVA
// Caffeine cache (production-grade, used by Spring Boot)
// Cache<String, BigDecimal> balanceCache = Caffeine.newBuilder()
//         .maximumSize(10_000)
//         .expireAfterWrite(Duration.ofMinutes(5))
//         .recordStats()
//         .build();
// balanceCache.get("SAV2001", key -> loadFromDB(key));
// Thread-safe + eviction + TTL + atomic loading + metrics

What are common interview follow-up questions?

  • Why does ConcurrentHashMap not allow null keys or values? Because get() returning null would be ambiguous: it could mean the key is absent or the value is null, and there is no way to distinguish without locking.
  • Can you iterate a ConcurrentHashMap safely? Yes, iterators are weakly consistent: they reflect the state at some point during or after construction but will not throw ConcurrentModificationException.
  • What is the default concurrency level? In Java 8 and later, the internal design uses per-bucket locking so the old concurrency-level hint is ignored. The constructor parameter exists only for backward compatibility.

Visual Overview

DIAGRAM
  HashMap under concurrent access
  ════════════════════════════════

  Thread-1: put("SAV2001", 50K)     Thread-2: put("SAV2001", 75K)
       │                                  │
       ▼                                  ▼
  ┌──────────────────────────────────────────┐
  │  HashMap (NO synchronization)            │
  │  ┌────────┬────────┬────────┬─────────┐ │
  │  │ Bucket0│ Bucket1│ Bucket2│ Bucket3 │ │
  │  └────────┴────────┴────────┴─────────┘ │
  │  Both threads write to same bucket       │
  │  → Lost update! One value disappears     │
  └──────────────────────────────────────────┘

  ConcurrentHashMap (thread-safe)
  ════════════════════════════════

  Thread-1: put("SAV2001", 50K)     Thread-2: put("CUR3001", 75K)
       │                                  │
       ▼                                  ▼
  ┌──────────────────────────────────────────┐
  │  ConcurrentHashMap                       │
  │  ┌────────┬────────┬────────┬─────────┐ │
  │  │Bucket0 │Bucket1 │Bucket2 │Bucket3  │ │
  │  │ LOCK   │        │ LOCK   │         │ │
  │  └────────┴────────┴────────┴─────────┘ │
  │  Different buckets → parallel writes     │
  │  Same bucket → one waits, then proceeds  │
  └──────────────────────────────────────────┘

Key Takeaway

A plain HashMap is fundamentally unsafe for multi-threaded access. In Java 7, concurrent resize could create circular linked lists that caused get() to loop forever at 100% CPU with no exception. Java 8 fixed the infinite loop but concurrent writes still silently corrupt the internal structure, losing entries and returning wrong values. Even with a single writer and multiple readers, the lack of happens-before guarantees means readers may see stale values from their CPU cache indefinitely.

The correct replacement is ConcurrentHashMap. It uses per-bucket synchronization for writes (CAS for empty buckets, synchronized on the first node for non-empty buckets) and volatile reads for gets with zero lock overhead. Its atomic compound operations like computeIfAbsent prevent cache stampede: even if 100 threads request the same missing key simultaneously, exactly one thread loads the value and the others wait and reuse the result.

For production caches, consider Caffeine or Guava Cache on top of ConcurrentHashMap for TTL expiration, size-based eviction, and metrics. Collections.synchronizedMap works but uses a single coarse lock for everything, which becomes a bottleneck under load. At National Bank of India, replacing a shared HashMap balance cache with ConcurrentHashMap eliminated both lost updates and an intermittent infinite-loop hang that had been crashing the overnight batch process.

Remember one sentence: never use a plain HashMap in multi-threaded code because it has zero synchronization, and ConcurrentHashMap gives you thread safety with fine-grained locking and lock-free reads.

---

Share
Use ← → arrow keys to navigate