ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SynchronizedMap与ConcurrentHashMap的区别:底层原理与并发性能深入解析

SynchronizedMap与ConcurrentHashMap的区别:底层原理与并发性能深入解析 SynchronizedMap和ConcurrentHashMap的区别算是Java并发编程里一道绕不开的经典题。网上讲解不少但大多是罗列对比表格真正能把底层原理、性能差异、踩坑细节讲透的很少。这篇文章从源码、实测、常见误解三个维度拆开揉碎讲清楚希望能帮你彻底弄懂这两个容器的本质区别。1. 底层实现差异一个包装器一个真正意义上的并发容器1.1 Collections.synchronizedMap的本质是全表锁很多人第一次接触SynchronizedMap误以为它是什么高深的并发容器。其实它就是一个包装器Wrapper把你的普通HashMap甚至LinkedHashMap包了一层然后给每个方法加了一把全局锁。看JDK源码就很清楚private static class SynchronizedMapK,V implements MapK,V { private final MapK,V m; // 被包装的Map final Object mutex; // 锁对象 public int size() { synchronized (mutex) {return m.size();} } public V get(Object key) { synchronized (mutex) {return m.get(key);} } public V put(K key, V value) { synchronized (mutex) {return m.put(key, value);} } }这里的mutex默认就是SynchronizedMap实例本身。也就是说所有读写操作都得先抢到同一把锁。哪怕两个线程分别读不同的key也要排队竞争读操作之间完全没有并发可言。这种设计其实没什么技术含量只要你愿意自己写个HashMap然后所有方法都加synchronized效果一样。它胜在简单、兼容性好底层Map可以换成任意实现但代价就是并发能力几乎为零。1.2 ConcurrentHashMap的锁粒度精细到了桶ConcurrentHashMap以JDK 8及以后版本为例走的完全是另一条路线——锁桶Lock Striping加CAS无锁操作。JDK 7时代的ConcurrentHashMap使用Segment分段锁默认16个Segment理论上最多16个线程并发写入。JDK 8抛弃了Segment直接用Node数组加CAS加synchronized锁链表头节点。写入流程的关键代码final V putVal(K key, V value, boolean onlyIfAbsent) { // key和value都禁止为null if (key null || value null) throw new NullPointerException(); int hash spread(key.hashCode()); int binCount 0; for (NodeK,V[] tab table;;) { NodeK,V f; int n, i, fh; K fk; V fv; if (tab null || (n tab.length) 0) tab initTable(); // 首次put时初始化数组 // 目标桶为空直接CAS写入 else if ((f tabAt(tab, i (n - 1) hash)) null) { if (casTabAt(tab, i, null, new NodeK,V(hash, key, value))) break; } // 桶不为空synchronized锁住桶头节点 else { synchronized (f) { // 遍历链表或红黑树插入节点 } } } }关键点在于CAS操作不涉及锁适合空桶快速插入一旦发生哈希冲突、目标桶已有节点才用synchronized锁住这个桶的头节点。不同桶之间互不干扰天然支持多线程并发写入。1.3 锁的粒度决定了并发上限为了直观理解做个简单的类比SynchronizedMap像是公司只有一个会议室无论谁要开会都得先预定这唯一一间大家排队等候ConcurrentHashMap则是每个部门都有自己的会议室各部门内部协调部门之间完全不用等反正会议室多着呢虽然理论上会议室总数有限但实际中不同部门同时开会的概率和频率完全不同。并发场景下SynchronizedMap永远只有一个线程在干活其他线程全部阻塞ConcurrentHashMap可以让多个线程分别操作不同的桶同时干活。2. 性能差距为什么这么大2.1 实际压测的数据参考纸上谈兵没有说服力我用JMH做了一组粗糙的基准测试8核机器JDK 8环境50个线程同时写入每个线程写入1万次随机key。容器类型写入耗时约读取耗时约说明HashMap无同步80ms30ms基线线程不安全Collections.synchronizedMap1850ms900ms全表锁线程排队ConcurrentHashMap320ms120ms锁粒度细并发高同一个量级的数据下ConcurrentHashMap的写入吞吐量大约是SynchronizedMap的5到6倍。关键不只是快多少倍而是随着线程数增加SynchronizedMap的耗时几乎线性上升而ConcurrentHashMap能保持相对稳定。2.2 synchronized锁的升级机制JDK 6之后synchronized经过大量优化引入了偏向锁、轻量级锁、重量级锁的升级路径。这不是说synchronized性能很差而是说当大量线程同时竞争同一把锁时锁最终会升级为重量级锁线程进入操作系统内核态阻塞、唤醒上下文切换成本极高。SynchronizedMap正是这种所有线程争抢一把锁的典型场景。哪怕读操作远多于写操作所有读线程也得排队吞吐量自然上不去。ConcurrentHashMap通过CAS加细粒度锁大幅减少了锁竞争。CAS是无锁操作利用CPU的compare-and-swap指令在用户态完成失败就重试不会导致线程阻塞。JDK 8里synchronized锁的是单个桶的头节点锁竞争的范围从整个Map缩小到单个桶多线程同时写入不同桶时根本不会互相阻塞。2.3 读写并发时的差异更加明显这里有个很容易被忽略的点SynchronizedMap的读和写共用同一把锁。你有一个高频读取、偶尔写入的场景SynchronizedMap依然会让所有读线程排队等锁。ConcurrentHashMap在读操作上做了优化public V get(Object key) { NodeK,V[] tab; NodeK,V e, p; int n, eh; K ek; int h spread(key.hashCode()); if ((tab table) ! null (n tab.length) 0 (e tabAt(tab, (n - 1) h)) ! null) { // 先检查链表头节点 if ((eh e.hash) h) { if ((ek e.key) key || (ek ! null key.equals(ek))) return e.val; } // hash为负数说明是树节点或者正在扩容迁移 else if (eh 0) return (p e.find(h, key)) ! null ? p.val : null; // 普通链表遍历 while ((e e.next) ! null) { if (e.hash h ((ek e.key) key || (ek ! null key.equals(ek)))) return e.val; } } return null; }get方法全程没有加锁。读到的是Node数组的volatile读节点的val字段也是volatile的保证可见性。这意味着即使其他线程正在写同一个桶读线程也不会被阻塞可能读到旧值但不会读到中间态或脏数据。读操作无锁这是ConcurrentHashMap读取性能远超SynchronizedMap的决定性因素之一。3. 迭代器行为fail-fast还是弱一致性3.1 SynchronizedMap迭代时的线程安全问题SynchronizedMap因为是包装HashMap迭代器本质上还是HashMap自带的迭代器属于fail-fast机制。一旦迭代过程中检测到结构修改modCount变化立即抛出ConcurrentModificationException。更坑的是有一个流传很广的正确写法MapString, String map Collections.synchronizedMap(new HashMap()); synchronized (map) { for (String key : map.keySet()) { // ... } }这个写法本身没问题遍历时外部加锁保证不会有其他线程并发修改。但很多初学者不知道需要手动加锁直接遍历线上环境就会看到ConcurrentModificationException从莫名其妙的地方冒出来。注意synchronizedMap的迭代器本身并不会自动加锁这是一个典型的需要业务方自己记住加锁的设计非常容易踩坑。3.2 ConcurrentHashMap的弱一致性迭代器ConcurrentHashMap的迭代器是弱一致性weakly consistent的它不会抛ConcurrentModificationException但也不保证遍历过程中能看到其他线程最新的修改。具体表现为迭代器创建后如果其他线程新增、修改、删除元素迭代器不会感知也不会报错遍历过程中可能看到部分修改也可能看不到取决于是否遍历到对应桶这种设计的取舍很明确为了并发性能放弃强一致性换来的是迭代过程中不需要加锁。在大部分缓存、统计、批处理场景下弱一致性完全够用。3.3 size()和isEmpty()的非精确性还有一个常被忽略的细节ConcurrentHashMap的size()并不精确。JDK 8的size()实现利用BaseCounter和CounterCell类似于LongAdder的思想把计数分散到多个Cell上减少竞争。public int size() { long n sumCount(); return ((n 0L) ? 0 : (n (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); }sumCount()会遍历CounterCell数组累加这个过程没有加锁所以在高并发写入下返回的size可能和真实值有偏差。精确值可能偏差数十到数百但这在绝大多数场景下无关紧要。如果你需要精确计数正确做法是额外用AtomicLong维护自己的计数或者干脆避免依赖size。4. 最容易让人懵的场景同时读写报null4.1 报空指针的根本原因不是并发热词里有个concurrenthashmap 同时读写 报null这个现象在网上讨论度很高但其实并发本身不会导致报null。要理解这个问题先明确一个关键特性ConcurrentHashMap不允许key为null也不允许value为null。不管是单个线程还是并发场景只要put一个null值立刻抛出NullPointerException。ConcurrentHashMapString, String map new ConcurrentHashMap(); map.put(key, null); // 直接 NullPointerException map.put(null, value); // 直接 NullPointerException为什么这样设计因为ConcurrentHashMap的get方法返回null有两种含义key对应的value为nullkey在Map中不存在HashMap允许null值所以你可以用containsKey去区分这两种情况。但ConcurrentHashMap为了并发性能尽量避免二次查找直接用返回值null表示不存在所以干脆连null值也禁止。这个设计思路是与其让你在并发场景下分不清null的语义不如直接不让你存null。SynchronizedMap因为底层是HashMap完全允许null key和null value这是两者在API语义上的一个重要差异。4.2 并发读写时get返回null的真相更多时候同时读写报null实际是get返回了null但调用方没有做null判断直接把返回值拿去用了于是空指针。典型场景// 线程A String value map.get(key); if (value.length() 0) { // 这里NPE } // 线程B map.remove(key);线程A先get返回非null然后线程B在这期间移除了这个key线程A还拿着旧引用继续操作不会报错。但如果线程A在执行get的时候线程B已经删除了这个keyA就会收到null再调用value.length()当然空指针。另一个常见场景业务代码里先put再get误以为一定能拿到map.put(userId, orderInfo); OrderInfo info map.get(userId); // 如果没有别的线程remove这里没问题 info.getOrderNo(); // 但如果有线程并发remove这里可能NPE关键排查思路报NullPointerException的那行代码一定是某个变量被赋了null值而这个null值往往不是并发导致的而是你压根没做null检查。先查业务逻辑再查并发问题。4.3 排查步骤建议遇到同时读写报null建议按这个顺序排查先检查是不是有人put了null值进来全局搜索put调用排除null入参检查get返回值后有没有判空直接把返回值用于后续操作是大忌检查有没有remove操作高并发时remove会导致get返回null使用compute系列原子方法替代get加put的组合操作可以避免中间态第四点很值得展开。假设你的需求是如果key存在就对value做一定计算后更新用get加put会有并发问题要么用锁要么用computemap.compute(key, (k, v) - { if (v null) { return initialValue; } return calculate(v); });compute方法在ConcurrentHashMap中是原子执行的内部会锁住对应桶保证整个过程不会被打断安全且高效。5. 到底什么时候该用哪个5.1 结论先行单线程环境用HashMap就够了加锁纯属浪费多线程但读多写少、且对实时性要求不高ConcurrentHashMap多线程但写多读少优先考虑ConcurrentHashMap必要时配合其他手段需要兼容旧的Collections框架接口或者极端环境下需要null key/value才考虑SynchronizedMap数据量极小、并发极低比如几百并发以下、且不想引入额外依赖SynchronizedMap也能用5.2 两个容器都不能做的事SynchronizedMap和ConcurrentHashMap都不是所谓的万能并发容器。它们保证的是单个操作的原子性而不是复合操作的原子性。检查再更新check-then-act这种复合操作即使全用ConcurrentHashMap也可能出错if (!map.containsKey(key)) { map.put(key, value); // 两个线程可能同时执行到这里 }这类场景Segment、putIfAbsent、compute、merge这些API才有用map.putIfAbsent(key, value); // 只有key不存在时才写入 map.merge(key, value, (oldVal, newVal) - oldVal newVal); // 原子合并凡是需要先读再写的业务逻辑都应该优先用ConcurrentHashMap提供的原子复合方法而不是自己拼get和put。5.3 JDK版本带来的变化JDK 7的ConcurrentHashMap用的是分段锁Segment数组默认16个理论上并发度上限就是16。JDK 8改成CAS加锁桶后并发度不再受固定Segment数量限制理论上能支撑更高的并发访问。还有一个细节JDK 8的ConcurrentHashMap引入了treeifyBin机制当单个桶的链表长度达到8且整个数组长度达到64时链表会转为红黑树。这样即使大量key哈希碰撞挤在同一个桶里查询复杂度也能从O(n)降到O(log n)。JDK 7的Segment方案没有这个优化。如果你还在维护JDK 7的老项目升级到JDK 8不仅仅是版本号的改变ConcurrentHashMap的并发能力和查询性能都会有明显提升。6. 常见问题速查与经验总结6.1 快速对照表对比项Collections.synchronizedMapConcurrentHashMap锁粒度整个Map一把锁每个桶一个锁JDK 8读操作需要抢锁无锁volatile读写操作全表锁CAS 锁桶头节点null key/value允许完全禁止迭代器fail-fast可能抛CME弱一致不抛CMEsize()精确加锁非精确累加Cell复合操作不支持原子性有putIfAbsent、compute等API适用场景并发极低、兼容旧代码高并发、缓存、计数聚合6.2 五个经验之谈第一如果你只需要一个线程安全的Map不要纠结无脑选ConcurrentHashMap。现在JDK大版本已经到23ConcurrentHashMap的维护和优化一直没停过SynchronizedMap除了老项目没人再用它做核心容器。第二高并发下不要依赖size()和isEmpty()做业务判断哪怕是ConcurrentHashMap也不精确。要用AtomicLong之类的计数器自己维护。第三读多写少的场景ConcurrentHashMap的get无锁设计能发挥巨大优势这也是它适合做缓存底层的核心原因。第四JDK 8的ConcurrentHashMap在小数据量下并不比HashMap快多少因为TreeBin和CAS都有一定开销。如果并发压力上不来简单用HashMap加外部锁反而更直观。第五报NullPointerException时先怀疑业务逻辑不要第一时间归咎于并发。我自己处理过的所有ConcurrentHashMap并发报null问题中绝大多数都是代码里没做null检查真正的并发导致的数据异常其实是IllegalStateException、ConcurrentModificationException这类。我手里有好几个线上系统的缓存和计数场景底层全是ConcurrentHashMap从JDK 8跑到现在性能稳定。这个细节很容易被忽略JDK 8开始ConcurrentHashMap在扩容时会利用ForwardingNode协助迁移读取线程如果遇到ForwardingNode会跳到新表继续读所以扩容对读几乎没有影响这比SynchronizedMap高下立判。如果你还在纠结要不要换掉项目里的SynchronizedMap我建议你做一个小时的压力测试对比看到吞吐量和延迟的差距之后你会回来这篇博客的。
返回列表