ConcurrentHashMap in Java: Internals, Atomic Methods, Pitfalls

Learn how ConcurrentHashMap works since Java 8, with atomic merge and compute methods, caching, bulk operations, newKeySet, sizing and common mistakes.

ConcurrentHashMap table with an empty bin updated by CAS, a list bin locked on its first node and a tree bin

ConcurrentHashMap is the thread-safe hash map of the java.util.concurrent package, built so that many threads can read and update it at the same time without locking the whole map. Reads take no lock at all, and an update locks at most the one bucket it changes.

We use ConcurrentHashMap for shared caches, counters per key, session or connection registries and any lookup table that several threads change while the app runs. Its atomic methods, such as merge() and computeIfAbsent(), replace the code with separate get() and put() calls that breaks under concurrency.

The following example shows the operations this guide covers, with the result of each call as a comment.

ConcurrentHashMap<String, Integer> views = new ConcurrentHashMap<>();
views.put("home", 1);
Integer existing = views.putIfAbsent("home", 5);          // 1, no change
Integer homeViews = views.merge("home", 1, Integer::sum); // 2
Integer cartViews = views.computeIfAbsent("cart", k -> 0); // 0
Integer cartAfter = views.compute("cart", (k, v) -> v + 10); // 10
boolean replaced = views.replace("cart", 10, 11);         // true
Integer helpViews = views.getOrDefault("help", 0);        // 0
long entries = views.mappingCount();                      // 2
Integer total = views.reduceValues(1, Integer::sum);      // 13

Notice that every update method returns the value it left in the map, so we never need a separate get(). We start with how the map works since Java 8, cover the atomic methods, caching, null rules, iteration, bulk operations, concurrent sets and sizing, and finish with the mistakes that show up most often in code reviews.

1. How ConcurrentHashMap Works Since Java 8

The map keeps its entries in one array of buckets, also called bins. The array is created on the first insert, not in the constructor. Each bin holds either nothing, a short linked list of nodes or, for crowded bins, a balanced tree.

ConcurrentHashMap table with an empty bin updated by CAS, a list bin locked on its first node and a tree bin
Reads never lock; an update uses CAS on an empty bin or locks only the first node of its own bin
  • A get() takes no lock. The node fields it reads are volatile, so it sees every update that finished before the read started.
  • A put() into an empty bin uses a compare-and-swap (CAS) instruction, which sets the slot only if it is still empty, so no lock is needed.
  • A put() into a bin that already has nodes locks the first node of that bin with synchronized. Updates to other bins go ahead at the same time.
  • When a bin reaches 8 nodes and the table has at least 64 bins, the bin turns into a red-black tree, so lookups stay fast even with poor hash codes.
  • When the table grows, threads that call put() during the resize help move bins to the new array instead of waiting.

Java 7 and earlier split the map into 16 segments, each with its own lock. JEP 155 replaced that design in Java 8 with the per-bin scheme above, so articles that describe segments describe a map that no longer exists.

2. Atomic Methods for Compound Updates

Each single call on ConcurrentHashMap is thread safe, but two calls in a row are not one atomic step. Code that reads a value with get() and writes a new one with put() lets another thread write in between, and its update is lost.

Say a web app counts page views per URL path from all request threads. The following example runs a version with separate get() and put() calls and the merge() version from two threads each.

ConcurrentHashMap<String, Integer> pageViews = new ConcurrentHashMap<>();
ConcurrentHashMap<String, Integer> safeViews = new ConcurrentHashMap<>();
Runnable visit = () -> {
    for (int i = 0; i < 100_000; i++) {
        Integer current = pageViews.get("home");             // check
        pageViews.put("home", current == null ? 1 : current + 1);   // then act
        safeViews.merge("home", 1, Integer::sum);            // one atomic step
    }
};
Thread visitor1 = Thread.ofPlatform().start(visit);
Thread visitor2 = Thread.ofPlatform().start(visit);
visitor1.join();
visitor2.join();
int racyCount = pageViews.get("home");                       // less than 200000 in most runs
int mergedCount = safeViews.get("home");                     // 200000

Any read followed by a write on the same key belongs in one of the atomic methods, never in separate get() and put() calls. The whole method call, including the function we pass, runs atomically for that key.

MethodWhat it does atomically
putIfAbsent(k, v)Adds the entry only if the key is missing, returns the current value
computeIfAbsent(k, fn)Creates the value with fn only if the key is missing
computeIfPresent(k, fn)Replaces or removes (when fn returns null) an existing value
compute(k, fn)Computes a new value from the key and the old value, or null to remove
merge(k, v, fn)Puts v if absent, otherwise combines the old value and v with fn
replace(k, old, new)Replaces only if the current value equals old
remove(k, v)Removes only if the current value equals v

While the function runs, other threads that update the same bin wait. So the function must be short, must not call slow services, and must not change other keys of the same map, which can throw IllegalStateException with the message “Recursive update”. The ConcurrentMap guide compares these methods across both concurrent map classes.

3. Caching Expensive Values With computeIfAbsent()

The method computeIfAbsent() is the standard way to build a lazy cache. For a missing key it calls the loader once, stores the result and returns it. Other threads that ask for the same key at the same moment wait for that result instead of loading it again.

class PriceCache {
    private final ConcurrentHashMap<String, Double> prices = new ConcurrentHashMap<>();
    private final AtomicInteger loads = new AtomicInteger();

    double price(String product) {
        return prices.computeIfAbsent(product, this::loadPrice);
    }

    private double loadPrice(String product) {
        loads.incrementAndGet();                 // stands in for a database call
        return product.length() * 1.5;
    }

    int loads() {
        return loads.get();
    }
}
PriceCache cache = new PriceCache();
try (ExecutorService shoppers = Executors.newFixedThreadPool(4)) {
    for (int i = 0; i < 1_000; i++) {
        shoppers.submit(() -> cache.price("apple"));
    }
}
double applePrice = cache.price("apple");        // 7.5
int loaderCalls = cache.loads();                 // 1

A thousand lookups from four threads load the price once. The trade-off is that a slow loader blocks other writers in the same bin while it runs. When loading takes seconds, we cache a CompletableFuture as the value, so computeIfAbsent() only starts the load and returns at once.

4. Null Keys and Null Values Are Not Allowed

Unlike HashMap, ConcurrentHashMap rejects null as a key and as a value. In a concurrent map, a null from get() must mean that the key is absent, because no lock lets us check with containsKey() before another thread changes the map.

ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
Integer missing = stock.get("kiwi");                 // null, key is absent
Integer withDefault = stock.getOrDefault("kiwi", 0); // 0
Integer nullValue = stock.put("kiwi", null);         // NullPointerException
Integer nullKey = stock.put(null, 5);                // NullPointerException

When a value can be legitimately unknown, we store an Optional, a sentinel object or a special value, or we remove the key.

5. Iteration, size() and mappingCount()

The iterators of ConcurrentHashMap are weakly consistent. They never throw ConcurrentModificationException, they return each entry at most once, and they may or may not show changes made after the iteration started. So we can remove entries while we loop over the map.

ConcurrentHashMap<String, Integer> carts = new ConcurrentHashMap<>(Map.of("anna", 0, "ben", 2, "carl", 0));
for (Map.Entry<String, Integer> cart : carts.entrySet()) {
    if (cart.getValue() == 0) {
        carts.remove(cart.getKey());             // no ConcurrentModificationException
    }
}
int activeCarts = carts.size();                  // 1

The size() method returns an int, whereas mappingCount() returns a long and is the recommended method for large maps. Both return an estimate while other threads add or remove entries, so we never use the size to decide whether a key exists.

6. Bulk Operations With a Parallelism Threshold

Since Java 8, the map has its own forEach(), search() and reduce() methods that can split the work across the common ForkJoinPool. The first argument is a parallelism threshold, the number of entries above which the work runs in parallel.

  • A threshold of Long.MAX_VALUE runs the operation sequentially in the calling thread.
  • A threshold of 1 uses as much parallelism as the pool offers.
  • A value in between, such as 10,000, runs in parallel only for maps larger than that.
ConcurrentHashMap<String, Integer> inventory = new ConcurrentHashMap<>(Map.of("apple", 5, "banana", 3, "cherry", 12));
Integer totalUnits = inventory.reduceValues(1, Integer::sum);                 // 20
Integer largest = inventory.reduceValues(Long.MAX_VALUE, Integer::max);       // 12
String lowStock = inventory.search(1, (fruit, units) -> units < 4 ? fruit : null);   // "banana"
long fruitKinds = inventory.mappingCount();                                   // 3

The search() method stops as soon as the function returns a non-null result. The bulk methods do not freeze the map, so on a map that other threads change at the same time, the result reflects some mix of old and new entries.

7. A Concurrent Set With newKeySet()

The JDK has no ConcurrentHashSet class. The static method ConcurrentHashMap.newKeySet() returns a thread-safe Set backed by a ConcurrentHashMap, with the same lock-free reads.

Set<String> onlineUsers = ConcurrentHashMap.newKeySet();
boolean added = onlineUsers.add("lokesh");           // true
boolean addedAgain = onlineUsers.add("lokesh");      // false
boolean removed = onlineUsers.remove("lokesh");      // true

The instance method keySet(defaultValue) returns a key view of an existing map that also supports add(), which puts the key with the default value.

ConcurrentHashMap<String, Integer> scores = new ConcurrentHashMap<>();
Set<String> players = scores.keySet(0);
players.add("alex");
Integer alexScore = scores.get("alex");              // 0

8. Sizing the Map and the concurrencyLevel Argument

The constructor argument initialCapacity is the number of entries the map should hold without resizing, so new ConcurrentHashMap<>(1_000) fits 1,000 entries before it grows. Presizing helps when we know the size up front, because each resize copies all bins.

The three-argument constructor still accepts loadFactor and concurrencyLevel, but since Java 8 both are sizing hints only. In the JDK 25 source, the constructor raises initialCapacity to concurrencyLevel if it is smaller and uses the load factor once to compute the first table size. After that, the map always resizes at 75% load.

Constructor callEffect in Java 8 and later
new ConcurrentHashMap<>()Empty map, table of 16 bins created on first insert
new ConcurrentHashMap<>(1_000)Table sized for 1,000 entries without a resize
new ConcurrentHashMap<>(16, 0.9f, 1)Table sized for 16 entries at 0.9 density; no segments, no extra objects
new ConcurrentHashMap<>(16, 0.75f, 64)Same as an initial capacity of 64

Tutorials written for Java 7 advise setting concurrencyLevel to 1 to avoid creating 16 segments. That advice applies to Java 7 only, and current code needs neither the argument nor the workaround.

9. Common ConcurrentHashMap Mistakes

Most bugs with ConcurrentHashMap come from treating a thread-safe map as if it made all code around it thread safe. The map protects its own structure and each single call, nothing more.

  • Using separate containsKey(), get() and put() calls for one logical update, as in section 2.
  • Storing mutable values such as an ArrayList and changing them after get(). The map does not protect the value object.
  • Running slow I/O or updates to other keys inside compute() or computeIfAbsent().
  • Deciding logic on size() or isEmpty() while other threads change the map.
  • Expecting an order. For sorted keys we use ConcurrentSkipListMap.

The mutable-value mistake is common with maps of lists. The fix is a thread-safe value type, such as a concurrent set from newKeySet(), created inside computeIfAbsent().

ConcurrentHashMap<String, Set<String>> tagsByPost = new ConcurrentHashMap<>();
tagsByPost.computeIfAbsent("post-1", k -> ConcurrentHashMap.newKeySet()).add("java");
tagsByPost.computeIfAbsent("post-1", k -> ConcurrentHashMap.newKeySet()).add("threads");
int tagCount = tagsByPost.get("post-1").size();      // 2

When the code is single-threaded, a plain HashMap is simpler and faster. The synchronized HashMap comparison explains when Collections.synchronizedMap() is enough, and the Java concurrency guide lists the other concurrent collections.

10. ConcurrentHashMap FAQs

Most of these questions start from tutorials written for Java 7, when the map still had segments.

10.1. Are there still segments inside a ConcurrentHashMap?

No. Since Java 8, the map uses one table with CAS for empty bins and a lock on the first node of each non-empty bin. A Segment class still exists in the source, but only to keep old serialized maps readable.

10.2. Is ConcurrentHashMap completely thread safe?

Each method call is thread safe and the map never gets corrupted. A sequence of calls is not atomic, so compound updates must use compute(), merge() or the other atomic methods.

10.3. Does ConcurrentHashMap lock on reads?

No. The get() method and iteration take no locks, so reads never wait for writers. A read returns the result of the most recently completed update for that key.

10.4. Can we use ConcurrentHashMap with virtual threads?

Yes. The map blocks only for the short time an update holds a bin lock, and since Java 24 a virtual thread that blocks on synchronized no longer pins its carrier thread (JEP 491).

11. Conclusion

ConcurrentHashMap gives lock-free reads and per-bin locking for writes, which is why it scales far better than a map behind one lock. Since Java 8, its design has no segments, and concurrencyLevel is only a sizing hint.

We get correct results by putting every read and write of a key into merge(), compute() or computeIfAbsent(), keeping those functions short, and storing immutable or thread-safe values. The bulk methods, newKeySet() and mappingCount() cover aggregation, sets and size for large maps.

12. References

Happy Learning !!

Source Code on Github

Leave a Comment

  1. My question is suppose a iteration is going on the list as its read there is no lock. In between a write is done. Now control comes back to iteration so i think now concurrentModification will be thrown. Could you please explain how above scenario is handled. Is there any way to replicate such scenario?

    • Modification is being done on the copy of original map so the user will be able to see the original map that was present before current iteration. As soon as iteration completes the new map will be available for read purpose.

  2. Hi lokesh,
    Nice articles. Thanks for sharing n making our work easier. Could you please explain what is meaning of volatile read and what is its internal working?

  3. Irrelevant?
    When looking at the constructors of ConcurrentHashMap, the concurrencyLevel parameter does not do much, only to adjust the initialcapacity parameter if it is smaller than concurlevel.

    • Knowing these things become relevant when you go for performance improvement of application/ or avoid future performance issues, specially in high volume application.

  4. Hi,

    Thanks for this amazing blog, concepts are very well explained. When you get a chance could you please answer my question –

    1. Say for example I have multiple threads (count = 8) that would add or update my Hashmap. What would be concurrencyLevel you would recommend?
    2.

    ConcurrentHashMap<String, Integer> instance = new ConcurrentHashMap<String, Integer>(16, 0.9f, 1); 
    		//OR
    Collections.synchronizedMap( HashMap )

    Won’t they have almost same performance since ConcurrentHashMap has only 1 Segment and all Threads will have to wait while updating HashEntry?

    • Load factor is the ratio between the number of “buckets” in the map and the number of expected elements. A value of 0.75 will suggest that if the buckets are more than 75% full, the Map should be resized. Similarly, 0.90 will suggest that if the buckets are more than 90% full, the Map should be resized.

      • I think you are wrong regarding your load factor explanation.
        If hashCode always returns 1, which means all entries will go to 1 bucket only. Do you mean the map will never resize?

          • So the correct definition of load factor should be
            the ratio between
            1) the number of “Entries”
            2) initialCapacity

            not as you said
            1) the number of “buckets” in the map
            2) the number of expected elements.

            Please be really cautious with basic knowledge of HashMap.

  5. Nice article. But I would suggest one small correction

    “First two are fairly simple as their name implies but last one is tricky part. This denotes the number of shards. ”

    The parameter concurrencyLevel isn’t always equal to the number of segments created. It will only be true if it is a power of 2.

  6. What is the bucket in HashMap ?

    is it the Entry Array ? I am looking for collision condition solution when we have same hash code for 2 objcets.

    In case of collision , objects are stored in same bucket and retrieved using equals function to get exact object.

  7. Hi,

    I have doubt over the concurrencyLevel parameter.

    Suppose I have initialized my ConcurrentHashMap with concurrencyLevel of 2.

    I have 2 threads trying to update the first shard elements @ same time.

    How this situation will be handled?

    Do we need to apply extra care and synchronization for such cases ? Or it is handled internally by ConcurrentHashMap itself ?

  8. Hi,
    No doubt it’s a wonderfull post. But I have two questions.

    1) Is Shards and Segments are same?

    2) What is the use of Segment

    • 1) Yes, both are same.
      2) Segments are to create different “groups of key-value pairs” so that different groups can be used used concurrently by different threads.

      • Is that means that each segments contains same key-value pair? Secondly if I create 20 shards then it means that 20 threads can do concurrent transaction on my map. Correct me if I am wrong.

        Is that possible for you to explain that if I enter a key value pair in the concurrent hash map how it’s getting updated in multiple segments.

        • NO. A key-value pair inserted in ConcurrentHashMap will be stored in one-and-only-one segment. Let say you store 200 key-value pairs in ConcurrentHashMap having 20 segments, and if they are distributed equally the each segment will have 10 key-value pairs. Now these 20 shards are available to be concurrently accessed by 20 different threads.

          If you try to lookup for value for any key, then first suitable segment is located and then suitable entry is located inside segment.

  9. Nice article..Thanks for that..Suppose I am having a concurrent hashmap with Concurrency level as 2 and i need to read/write values in it by using multiple threads .In the scenario, if one thread has locked up one segment and checking the value and if another thread comes in will it bypass that segment or wait ?
    In case of bypass, how come it will check the locked up segment..(because my scenario is to check whole map for a particular count).

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.