ConcurrentHashMap.size() 为什么“不靠谱”?答案比你想的复杂

前几天在优化一段并发代码时,我用到了 ConcurrentHashMap 来存储共享数据。在某个逻辑里,我需要获取这个 map 的元素数量,下意识地就调用了 size() 方法。结果却发现,这个方法的表现和我熟悉的 HashMap.size() 有点不一样------有时候返回的数量并不准确,甚至会随着并发操作轻微波动。后来查了一下资料才知道,ConcurrentHashMapsize() 方法在设计上本就和普通的 HashMap 不一样,核心原因是出于并发性能的优化和线程安全之间的权衡。那么,它到底是怎么设计的?我们在实际使用中该如何正确处理这种情况呢?


ConcurrentHashMap 的 size() 方法在设计上与普通的 HashMap 不同,其核心原因是 并发环境下的性能优化线程安全的权衡

JDK 1.7 的实现:分段锁 + 多次尝试

在 JDK 1.7 中,ConcurrentHashMap 使用 分段锁(Segment) 结构,每个 Segment 是一个独立的小型哈希表,包含自己的锁和计数器(count)。size() 方法的实现分为两步:

  1. 无锁尝试 :遍历所有 Segment,累加每个段的 count 值。如果连续两次遍历的结果一致,则认为当前没有并发修改,返回结果。

  2. 加锁重试 :如果无锁尝试失败(即结果不一致),则对所有 Segment 加锁,重新计算总大小。

为什么不能直接返回一个全局变量?

  1. 分段锁设计 :每个 Segmentcount 是独立维护的,没有全局的 size 变量。直接维护全局变量会导致 锁竞争,降低并发性能。

  2. 弱一致性 :并发修改时,size() 返回的是一个近似值,而非实时精确值。这种设计牺牲了绝对准确性,但大幅提升了性能。


JDK 1.8 的实现:CAS + CounterCell 分段统计

在 JDK 1.8 中,ConcurrentHashMap 放弃了分段锁,改用 CAS(Compare and Swap)CounterCell 数组 来优化 size() 的计算。核心机制如下:

  • baseCount:基础计数器,记录未发生竞争时的元素数量。
  • CounterCell[] :当多个线程并发更新 baseCount 失败时,会通过 CAS 更新 CounterCell 数组中的某个元素,最终通过 baseCount + sum(CounterCell[]) 得到总大小。

计算流程

  1. 调用 size() 时,通过 sumCount() 方法累加 baseCount 和所有 CounterCell 的值。
  2. 最终结果是baseCount + 所有CounterCell中值的总和
  3. put()remove() 等操作会通过 CAS 更新 baseCountCounterCell,确保线程安全。
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() 的结果可能不准确?

ConcurrentHashMapsize() 方法设计为 弱一致性,原因如下:

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()(避免溢出)

ConcurrentHashMapsize() 方法通过分段统计和 CAS 优化,在 性能与准确性之间取得平衡。直接返回全局变量会导致并发瓶颈,而当前的设计通过分散竞争和弱一致性,既保证了高吞吐量,又满足了大多数场景的需求。

相关推荐
徐子童1 小时前
《Spring Cloud Gateway 快速入门:从路由到自定义 Filter 的完整教程》
java·开发语言·spring cloud·nacos·gateway
无名之逆1 小时前
[特殊字符]For Speed Enthusiasts: The Ultimate Evolution of Rust HTTP Engines
开发语言·前端·后端·网络协议·http·rust
Maxwellhang2 小时前
【音频处理】java流式调用ffmpeg命令
java·ffmpeg·音视频
Maỿbe3 小时前
阻塞队列的学习以及模拟实现一个阻塞队列
java·数据结构·线程
we风4 小时前
【SpringCache 提供的一套基于注解的缓存抽象机制】
java·缓存
趙卋傑6 小时前
网络编程套接字
java·udp·网络编程·tcp
两点王爷6 小时前
Java spingboot项目 在docker运行,需要含GDAL的JDK
java·开发语言·docker
云泽8088 小时前
模块化设计,static和extern(面试题常见)
c语言·面试·职场和发展
万能螺丝刀18 小时前
java helloWord java程序运行机制 用idea创建一个java项目 标识符 关键字 数据类型 字节
java·开发语言·intellij-idea
zqmattack8 小时前
解决idea与springboot版本问题
java·spring boot·intellij-idea