做Java开发的朋友,多少都被ConcurrentHashMap“教育”过。面试要问,源码分析要讲,线上排查高并发问题也绕不开它。而只要一聊到源码,1.7和1.8这两个版本的区别就是永远躲不开的坎:一个是Segment分段锁,一个是CAS加synchronized,一个锁粒度粗但模型简单,一个锁粒度细但实现极其精巧。
我最初在1.7时代看这段源码时,觉得分段锁的设计已经挺聪明了,至少比HashTable全表上锁强太多。后来1.8出来,看到它直接把Segment整个砍掉,换成“CAS空桶插入 + synchronized锁桶头节点”的组合,说实话第一感觉是:这也太敢改了。但真正把两版源码放在一起逐行对比之后,才理解1.8这一步不是炫技,而是彻底解决了1.7在高并发下的几个结构性痛点。
这篇文章我打算从设计思路、数据结构、读写流程、计数方式、扩容机制几个维度,把1.7和1.8的差异一次性讲透,最后再补上我在实际项目里从1.7迁移到1.8后踩过的坑。无论你是准备面试,还是想搞清楚线上该用哪个版本,这篇都适合从头读到尾。
1. 设计思路的演变:从“分段锁银行柜台”到“精细到每个窗口”
1.1 1.7的Segment分段锁,解决的是HashTable的全表锁问题
在1.7以及更早的版本里,Java官方提供的线程安全Map只有HashTable,它的实现很粗暴:所有方法上加一把大锁,也就是把整个表锁住。并发的读和读之间都会被卡住,更别说读写混合的场景。在低并发下问题不大,一旦并发量上来,这把锁就成了系统瓶颈。
ConcurrentHashMap 1.7引入了一个非常经典的思路:既然一把锁锁全表太浪费,那就把表拆成很多段,每段一把锁。这个思路就是银行办理业务时的柜台模型——大厅里不是只有一个窗口让所有人都挤在一起,而是开多个窗口,不同的客户去不同的窗口排队,窗口之间互不影响。
这就是Segment。默认情况下,ConcurrentHashMap 1.7内部有一个Segment数组,默认长度是16,也就是说并发度默认是16。每次put的时候,先用哈希值定位到某一个Segment,然后只锁这个Segment,其他Segment的读写完全不受影响。理论上支持16个线程同时写不同的Segment,这个并发度在当时已经非常可观了。
每个Segment本身是一个小型HashMap:内部维护一个HashEntry数组,HashEntry里面是key、hash、value以及next指针。发生哈希冲突的时候就拉出一个链表,遍历链表完成插入或更新。这个结构和1.7的HashMap几乎一样,只不过所有操作都被Segment加了一层锁保护。
1.2 1.8为什么敢推翻Segment:锁粒度进一步细化才是正解
1.8的改动,表面上把Segment去掉了,实际上是保留分段思想的同时,把锁粒度从“段”降到了“桶”。所谓桶,就是table数组里的每个槽位。每一个桶都是一把独立的很小很小的“锁”,竞争的线程只有落到同一个桶上才会被阻塞,落到不同桶的线程不管多少都能并行推进。
那为什么不继续用Segment,而是改用synchronized?有以下几个原因。
第一,JDK团队在1.6之后对内置锁synchronized做了大量优化,引入了偏向锁、轻量级锁、重量级锁的升级路径,synchronized在低竞争场景下开销已经非常小,甚至低于ReentrantLock。用内置锁处理“锁单个桶头节点”这种细粒度操作,性价比很高。
第二,Segment数量是固定的,默认16之后就不能再变。这意味着就算你把table扩容到几千个桶,单个Segment内部管着的仍然是几十甚至上百个桶,锁竞争还是集中在那16把锁上。换个角度说,1.7的并发度上限被写死在代码里了,扩容解决的是哈希冲突问题,却解决不了高并发下的锁瓶颈问题。
第三,1.8还把数据结构升级了:单个桶里的链表如果过长,会自动转成红黑树。这样即便哈希冲突再严重,桶内的查询复杂度也能从O(n)降到O(log n)。链表里的节点变多了,锁一个桶的时间也就那么短,锁竞争压力自然小很多。
锁粒度从Segment级别降到桶级别,是整个设计思路最核心的变化,后续所有代码差异都是围绕这个点展开的。
1.3 锁粒度细化以后,并发度到底提升了多少
很多人只记得“1.8锁更细了”,但没有量化概念。锁粒度等于什么?等于你允许多少个线程同时做写操作。
1.7的并发度 = Segment数组长度,默认16,固定不可变。
1.8的并发度 = table数组的当前长度,也就是桶的个数,这个值会随着扩容变大,初始默认16,扩容后翻倍到32、64、128……而且就算某个桶在写,其他桶的线程依然可以写。举个例子,如果table长度是64,极端情况下可以允许64个线程分别写不同桶,只要它们落在不同的桶上就完全无冲突。
这个提升是数量级的。当然,实际并发写入未必能打满桶数那么多,因为哈希分布不一定完全均匀,但至少代码层面已经不再人为限制并发度上限了。
另外还有一个容易被忽略的点:内存占用。1.7的Segment本身就是对象,一个Segment内部又有HashEntry数组,存在大量额外对象头引用;1.8把Segment去掉,直接用table数组,对象结构更扁平,GC压力也小一些。线上大数据量场景下,这一点体感会比较明显。
2. 数据结构与寻址方式:两版的内存布局完全不是一回事
2.1 1.7的内存布局:多了一层Segment包装
1.7的整体结构可以画成这样:最外层是Segment数组,每个Segment里面是一个HashEntry数组,每个HashEntry数组的槽位上是一个链表。
所以一次put要经历两级寻址:
第一步,根据key的hash值经过一次再哈希(rehash),降低哈希冲突概率,然后通过“hash右移segmentShift,再与segmentMask做与运算”,定位到具体的Segment。
第二步,进入Segment内部后,用另一个哈希定位到Segment内部table数组的某个槽位。
这里Segment数组的长度参数很重要:默认16个Segment,对应segmentShift是28,segmentMask是15。如果你传入自定义的concurrencyLevel,最终Segment数组长度也会向上取整到2的幂次。
HashEntry节点的next指针在1.7中是非volatile的,但value是volatile的。写的时候通过加锁来保证安全,读的时候依赖volatile保证可见性。这就是那个经典的无锁读思路:写的时候锁住,读的时候不加锁,靠volatile读最新的value。
2.2 1.8的内存布局:去掉Segment,扁平化tag数组
1.8是真正的扁平化结构:一个大的Node数组,每个Node节点要么是普通链表节点,要么是红黑树的树节点。没有包装层,没有Segment。
Node节点的hash和value字段都是volatile的,next也是volatile。这一点比1.7更进一步,链表节点的结构信息在并发读写下的可见性得到了完整保障。
当某个桶的链表长度达到8(TREEIFY_THRESHOLD)时,代码不会立即转成红黑树,还有一个前置条件:当前table数组长度必须大于等于64(MIN_TREEIFY_CAPACITY)。如果还没到64,优先扩容,而不是树化。这是因为数组短的时候,大家都挤在很少的桶里,单纯树化解决不了整体哈希分布差的问题,扩容才是更合理的方案。
红黑树结构的实际载体是TreeBin。TreeBin本身不存储key和value,它内部持有红黑树的根节点,并且维护读写锁状态。读线程在红黑树上查找时不需要加锁,写线程在维护树结构时通过CAS加锁。这个设计相当精妙:读多写少时,读操作几乎无阻塞。
2.3 哈希寻址方式的变化:两次定位变成一次定位
1.7里为了保证哈希值够“散”,用了一个再哈希函数对原始hashCode做二次扰动。这个再哈希逻辑我记了很多年,它是一个类Wang/Jenkins的移位混合算法,把高位信息和低位信息充分打乱,从而降低哈希冲突概率。代价是要多算一轮位运算,在极端热路径上CPU开销略高。
1.8把再哈希函数换成了一个更轻量的扰动:直接拿key.hashCode()异或上它右移16位的结果。也就是:
static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; }这个扰动方式把高16位的信息混合到低16位里,因为table数组的下标计算用的是 (n - 1) & hash,如果hash值只有低16位在起作用,那高16位的信息就浪费了。异或一下右移16位,可以让高16位也参与下标计算。
1.8能放心简化再哈希函数,很重要的原因是底下有红黑树兜底:即使冲突变多了,链表过长会被树化,查询效率不会退化到无法接受。这是一个“源头降载加末端兜底”的组合拳。
3. 核心操作逐项对比:put、get、size、扩容
3.1 put操作:从锁Segment到CAS加synchronized
1.7的put逻辑是这样的:先定位到Segment,然后调用Segment的put方法,这个方法内部会先尝试tryLock,加锁成功就直接操作;如果tryLock失败,说明这个Segment正在被其他线程写,当前线程会进入scanAndLockForPut逻辑,一边自旋一边等锁,自旋超过阈值就挂起,直到拿到锁为止。
加锁成功之后,会检查当前Segment内部的元素个数是否超过阈值。如果超过,就在锁内进行rehash扩容,然后执行头插法把新的HashEntry插入链表头部。头插法是1.7的一个特点:新节点插在链表头部,时间复杂度是O(1),不需要遍历到链表尾部。
1.8的put流程是一个死循环里反复尝试的过程,我用伪代码和文字结合的方式说一下关键步骤:
线程进入putVal之后,先检查table是否为null,如果为null就调用initTable初始化数组。然后计算下标,检查这个位置是否为空。如果为空,就通过CAS操作把新节点直接放进去,这一步完全不涉及加锁,是最理想的路径。
如果这个桶不为空,说明发生了哈希冲突,这时候才走加锁逻辑。加锁方式变成了内置synchronized,锁对象是这个桶的头节点。拿到锁之后,再次检查头节点是否还是原来的节点,防止期间被其他线程改了结构。然后分两种情况处理:如果头节点是普通链表节点,就遍历链表,找到相同key就替换value,没找到就尾插法加在链表后面;如果头节点是TreeBin,说明这个桶已经树化,走红黑树的插入逻辑。
插入完成之后,检查链表长度是否达到8,达到就尝试转为红黑树。整个过程结束后,如果链表长度变化了,还需要维护节点修改计数相关的字段。
关键差异我列个表更直观:
| 对比项 | 1.7 | 1.8 |
|---|---|---|
| 锁机制 | Segment内部ReentrantLock | CAS + synchronized |
| 锁对象 | Segment对象 | 桶头节点 |
| 无锁路径 | 无,必须加锁 | 桶为空时CAS直接插入 |
| 链表插入方式 | 头插法 | 尾插法 |
| 树化 | 不支持 | 链表长度>=8且数组长度>=64 |
这里有一个很多人忽略的细节:1.8的put在遇到桶头节点是ForwardingNode,也就是这个桶正在被扩容迁移时,当前线程不会傻等,而是主动帮助扩容。这个后面在扩容部分具体讲。
3.2 get操作:两版都是无锁读,但可见性保障有差别
ConcurrentHashMap的读操作一直不做加锁,这是它性能的核心来源之一。get的核心思路是:定位到桶,遍历链表或红黑树,找到hash值相等且key相等的节点,然后读取volatile的value字段并返回。
1.7的get有两级定位,先定位Segment,再定位Segment内部table的桶,然后遍历链表。因为value字段是volatile的,所以即使写入线程还没退出临界区,读线程也能读到最新写入的值。
1.8的get定位更直接:tabAt方法通过Unsafe的getObjectVolatile读取table数组的指定位置,保证读到的table最新、每个桶的头节点引用最新。然后检查头节点的hash值:
- hash < 0 说明这个节点有特殊含义,比如ForwardingNode表示正在扩容,TreeBin表示这个桶已经是红黑树,ReservationNode表示占位。
- 如果是ForwardingNode,说明table正在扩容,当前线程会跳到nextTable去继续查找,而不是原地等待,所以扩容期间get不会被卡住。
- 如果是TreeBin,走红黑树查找逻辑。
- 如果是普通Node,就遍历链表。
1.8的get也不需要加锁,因为它依靠的是volatile的读语义,以及Node节点内部hash和next字段的volatile可见性。
一个值得说的细节是,1.8在get的链表遍历中,判断key相等用的逻辑是先比较hash,再比较key本身,因为hash相等是进入下一步比较的必要条件,可以过滤大量错误节点。这个在1.7中也有类似逻辑,但1.8因为hash负数有特殊含义,判断流程更谨慎。
3.3 size计数机制:从“锁全表统计”到“近似值算账”
1.7的size操作最让我觉得“重”:先不加锁地统计两次所有Segment的modCount之和,如果前后两次相等,说明统计期间没有写操作发生,那么这次的结果是准确的;如果发现两次不等,说明有写操作,这时候就把所有Segment锁一遍,逐个统计count,再求和。如果并发量一高,size容易触发全表锁,开销很大。
1.8彻底改掉了这个思路。它维护了一个baseCount,每次写操作完成后,线程会通过CAS尝试更新baseCount。如果CAS失败,说明有竞争,就让这些竞争线程把计数写到一组CounterCell数组里,每个线程分散到不同的CounterCell上,避免大家一起抢同一个变量。
计算size的时候,直接把baseCount和所有CounterCell的值加起来。这个求和过程没有加锁,所以它理论上不是一个绝对精确的实时值,而是一个趋于准确的值。JDK源码里甚至加了sumCount的注释强调这一点。
为什么1.8敢这么做?因为很多用到size的场景,本身就不需要强一致的精确值。比如做一个统计报表、判断是否需要清理缓存,误差几百万分之一根本不影响业务。如果有人真的需要精确大小,应该在业务层自己加锁或者用其他机制。
3.4 扩容机制:从“各扩各的段”到“大家一起搬”
扩容是1.7和1.8差异最大的一块,也是1.8实现最复杂的地方。
1.7扩容发生在Segment内部。当某个Segment内元素数量超过阈值,就在该Segment加锁状态下,把内部table数组扩容到原来的两倍,然后重新哈希当前Segment中的所有节点到新数组。因为锁是粒度为Segment级别的,所以扩容时其他Segment不受影响,可以继续工作。
但这种做法的问题是:扩容成本完全由触发扩容的那个线程独自承担。如果某个Segment内的数据特别多,rehash迁移节点就需要很长时间,其他想写这个Segment的线程只能等着,这就是所谓的“一段扩容,同段阻塞”。
1.8的扩容变成了一场“集体大迁移”。触发扩容后,会创建一个新的table数组,长度变为原来的两倍,然后多个线程可以一起参与搬迁。每次搬迁的最小单位是一个区间,这个区间叫stride,代码里会根据CPU核数计算每个线程应该负责的桶区间大小。
搬迁过程中,已经处理完的桶会放一个ForwardingNode占位。其他线程无论是put还是get,遇到ForwardingNode就知道这个桶已经被搬走了。put线程看到它会主动帮忙继续扩容;get线程看到它会直接去新table里查找。这种设计让扩容不再是单点阻塞,而是充分利用并发性能。
但这个多线程协作扩容也带来了极复杂的并发控制,包括扩容进度标记、nextTable共享、节点迁移顺序保证,每一步都可能踩并发坑。这也是为什么1.8的ConcurrentHashMap源码行数比1.7翻了近两倍。
4. 面试与实战中最常遇到的问题排查
4.1 并发度到底是多少,别再记错的固定答案
1.7的并发度由Segment数组长度决定,默认16。你可以在构造时传入concurrencyLevel,但ConcurrentHashMap不会因为table扩容而增加Segment数量,所以并发度写死在那里。这个“写死”才是1.7的槽点。
1.8没有并发度的概念。理论上并行写能力由table数组长度决定,而且会随扩容提升。所以如果面试官问“1.8的并发度是多少”,标准答案不是数字,而是“桶的数量,动态变化”。
4.2 红黑树什么时候触发,什么时候退化
链转树的条件是两个:链表长度大于等于8,同时table数组长度大于等于64。为什么阈值是8?因为源码注释里有一个泊松分布说明——在随机哈希码下,链表长度达到8的概率已经低到千万分之几。选8是为了让树化极少发生,避免红黑树本身的维护成本白白吃掉性能。
树转链的条件是拆分红黑树时,红黑树节点小于等于6(UNTREEIFY_THRESHOLD)。这里特意保留了“8变树、6变链”的差值,避免阈值附近频繁来回切换,造成抖动。
实际工作中,如果发现某个场景频繁触发树化,不要只盯着阈值,更可能的原因是哈希函数分布不均匀。检查一下你的key有没有重写hashCode,比如很多自定义对象没有合理重写hashCode,导致大量对象hash值低位相同,全部挤到同几个桶里。
4.3 为什么ConcurrentHashMap不允许key或value为null
这个问题属于“看着简单,背后深刻”的类型。HashMap允许一个null key和多个null value,但ConcurrentHashMap从1.7到1.8都不允许。
官方也做过解释,最主流的说法是:如果允许null value,那么get返回null时,无法区分“key不存在”和“key存在但是value是null”这两种情况。HashMap设计得比较粗糙,把选择权交给用户;ConcurrentHashMap是并发容器,如果把判断逻辑建立在“返回null代表不存在”这个假设上,在多线程环境里很容易引入歧义和误判。
如果你在源码里翻过putVal,会发现代码里有一行明确判断:如果key或者value为null,直接抛出NullPointerException。这也是一个容易在线上踩的坑——从HashMap迁移到ConcurrentHashMap时,原来可以put进去的null值现在直接异常。
4.4 弱一致性的迭代器到底意味着什么
无论1.7还是1.8,ConcurrentHashMap的迭代器都是弱一致性的。意思是,创建迭代器之后,如果在迭代过程中其他线程修改了map,迭代器不会抛ConcurrentModificationException,也不会保证一定读到最新数据,它可能读到修改前、修改后的数据,或者跳过某些节点。
这和HashMap的fail-fast迭代器是两个极端。HashMap遇到结构修改直接抛异常,很多老项目定期全量遍历HashMap和ConcurrentHashMap时的行为完全不同。如果你在迭代过程中依赖“读取结果必须和某一时刻的全体快照一致”,那应该先用putAll构建快照或者干脆做全局锁。
4.5 两版关键差异速查表
| 维度 | 1.7 | 1.8 |
|---|---|---|
| 锁机制 | Segment继承ReentrantLock | CAS + synchronized |
| 锁粒度 | 默认16个Segment | table单个桶 |
| 并发度 | 固定16(可配置,但固定后不可变) | 随table扩容动态提升 |
| 数据结构 | Segment + HashEntry + 链表 | Node + 链表 + 红黑树 |
| 哈希定位 | 再哈希 + 两级定位 | spread扰动 + 一次定位 |
| put无锁路径 | 无 | 桶为空时CAS直接插入 |
| 链表插入 | 头插 | 尾插 |
| size | modCount统计,失败锁全表 | baseCount + CounterCell近似值 |
| 扩容 | 单Segment内独自扩容 | 多线程协作,ForwardingNode辅助 |
| 迭代器 | 弱一致 | 弱一致 |
5. 从1.7迁移到1.8时,我在实际项目里踩过的坑
5.1 第一个坑:size不准,但很多人拿它判断是否清理缓存
以前用1.7时,我们有个全局注册表,上限是十万条数据,超过就淘汰一部分不活跃的key。旧代码里用size()判断容量,这个值在1.7里相对准确,因为它在检测到变化后会锁全表重统计,代价是慢。
迁移到1.8之后,线上有个监控报警显示注册表大小浮动异常大,偶尔比实际业务量差很多。查下来原因很简单:size变成了近似值,在写入非常频繁的窗口期,CounterCell还没被及时统计到baseCount里去,测出来的数总比真实值少一些。
后来调整成“不一定精确,但必须持续稳定”的淘汰策略之后,这个问题就消失了。我的建议是:不要用ConcurrentHashMap的size做精确的业务判断,如果非要判断,可以做一个定时任务定期全量扫描,或者自己维护一个AtomicLong计数器。
5.2 第二个坑:热点Key造成单桶锁膨胀,性能瞬间下滑
1.8把锁粒度降到桶,但理论上仍然可能出现极端情况:大量并发写同一个key。比如业务里有一个全局配置项,所有机器启动时都去更新这个key,几十个线程同时写,锁全砸到那一个桶上,synchronized锁升级之后,整个系统在这一瞬间的表现和全表锁没有区别。
这种场景下,代码结构再怎么优化也救不了锁膨胀,应该从业务侧分流。后来我们的方案是给每个key增加一个随机后缀分片,比如把配置项拆成64个分片,写入时随机选分片,读取时遍历全部分片合并结果。写并发被稀释到64个桶上,性能问题自然消失。
5.3 第三个坑:自定义对象作为key,hashCode写得太敷衍
1.8的spread函数虽然已经足够好,但它管不了key自己的hashCode质量。有一次线上出现某个桶的链表长度长时间维持在十几,触发了树化,但树化之后读写效率还是不高。
排查之后发现,那个key对象是自定义的结构体,hashCode只返回了其中一个字段的值,大量不同对象的hashCode完全相同,直接导致spread之后低16位全一样,全部落到同一个桶。这从侧面说明:再好的并发容器也架不住业务方把key的哈希分布搞砸。解决办法是重写hashCode,或者干脆换一个散列性更好的key类型。
5.4 最后一个心得
从1.7到1.8的升级,本质是并发控制思路从“锁竞争更少”变成了“锁存在时间更短、粒度更细”,再加上数据结构的配合,让读写路径都尽可能无锁化。源码里那些精巧的位运算和CAS逻辑,不是用来炫技的,而是每一个设计都在为解决真实的高并发痛点服务。
现在我再看这两版代码,反而更推荐大家不要死记硬背结论,而是亲手打开源码,跟着put和get两条路径走一遍。你会发现,1.8里那些看似随意的数值,从8、64到6,每一个都有来源、有计算、有取舍。真正理解了这些设计背后的权衡,你在面试里不管被问到多刁钻的问题,都能从根上给出让人信服的回答。