并发篇:ConcurrentHashMap 是怎么做到线程安全的 📅 2026/7/11 3:32:46 前两篇把 HashMap 的核心机制和进阶原理都聊完了。这篇该聊聊并发场景下怎么用 Map 了。事情是这样的我学完 HashMap 之后觉得行了Map 这块我掌握了。直到有一天我在一个多线程程序里直接用了 HashMap结果跑着跑着数据就丢了有时候还直接抛 ConcurrentModificationException。查了一圈才发现HashMap 不是线程安全的。多线程同时写它会出各种奇奇怪怪的问题。那问题来了如果我的程序就是多线程的我又需要用 Map 存数据该怎么办Java 给了好几个选择。但说到并发 Map绕不开的就是 ConcurrentHashMap。先看看 Java 里有哪些线程安全的集合在讲 ConcurrentHashMap 之前我觉得有必要先对整个线程安全集合体系有个概念。不然只知道个 ConcurrentHashMap面试问还有哪些就卡住了。Java 的线程安全集合分两大类。java.util 包里的老前辈这个包里线程安全的集合其实就两个Vector线程安全的动态数组。它的方法基本都加了 synchronized。但说实话现在很少用了性能差而且有更好的替代品。Hashtable线程安全的哈希表。跟 Vector 一样每个方法都加 synchronized锁住整个对象。性能也是问题。这两个的共同缺点是锁的粒度太大。一个线程在读其他所有线程都得等着。用起来有点像一个人干活一群人围观。java.util.concurrent 包里的新家伙JDK 1.5 之后引入了 java.util.concurrent 包简称 JUC里面提供了一整套线程安全的集合。我当初学到这个包的时候感觉像是发现了一个新世界。并发 MapConcurrentHashMap主角。线程安全的哈希表高性能。ConcurrentSkipListMap基于跳表实现可以排序。适合需要有序的并发场景。并发 SetConcurrentSkipListSet基于 ConcurrentSkipListMap 实现有序。CopyOnWriteArraySet基于 CopyOnWriteArrayList适合读多写少的场景。并发 ListCopyOnWriteArrayList这个的设计思路很有意思写的时候复制一份新数组读的时候读旧数组读写互不干扰。并发 QueueConcurrentLinkedQueue基于 CAS 实现的无锁队列性能很高。BlockingQueue这个不是提升性能的而是提供阻塞等待机制队列空了消费者就等着队列满了生产者就等着。并发 DequeLinkedBlockingDeque线程安全的双端队列同一时间只能有一个线程操作。ConcurrentLinkedDeque基于链表的无锁双端队列。我当时看到这一堆名字确实头大。但后来发现其实不用全都记住ConcurrentHashMap 最常用CopyOnWriteArrayList 也很常见其他的遇到了再查也不晚。说说 Hashtable 为什么不行Hashtable 和 ConcurrentHashMap 都是线程安全的 Map但两者的性能差很多。Hashtable 的做法很粗暴在每个方法上加了 synchronized。比如 put 和 get 都是public synchronized V put(K key, V value)。这意味着什么同一时刻只有一个线程能操作 Hashtable。即使两个线程只是在读不同的 key也得排队等着。这就好比只有一个收银台的超市不管顾客买什么都得排一条队。ConcurrentHashMap 就不一样。它允许多个线程同时读写只要操作的不是同一个段或桶。这就是它性能好的根本原因。Hashtable 现在基本已经被淘汰了。面试可能会问到它的区别但实际项目里没人会用。它的位置被 ConcurrentHashMap 取代了。JDK 1.7 的 ConcurrentHashMap分段锁先说说 JDK 1.7 的实现虽然现在已经不用这个版本了但分段锁的设计思路很值得了解。JDK 1.7 的 ConcurrentHashMap 结构是这样的一个大数组 Segment 每个 Segment 里有个小数组 HashEntry。Segment 是个什么它继承了 ReentrantLock本身就是一个锁。每个 Segment 维护着一部分数据。你往 ConcurrentHashMap 里 put 一个键值对的时候先根据 key 的 hash 值找到对应的 Segment给这个 Segment 加锁在 Segment 内部的 HashEntry 数组里操作跟 HashMap 1.7 差不多数组加链表关键在这里不同 Segment 之间的操作完全互不影响。比如 Segment 0 正在被线程 A 操作Segment 1 照样可以被线程 B 操作。这就是分段锁的核心理念把数据切成多段每段一把锁锁竞争被分散了。举个例子如果 ConcurrentHashMap 有 16 个 Segment理论上最多可以支持 16 个线程同时写入每个线程写不同的 Segment。而 Hashtable 在同一时刻只能有一个线程写入。我当时理解这个设计之后觉得挺巧妙的不是不加锁而是把锁的范围缩小让多线程各管各的。Segment 用的锁是 ReentrantLock可重入的。可重入的意思是同一个线程可以多次获取同一把锁而不会把自己卡死。比如一个线程先给 Segment 加了锁然后在锁里面又调了一个也需要这把锁的方法不会死锁这就是可重入锁的作用。JDK 1.8 的 ConcurrentHashMap更进一步JDK 1.8 对 ConcurrentHashMap 做了大改。最核心的变化是取消了 Segment直接在桶的头节点上加锁。为啥要取消 Segment因为 Segment 虽然比 Hashtable 好但还是有优化空间。比如两个线程同时操作同一个 Segment 里的不同桶理论上是可以并发的但因为它们共享同一把 Segment 锁所以还是得排队。JDK 1.8 的 ConcurrentHashMap 基本结构跟 JDK 1.8 的 HashMap 一样数组 链表 / 红黑树。但加上了并发控制。它的并发控制是组合拳volatile CAS synchronized。先说说这三个东西各自干什么volatile保证变量的可见性。一个线程修改了某个变量其他线程能立刻看到。ConcurrentHashMap 里的 Node 数组和 Node 的 next 指针都用 volatile 修饰了。CASCompare And Swap乐观的并发操作。比较并交换如果内存中的值跟我预期的一样我就改成新值。这是一个原子操作。synchronized传统的悲观锁。JDK 1.8 对 synchronized 做了很多优化偏向锁、轻量级锁等性能已经不错了。具体到 put 操作流程是这样的情况一数组还没初始化如果 table 数组是空的先初始化。这里用的是 volatile CAS多个线程同时看到数组是空的但它们通过 CAS 竞争谁先 CAS 成功谁就负责初始化其他线程看到已经初始化了就跳过。情况二目标位置是空的如果算出来的位置没有任何节点直接用 CAS 把新节点放进去。因为哈希碰撞的概率本来就不高所以大多数情况下 CAS 一次就能成功不需要加锁。情况三目标位置已经有节点了这个时候发生了哈希碰撞需要遍历链表或者红黑树。CAS 就不合适了操作步骤多需要保护整条链的结构。所以这里用 synchronized 锁住桶的头节点然后遍历、比较、替换或者追加。换句话说不冲突的时候用 CAS冲突了用 synchronized。锁粒度对比我画了一个对比这样看更清楚HashtableConcurrentHashMap 1.7ConcurrentHashMap 1.8锁粒度整个对象每个 Segment每个桶的头节点并发度116默认数组长度锁类型synchronizedReentrantLockCAS synchronized数据结构数组链表Segment HashEntry数组链表/红黑树从 1 到 16 到数组长度锁粒度越来越小并发度越来越高。为什么用了 synchronized 还要用 CAS这个问题我一开始也有点困惑。既然 JDK 1.8 用了 synchronized那为什么还要用 CAS直接用 synchronized 不行吗原因是这样的两种锁的开销不同适合的场景也不同。CAS 是乐观锁开销很小适合竞争概率低的场景。比如往一个空的桶里放元素因为哈希值经过扰动处理碰撞的概率不高CAS 大概率一次就成功了。如果用 synchronized加锁解锁都有额外开销反而不划算。synchronized 是悲观锁开销相对大一些但它能保护复杂的操作。比如遍历链表、比较 key、决定是替换还是追加这些步骤不能被打断需要用 synchronized 保证原子性。所以 ConcurrentHashMap 的策略是用 CAS 处理简单操作空桶插入用 synchronized 处理复杂操作哈希碰撞后的遍历。各取所长。我当时觉得这个设计挺务实的没有死守一种方案而是根据场景选择最合适的工具。ConcurrentHashMap 用了悲观锁还是乐观锁两个都用。简单总结一下就是检查到数组为空用 CAS乐观锁来初始化检查到目标位置为空用 CAS乐观锁设置节点检查到目标位置不为空发生碰撞用 synchronized悲观锁锁住头节点所以回答ConcurrentHashMap 用了悲观锁还是乐观锁这个面试题的时候标准答案是两个都用不同场景用不同的策略。写时复制CopyOnWriteArrayList 的思路虽然这篇的主角是 ConcurrentHashMap但我觉得 CopyOnWriteArrayList 的设计思路也值得说一下因为它代表了一种完全不同的并发策略。CopyOnWriteArrayList 的核心思想是读不加锁写的时候复制一份副本。读操作直接读老数组不需要任何同步publicEget(intindex){returnget(getArray(),index);}写操作先复制一份新数组在新数组上修改然后把新数组的引用赋值给 volatile 变量publicbooleanadd(Ee){synchronized(lock){Object[]elementsgetArray();intlenelements.length;Object[]newElementsArrays.copyOf(elements,len1);newElements[len]e;setArray(newElements);returntrue;}}读的时候永远读到的是快照——要么是旧数组写还没完成要么是新数组写已完成。但永远不会读到写到一半的脏数据。这个适合什么场景读多写少的场景。比如事件监听器列表——监听器很少变动但每次事件触发都要遍历整个列表去通知所有人。缺点也很明显写操作代价高。每次 add 都要复制整个数组如果元素很多或者写操作很频繁性能就很差。几种方案的选型建议学完这么多并发集合到最后还是得落到实际选择上。我的经验是单线程用 HashMap。简单高效没有并发开销。多线程读多写少用 ConcurrentHashMap。几乎总是最优选择。多线程需要排序用 ConcurrentSkipListMap。它基于跳表支持范围查询。多线程需要 List读多写少用 CopyOnWriteArrayList写多读少老老实实加锁。多线程需要 Queue用 ConcurrentLinkedQueue 或 BlockingQueue 家族。Hashtable 和 Vector基本不用了。面试可能会问但项目里很少有人主动用了。写在最后这篇从线程安全集合的总览开始聊了 ConcurrentHashMap 从 JDK 1.7 分段锁到 1.8 CAS synchronized 的演进也顺带提了 CopyOnWriteArrayList 的写时复制思路。三篇 HashMap 相关的文章写下来我自己也梳理了一遍。从核心机制到进阶原理再到并发安全这个链条算是完整了。回想当初我刚接触 HashMap 的时候只知道它存键值对的。后来一边踩坑一边翻源码才慢慢理解那些设计背后的考量。为什么是 2 的 n 次方为什么用红黑树为什么分段锁又改成了 CAS 加 synchronized——这些问题没有一个答案是孤立的它们连起来就是一套完整的设计哲学。