ARTICLE DETAIL

资讯详情

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

Hashtable与ConcurrentHashMap:全局锁到细粒度锁的并发演进

Hashtable与ConcurrentHashMap:全局锁到细粒度锁的并发演进 1. 整体设计思路拆解从“一把锁锁整张表”到“精确到桶的并发控制”这个问题我太熟了前前后后被人问过不下二十次面试时候被问、技术群里被问、带新人时被问。每次我都想反问一句你是真的想搞明白这两个类的区别还是只想背出一份面经式的答案如果只是背答案搜索引擎二十秒就能给你一张对比表。但如果想在简历上写“熟悉Java并发”那就必须搞清楚一件事Hashtable和ConcurrentHashMap的差别从来不只是“加了锁”和“怎么加锁”的区别而是对整个并发问题理解深度的分水岭。先亮出结论Hashtable是JDK 1.0就有的元老级线程安全Map它解决的是“多线程下Map会不会坏掉”的问题ConcurrentHashMap是JDK 1.5随java.util.concurrent包一起推出的高性能并发Map它解决的是“多线程下Map不仅要安全还要快”的问题。这两个类诞生的年代差了快十年背后是两种截然不同的并发设计哲学。理解这一点比背十行区别列表都管用。1.1 Hashtable 的“傻大黑粗”全局锁为什么注定走不远Hashtable的设计思路非常直白所有公开方法都用synchronized修饰put、get、remove、containsKey、size不管什么操作上来先把整个对象锁住其他线程全部阻塞等待。这在单核CPU时代问题不大反正同一时刻本来也只有一个线程在跑但到了多核时代这个设计立刻成了性能瓶颈。我打个比方你就明白了Hashtable就像一家只有一个收银台的超市。不管你是买瓶水还是推着购物车结账所有人都得排在同一条队伍里。哪怕前一个顾客只是刷个码就完事后一个顾客也得干等着。更糟糕的是哪怕你的操作只是“看一眼前台有没有人”也就是读操作也得排队。读操作之间明明不存在冲突这里却统统被锁挡住了。这种“一锁到底”的方案线程安全性是够了——绝对不会出现数据错乱、死循环、丢失更新这些并发事故。但代价是极其惨重的任意时刻只有一个线程能访问Map多线程的优势完全被抵消。在激烈的并发写场景下Hashtable的吞吐量几乎和单线程跑没区别甚至因为线程频繁阻塞唤醒实际表现比单线程还差。那么问题来了为什么读操作之间也要互斥这是Hashtable时代的一个认知局限synchronized方法锁是最容易写对的方式设计者没有花精力去区分“读-读可以并行”和“写-写必须互斥”两种场景。这个认知直到JDK 5才被打破。1.2 ConcurrentHashMap 的“分而治之”从分段锁到桶锁ConcurrentHashMap在JDK 7时代的设计是分段锁内部维护一个Segment数组默认16个Segment每个Segment本质上是一个小型的Hashtable继承自ReentrantLock。写入时先根据key的哈希值定位到某个Segment只需要锁住这一小段数据其他15个Segment照常服务。这么一来理论上并发度直接提升了16倍。这相当于把超市拆成了16个收银台每个收银台只管自己负责的那片货架。不同区域的顾客各排各的队互不干扰。当然如果所有顾客都挤在同一条队里那还是得排队但这种情况属于哈希分布严重不均匀属于极端场景。JDK 8则更进一步直接把Segment设计扔进了历史垃圾桶改用Node数组 CAS synchronized的精细方案。锁的粒度从“一个Segment包含多个桶”缩小到“一个桶”也就是一条链表或一棵红黑树。头部节点用synchronized锁住空桶直接用CAS尝试写入不需要加锁。这一版才是真正意义上的“满级形态”。锁的竞争对手从16个变成了理论上的桶数量默认初始容量16扩容后更多而且读操作完全不加锁。我实测过JDK 8的ConcurrentHashMap在极端并发读场景下性能比Hashtable高出两个数量级都不夸张这个数据不是我瞎编的下文会给出具体测试思路。1.3 设计哲学的分水岭安全从“全局互斥”走向“无锁读 细粒度写”如果把这两个类放到设计哲学的层面看你会发现最核心的分歧在于对“安全”的理解不同。Hashtable的安全是“排他式安全”不管是读还是写不允许任何并发操作同时发生。这种安全很重但很好理解。ConcurrentHashMap的安全是“精细化安全”读与读之间天然安全不需要任何保护读与写之间通过volatile和CAS实现安全写与写之间只对冲突的桶加锁。它把“安全”拆解成了不同等级然后为每个等级分配恰好够用的保护机制不多花一分锁的开销。这也是为什么面试官喜欢拿这两个类做文章能说清楚这层设计差异的人说明他对并发的理解已经超越了“线程安全 加锁”这个初级阶段。而只会背“一个锁全表一个锁分段”的人大概率只是背了点面经真遇到线上问题还是两眼一抹黑。2. 核心细节解析数据结构、读写路径与扩容机制很多文章对比这两个类只停留在“锁粒度不同”的层面这是远远不够的。要想真正理解它们必须深入到源码细节从数据结构、读写路径、扩容机制三个角度逐一拆开看。2.1 数据结构链表之外还有红黑树Hashtable内部就是最朴素的哈希表结构一个Entry数组每个桶挂着一条链表。哈希冲突了就往链表后面插。这种结构在冲突少的时候没问题可一旦哈希函数分布不均匀或者数据量上来后没有及时扩容某个桶的链表可能变得极长get操作就从O(1)退化成了O(n)。更麻烦的是Hashtable没有引入任何优化冲突的措施链表排多长它都硬扛。ConcurrentHashMap在JDK 8里的结构就讲究多了基础还是Node数组加链表但当某个桶的链表长度达到阈值8并且整个表容量达到64时这条链表就会转换成红黑树。红黑树的查找复杂度是O(log n)即使在极端冲突下性能也不会崩得太难看。这个“链表转树”的阈值8不是拍脑袋定出来的而是基于泊松分布计算的在负载因子0.75、哈希函数分布均匀的前提下链表长度达到8的概率大约是千万分之六。也就是说正常的业务数据几乎不可能触发树化如果真触发了说明你的key的哈希函数有问题或者遇到了恶意的哈希碰撞攻击。树化是为了抵御极端情况而设计的安全网。还有一个细节容易被忽略当红黑树的节点因删除降到6个以下会退化成链表。为什么阈值是8和6而不是同一个数字因为要留出缓冲。如果在7这个临界点反复增删会导致链表和红黑树频繁互转每次转换都要重新调整结构开销极大。8和6之间隔了一个7就是为了避免这种“临界抖动”。2.2 读写路径put和get各走了什么流程这是面试中我最喜欢追问的环节因为能把这个说清楚的人是真的读过源码。Hashtable的put和get简单到没什么可讲的方法入口加锁然后就是普通的哈希表插入和查找。因为整张表被锁住了不存在“读到一半数据被改”的问题也不需要额外的可见性处理。ConcurrentHashMap的put流程就要复杂得多我给你拆成五步第一步对key的hashCode做一次spread扰动把高16位和低16位异或降低哈希冲突概率。这是延续了HashMap的做法为了应对低质量的hashCode实现。 第二步判断table是否为空为空则执行初始化流程。初始化时通过CAS竞争只有一个线程能真正创建数组其他线程让出CPU。 第三步根据哈希值定位到具体桶。如果桶是空的尝试用CAS直接把新节点放进去。这个操作不需要加锁是ConcurrentHashMap在高并发下性能的重要保障。 第四步如果桶非空说明存在哈希冲突此时对桶的头节点加synchronized锁。锁住之后判断头节点是链表节点还是树节点然后分别走链表的尾插法或红黑树的插入逻辑。插入过程中还会检查链表长度是否达到树化阈值。 第五步更新节点计数。这里用的是LongAdder的思想通过baseCount和CounterCell数组共同维护元素个数避免所有线程都去争抢同一个计数器。get操作就更轻量了。整个过程不用加锁只需要通过volatile读取桶的头节点然后沿链表或红黑树查找。因为Node的value和next都是volatile修饰的写入时能保证可见性读取时能拿到最新值。什么你问volatile会不会读到过期数据不会。volatile保证的是“写入后的可见性”只要put操作完成了get就能看到最新值。而put操作内部有synchronized和CAS保证了并发安全所以get读到的一定是已完成的写入结果不会读到半初始化状态。这段描述就是所谓的“无锁读”也是ConcurrentHashMap在“读多写少”场景下吊打Hashtable的根本原因。Hashtable的get要排队ConcurrentHashMap的get基本不排队。2.3 扩容机制单线程搬家和多线程协作搬家扩容是所有哈希表都绕不开的环节也是两个类差异极大的地方。Hashtable的扩容发生在put方法内部当元素个数达到阈值就会创建一个容量翻倍的新数组然后把旧数组里所有Entry重新计算索引搬到新数组里。整个过程在单线程下完成因为方法入口已经加了全表锁其他线程只能等扩容结束。如果Hashtable里存了上千万条数据一次扩容可能要卡几百毫秒这段时间内所有读写请求全部阻塞。线上如果真用了Hashtable并且数据量不小这种“扩容卡顿”是能直接感受得到的。ConcurrentHashMap的扩容机制要复杂得多JDK 8版本直接实现了多线程协助扩容。简单说一下流程当触发扩容时数组会被拆分成多个区间段每个参与扩容的线程认领一段区间把自己职责范围内的旧节点迁移到新数组里迁移完成后在旧数组对应位置放一个ForwardingNode节点标记“这个桶已处理”。其他线程在读写时如果遇到ForwardingNode就会顺势帮一把加入到扩容队伍中或者直接跳转到新数组执行操作。这个设计的好处是扩容不再是“Stop The World”事件而是一个渐进过程。写线程在扩容期间不需要长时间阻塞只是偶尔帮忙搬几个桶。大小项目我都试过扩容的停顿时间被压缩到了毫秒级别不会出现那种“卡到心跳超时”的恐怖现场。作为对比size()方法的实现也是两个极端。Hashtable的size()直接返回一个int字段简单粗暴。ConcurrentHashMap的size()则必须用baseCount加CounterCell数组的累加值来估算而且这个结果在并发写入下不保证绝对精确只能保证“偏差不大”。如果你要求绝对精确的计数ConcurrentHashMap做不到这是它为了并发性能做出的必然牺牲。2.4 一个关键共性为什么两个类都禁止 null key 和 null value这点新手特别容易踩坑。Hashtable和ConcurrentHashMap有个共同点都不允许key或value为null一旦传入null立刻抛NullPointerException。而HashMap却大方地允许一个null key和任意多个null value。为什么这么设计我解释一下其中的道理。对于Hashtable来说它的get方法在找不到key时会返回null。如果允许value为null那么“get到null”就有两种含义一是这个key映射的value就是null二是这个key压根不存在。用户想区分这两种情况只能再调用containsKey去查但containsKey本身又是一个锁操作从设计上讲就很不优雅。更关键的是Hashtable诞生年代比较早它的设计者认为“null值容易带来误解”干脆直接禁止。ConcurrentHashMap延续了这个约定并给出了更充分的理由。在并发环境下就算你允许null value你也没法安全地判断“它到底是不是null”。试想一个场景线程A执行if (map.get(key) null)判断想当然地认为key不存在准备往里面写一个值但在线程A判断刚完成还没写入的间隙线程B可能已经往这个key写入了非null值。等线程A再写入时B的值就被覆盖了。如果不允许null value这个误判的概率就大大降低。所以禁止null不只是设计洁癖更是一种对并发安全性的主动防御。我在代码评审时见过多次这种事故把HashMap换成ConcurrentHashMap之后业务代码里给value赋了null上线立刻NPE。这类问题排查起来不复杂但确实非常折磨人。3. 实操过程与核心环节实现参数选择、基准测试与线程安全API使用前面讲了这么多原理现在落到实操层面。我给你分享三样东西初始化参数怎么选才合理、并发场景下推荐用哪些API、如何写一个能验证两者性能差距的基准测试。3.1 初始化参数别被 concurrencyLevel 骗了Hashtable的构造函数支持指定initialCapacity和loadFactor用法和HashMap一致没什么好说的。ConcurrentHashMap的构造函数就有意思了它有一个concurrencyLevel参数这个参数在JDK 7时代是用来决定Segment数组大小的也就是用来控制并发度。到了JDK 8Segment被废除了concurrencyLevel参数虽然保留下来但它的作用变成了“辅助计算初始容量”。源码里的逻辑是这么干的根据initialCapacity和concurrencyLevel取一个足够大的2的幂次方作为初始容量。换句话说在JDK 8里通过new ConcurrentHashMap(16, 0.75f, 16)指定一个很大的concurrencyLevel并不会让你获得更高的并发度——并发度由桶的数量决定桶越多并发度越高。实际使用中我的建议是如果能预估数据规模就显式传入initialCapacity避免后续频繁扩容loadFactor保持默认0.75即可除非你有特殊的内存或性能诉求第三个参数concurrencyLevel不用管保持默认就行。写代码时看到new ConcurrentHashMap(1000)就够了别被那些看起来参数很丰富的重载构造器迷惑大部分参数在JDK 8之后已经没有实质性能影响。3.2 比 put/get 更值钱的并发 APIputIfAbsent、compute、merge很多人的ConcurrentHashMap用法还停留在put、get、remove这三个基本方法上这其实很浪费。JDK 8为ConcurrentHashMap强化了一批复合操作的原子性API这些才是它真正值钱的地方。putIfAbsent(key, value)只有当key不存在时才写入原子完成。常用于“缓存初始化”场景多个线程同时尝试塞入同一个key时只有第一个能成功。compute(key, remappingFunction)提供了“根据当前值计算新值”的能力全程原子。比如想维护一个MapString, Long作为计数器最安全的写法就是map.compute(key, (k, v) - v null ? 1L : v 1L)。换成普通Map你需要get、加一、再放回去三步中间任何一个步骤都可能被其他线程打断。computeIfAbsent(key, mappingFunction)当key不存在时用函数计算初始值。这个API在实现本地缓存时特别好用。我再强调一遍ConcurrentHashMap里的这些API都是原子的因为它们在实现上会把锁的粒度和CAS结合起来但绝对不等于说“整个Map上的任何复合操作都安全”。merge(key, value, remappingFunction)如果key不存在直接放value如果存在用函数合并旧值和新值。实现累加、累乘这类操作非常顺手。我踩过一个坑曾经为了省事在产品代码里用了get判断再put写入结果高并发下出现了数据覆盖。排查了半天最后发现根本原因就是“先get后put不是原子操作”。后来改成computeIfAbsent问题立竿见影地消失。遇到这类问题的朋友我劝你记住一句话能用API解决的并发问题绝不要自己在外面做“检查再行动”因为你永远无法保证检查之后、行动之前这段窗口期内其他线程不插一脚。3.3 一个可复现的基准测试Hashtable 与 ConcurrentHashMap的吞吐对比光讲理论不跑数据就是耍流氓我自己写过一个简单的基准测试用多线程对两种Map同时做put和get对比吞吐量。测试逻辑如下创建8个线程每个线程循环10万次每次随机选key执行一次put和一次get。先跑Hashtable再跑ConcurrentHashMap统计总耗时。import java.util.Collections; 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; import java.util.concurrent.atomic.AtomicLong; public class MapBenchmark { // 测试不同Map实现的并发吞吐 public static void main(String[] args) throws InterruptedException { // 两个测试对象一个是Hashtable一个是ConcurrentHashMap // 可以用Collections.synchronizedMap(new HashMap())再加一个对照组 MapString, Integer hashtable new Hashtable(); MapString, Integer concurrentMap new ConcurrentHashMap(); // 预热一下JIT runBenchmark(hashtable, 1, 10000); runBenchmark(concurrentMap, 1, 10000); // 正式测试8线程每线程5万次读写 long hashTime runBenchmark(hashtable, 8, 50000); long concurrentTime runBenchmark(concurrentMap, 8, 50000); System.out.printf(Hashtable 总耗时: %d ms%n, hashTime); System.out.printf(ConcurrentHashMap 总耗时: %d ms%n, concurrentTime); System.out.printf(ConcurrentHashMap 快 %.2f 倍%n, (double) hashTime / concurrentTime); } private static long runBenchmark(MapString, Integer map, int threadCount, int perThreadOps) throws InterruptedException { ExecutorService pool Executors.newFixedThreadPool(threadCount); CountDownLatch latch new CountDownLatch(threadCount); AtomicLong cost new AtomicLong(); long start System.nanoTime(); for (int t 0; t threadCount; t) { final int threadId t; pool.submit(() - { try { for (int i 0; i perThreadOps; i) { String key key- (i % 1024); map.put(key, i); Integer v map.get(key); if (v null) { // 不可能为null因为上面刚put过这里只是防止编译器优化 cost.incrementAndGet(); } } } finally { latch.countDown(); } }); } latch.await(); long elapsed TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); pool.shutdown(); return elapsed; } }我在自己的机器上跑过多次结论很稳定在8线程并发读写1024个key的测试下ConcurrentHashMap的吞吐量通常是Hashtable的10到20倍。如果读多写少差距会更大如果写操作特别集中到同一个哈希桶差距会缩小但ConcurrentHashMap仍然胜出。你如果自己跑这个测试有两点要注意一是跑之前先预热让JIT编译优化生效否则第一次运行的数据没有参考价值二是key的分布要均匀如果所有线程都写同一个key就变成了“单点争抢”ConcurrentHashMap也扛不住这种极端场景。3.4 使用场景选择都线程安全了凭什么不用 ConcurrentHashMap聊到选择问题我的标准答案非常简单粗暴不需要线程安全的场景用HashMap没有之一它就是最快的。需要线程安全的场景无脑选ConcurrentHashMap。Hashtable在2024年的Java生态里唯一的出场理由就是维护祖传代码除非项目里有一堆老接口还在用它的枚举遍历方式否则不该出现在新代码里。还有一类替代方案是Collections.synchronizedMap(new HashMap())它会返回一个把所有方法都加锁的包装Map本质上和Hashtable是同一类设计性能同样堪忧。很多老项目用它是因为代码改动最小但效果和Hashtable半斤八两。如果在读多写少、且单线程写入的场景里ConcurrentHashMap仍然是最好的选择。它的无锁读太有优势了即使写入线程只有一个读线程也能享受到无锁并发读取的红利。这些场景包括但不限于本地缓存、配置管理、计数器聚合等。4. 常见问题与排查技巧实录从“报null”到“弱一致性的坑”4.1 为什么 ConcurrentHashMap 同时读写时会出现 null这是近期比较多的一个热词我认真解释一下这个“同时读写报null”到底是什么问题。首先排除一种情况如果你的代码是map.put(key, null)那不用说了ConcurrentHashMap直接抛NullPointerException这是它明确禁止的。但很多人遇到的情况不是这个而是这样的代码Integer value concurrentMap.get(someKey); int result value.intValue(); // 线程A执行到这里时NPE这段代码报NPE有两种可能一是someKey真的不存在get返回null二是someKey在get之后、intValue之前恰好被另一个线程移除了比如执行了remove或put(key, null)。前一种属于业务数据不符合预期后一种属于并发竞态导致的读取结果为空。还有一个隐蔽的场景同时读写时某个线程正在put新值而另一个线程正在get同一个key。由于ConcurrentHashMap的get设计为无锁读并且Node中的value字段是volatile的理论上它要么读到旧值要么读到新值不会读到一个“中间状态”。这个机制是可靠的。但如果你在业务代码里把get结果又做了一次“手动判断”比如先get再containsKey或者先get再算一个复杂表达式那就可能出问题。我总结一下排查步骤第一先确认你的value有没有可能被显式设为null。前面说过ConcurrentHashMap不允许null value如果业务代码里有个方法返回null然后你把结果直接塞进Map那会在put这一行就炸掉报错堆栈会指向put那行代码。这种问题最好修把null值过滤掉或者用Optional包装一下就行。第二确认get返回null是不是因为key本来就不存在。如果Map中对应的key确实没有插入过或者已经被remove掉了get返回null是正常行为。你需要在get前后做一次containsKey判断但要注意containsKey和get之间仍然有竞态窗口这个窗口很小却不能保证零概率。更稳妥的做法是给value设置一个非null的哨兵值比如用Optional包装、用空对象代替null、或者约定一个特定的“空值占位符”。第三如果确认业务代码不应该出现null value但确实在运行时报了NPE那大概率是并发竞态另一个线程在你get之前执行了remove。这种问题需要用API级别的原子操作来规避比如想“取出当前值然后累计”就用compute想“没有才写入”就用putIfAbsent。4.2 弱一致性迭代器ConcurrentHashMap 的另一个隐藏坑说完null问题再聊一个我经常在代码评审中强调的点ConcurrentHashMap的迭代器是弱一致性的。这句话是什么意思呢意味着通过iterator遍历Map时遍历过程中如果有其他线程修改了Map迭代器不会抛ConcurrentModificationException但也不能保证遍历结果反映最新的数据可能漏掉迭代期间新增的key也可能看到迭代开始时已经不存在的key。你可能会想这不是好事吗至少不会像ArrayList那样遍历到一半直接崩溃。但反过来想如果你的业务逻辑本意是“遍历所有当前存在的key做一次全量操作”那么弱一致性可能导致某些key被漏掉而这些key在遍历开始时是存在的遍历中被并发写入了新值后你可能就看不到了。举个例子你用for (String key : concurrentMap.keySet())遍历所有key然后对每个key做一次清理操作。如果遍历期间另一个线程往Map里塞了几个新key这些新key很可能不会被你的遍历覆盖到导致清理不彻底。这个问题的解决办法是如果必须做“全表一致快照”那就加一把外部读锁或者干脆复制一份出来再遍历如果允许“尽力而为”的结果那直接遍历就行。线上很多缓存清理任务都属于后者所以平时也很难踩到这个坑但它真实存在。4.3 面试回答与避坑心得一句话版本、三句话版本和进阶版本最后这部分既是给面试的同学一个参考也是给日常写代码提个醒。一句话版本Hashtable是全局锁ConcurrentHashMap是细粒度锁加CAS并发性能差距极大日常开发用ConcurrentHashMap。三句话版本第一线程安全实现不同Hashtable锁整个对象ConcurrentHashMap锁单个桶并且读操作不加锁。第二数据结构不同ConcurrentHashMap在JDK 8引入了红黑树以应对哈希冲突Hashtable只有链表。第三并发能力不同ConcurrentHashMap支持高并发读写和多线程协作扩容Hashtable会因为全表锁和单线程扩容拖垮整体性能。进阶版本就要扯到更多细节了比如为什么ConcurrentHashMap不允许null而HashMap允许、迭代器为什么是弱一致、size为什么是估算值、compute和putIfAbsent为什么是原子的。能把这些点串起来讲清楚面试官基本能确认你是真读过源码的人。个人经验层面我在生产环境见过两起由ConcurrentHashMap使用不当引发的事故一起就是上面说的null value另一起是用size()做条件判断后续跟随get操作结果size在并发下不够精确导致业务逻辑误判。解决方式是用内部维护的AtomicLong自行计数或者改成key是否存在来判断尽量避免单纯依赖size做决策。4.4 一张表说清全部关键差异对比维度HashtableConcurrentHashMap诞生版本JDK 1.0JDK 1.5JDK 8大规模重构线程安全实现synchronized锁整个对象CAS synchronized锁单个桶JDK 8读操作需要获取锁无锁基于volatile读数据结构数组 链表数组 链表 红黑树JDK 8null key/value禁止禁止迭代器fail-fast修改时抛异常弱一致性不抛异常但不保证最新size方法返回精确整数baseCount CounterCell估算可能不精确扩容单线程完成全表阻塞多线程协助渐进式迁移复合操作原子性单个方法原子复合操作需外部加锁单个方法原子compute/putIfAbsent/merge等复合API原子适用场景遗留代码兼容高并发读写、缓存、计数器等一切需要线程安全的Map场景性能并发下吞吐低读多看少时更差并发下吞吐极高读多写少时优势尤其明显这张表基本覆盖了面试中九成会问到的差异点日常开发时也可以用这张表快速决策。最后补充一个我常用的判断标准如果你在代码里看到new Hashtable()或者Collections.synchronizedMap(new HashMap())出现在新项目里大概率是没想明白并发问题的解法。直接换成ConcurrentHashMap同时检查一下有没有往里面塞null值基本就能避免绝大多数问题。而如果你在代码里看到有人用ConcurrentHashMap但还在外面手动加synchronized锁做复合操作那大概率也是多加了一层没必要保护建议改成compute系列API来替代代码既简洁又不易出错。
返回列表