要彻底理解ConcurrentHashMap的size()流程,核心是抓住不同JDK版本的设计差异(JDK7基于分段锁,JDK8基于无锁计数),以及「并发下计数准确性」与「性能」的平衡思路。以下是分版本的详细拆解:
一、核心背景:为什么size()不能简单累加?
ConcurrentHashMap的核心优势是无全局锁,支持高并发读写。如果直接加全局锁统计元素个数,会丧失并发优势;如果不加锁直接累加,又会因并发修改导致计数不准。因此size()的设计目标是:在尽可能少的锁开销下,获取足够准确的元素个数(注意:返回的是「近似值」,非绝对精确)。
二、JDK7版本:基于Segment分段锁的size()流程
1. JDK7 ConcurrentHashMap 结构基础
JDK7的核心是「分段锁(Segment)」:
- 整个容器分为多个
Segment(默认16个),每个Segment是一个独立的哈希表,继承ReentrantLock,自身维护一个count变量(记录该Segment内的元素个数); - 并发读写时,仅锁定单个Segment,不同Segment的操作互不阻塞。
2. size() 具体执行流程
size()的本质是累加所有Segment的count值,流程分「轻量级尝试」和「重量级加锁」两步:
| 步骤 | 操作逻辑 | 目的 |
|---|---|---|
| 1. 轻量级尝试(最多3次) | ① 遍历所有Segment,不加锁,逐个读取Segment.count并累加,得到总和sum1;② 再次遍历累加,得到 sum2;③ 第三次遍历累加,得到 sum3; | 避免一上来加全局锁,利用“短时间内无并发修改”的概率,提升性能 |
| 2. 判断尝试结果 | 如果sum1 == sum2 == sum3:说明3次累加过程中无并发修改,计数准确,直接返回该值;如果不一致:说明有线程在并发添加/删除元素,进入「重量级加锁」; | 3次是经验值,平衡“重试开销”和“锁开销” |
| 3. 重量级加锁 | ① 遍历所有Segment,调用Segment.lock()加锁(全局锁,所有Segment被锁定);② 重新遍历所有Segment,累加 count值;③ 遍历完成后,调用 Segment.unlock()解锁;④ 返回最终累加结果; | 保证计数绝对准确,但牺牲了并发性能(仅在轻量级尝试失败时触发) |
3. JDK7 size() 关键细节
count是volatile变量:每个Segment的count用volatile修饰,保证单个Segment的计数可见性;- 为什么最多试3次?:如果重试次数太少,容易误触发全局锁;次数太多,重试本身的开销会超过锁开销,3次是JDK团队的经验最优值;
- 性能特点:低并发下(无修改),仅遍历3次,无锁开销;高并发下(有修改),触发全局锁,性能下降。
三、JDK8版本:基于无锁计数的size()流程(推荐重点掌握)
1. JDK8 ConcurrentHashMap 结构基础
JDK8彻底取消了Segment,核心结构改为「数组+链表+红黑树」,并发控制依赖CAS + synchronized(桶级锁);计数逻辑借鉴了LongAdder的思想(分段计数),核心是baseCount + CounterCell[]。
2. 计数核心组件
| 组件 | 作用 | 特性 |
|---|---|---|
baseCount(volatile long) | 基础计数值 | 无并发竞争时,直接CAS更新该值,效率最高 |
CounterCell[](数组) | 分段计数值 | 当CAS更新baseCount失败(有竞争),将增量哈希到某个CounterCell,更新该Cell的值(避免所有线程竞争同一个变量) |
modCount(volatile int) | 修改计数器 | 记录容器的修改次数(添加/删除元素时+1),用于判断size()累加过程中是否有并发修改 |
3. size() 具体执行流程
JDK8的size()是mappingCount()的简化版(size()返回int,可能溢出;mappingCount()返回long,推荐使用),流程如下:
graph TD A[初始化总和sum=0] --> B[累加baseCount到sum] B --> C{CounterCell数组是否非空?} C -- 是 --> D[遍历CounterCell数组,累加每个Cell的value到sum] C -- 否 --> E[检查并发修改] D --> E[检查modCount是否变化(是否有并发修改)] E -- 有修改 --> A[重试累加(自旋)] E -- 无修改 --> F[返回sum]分步拆解:
- 基础累加:先将
baseCount(基础计数)的值累加到总和sum; - 分段累加:如果
CounterCell[]非空(说明有并发竞争),遍历数组,将每个CounterCell的value(分段计数)累加到sum; - 并发校验:检查累加过程中
modCount是否变化(若变化,说明有线程在添加/删除元素,计数可能不准); - 自旋重试:如果检测到并发修改,重新执行「基础累加+分段累加」,直到无修改或重试次数足够;
- 返回结果:返回最终的
sum(近似值,累加过程中可能仍有线程修改,但误差可接受)。
4. JDK8 size() 核心设计亮点
- 无锁计数:全程无全局锁,仅通过CAS和自旋保证计数准确性,符合JDK8“减少锁依赖”的设计思路;
- 分段计数(CounterCell):借鉴
LongAdder,将计数压力分散到多个Cell,避免所有线程竞争同一个baseCount,并发计数性能比JDK7提升一个量级; - 近似值妥协:放弃“绝对精确”,换取“无锁高性能”——因为并发场景下,
size()的绝对精确无实际意义(返回值的瞬间,元素个数可能已变化)。
四、JDK7 vs JDK8 size() 核心差异对比
| 维度 | JDK7 | JDK8 |
|---|---|---|
| 核心依赖 | Segment.count + 分段锁 | baseCount + CounterCell[] + CAS |
| 锁开销 | 低并发无锁,高并发全局锁 | 全程无锁(仅自旋) |
| 计数准确性 | 轻量级尝试失败后,加锁保证绝对准确 | 始终返回近似值(误差可控) |
| 性能 | 高并发下因全局锁性能下降 | 高并发下仍保持高性能(分段计数) |
| 核心思想 | 「锁分段」→ 减少锁范围 | 「无锁分段计数」→ 彻底无锁 |
五、面试官常追问的关键细节
1. size() 返回的是精确值吗?
- JDK7:轻量级尝试失败后,加全局锁返回精确值;轻量级尝试成功时,返回“近似精确值”(3次累加一致,大概率无修改);
- JDK8:始终返回近似值(累加过程中可能有线程修改),但误差极小,满足绝大多数业务场景(如监控、统计)。
2. JDK8为什么用CounterCell而不是Segment?
JDK8取消Segment的核心是「细化锁粒度」(从Segment级→桶级),而CounterCell是「计数维度的分段」,与存储结构解耦:
- Segment是“存储+锁”的分段,CounterCell仅负责“计数”的分段;
- CounterCell的数量可动态扩展(按需创建),而Segment数量固定(默认16),更灵活。
3. JDK8为什么推荐用mappingCount()替代size()?
size()返回int类型,最大值为2^31-1(约21亿),当ConcurrentHashMap元素超过该值时会溢出;mappingCount()返回long类型,无溢出风险,语义与size()完全一致。
4. 计数时如何保证线程安全?
baseCount和CounterCell.value都是volatile修饰,保证可见性;- 更新
baseCount时用CAS(Unsafe.compareAndSwapLong),更新CounterCell时也用CAS,避免线程冲突; - 遍历CounterCell时,通过
Unsafe直接访问内存,保证遍历过程的原子性。
六、总结
ConcurrentHashMap的size()流程,本质是并发场景下“计数准确性”与“性能”的权衡:
- JDK7:先无锁重试,失败后加全局锁,兼顾“低并发性能”和“高并发准确性”;
- JDK8:彻底无锁,基于分段计数(CounterCell)返回近似值,极致优化高并发性能;
- 核心思想:并发容器的计数无需绝对精确,优先保证读写性能,这也是ConcurrentHashMap与Hashtable(全表锁计数)的核心区别。
------------------------------------------------------------------------------------------------------
要彻底讲清ConcurrentHashMap的size()执行过程,我们分JDK7(分段锁)和JDK8(无锁分段计数)两个版本展开,且聚焦「代码级的具体执行步骤 + 底层原理」,而非仅框架性描述。
前置说明
size()的核心目标是在并发读写场景下,以最小的性能开销获取元素个数(JDK7 兼顾“精确性”,JDK8 优先“高性能”,放弃绝对精确)。
先明确两个版本的核心计数载体:
| 版本 | 核心计数变量 | 并发控制方式 |
|---|---|---|
| JDK7 | 每个Segment内置volatile int count(记录该分段的元素数) | 分段锁(ReentrantLock) |
| JDK8 | baseCount(基础计数,volatile long) +CounterCell[](分段计数数组,每个元素是volatile long value) | CAS + 自旋 + 无锁 |
一、JDK7 版本:基于 Segment 分段锁的 size() 详细流程
1. JDK7 底层结构回顾
ConcurrentHashMap被拆分为16个默认Segment(可通过构造参数调整),每个 Segment 是独立的哈希表,且继承ReentrantLock(分段锁)。
- 每个 Segment 有
volatile int count:记录该分段内的元素个数,volatile保证单个分段计数的可见性; - 新增/删除元素时,仅锁定目标 Segment,修改其
count; size()需累加所有 Segment 的count。
2. size() 完整执行步骤(附伪代码)
publicintsize(){// 步骤1:定义核心变量finalSegment<K,V>[]segments=this.segments;intsum;// 最终累加结果intretries=-1;// 重试次数(初始-1,标记首次尝试)// 步骤2:轻量级无锁尝试(最多3次)for(;;){if(retries++==3){// 重试3次失败,触发重量级加锁// 步骤3:全局加锁(锁定所有Segment)for(inti=0;i<segments.length;++i){segments[i].lock();// 逐个锁定Segment(全局锁)}break;// 跳出循环,准备累加}sum=0;// 每次尝试重置总和intcheck=0;// 校验值,记录修改次数(防并发修改)// 步骤4:遍历所有Segment,无锁累加countfor(inti=0;i<segments.length;++i){Segment<K,V>seg=segments[i];if(seg!=null){sum+=seg.count;// 读取volatile count,累加check+=seg.modCount;// modCount:该Segment的修改次数(增/删元素+1)}}// 步骤5:校验是否有并发修改(check=0 表示所有Segment都无修改)if(check==0){break;// 无修改,计数准确,跳出循环}// 有修改 → 重试(进入下一轮for循环)}// 步骤6:若加了全局锁,需解锁if(retries==3){for(inti=0;i<segments.length;++i){segments[i].unlock();// 逐个解锁Segment}}// 步骤7:返回结果(int可能溢出,JDK7后期新增了long size64())returnsum;}3. 每一步的细节拆解
步骤2:轻量级无锁尝试(最多3次)
- 为什么是3次?JDK团队的经验值:3次重试的开销 < 全局加锁的开销,且3次内大概率能遇到“无并发修改”的窗口期;
- 每次尝试都会重新遍历所有Segment,读取最新的
count(因为count是volatile,读取的是内存最新值)。
步骤4:累加 count + 校验 modCount
modCount是每个 Segment 的修改计数器(增/删元素时原子+1),check累加所有 Segment 的modCount:- 若
check=0:所有 Segment 自本次尝试开始后无任何修改,sum是准确值; - 若
check≠0:有 Segment 被并发修改,sum可能不准,触发重试。
- 若
步骤3:全局加锁(重试3次失败后)
- 锁定所有 Segment:此时所有增/删操作都会被阻塞(因为增/删需要先锁对应 Segment);
- 加锁后重新累加
count:此时无并发修改,sum是绝对精确值。
步骤6:解锁
- 必须保证“加锁的Segment全部解锁”,否则会导致后续所有操作阻塞(死锁风险)。
4. JDK7 size() 关键特性
- 低并发场景:3次内大概率
check=0,无锁开销,性能极高; - 高并发场景:重试3次失败后加全局锁,性能骤降,但保证计数精确;
- 结果:返回值要么是“无修改的精确值”,要么是“加锁后的精确值”。
二、JDK8 版本:基于无锁分段计数的 size() 详细流程(重点)
1. JDK8 底层结构回顾
- 取消 Segment,核心结构为「数组+链表+红黑树」,并发控制依赖
CAS + synchronized(桶级锁); - 计数逻辑借鉴
LongAdder:将计数分散到baseCount和CounterCell[],避免所有线程竞争同一个变量。
2. 核心计数变量(先理解“写计数”,再理解“读计数”)
size()是“读计数”,需先知道增/删元素时“写计数”的逻辑,才能理解读取流程:
| 变量 | 类型 | 作用 | 写计数逻辑(增/删元素时) |
|---|---|---|---|
baseCount | volatile long | 基础计数 | 无并发竞争时,直接 CAS 更新baseCount(+1/-1);CAS 失败则走CounterCell |
counterCells | transient volatile CounterCell[] | 分段计数数组 | 1. 计算当前线程的哈希值(ThreadLocalRandom.getProbe()),定位到数组中的一个CounterCell;2. CAS 更新该 CounterCell的value(+1/-1);3. 若数组未初始化/目标 Cell 为 null,初始化数组并创建 Cell; 4. 若 CAS 仍失败,重新计算哈希值,重试(避免哈希冲突) |
modCount | volatile int | 修改计数器 | 每次增/删元素时modCount++,用于校验读取过程中是否有并发修改 |
CounterCell | 静态内部类 | 分段计数单元 | 仅含一个volatile long value字段,保证计数可见性 |
3. size() 完整执行步骤(附伪代码 + 逐行解释)
JDK8 中size()是mappingCount()的简化版(size()返回 int,可能溢出;mappingCount()返回 long,推荐使用),核心逻辑一致:
publiclongmappingCount(){// 步骤1:初始化变量longsum=0L;// 最终累加结果longcheck=0L;// 校验用的modCount快照intretries=-1;// 重试次数// 步骤2:获取计数核心组件(通过Unsafe直接访问底层变量)CounterCell[]cs=counterCells;// 步骤3:自旋累加(直到无并发修改或重试足够)for(;;){if(retries++==1){// 重试1次失败,放弃modCount校验(避免无限自旋)break;}sum=0L;// 重置总和check=modCount;// 记录当前modCount(快照)// 步骤4:累加baseCount(基础计数)sum+=baseCount;// 步骤5:累加CounterCell数组(分段计数)if(cs!=null){for(CounterCellc:cs){// 遍历所有非空Cellif(c!=null){sum+=c.value;// 读取volatile value,累加}}}// 步骤6:校验是否有并发修改(modCount未变 → 无修改)if(modCount==check){break;// 无修改,跳出循环}// 有修改 → 自旋重试(进入下一轮for循环)}// 步骤7:返回累加结果(近似值)returnsum;}4. 每一步的细节拆解
步骤1:变量初始化
sum:最终返回的元素个数,初始为0;check:记录读取开始时的modCount,用于后续校验;retries:重试次数初始为-1,首次循环retries++变为0,重试1次后变为1,触发跳出。
步骤3:自旋累加(核心)
- 自旋的目的:尽可能获取“无并发修改”的计数结果;
- 仅重试1次:JDK8 认为“近似值足够”,过多重试会浪费性能(即使返回近似值,误差也极小)。
步骤4:累加 baseCount
baseCount是volatile类型,读取时直接获取内存最新值,无锁开销;- 若并发度低(无线程竞争),
counterCells为 null,sum就是baseCount,直接返回。
步骤5:累加 CounterCell 数组
- 遍历
counterCells时,仅读取非空 Cell 的value(value是 volatile,保证最新); - 遍历过程中可能有线程修改 Cell 的
value,但因为是“读取”,无需加锁,仅会导致 sum 是“瞬时近似值”。
步骤6:校验 modCount
modCount是volatile类型,读取的是最新值;- 若
modCount == check:说明从“记录check”到“累加完成”的过程中,无线程增/删元素,sum 是“准精确值”; - 若
modCount != check:说明有并发修改,自旋重试1次;若重试后仍有修改,直接返回当前 sum(放弃精确性)。
步骤7:返回结果
- 结果是近似值:即使
modCount未变,累加过程中也可能有线程修改baseCount/CounterCell(因为读取是“非原子的遍历”),但误差在高并发下可忽略; - 为什么放弃绝对精确?:并发场景下,
size()返回值的瞬间,元素个数可能已变化,绝对精确无业务意义,不如优先保证性能。
5. JDK8 size() 关键细节补充
(1)CounterCell 的哈希定位逻辑
线程更新CounterCell时,通过ThreadLocalRandom.getProbe()获取当前线程的“探针值”(哈希值),再通过probe & (cs.length - 1)定位到数组下标(类似哈希表的取模),避免所有线程竞争同一个 Cell。
(2)Unsafe 工具类的作用
baseCount、counterCells、modCount都是通过Unsafe类直接操作内存地址更新/读取,绕过 JVM 层面的锁,保证操作的原子性和性能。
(3)为什么 JDK8 不考虑加锁?
JDK8 的设计理念是“并发容器的计数无需绝对精确”:
- 业务场景中,
size()多用于“监控/统计/限流阈值判断”,近似值足够; - 加锁会阻塞所有增/删操作,违背
ConcurrentHashMap“高并发”的核心设计目标。
三、两个版本 size() 过程的核心差异总结
| 维度 | JDK7 执行过程 | JDK8 执行过程 |
|---|---|---|
| 核心动作 | 无锁重试3次 → 失败则全局加锁 → 精确累加 | 无锁自旋1次 → 累加 baseCount + CounterCell → 近似返回 |
| 锁开销 | 低并发无锁,高并发全局锁(性能骤降) | 全程无锁(仅自旋,性能无损耗) |
| 结果特性 | 绝对精确(加锁后)/ 准精确(无锁重试成功) | 始终近似(误差可控) |
| 底层依赖 | Segment 的 count(volatile) + ReentrantLock | baseCount + CounterCell(volatile) + CAS + Unsafe |
| 性能优先级 | 精确性 > 性能 | 性能 > 精确性 |
四、面试官常追问的“细节中的细节”
1. JDK7 中如果 Segment 被扩容,size() 会出错吗?
不会。Segment 扩容是“分段扩容”(仅扩容当前 Segment),扩容过程中该 Segment 的count仍会正确更新(扩容时先加锁,再迁移元素,最后更新 count),size() 读取的是扩容后的 count。
2. JDK8 中 CounterCell 数组是怎么初始化的?
首次 CAS 更新baseCount失败时,会通过fullAddCount()方法初始化counterCells:
- 先尝试 CAS 创建长度为2的数组;
- 若仍有竞争,数组会按2倍扩容(类似 HashMap 扩容),避免哈希冲突。
3. size() 返回的“近似值”误差有多大?
极端场景下(百万级并发增/删),误差可能在“个位数到百位数”之间;但常规业务场景(万级并发),误差几乎可以忽略(通常≤10)。
4. 如何在 JDK8 中获取绝对精确的元素个数?
若业务必须精确,可手动加全局锁(不推荐):
// 不推荐:会阻塞所有并发操作,丧失ConcurrentHashMap的优势synchronized(concurrentHashMap){longexactSize=concurrentHashMap.mappingCount();}替代方案:业务层面通过“分布式计数器”(如 Redis)记录元素个数,而非依赖size()。