资讯详情

资讯详情

SynchronizedMap与ConcurrentHashMap:锁粒度、性能与null陷阱全解析

很多朋友第一次见到这个对比是在面试题里第二次是在压测现场发现一个跑得飞快、一个卡得不忍直视。SynchronizedMap和ConcurrentHashMap看起来都是“线程安全的Map”但在并发度、迭代语义、对null值的容忍度上完全是两套设计哲学。这篇文章我按自己多年的使用经验从底层实现、行为差异、实测数据到“同时读写报null”这个高频坑把两者彻底摊开讲清楚。不管你是刚接触并发还是已经在项目里替换过两者都能从中找到有价值的东西。1. 先搞清楚这两个家伙到底是谁1.1 SynchronizedMap一件给HashMap穿上的“防弹背心”SynchronizedMap不是一个新的Map实现类它是Java集合框架里Collections工具类提供的一个装饰器。你自己写一行代码就能得到它MapString, String syncMap Collections.synchronizedMap(new HashMap());本质上它把HashMap原封不动装进一个包装类然后在每个方法上都套了一层synchronized代码块。也就是说原本非线程安全的HashMap穿上这件“防弹背心”后读写操作会被互斥锁保护起来同一时刻只能有一个线程执行Map上的方法。这个设计的最大好处是改造极小。你原来用HashMap想升级成线程安全版本把new HashMap()换成Collections.synchronizedMap(new HashMap())其他业务代码基本不用动。但代价也很明显无论你开多少个线程最终都会挤在同一把锁上。读和读互相排斥读和写互相排斥写和写更互相排斥。这把锁就是整张Map唯一的“门禁”门禁只有一个所有人都要排队。1.2 ConcurrentHashMap为高并发而生的“独立王国”ConcurrentHashMap同样是个线程安全的Map但它不是哪张Map的包装而是一个从底层就为并行设计的完整数据结构。JDK 7时代它采用分段锁把整张表切成16段默认不同线程操作不同段时互不干扰。JDK 8及以后实现又升级为CAS 桶级别锁锁粒度进一步缩小到单个哈希桶读操作甚至完全不加锁。换句话说SynchronizedMap是给原有数据结构做了一层安全保障ConcurrentHashMap则是把“并发性能”当成第一优先级来设计的。这里先记住一个最简单的印象差异同是线程安全SynchronizedMap的并发能力约等于1ConcurrentHashMap的并发能力约等于桶的数量。后面所有行为差异几乎都是由这个底层设计引申出来的。1.3 一个常见的入门误区我刚工作那会儿一直以为Hashtable、SynchronizedMap、ConcurrentHashMap三者差不多反正都是线程安全的Map性能有点差别而已。后来在项目里做一次高并发接口优化把SynchronizedMap换成ConcurrentHashMap接口的P99耗时直接降了一个量级。这才开始真正研究底层发现它们仨的差异根本不是“性能稍微好点”这种程度而是“设计目标完全不同”。Hashtable是最老派的全表加锁和SynchronizedMap类似ConcurrentHashMap是新一代并发容器它的设计目标是读操作尽量无锁写操作只锁住必要的极小范围扩容、统计、遍历都围绕高并发场景做了专门优化。2. 底层实现差异决定了性能天花板2.1 SynchronizedMap一把大锁锁全表看JDK源码Collections.SynchronizedMap内部维护了一个互斥锁mutex一般在构造函数里默认是this你也能通过两参构造手动指定锁对象。它每个核心方法几乎都是同一个套路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); } }把这段代码拆开看你就知道两个问题一是锁范围是整个Map对象。不同key的读写甚至相同key的读读之间都在竞争同一把锁。在CPU密集型服务里线程越多竞争越惨烈线程大部分时间不是在干活而是在等锁。二是迭代器并没有被包装。entrySet()、keySet()、values()返回的是底层HashMap的迭代器它只保证单线程遍历安全不保证多线程场景下的安全。如果你在遍历的同时有其他线程修改Map迭代器会快速失败直接抛ConcurrentModificationException。官方源码注释里明确告诉你遍历时必须你自己手动加外部锁synchronized (syncMap) { for (Map.EntryString, String entry : syncMap.entrySet()) { // 遍历逻辑 } }所以SynchronizedMap更像一个“能用但你要时刻记得补锁”的半成品。它把单线程世界里的Map强行塞进多线程环境能用但用得不痛快。2.2 ConcurrentHashMapJDK 7分段锁与JDK 8锁桶的演进先看JDK 7的实现。ConcurrentHashMap内部维护一个Segment数组默认容量16。每个Segment本身是一张小的哈希表同时继承ReentrantLock。写入时先根据key的hash定位到某个Segment然后只锁住这个Segment。两个线程只要操作的是不同Segment就能并行执行互不阻塞。这就是“分段锁”字面意思把一整张Map切分成多段每段拥有独立的锁。理论上并发度就是Segment数量默认16。那个年代ConcurrentHashMap能吊打Hashtable靠的就是这个设计。JDK 8开始Segment方案被废弃了不再搞“锁段”而是把锁粒度直接降到单个哈希桶。写入一个key时先做一次CAS尝试如果目标桶为空直接CAS把新节点放进去整个过程不加锁。如果目标桶不为空再对桶头节点执行synchronized加锁锁住一条链或者一棵红黑树然后执行插入或更新。读取Get就更彻底了完全不加锁。它依赖volatile语义来保证可见性通过tabAt和getObjectVolatile这类Unsafe方法直接读取节点数组中的元素。所以JDK 8的ConcurrentHashMap实际锁范围比JDK 7更小了而且并发扩容时还有多线程协作迁移机制整体设计更加激进。2.3 从锁粒度推导出并发上限锁粒度差异直接体现在并发上限上。SynchronizedMap的锁粒度是整张表无论多少线程同时只有一个能进入方法并发能力是1。ConcurrentHashMap的锁粒度是单个桶在理想情况下key散列均匀每个线程操作一个空桶时连锁都不用只有哈希冲突了才锁桶。并发能力不存在硬性上限而是随着桶数量、冲突概率和数据规模动态变化。这里可以拿生活中的场景类比。SynchronizedMap相当于一个只有一名收银员的小超市买再小的东西也要排队ConcurrentHashMap相当于每个货架都配了独立收银员顾客分散在各个货架前只有碰巧挑同一个货架的人才会互相等待。这也是为什么很多团队在低并发场景下用了很久SynchronizedMap都没出问题一旦流量涨到某个临界点接口耗时就开始断崖式恶化。不是代码逻辑变了是锁变成了瓶颈。3. 五个维度把两者掰开揉碎对比3.1 锁粒度与吞吐量最核心的差异就是锁粒度。SynchronizedMap全表锁任何操作都需要mutex串行化吞吐量随线程数增加几乎不增长甚至因为锁竞争导致性能下降。ConcurrentHashMap读无锁写仅在哈希冲突时锁桶不同桶之间完全并行吞吐量能随线程数扩展。我在文章后面放了一组压测数据这里先给结论并发线程数越高两者的吞吐差距越大低并发下差别不明显但一旦超过四核八线程场面会完全拉开。3.2 迭代器fail-fast还是弱一致SynchronizedMap的迭代器继承自HashMap行为是fail-fast。遍历过程中如果检测到结构性修改add、remove这类改变元素数量的操作会立刻抛出ConcurrentModificationException中断遍历。ConcurrentHashMap的迭代器则采用弱一致性设计。它不保证遍历开始时点的完整快照但也不抛CME。遍历过程中你可以看到其他线程已经完成的修改也可能看不到正在进行的修改已经遍历过去的部分即使被改动了迭代器也不会回头感知。听起来好像“不如SynchronizedMap可靠”其实恰恰相反。实际开发里遍历一个大Map时你往往不希望一个并发修改直接让整个业务崩溃。弱一致迭代器省去了外部加锁的繁琐也不会因为别的线程碰一下Map就抛异常。代价是如果你想拿到一个绝对精确、不会再变的快照ConcurrentHashMap做不到需要自行实现深度拷贝或额外同步。3.3 复合操作的原子性一个靠锁一个靠方法所谓复合操作是指“先检查再操作”这种多个步骤的组合最常见的两个是putIfAbsent不存在才写入removeIfEqual只有值等于预期值才删除在SynchronizedMap里这些操作都需要你手动加锁比如synchronized (syncMap) { if (!syncMap.containsKey(key)) { syncMap.put(key, value); } }一旦忘记套synchronized两个线程就会同时通过containsKey检查然后同时执行put有一方数据被覆盖。ConcurrentHashMap则把所有常见复合操作做成了原子方法chm.putIfAbsent(key, value); chm.remove(key, expectedValue); chm.replace(key, oldValue, newValue); chm.compute(key, (k, old) - newValue); chm.computeIfAbsent(key, k - loadFromDB(k)); chm.merge(key, value, (old, next) - old next);这些方法内部已经正确处理了并发竞争你不需要也不能自己再加锁直接用就行。对写业务代码的人来说这才是它真正友好的地方并发容器把线程安全细节封装好了你只需要关心业务逻辑。3.4 null键和null值一个允许一个直接拒绝这是一个非常隐蔽但又非常容易踩的差异。SynchronizedMap包装的是HashMapHashMap本身允许一个null键和任意多个null值所以MapString, String syncMap Collections.synchronizedMap(new HashMap()); syncMap.put(null, ok); // 正常 syncMap.put(key, null); // 正常ConcurrentHashMap则相反从设计上就禁止null键和null值ConcurrentHashMapString, String chm new ConcurrentHashMap(); chm.put(null, value); // 抛 NullPointerException chm.put(key, null); // 抛 NullPointerException为什么ConcurrentHashMap这么“不近人情”因为null对它来说是个歧义值。你用get(key)拿到null时到底代表“这个key不存在”还是“这个key对应的值本身就是null”在无锁并发环境下如果允许null值代码内部就要设计一个特殊标记来区分这两种情况大大增加复杂度。更关键的是ConcurrentHashMap的方法大量使用null作为“不存在”的哨兵值。比如computeIfAbsent方法规定映射函数返回null就代表不建立映射。如果存进去的value也可以是null那这个语义就全乱了。所以它干脆一刀切禁止null。这也是从SynchronizedMap迁移到ConcurrentHashMap时最常遇到的第一个兼容性问题很多人一上线就收到一堆NullPointerException就是这个原因。3.5 扩容策略全员暂停还是协作迁移HashMap扩容时要做rehash相当于把老数组里的所有元素重新散列到新数组。SynchronizedMap在扩容时所有线程都在互斥锁的保护下排队等待扩容期间整张表对外的读写全部暂停。ConcurrentHashMap的扩容则是多线程协作的。它会把老table的数据分片由多个正在进行写操作的线程一起帮忙迁移同时其他线程依然可以执行读操作。如果某个线程在扩容期间访问到了已经被迁移走的桶会通过ForwardingNode转发到新table上继续查找。这个设计保证了一个结果就算Map正在扩容量翻倍读写也不会完全停止服务的抖动被控制在最小范围。在缓存类场景里这一点对稳定延迟非常重要。4. 实测一组数据看差距到底有多大4.1 压测环境和代码思路说再多原理不如跑一组数据直观。我设计了一个简单的并发读写压测模拟8个线程同时对一个Map进行put和get操作key空间固定为10万个随机数每个线程各执行50万次混合读写。同样的业务代码只替换Map实现分别跑SynchronizedMap和ConcurrentHashMap。核心压测代码大概这样public class MapBenchmark { private static final int THREAD_NUM 8; private static final int OPS_PER_THREAD 500_000; private static final int KEY_RANGE 100_000; private static final Random RANDOM new Random(); private static void run(MapInteger, Integer map, String name) throws InterruptedException { long start System.currentTimeMillis(); ExecutorService pool Executors.newFixedThreadPool(THREAD_NUM); CountDownLatch latch new CountDownLatch(THREAD_NUM); for (int i 0; i THREAD_NUM; i) { pool.execute(() - { for (int j 0; j OPS_PER_THREAD; j) { int key RANDOM.nextInt(KEY_RANGE); if (j % 5 0) { map.put(key, j); } else { map.get(key); } } latch.countDown(); }); } latch.await(); pool.shutdown(); long cost System.currentTimeMillis() - start; System.out.println(name 耗时: cost ms); } public static void main(String[] args) throws InterruptedException { run(Collections.synchronizedMap(new HashMap()), SynchronizedMap); run(new ConcurrentHashMap(), ConcurrentHashMap); } }4.2 结果与解读这里把我某次测试的数据列出来具体耗时会受CPU核数、JDK版本影响但趋势非常有代表性Map实现8线程总耗时相对开销SynchronizedMap1832 ms约4.1倍ConcurrentHashMap447 ms1倍同样是400万次操作耗时差了将近4倍。我还试着把线程数从1逐步增加到16观察两者的走势。1线程时两者几乎打平SynchronizedMap甚至有极微小优势毕竟装饰器套层不如原生路径直接。到8线程时差距开始拉开16线程时SynchronizedMap已经基本不再随线程数提升性能反而因为锁竞争变得更慢。这个现象背后的逻辑很简单SynchronizedMap的所有线程都被同一把锁串行化加线程只增加排队和上下文切换的负担ConcurrentHashMap的读操作不占锁写操作分散到不同桶新增线程只要去操作不同的key就几乎不产生锁竞争。顺便提一句如果你用JMH做更规范的基准测试核心槽点还是同一个线程越多SynchronizedMap的劣势越明显。4.3 顺带测一下“同时插入不同key”我又单独做了一个场景16个线程同时插入不可重复的新key每个线程插5万个最后统计Map的size。SynchronizedMap在这个场景下耗时同样远高于ConcurrentHashMap而且因为它全程串行化物理耗时几乎是“总任务量除以单线程速度”的线性结果。ConcurrentHashMap则因为不同key散落到不同桶大部分插入走CAS几乎无锁冲突耗时远低于前者。这组测试给我留下的最深刻印象是线程安全只是底线并发性能才是这类容器真正的分水岭。5. “同时读写报null”到底是怎么回事5.1 一个真实的报错现场热搜里的“concurrenthashmap同时读写报null”我基本可以确定是下面这种代码引发的ConcurrentHashMapString, String map new ConcurrentHashMap(); map.put(order-001, created); // 线程A处理完订单后删除 new Thread(() - map.remove(order-001)).start(); // 线程B读取订单状态 new Thread(() - { String status map.get(order-001); if (status.equals(created)) { // 这里抛 NullPointerException System.out.println(订单正常创建); } }).start();如果线程A先运行完remove线程B再执行getmap.get(order-001)返回的就是null然后这句status.equals(created)直接空指针。很多人的第一反应是“ConcurrentHashMap是不是有并发读写的bug为什么读的时候拿不到数据”5.2 为什么这里会返回null这里没有任何bug行为完全符合Map的语义。ConcurrentHashMap里的key被移除后get自然返回null。这和“HashMap移除某个key后get返回null”没有任何区别。真正的问题是调用方把“返回null”当成“不可能发生”来写了没有做空值保护。注意这里不存在“弱一致”导致读到旧值或者读不到已提交值的问题。只要remove方法已经返回后续的get就能感知到key不存在可见性是没问题的。区别在于如果remove和get交错执行get到底看到的是“移除前”还是“移除后”的状态有不确定性。你正好赶在remove完成之后去get拿到的就是null。另外一个容易混淆的点是很多人听说ConcurrentHashMap读操作不加锁就以为“读只能拿到旧值”。实际上读不加锁只是说不阻塞、不参与锁竞争volatile读依然能感知到其他线程已经完成的变化。拿不到值只会是key真的不存在而不是“并发让我看不见”。还有一个完全不相关的“报null”可能大家都忘了如果你直接执行map.put(key, null)ConcurrentHashMap会立刻抛NullPointerException。把这种“主动放null”的行为说成“并发报null”属于用错了API。5.3 三个最容易踩的null陷阱第一get的返回值不做判空直接解引用。这是最常见的一种上面的订单案例就是典型。第二把ConcurrentHashMap当缓存用value允许为null。业务里某个value确实可能是null在HashMap里没问题在ConcurrentHashMap里直接写入失败。这种场景下建议单独用一个Set或标志来表示“存在”或者重新设计值类型用Optional包装。第三computeIfAbsent的映射函数返回null。这个很容易被忽略Object v map.computeIfAbsent(key, k - findFromDB(k)); // 如果 findFromDB 返回 nullv 也是 null在JDK 8的设计里映射函数返回null时不会建立映射computeIfAbsent也会返回null。你以为是“拿不到值会抛异常”实际上它问你要了一个null如果你再接着解引用空指针就来了。5.4 正确的并发读写姿势并发读写的正确写法核心就一条永远不假设get一定会返回非null除非你能证明key在此之前一定存在且从未被移除。以订单场景为例安全写法是String status map.get(order-001); if (status ! null) { // key 存在正常处理 } else { // key 不存在或已被移除按业务规则走别的分支 }如果希望“存在才更新不存在就别动”应该使用ConcurrentHashMap自带的原子方法map.computeIfPresent(order-001, (k, oldStatus) - paid);如果希望“不存在就初始化并返回”用computeIfAbsentOrder order map.computeIfAbsent(orderId, k - loadFromDB(k));总之当你的业务逻辑是“同时可能有线程删除某个key又有线程读取这个key”时唯一的确定性保障就是先判空再使用。不要指望Map本身给你的get调用提供“永远有值”的承诺。6. 项目选型经验与建议6.1 决策对照表什么场景该用谁这几年我帮团队评审了不少并发方案的选型下面这张表基本能覆盖大部分场景业务场景推荐方案原因老项目低并发、只想快速加个线程安全兜底SynchronizedMap改动极小一行代码搞定高并发读多写少ConcurrentHashMap读不加锁吞吐量高并发下需要原子复合操作ConcurrentHashMapputIfAbsent、compute、merge 原生支持不允许null值参与业务ConcurrentHashMap直接拒绝防脏数据需要精确遍历快照两者都不完美建议手动深拷贝或加全局锁需要按key自然顺序遍历ConcurrentSkipListMap比这两个更适合缓存容量不大且并发量低哪种都行性能瓶颈在别处Map不是热点6.2 我实际项目里的选择习惯如果是新写的代码我几乎无脑选ConcurrentHashMap哪怕是低并发场景也不犹豫。原因不是性能而是它暴露的API更安全。我不用自己记得加锁不用害怕遍历时突然抛CMEputIfAbsent这类方法还能少写不少样板代码。如果是老接口改造原来用HashMap并发量也不高我才会考虑用Collections.synchronizedMap顶一下。它确实能让代码快速上线但我会顺手加个技术债记录等接口流量上来了再替换。如果项目已经用了Spring的缓存抽象比如Cacheable那更轮不到这两个容器本地缓存选Caffeine分布式缓存选Redis比手撸Map靠谱得多。ConcurrentHashMap适合的是业务代码内部那些“需要临时共享状态”的高频场景。6.3 关于强一致性的提醒最后想特别强调一点ConcurrentHashMap不是强一致容器size()返回的是近似值isEmpty()也不保证实时准确。如果你拿着size()去做限额判断比如用户获得了某个权益后要顺便扣减Map里的剩余额度这在并发下会出问题。两个线程同时读到的size()可能都是“还有1个名额”结果都通过了校验。这种场景应该用AtomicInteger、LongAdder或者把计数放到数据库事务里而不是指望ConcurrentHashMap的size()给你精确值。我踩过的最深的一个坑就是早期用ConcurrentHashMap的size()做库存扣减计数上线后出现了超卖。后来改成AtomicLong维护剩余量问题才消失。这也是我想传达的核心经验认识一个类不能只看它能干什么更要看它不承诺什么。ConcurrentHashMap承诺的是并发访问下的线程安全不是强一致的全局快照。想清楚这一点你在选型和使用时就不会犯方向性错误。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →