前几天在优化一段并发代码时,我用到了
ConcurrentHashMap
来存储共享数据。在某个逻辑里,我需要获取这个 map 的元素数量,下意识地就调用了size()
方法。结果却发现,这个方法的表现和我熟悉的HashMap.size()
有点不一样------有时候返回的数量并不准确,甚至会随着并发操作轻微波动。后来查了一下资料才知道,ConcurrentHashMap
的size()
方法在设计上本就和普通的HashMap
不一样,核心原因是出于并发性能的优化和线程安全之间的权衡。那么,它到底是怎么设计的?我们在实际使用中该如何正确处理这种情况呢?
ConcurrentHashMap 的 size()
方法在设计上与普通的 HashMap
不同,其核心原因是 并发环境下的性能优化 和 线程安全的权衡。
JDK 1.7 的实现:分段锁 + 多次尝试
在 JDK 1.7 中,ConcurrentHashMap
使用 分段锁(Segment) 结构,每个 Segment
是一个独立的小型哈希表,包含自己的锁和计数器(count
)。size()
方法的实现分为两步:
-
无锁尝试 :遍历所有
Segment
,累加每个段的count
值。如果连续两次遍历的结果一致,则认为当前没有并发修改,返回结果。 -
加锁重试 :如果无锁尝试失败(即结果不一致),则对所有
Segment
加锁,重新计算总大小。
为什么不能直接返回一个全局变量?
-
分段锁设计 :每个
Segment
的count
是独立维护的,没有全局的size
变量。直接维护全局变量会导致 锁竞争,降低并发性能。 -
弱一致性 :并发修改时,
size()
返回的是一个近似值,而非实时精确值。这种设计牺牲了绝对准确性,但大幅提升了性能。
JDK 1.8 的实现:CAS + CounterCell 分段统计
在 JDK 1.8 中,ConcurrentHashMap
放弃了分段锁,改用 CAS(Compare and Swap) 和 CounterCell 数组 来优化 size()
的计算。核心机制如下:
- baseCount:基础计数器,记录未发生竞争时的元素数量。
- CounterCell[] :当多个线程并发更新
baseCount
失败时,会通过 CAS 更新CounterCell
数组中的某个元素,最终通过baseCount + sum(CounterCell[])
得到总大小。
计算流程:
- 调用
size()
时,通过sumCount()
方法累加baseCount
和所有CounterCell
的值。 - 最终结果是
baseCount
+ 所有CounterCell
中值的总和 put()
、remove()
等操作会通过 CAS 更新baseCount
或CounterCell
,确保线程安全。
java
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
为什么不能直接返回一个全局变量?
- 高并发场景下的性能瓶颈 :如果直接使用一个全局的
volatile int size
,每次修改都需要通过 CAS 更新该变量,会导致大量线程竞争,降低吞吐量。而CounterCell
数组通过 分散竞争(每个线程更新不同的槽位)减少了冲突。 - 伪共享(False Sharing)优化 :
CounterCell
使用@sun.misc.Contended
注解,避免多个线程更新相邻内存地址导致的缓存行伪共享问题,进一步提升性能。
为什么 size()
的结果可能不准确?
ConcurrentHashMap
的 size()
方法设计为 弱一致性,原因如下:
1、性能优先 :ConcurrentHashMap
的设计目标是 快速返回一个合理近似值 ,而非严格精确值。在高并发场景下,绝对准确的 size()
需要冻结整个表(如加锁),直接使用一个全局变量会导致极高的竞争,成为性能瓶颈这会严重拖慢性能。每次put/remove都需要同步更新这个变量,严重影响并发性能
2、全局锁:计算过程中其他线程可以继续修改哈希表,可能导致结果与实际值存在偏差。
3、LongAdder设计思想: JDK8借鉴了LongAdder的分段计数思想,通过分散热点到多个CounterCell,减少竞争,只有在需要获取大小时才进行汇总计算
4、空间换时间:使用额外的CounterCell数组空间,换取更高的并发更新性能
如何获取更准确的大小?
如果对结果的准确性要求极高,可以通过以下方式替代 size()
:
1、遍历整个哈希表 :使用迭代器(如 entrySet().size()
)强制遍历所有键值对,但会阻塞并发修改,性能代价较大。
2、使用 mappingCount()
:JDK 1.8 提供 mappingCount()
方法,返回 long
类型的值(避免 size()
的 int
溢出问题),但仍是弱一致性的。
特性 | JDK 1.7 | JDK 1.8 |
---|---|---|
数据结构 | 分段锁(Segment) | 数组 + 链表/红黑树 + CounterCell |
size() 实现 | 多次无锁尝试 + 分段加锁 | CAS + CounterCell 分散更新 |
性能 | 中等(分段锁竞争) | 高(CAS 和 CounterCell 减少竞争) |
准确性 | 弱一致性 | 弱一致性 |
推荐方法 | 无 | 使用 mappingCount() (避免溢出) |
ConcurrentHashMap
的 size()
方法通过分段统计和 CAS 优化,在 性能与准确性之间取得平衡。直接返回全局变量会导致并发瓶颈,而当前的设计通过分散竞争和弱一致性,既保证了高吞吐量,又满足了大多数场景的需求。