Skip to main content
fast by design

ConcurrentHashMap and Specialized Maps

12 min read Chapter 30 of 90

ConcurrentHashMap and Specialized Maps

HashMap is not thread-safe. Two threads calling put concurrently can corrupt the internal structure: lost entries, infinite loops during resize (in older JDK versions), and ConcurrentModificationException during iteration. The standard response is “use ConcurrentHashMap,” but ConcurrentHashMap is not a drop-in replacement. It has different performance characteristics, different atomicity guarantees, and different memory overhead.

This section benchmarks ConcurrentHashMap against alternatives, then covers three specialized Map implementations that outperform HashMap for specific key types.

ConcurrentHashMap Architecture

ConcurrentHashMap in JDK 8+ abandoned the segment-based design of earlier versions. The current implementation uses a combination of:

  1. CAS (Compare-And-Swap) on the bucket array: When inserting into an empty bucket, ConcurrentHashMap uses a CAS operation to atomically place the new node. No lock required.

  2. Per-bucket synchronization: When inserting into a non-empty bucket (collision), ConcurrentHashMap synchronizes on the first node of that bucket’s chain. This means concurrent writes to different buckets proceed in parallel; only writes to the same bucket serialize.

  3. Volatile reads: The bucket array and node values use volatile semantics, ensuring that reads see the latest written value without acquiring a lock.

// Simplified ConcurrentHashMap.put logic (conceptual)
final V putVal(K key, V value, boolean onlyIfAbsent) {
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // Empty bucket: CAS the new node in
            if (casTabAt(tab, i, null, new Node<>(hash, key, value)))
                break; // success, no lock needed
        } else {
            // Non-empty bucket: synchronize on the head node
            synchronized (f) {
                // Walk chain, insert or update
            }
        }
    }
}

This design means:

  • Read operations (get, containsKey) never block. They use volatile reads.
  • Write operations to different buckets never contend.
  • Write operations to the same bucket serialize, but only for that bucket.
  • The effective concurrency level is the number of buckets, not a fixed segment count.

computeIfAbsent: Atomic Cache Population

The single most important ConcurrentHashMap method for cache patterns is computeIfAbsent. It atomically checks if a key exists and computes the value if absent, without a race window:

// SLOW: check-then-act race condition
public class ArticleCache {

    private final ConcurrentHashMap<String, ArticleMetadata> cache =
        new ConcurrentHashMap<>();

    public ArticleMetadata get(String articleId) {
        ArticleMetadata meta = cache.get(articleId); // SLOW: race window
        if (meta == null) {
            meta = loadFromDatabase(articleId); // Two threads may both load
            cache.put(articleId, meta); // Second put overwrites first
        }
        return meta;
    }
}
// FAST: atomic compute-if-absent, no race
public class ArticleCache {

    private final ConcurrentHashMap<String, ArticleMetadata> cache =
        new ConcurrentHashMap<>();

    public ArticleMetadata get(String articleId) {
        return cache.computeIfAbsent(articleId, this::loadFromDatabase); // FAST
    }

    private ArticleMetadata loadFromDatabase(String id) {
        // Only called once per key, even under concurrent access
        return new ArticleMetadata(id, "title", "category");
    }
}

computeIfAbsent holds the bucket lock while computing, which means:

  • Only one thread computes the value for a given key.
  • Other threads waiting for the same key block until the computation completes, then get the cached result.
  • Threads accessing different keys are not affected.

Warning: Do not perform long-running operations inside the computation function. The bucket lock is held for the entire duration. If the computation takes 100ms (a database call), all other threads writing to the same bucket wait 100ms. For expensive computations, consider computing outside the map and using putIfAbsent:

// FAST: compute outside, insert atomically
public ArticleMetadata get(String articleId) {
    ArticleMetadata cached = cache.get(articleId);
    if (cached != null) return cached;

    ArticleMetadata computed = loadFromDatabase(articleId);
    ArticleMetadata existing = cache.putIfAbsent(articleId, computed);
    return existing != null ? existing : computed;
    // Two threads may both compute, but only one's value is stored.
    // The wasted computation is the trade-off for not holding a lock during I/O.
}

Benchmark: ConcurrentHashMap vs Synchronized HashMap

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 5)
@Fork(2)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class ConcurrentMapBenchmark {

    private static final int MAP_SIZE = 10_000;

    private ConcurrentHashMap<String, String> concurrentMap;
    private Map<String, String> synchronizedMap;
    private String[] keys;

    @Setup
    public void setup() {
        keys = new String[MAP_SIZE];
        concurrentMap = new ConcurrentHashMap<>(MAP_SIZE);
        Map<String, String> backing = HashMap.newHashMap(MAP_SIZE);

        for (int i = 0; i < MAP_SIZE; i++) {
            keys[i] = "article-" + i;
            concurrentMap.put(keys[i], "meta-" + i);
            backing.put(keys[i], "meta-" + i);
        }
        synchronizedMap = Collections.synchronizedMap(backing);
    }

    @Benchmark
    @Threads(1)
    public String concurrentGet_1thread() {
        return concurrentMap.get(keys[ThreadLocalRandom.current().nextInt(MAP_SIZE)]);
    }

    @Benchmark
    @Threads(1)
    public String synchronizedGet_1thread() {
        return synchronizedMap.get(keys[ThreadLocalRandom.current().nextInt(MAP_SIZE)]);
    }

    @Benchmark
    @Threads(8)
    public String concurrentGet_8threads() {
        return concurrentMap.get(keys[ThreadLocalRandom.current().nextInt(MAP_SIZE)]);
    }

    @Benchmark
    @Threads(8)
    public String synchronizedGet_8threads() {
        return synchronizedMap.get(keys[ThreadLocalRandom.current().nextInt(MAP_SIZE)]);
    }

    @Benchmark
    @Threads(8)
    public void concurrentMixedReadWrite_8threads(Blackhole bh) {
        int idx = ThreadLocalRandom.current().nextInt(MAP_SIZE);
        if (idx % 10 == 0) {
            concurrentMap.put(keys[idx], "updated-" + idx);
        } else {
            bh.consume(concurrentMap.get(keys[idx]));
        }
    }

    @Benchmark
    @Threads(8)
    public void synchronizedMixedReadWrite_8threads(Blackhole bh) {
        int idx = ThreadLocalRandom.current().nextInt(MAP_SIZE);
        if (idx % 10 == 0) {
            synchronizedMap.put(keys[idx], "updated-" + idx);
        } else {
            bh.consume(synchronizedMap.get(keys[idx]));
        }
    }
}
ScenarioConcurrentHashMap (ops/ms)synchronized HashMap (ops/ms)Ratio
Get, 1 thread48,00042,0001.1x
Get, 8 threads350,00052,0006.7x
Mixed R/W, 8 threads280,00038,0007.4x

At 1 thread, the difference is small: ConcurrentHashMap’s volatile read is slightly faster than synchronized’s monitor acquire/release. At 8 threads, synchronized HashMap collapses because every operation acquires the same lock. ConcurrentHashMap scales linearly because reads are lock-free and writes only lock their target bucket.

The 90/10 read/write mix at 8 threads shows 7.4x advantage. The 10% writes occasionally lock buckets, but 90% of operations proceed without contention.

EnumMap: O(1) Guaranteed

When keys are enum constants, EnumMap provides the fastest possible lookup: a direct array index using the enum’s ordinal value.

public enum ContentType {
    ARTICLE, NEWS, ANALYSIS, BOOK_CHAPTER, TUTORIAL
}

// SLOW: HashMap with enum keys, wastes hashing overhead
Map<ContentType, ContentProcessor> processors = new HashMap<>();
processors.put(ContentType.ARTICLE, new ArticleProcessor());
processors.put(ContentType.NEWS, new NewsProcessor());
// ...

// FAST: EnumMap, direct ordinal indexing
Map<ContentType, ContentProcessor> processors = new EnumMap<>(ContentType.class);
processors.put(ContentType.ARTICLE, new ArticleProcessor());
processors.put(ContentType.NEWS, new NewsProcessor());
// ...
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 2)
@Measurement(iterations = 5, time = 5)
@Fork(2)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class EnumMapBenchmark {

    enum Category {
        TECH, SCIENCE, BUSINESS, HEALTH, SPORTS,
        POLITICS, ENTERTAINMENT, TRAVEL, FOOD, EDUCATION,
        FINANCE, GAMING, MUSIC, ART, HISTORY
    }

    private Map<Category, String> hashMap;
    private EnumMap<Category, String> enumMap;
    private Category[] lookups;

    @Setup
    public void setup() {
        hashMap = new HashMap<>();
        enumMap = new EnumMap<>(Category.class);
        for (Category cat : Category.values()) {
            hashMap.put(cat, "handler-" + cat.name());
            enumMap.put(cat, "handler-" + cat.name());
        }
        lookups = Category.values();
    }

    @Benchmark
    public String hashMapGet() {
        return hashMap.get(lookups[ThreadLocalRandom.current()
            .nextInt(lookups.length)]);
    }

    @Benchmark
    public String enumMapGet() {
        return enumMap.get(lookups[ThreadLocalRandom.current()
            .nextInt(lookups.length)]);
    }
}
Map TypeGet (ns)Memory (bytes)
HashMap (15 enum keys)12~1,200
EnumMap (15 enum keys)4120

EnumMap is 3x faster and uses 10x less memory. It stores values in a Object[] of size Enum.values().length. Lookup is values[key.ordinal()]. No hashing, no collision handling, no Node objects.

Use EnumMap whenever the key type is an enum. The content platform uses it for content type dispatching, HTTP method routing, and analytics event categorization.

IdentityHashMap: Reference Equality

IdentityHashMap uses == instead of equals() for key comparison and System.identityHashCode() instead of hashCode(). This is useful for object graph traversal where you need to track whether you have visited a specific object instance, not a logically equal object.

// Content platform: detecting circular references in content linking
public class CircularReferenceDetector {

    public boolean hasCircle(Article root) {
        // IdentityHashMap treats each Article instance as unique
        // even if two articles are equals() to each other
        Set<Article> visited = Collections.newSetFromMap(new IdentityHashMap<>());
        return walk(root, visited);
    }

    private boolean walk(Article article, Set<Article> visited) {
        if (!visited.add(article)) return true; // same instance seen before
        for (Article linked : article.linkedArticles()) {
            if (walk(linked, visited)) return true;
        }
        return false;
    }
}

IdentityHashMap uses open addressing (linear probing) instead of chaining. This means:

  • No Node objects: keys and values are stored directly in the backing array
  • Better cache locality for small maps: no pointer chasing through Node chains
  • Worse worst-case when load factor is high: long probe sequences

For object graph traversal patterns (serialization, deep copy, cycle detection), IdentityHashMap is the correct choice. Using HashMap with mutable keys risks the hash-corruption bug described in Section 1.

LinkedHashMap as LRU Cache

LinkedHashMap maintains insertion order (default) or access order (with the access-order constructor). Combined with removeEldestEntry(), it becomes a bounded LRU cache:

// Content platform: bounded LRU cache for rendered article HTML
public class RenderedArticleCache {

    private static final int MAX_ENTRIES = 1_000;

    private final Map<String, String> cache =
        new LinkedHashMap<>(MAX_ENTRIES, 0.75f, true) { // true = access order
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
                return size() > MAX_ENTRIES;
            }
        };

    public String getRenderedHtml(String articleId) {
        return cache.get(articleId); // moves to tail (most recently accessed)
    }

    public void cacheRenderedHtml(String articleId, String html) {
        cache.put(articleId, html); // evicts eldest if size > MAX_ENTRIES
    }
}

With accessOrder = true, every get() call moves the accessed entry to the tail of the linked list. The head is always the least-recently-accessed entry. When removeEldestEntry() returns true (size exceeds the limit), the head entry is removed.

Memory overhead: LinkedHashMap adds before and after pointers to each entry (8 bytes with compressed oops), plus the linked list head reference. Total per-entry overhead: 40 bytes vs 32 bytes for HashMap.

@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 2)
@Measurement(iterations = 5, time = 5)
@Fork(2)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class LruCacheBenchmark {

    private static final int CACHE_SIZE = 1_000;
    private static final int KEY_SPACE = 5_000; // 5x cache size, 80% miss rate

    private Map<Integer, String> lruCache;
    private ConcurrentHashMap<Integer, String> concurrentCache;

    @Setup
    public void setup() {
        lruCache = new LinkedHashMap<>(CACHE_SIZE, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<Integer, String> eldest) {
                return size() > CACHE_SIZE;
            }
        };
        concurrentCache = new ConcurrentHashMap<>(CACHE_SIZE);

        for (int i = 0; i < CACHE_SIZE; i++) {
            lruCache.put(i, "val-" + i);
            concurrentCache.put(i, "val-" + i);
        }
    }

    @Benchmark
    public String lruGet() {
        int key = ThreadLocalRandom.current().nextInt(KEY_SPACE);
        return lruCache.get(key);
    }

    @Benchmark
    public String concurrentGet() {
        int key = ThreadLocalRandom.current().nextInt(KEY_SPACE);
        return concurrentCache.get(key);
    }

    @Benchmark
    public void lruPutEvict() {
        int key = ThreadLocalRandom.current().nextInt(KEY_SPACE);
        lruCache.put(key, "val-" + key);
    }

    @Benchmark
    public void concurrentPut() {
        int key = ThreadLocalRandom.current().nextInt(KEY_SPACE);
        concurrentCache.put(key, "val-" + key);
    }
}
OperationLinkedHashMap LRU (ns)ConcurrentHashMap (ns)
Get1412
Put (with eviction)2818

LinkedHashMap’s LRU get is slightly slower due to the access-order relink (move to tail). Put is slower because it may trigger eviction (remove eldest + insert new). But the LRU policy provides bounded memory usage, which ConcurrentHashMap does not offer without external eviction logic.

For production caching with bounded size, consider Caffeine (com.github.ben-manes.caffeine:caffeine), which provides a concurrent, bounded, near-optimal hit rate cache based on the W-TinyLFU admission policy. It outperforms hand-rolled LinkedHashMap LRU caches in both throughput and hit rate.

The Content Platform Concurrent Cache

Combining the patterns from this section, the production article metadata cache uses ConcurrentHashMap with periodic bulk refresh:

// FAST: production-grade concurrent cache with bulk refresh
public class ArticleMetadataService {

    // Read-heavy cache: volatile reference to immutable snapshot
    private volatile Map<String, ArticleMetadata> snapshot = Map.of();

    // Write-capable cache: for hot updates between refreshes
    private final ConcurrentHashMap<String, ArticleMetadata> hotCache =
        new ConcurrentHashMap<>();

    public ArticleMetadata get(String articleId) {
        // Check hot cache first (recent updates)
        ArticleMetadata hot = hotCache.get(articleId);
        if (hot != null) return hot;

        // Fall back to immutable snapshot (bulk-loaded)
        return snapshot.get(articleId);
    }

    public void updateSingle(String articleId, ArticleMetadata meta) {
        hotCache.put(articleId, meta);
    }

    // Called periodically (every 5 minutes) by a scheduler
    public void refreshAll(List<Article> articles) {
        Map<String, ArticleMetadata> building = HashMap.newHashMap(articles.size());
        for (Article article : articles) {
            building.put(article.id(), buildMetadata(article));
        }
        // Merge hot updates into the new snapshot
        building.putAll(hotCache);
        snapshot = Map.copyOf(building);
        hotCache.clear(); // hot updates are now in the snapshot
    }

    private ArticleMetadata buildMetadata(Article article) {
        return new ArticleMetadata(article.id(), article.title(),
            article.categories());
    }
}

This design:

  • Reads are lock-free: volatile read of snapshot reference, then Map.copyOf lookup (no synchronization)
  • Hot updates go to ConcurrentHashMap: concurrent writes are safe, per-bucket locking
  • Bulk refresh builds a new immutable snapshot and swaps the volatile reference
  • After refresh, hot cache clears because all updates are now in the snapshot
  • Memory-efficient: Map.copyOf has lower per-entry overhead than ConcurrentHashMap

Decision Matrix

ScenarioBest MapReason
General purpose, single-threadedHashMapFast, well-understood, good defaults
Known size, read-only after buildMap.copyOfImmutable, thread-safe, lower memory
Concurrent reads + writesConcurrentHashMapPer-bucket locking, lock-free reads
Enum keysEnumMapDirect array index, 3x faster, 10x less memory
Object identity trackingIdentityHashMap== comparison, no hashCode() needed
Bounded LRU cache (single-thread)LinkedHashMapBuilt-in access-order eviction
Bounded cache (concurrent, production)CaffeineW-TinyLFU, concurrent, near-optimal hit rate
Insertion-order iterationLinkedHashMapDoubly-linked list maintains order
Sorted key orderTreeMapRed-black tree, O(log n) operations