news 2026/10/7 11:25:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 8,Segment被废除了,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 更值钱的并发 API:putIfAbsent、compute、merge

很多人的ConcurrentHashMap用法还停留在put、get、remove这三个基本方法上,这其实很浪费。JDK 8为ConcurrentHashMap强化了一批复合操作的原子性API,这些才是它真正值钱的地方。

putIfAbsent(key, value):只有当key不存在时才写入,原子完成。常用于“缓存初始化”场景,多个线程同时尝试塞入同一个key时,只有第一个能成功。

compute(key, remappingFunction):提供了“根据当前值计算新值”的能力,全程原子。比如想维护一个Map<String, 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<>())再加一个对照组 Map<String, Integer> hashtable = new Hashtable<>(); Map<String, 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(Map<String, 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.5(JDK 8大规模重构)
线程安全实现synchronized锁整个对象CAS + synchronized锁单个桶(JDK 8)
读操作需要获取锁无锁,基于volatile读
数据结构数组 + 链表数组 + 链表 + 红黑树(JDK 8)
null key/value禁止禁止
迭代器fail-fast,修改时抛异常弱一致性,不抛异常但不保证最新
size方法返回精确整数baseCount + CounterCell估算,可能不精确
扩容单线程完成,全表阻塞多线程协助,渐进式迁移
复合操作原子性单个方法原子,复合操作需外部加锁单个方法原子,compute/putIfAbsent/merge等复合API原子
适用场景遗留代码兼容高并发读写、缓存、计数器等一切需要线程安全的Map场景
性能并发下吞吐低,读多看少时更差并发下吞吐极高,读多写少时优势尤其明显

这张表基本覆盖了面试中九成会问到的差异点,日常开发时也可以用这张表快速决策。最后补充一个我常用的判断标准:如果你在代码里看到new Hashtable()或者Collections.synchronizedMap(new HashMap())出现在新项目里,大概率是没想明白并发问题的解法。直接换成ConcurrentHashMap,同时检查一下有没有往里面塞null值,基本就能避免绝大多数问题。而如果你在代码里看到有人用ConcurrentHashMap但还在外面手动加synchronized锁做复合操作,那大概率也是多加了一层没必要保护,建议改成compute系列API来替代,代码既简洁又不易出错。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 11:23:38

揭秘智能体触达能力:从对话到真正落地的Agent-Reach体系

如果让我用一个词概括过去两年做智能体&#xff08;Agent&#xff09;项目的所有心得&#xff0c;我会选择"Agent-Reach"。这个词不是某个具体框架的名字&#xff0c;而是我在一次次联调、上线、被用户吐槽"这玩意儿怎么又没反应"之后&#xff0c;逐渐形成…

作者头像 李华
网站建设 2026/10/7 11:23:18

数据结构试题答案的工程化用法:从Word文档到可运行代码

简介&#xff1a;本资源是一份面向计算机专业本科生及考研备考者的数据结构专项训练资料&#xff0c;聚焦数组、链表、栈、队列、树与图等核心知识点的综合应用与算法分析能力提升。文档为单个Word文件&#xff08;.doc格式&#xff09;&#xff0c;共1个文件&#xff0c;大小5…

作者头像 李华
网站建设 2026/10/7 11:22:16

会议室管理系统毕设源码拆解:部署、联调与答辩演示全攻略

简介&#xff1a;一套面向软件学院毕业设计或课程设计的会议室管理系统完整源码包&#xff0c;以Java服务端搭配微信小程序移动端&#xff0c;覆盖会议室资产管理、人员管理、会议预约与调整、预约成功通知、数据统计等业务闭环。包内共319个文件&#xff0c;整体约38.71MB&…

作者头像 李华
网站建设 2026/10/7 11:20:49

eFuse与8位MCU协同:工业电源路径保护与PMBus监控实现

嵌入式系统里做电源的人&#xff0c;大多都经历过这种场景&#xff1a;整机联调到一半&#xff0c;现场传来消息说某一路供电打挂了&#xff0c;要么保险丝烧断&#xff0c;要么DC-DC芯片直接冒烟。排查到最后&#xff0c;往往就是插拔瞬间的浪涌、负载侧的意外短路&#xff0c…

作者头像 李华
网站建设 2026/10/7 11:20:08

GT-SUITE Token许可证优化:从瓶颈诊断到高效管理

1. Token许可证为什么突然成了GT-SUITE用户的焦虑源有个现象我观察了很久&#xff1a;不少仿真团队手里的GT-SUITE模块越来越多&#xff0c;但日常工作中反而总是被"许可证不够用"卡住。明明花钱买了新模块&#xff0c;加了几把"钥匙"&#xff0c;可一到项…

作者头像 李华
网站建设 2026/10/7 11:19:46

Caveman笔记法:用Markdown+Vim+Git打造纯文本个人知识库

从“caveman”这个热词开始说吧。这两年“回到穴居时代”在技术圈莫名其妙火了起来&#xff0c;一群写代码的人主动放弃 Notion、印象笔记、OneNote 这类功能越做越重的“效率神器”&#xff0c;重新拿起 Vim、Markdown 和 Git 三个老古董来管理自己的全部知识库。这套玩法有个…

作者头像 李华