HashMap并发问题与ConcurrentHashMap分段锁演进

引言

想象一下这样的场景:你正在开发一个电商系统的库存服务,使用 HashMap 缓存商品库存信息。上线初期一切正常,但随着双11大促的临近,系统开始频繁出现 CPU 飙升、死循环甚至服务宕机的问题。排查日志时,你发现 HashMap 在高并发写入时内部结构被破坏了。

这不是危言耸听。在 JDK 7 中,HashMap 在并发扩容时可能形成环形链表,导致 get 操作陷入死循环;即便在 JDK 8 中改进了扩容算法,put 操作仍可能导致数据丢失。如果你正在维护一个高并发的 Java 服务,理解这个问题并掌握 ConcurrentHashMap 的演进历程是必备技能。

核心概念

生活类比:餐厅后厨的储物柜

想象一家餐厅后厨,厨师们(线程)共用一个储物柜(Map)存放食材(键值对)。储物柜有很多格子(桶),每个格子可以放多个食材(链表)。

  • HashMap 就像没有锁的储物柜,厨师们可以随意取放,但当储物柜需要扩建(扩容)时,多个厨师同时搬移食材,可能把食材放错格子甚至弄丢。
  • Hashtable 给整个储物柜加了一把大锁,一次只允许一个厨师使用,虽然安全但效率极低。
  • ConcurrentHashMap 则聪明地将储物柜分成多个独立的小柜子(分段/桶),每个小柜子有自己的锁(分段锁/CAS+锁),不同厨师可以同时操作不同的小柜子。

技术定义

  • HashMap:基于哈希表的 Map 实现,非线程安全。JDK 7 使用数组+链表,JDK 8 使用数组+链表+红黑树。
  • Hashtable:线程安全的 HashMap,使用 synchronized 锁住整个表,并发度低。
  • ConcurrentHashMap:线程安全的 HashMap,JDK 7 采用分段锁(Segment),JDK 8 抛弃分段锁,改用 CAS + synchronized 锁住单个桶节点,并发度更高。

源码/原理深度分析

JDK 7 HashMap 并发死循环的根源

JDK 7 的 HashMap 扩容采用头插法,在并发环境下,两个线程同时扩容同一个桶时,可能形成环形链表:

// JDK 7 HashMap.transfer() 核心逻辑(简化)
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while (null != e) {
            // 线程A执行到这里被挂起
            Entry<K,V> next = e.next;
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

当线程A和线程B同时执行 transfer,线程A在 Entry next = e.next 处被挂起,线程B完成扩容后,线程A恢复执行,此时链表顺序已被反转,可能导致 e.next 指向自己,形成死循环。

JDK 8 HashMap 的改进与遗留问题

JDK 8 将头插法改为尾插法,解决了死循环问题,但并发 put 仍会丢失数据:

// JDK 8 HashMap.putVal() 关键片段
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 两个线程都检查到该位置为空,都执行赋值,后写的覆盖前面的
        tab[i] = newNode(hash, key, value, null);
    else {
        // ... 省略后续逻辑
    }
}

两个线程同时检查到 tab[i] == null,然后都执行赋值操作,后写的线程覆盖先写的,导致数据丢失。

JDK 7 ConcurrentHashMap 分段锁设计

JDK 7 的 ConcurrentHashMap 将整个表分成 16 个 Segment(默认),每个 Segment 就是一个小型的 HashMap,拥有独立的锁:

// JDK 7 ConcurrentHashMap 核心结构
static final class Segment<K,V> extends ReentrantLock implements Serializable {
    transient volatile HashEntry<K,V>[] table;
    transient int count;
    // ...
}

// 写操作需要获取 Segment 的锁
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    // ...
}

设计要点

  • 锁粒度是 Segment,默认并发度 16
  • get 操作不需要加锁(HashEntry 的 value 是 volatile)
  • 扩容只在 Segment 内部进行,不会影响其他 Segment

JDK 8 ConcurrentHashMap 的 CAS + synchronized 演进

JDK 8 抛弃了 Segment,直接用 Node[] 数组 + CAS + synchronized 实现:

// JDK 8 ConcurrentHashMap.putVal() 核心逻辑
final V putVal(K key, V value, boolean onlyIfAbsent) {
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 桶为空,用 CAS 无锁插入
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            // 桶不为空,锁住当前桶的头节点
            synchronized (f) {
                // ... 链表或红黑树插入逻辑
            }
        }
    }
    // ...
}

演进优势

  • 锁粒度从 Segment 细化到单个桶(Node),并发度从 16 提升到数组长度
  • 使用 CAS 无锁操作处理桶为空的场景,减少锁竞争
  • synchronized 在 JDK 6 优化后(偏向锁、轻量级锁),性能已不输 ReentrantLock

架构演进对比

graph TD A[HashMap JDK7] -->|并发死循环| B[Hashtable] A -->|不能使用| C[ConcurrentHashMap JDK7] B -->|全局锁 并发度低| C C -->|分段锁 并发度16| D[ConcurrentHashMap JDK8] D -->|CAS+synchronized 并发度=数组长度| E[最佳实践] style A fill:#ff6b6b style B fill:#ffd93d style C fill:#6bcb77 style D fill:#4d96ff style E fill:#6bcb77

实战代码

示例1:并发环境下的 HashMap 数据丢失复现

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 * 复现 HashMap 并发 put 导致的数据丢失问题
 * 
 * 运行结果:实际 size 小于 10000,说明有数据丢失
 */
public class HashMapConcurrencyIssue {
    
    private static final int THREAD_COUNT = 10;
    private static final int PUT_COUNT = 1000;
    
    public static void main(String[] args) throws InterruptedException {
        // 模拟高并发场景下的 HashMap
        Map<Integer, String> map = new HashMap<>(16);
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        
        long start = System.currentTimeMillis();
        
        for (int t = 0; t < THREAD_COUNT; t++) {
            final int threadId = t;
            executor.submit(() -> {
                try {
                    for (int i = 0; i < PUT_COUNT; i++) {
                        // 每个线程写入 1000 个键值对,键全局唯一
                        map.put(threadId * PUT_COUNT + i, "value-" + i);
                    }
                } finally {
                    latch.countDown();
                }
            });
        }
        
        latch.await(10, TimeUnit.SECONDS);
        executor.shutdown();
        
        long cost = System.currentTimeMillis() - start;
        int expectedSize = THREAD_COUNT * PUT_COUNT;
        
        System.out.println("期望元素数量: " + expectedSize);
        System.out.println("实际元素数量: " + map.size());
        System.out.println("丢失元素数量: " + (expectedSize - map.size()));
        System.out.println("耗时: " + cost + "ms");
        
        // 输出结果示例(每次运行可能不同):
        // 期望元素数量: 10000
        // 实际元素数量: 9973
        // 丢失元素数量: 27
        // 耗时: 45ms
    }
}

运行结果分析:每次运行丢失的元素数量不同,这是因为线程调度具有随机性。丢失的原因包括:两个线程同时检查桶为空然后同时赋值、扩容时旧表数据被覆盖等。

示例2:ConcurrentHashMap 并发写入的正确姿势

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.LongAdder;

/**
 * ConcurrentHashMap 在并发场景下的正确使用
 * 
 * 对比三种并发写入策略:
 * 1. put() - 直接覆盖,可能丢失中间状态
 * 2. merge() - 原子合并,适合累加场景
 * 3. compute() - 原子计算,适合复杂更新
 */
public class ConcurrentHashMapDemo {
    
    private static final int THREAD_COUNT = 50;
    private static final int OPERATIONS_PER_THREAD = 1000;
    
    public static void main(String[] args) throws InterruptedException {
        // 场景1:统计每个用户的下单次数
        testIncrement();
        
        // 场景2:合并复杂对象
        testMerge();
        
        // 场景3:使用 LongAdder 优化高频计数
        testLongAdder();
    }
    
    /**
     * 场景1:原子自增操作
     * 注意:map.put(key, map.getOrDefault(key, 0) + 1) 不是原子的!
     */
    private static void testIncrement() throws InterruptedException {
        Map<String, Long> orderCount = new ConcurrentHashMap<>();
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        
        for (int t = 0; t < THREAD_COUNT; t++) {
            final String userId = "user-" + (t % 5); // 5个用户,10个线程抢
            executor.submit(() -> {
                try {
                    for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
                        // 错误写法:先读后写,不是原子的
                        // orderCount.put(userId, orderCount.getOrDefault(userId, 0L) + 1);
                        
                        // 正确写法1:使用 merge,原子操作
                        orderCount.merge(userId, 1L, Long::sum);
                    }
                } finally {
                    latch.countDown();
                }
            });
        }
        
        latch.await(10, TimeUnit.SECONDS);
        executor.shutdown();
        
        System.out.println("\n=== 场景1:原子自增 ===");
        long totalOrders = orderCount.values().stream().mapToLong(Long::longValue).sum();
        System.out.println("总下单次数: " + totalOrders);
        System.out.println("期望总次数: " + (THREAD_COUNT * OPERATIONS_PER_THREAD));
        System.out.println("结果正确: " + (totalOrders == THREAD_COUNT * OPERATIONS_PER_THREAD));
    }
    
    /**
     * 场景2:合并复杂对象
     * 使用 compute 实现原子更新复杂对象
     */
    private static void testMerge() throws InterruptedException {
        Map<String, UserStats> statsMap = new ConcurrentHashMap<>();
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        
        for (int t = 0; t < THREAD_COUNT; t++) {
            final int threadId = t;
            executor.submit(() -> {
                try {
                    for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
                        String userId = "user-" + (threadId % 5);
                        int amount = i % 100;
                        
                        // 使用 compute 原子更新复杂对象
                        statsMap.compute(userId, (key, oldStats) -> {
                            UserStats newStats = oldStats == null ? new UserStats() : oldStats;
                            newStats.orderCount++;
                            newStats.totalAmount += amount;
                            return newStats;
                        });
                    }
                } finally {
                    latch.countDown();
                }
            });
        }
        
        latch.await(10, TimeUnit.SECONDS);
        executor.shutdown();
        
        System.out.println("\n=== 场景2:复杂对象合并 ===");
        statsMap.forEach((key, stats) -> 
            System.out.println(key + " 订单数=" + stats.orderCount + 
                             " 总金额=" + stats.totalAmount));
    }
    
    /**
     * 场景3:LongAdder 优化高频计数
     * 对于高频更新,LongAdder 比 AtomicLong 性能更好
     */
    private static void testLongAdder() throws InterruptedException {
        ConcurrentHashMap<String, LongAdder> counterMap = new ConcurrentHashMap<>();
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        
        for (int t = 0; t < THREAD_COUNT; t++) {
            executor.submit(() -> {
                try {
                    for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
                        // 先获取或创建 LongAdder,然后自增
                        counterMap.computeIfAbsent("total-requests", k -> new LongAdder()).increment();
                    }
                } finally {
                    latch.countDown();
                }
            });
        }
        
        latch.await(10, TimeUnit.SECONDS);
        executor.shutdown();
        
        System.out.println("\n=== 场景3:LongAdder 高频计数 ===");
        long total = counterMap.get("total-requests").sum();
        System.out.println("总请求数: " + total);
        System.out.println("期望请求数: " + (THREAD_COUNT * OPERATIONS_PER_THREAD));
        System.out.println("结果正确: " + (total == THREAD_COUNT * OPERATIONS_PER_THREAD));
    }
    
    /**
     * 用户统计实体
     */
    static class UserStats {
        int orderCount;
        int totalAmount;
        
        @Override
        public String toString() {
            return "UserStats{orderCount=" + orderCount + ", totalAmount=" + totalAmount + "}";
        }
    }
}

核心要点

  • put + getOrDefault 的组合不是原子的,必须使用 mergecompute
  • computeIfAbsent 适合"先检查后插入"的场景,且是原子的
  • 高频计数场景优先考虑 LongAdder 而非 AtomicLong

示例3:从 HashMap 迁移到 ConcurrentHashMap 的性能对比

import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 * 不同 Map 实现性能对比基准测试
 * 
 * 测试场景:8个线程并发读写,各执行 10000 次
 * 测试结果(仅供参考,实际因机器而异):
 * - HashMap: 最快但有数据丢失
 * - Hashtable: 最慢,全局锁竞争严重
 * - ConcurrentHashMap: 接近 HashMap 的性能,且线程安全
 */
public class MapPerformanceBenchmark {
    
    private static final int THREAD_COUNT = 8;
    private static final int OPERATIONS = 10000;
    
    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== Map 性能对比基准测试 ===");
        System.out.println("线程数: " + THREAD_COUNT + ", 每线程操作数: " + OPERATIONS + "\n");
        
        // 测试 HashMap(非线程安全)
        benchmarkMap("HashMap", new HashMap<>(), false);
        
        // 测试 Hashtable(全局锁)
        benchmarkMap("Hashtable", new Hashtable<>(), true);
        
        // 测试 ConcurrentHashMap(分段锁/CAS)
        benchmarkMap("ConcurrentHashMap", new ConcurrentHashMap<>(), true);
    }
    
    private static void benchmarkMap(String name, Map<Integer, Integer> map, 
                                     boolean threadSafe) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch readyLatch = new CountDownLatch(THREAD_COUNT);
        CountDownLatch startLatch = new CountDownLatch(1);
        CountDownLatch doneLatch = new CountDownLatch(THREAD_COUNT);
        
        // 预热 JVM
        for (int i = 0; i < 1000; i++) {
            map.put(i, i);
        }
        map.clear();
        
        long startTime;
        
        for (int t = 0; t < THREAD_COUNT; t++) {
            final int threadId = t;
            executor.submit(() -> {
                readyLatch.countDown();
                try {
                    startLatch.await(); // 等待所有线程就绪
                    
                    for (int i = 0; i < OPERATIONS; i++) {
                        int key = threadId * OPERATIONS + i;
                        // 混合读写操作
                        map.put(key, i);
                        map.get(key % OPERATIONS);
                        map.containsKey(i % 1000);
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    doneLatch.countDown();
                }
            });
        }
        
        readyLatch.await();
        startTime = System.nanoTime();
        startLatch.countDown(); // 同时启动所有线程
        doneLatch.await(30, TimeUnit.SECONDS);
        
        long cost = (System.nanoTime() - startTime) / 1_000_000;
        executor.shutdown();
        
        // 验证数据完整性
        int actualSize = map.size();
        int expectedSize = threadSafe ? THREAD_COUNT * OPERATIONS : map.size();
        
        String dataIntegrity = threadSafe ? 
            (actualSize == expectedSize ? "✓ 数据完整" : "✗ 数据丢失!") : 
            "(非线程安全,不检查)";
        
        System.out.printf("%-20s 耗时: %5dms  数据量: %d  %s%n", 
                         name, cost, actualSize, dataIntegrity);
        
        // 清理
        map.clear();
        System.gc();
        Thread.sleep(100);
    }
}

// 典型输出(某次运行的参考值):
// === Map 性能对比基准测试 ===
// 线程数: 8, 每线程操作数: 10000
// HashMap              耗时:    78ms  数据量: 79982  (非线程安全,不检查)
// Hashtable            耗时:   245ms  数据量: 80000  ✓ 数据完整
// ConcurrentHashMap    耗时:   105ms  数据量: 80000  ✓ 数据完整

性能分析

  • HashMap 最快,但不安全
  • Hashtable 比 ConcurrentHashMap 慢 2-3 倍,因为全局锁限制了并发度
  • ConcurrentHashMap 的性能接近 HashMap(约 1.3 倍耗时),但提供了线程安全保证

方案对比

ConcurrentHashMap vs 其他并发方案

| 方案 | 锁粒度 | 并发度 | 读性能 | 写性能 | 适用场景 |

|------|--------|--------|--------|--------|----------|

| Hashtable | 整表锁 | 1 | 低 | 低 | 不推荐使用 |

| Collections.synchronizedMap | 整表锁 | 1 | 低 | 低 | 简单包装现有 Map |

| ConcurrentHashMap (JDK7) | Segment | 16 | 高 | 中 | 旧系统维护 |

| ConcurrentHashMap (JDK8) | 单个桶 | 数组长度 | 高 | 高 | 首选方案 |

| ConcurrentSkipListMap | 无锁(CAS) | 高 | 中 | 中 | 需要有序键值对 |

ConcurrentHashMap 的局限性

  1. 不能存储 null 键值ConcurrentHashMap 不允许 null 键和 null 值,因为无法区分"值为 null"和"不存在"。如果业务需要 null 值,考虑使用 Optional 包装。
  1. 弱一致性迭代器ConcurrentHashMap 的迭代器是弱一致的,不保证迭代过程中看到最新数据。如果需要强一致性,需要额外同步。
  1. size() 和 isEmpty() 是估算值:并发环境下,size() 返回的是近似值,不保证精确。如果业务依赖精确计数,使用 LongAdder 单独维护计数。
  1. 复合操作需要自己保证原子性:如"先检查后操作"、"读取-修改-写入"等复合操作,需要配合 computemerge 等方法实现。

最佳实践与避坑指南

最佳实践

  1. 优先使用 ConcurrentHashMap:除非明确知道不需要并发访问,否则默认使用 ConcurrentHashMap 替代 HashMap
  1. 利用原子方法:多线程更新同一个 key 时,优先使用 mergecomputecomputeIfAbsent 等原子方法,避免手动"先读后写"。
  1. 初始化容量:预估数据量,设置合理的初始容量和并发度(JDK 7 中为 Segment 数量,JDK 8 中为数组长度),避免频繁扩容。
  1. 大量批量写入时使用 putAllputAll 内部会做优化,比逐个 put 更高效。

常见坑

  1. 不要用 HashMap 做缓存:如果缓存需要被多线程访问,使用 ConcurrentHashMapCaffeine 等专用缓存库。
  1. lambda 中的变量捕获computeIfAbsent 的 lambda 中不要捕获可变的外部变量,否则可能产生不可预期的结果。
  1. 迭代时修改:虽然 ConcurrentHashMap 支持迭代中修改,但弱一致性可能导致迭代结果不完整。如果业务需要一致的快照,先 new HashMap<>(concurrentMap) 再迭代。
  1. 不要依赖 size() 做精确判断:并发环境下 size() 是估算值,如果业务需要精确计数,使用 LongAdderAtomicLong 单独维护。
  1. 避免在锁内做耗时操作:虽然 ConcurrentHashMap 的锁粒度很小,但在 compute 等方法的 lambda 中执行耗时操作(如 RPC 调用)仍然会阻塞其他线程访问同一个桶。
// 错误示例:在 compute 中执行耗时操作
map.compute(key, (k, v) -> {
    String result = rpcClient.call(k); // 阻塞其他线程访问该桶
    return result;
});

// 正确示例:先计算,再合并
String result = rpcClient.call(key);
map.put(key, result);

总结

从 HashMap 的并发死循环,到 Hashtable 的全局锁,再到 ConcurrentHashMap 的分段锁和 CAS+synchronized 演进,我们看到了 Java 并发容器设计的精妙之处:

  • JDK 7 ConcurrentHashMap 通过分段锁将竞争分散到 16 个 Segment,显著提升了并发度
  • JDK 8 ConcurrentHashMap 进一步细化锁粒度到单个桶,结合 CAS 无锁操作,使得并发度达到数组长度,性能接近 HashMap

在实际开发中,理解这些底层机制能帮助我们做出更合理的技术选型。当面对高并发场景时,ConcurrentHashMap 无疑是首选;但也要认识到它的局限性,如弱一致性、不能存 null 等,在特定业务场景下可能需要额外的设计。

最后留一个思考题:Redis 的 Hash 结构与 ConcurrentHashMap 在并发控制上有哪些异同?如果让你设计一个分布式版本的 ConcurrentHashMap,你会怎么做?欢迎在评论区分享你的想法。