ConcurrentHashMap 的 size 方法原理分析

本文分析了JDK1.7和JDK1.8中ConcurrentHashMap的size方法实现原理。JDK1.7采用不加锁计算与加锁计算相结合的方式;JDK1.8则采用baseCount和counterCell结合CAS操作计算。推荐使用mappingCount方法以避免int类型的最大值限制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

ConcurrentHashMap 的 size 方法原理分析

原创: 许光明 杏仁技术站 1周前

作者 | 许光明

杏仁后端工程师。少青年程序员,关注服务端技术和农药。

前言

JAVA 语言提供了大量丰富的集合, 比如 List, Set, Map 等。其中 Map 是一个常用的一个数据结构,HashMap 是基于 Hash 算法实现 Map 接口而被广泛使用的集类。HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。但是 HashMap 并不是线程安全的, 在多线程场景下使用存在并发和死循环问题。HashMap 结构如图所示:

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

线程安全的解决方案

线程安全的 Map 的实现有 HashTable 和 ConcurrentHashMap 等。HashTable 对集合读写操作通过 Synchronized 同步保障线程安全, 整个集合只有一把锁, 对集合的操作只能串行执行,性能不高。ConcurrentHashMap 是另一个线程安全的 Map, 通常来说他的性能优于 HashTable。 ConcurrentHashMap 的实现在 JDK1.7 和 JDK 1.8 有所不同。

在 JDK1.7 版本中,ConcurrentHashMap 的数据结构是由一个 Segment 数组和多个 HashEntry 组成。简单理解就是ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 Segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组 + 链表 + 红黑树的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操作,整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。 通过 HashMap 查找的时候,根据 hash 值能够快速定位到数组的具体下标,如果发生 Hash 碰撞,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

如何计算 ConcurrentHashMap Size

由上面分析可知,ConcurrentHashMap 更适合作为线程安全的 Map。在实际的项目过程中,我们通常需要获取集合类的长度, 那么计算 ConcurrentHashMap 的元素大小就是一个有趣的问题,因为他是并发操作的,就是在你计算 size 的时候,它还在并发的插入数据,可能会导致你计算出来的 size 和你实际的 size 有差距。本文主要分析下 JDK1.8 的实现。 关于 JDK1.7 简单提一下。

在 JDK1.7 中,第一种方案他会使用不加锁的模式去尝试多次计算 ConcurrentHashMap 的 size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。 第二种方案是如果第一种方案不符合,他就会给每个 Segment 加上锁,然后计算 ConcurrentHashMap 的 size 返回。其源码实现:

public int size() {
  final Segment<K,V>[] segments = this.segments;
  int size;
  boolean overflow; // true if size overflows 32 bits
  long sum;         // sum of modCounts
  long last = 0L;   // previous sum
  int retries = -1; // first iteration isn't retry
  try {
    for (;;) {
      if (retries++ == RETRIES_BEFORE_LOCK) {
        for (int j = 0; j < segments.length; ++j)
          ensureSegment(j).lock(); // force creation
      }
      sum = 0L;
      size = 0;
      overflow = false;
      for (int j = 0; j < segments.length; ++j) {
        Segment<K,V> seg = segmentAt(segments, j);
        if (seg != null) {
          sum += seg.modCount;
          int c = seg.count;
          if (c < 0 || (size += c) < 0)
            overflow = true;
        }
      }
      if (sum == last)
        break;
      last = sum;
    }
  } finally {
    if (retries > RETRIES_BEFORE_LOCK) {
      for (int j = 0; j < segments.length; ++j)
        segmentAt(segments, j).unlock();
    }
  }
  return overflow ? Integer.MAX_VALUE : size;
}

JDK1.8 实现相比 JDK 1.7 简单很多,只有一种方案,我们直接看 size() 代码:

public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
           (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}

最大值是 Integer 类型的最大值,但是 Map 的 size 可能超过 MAX_VALUE, 所以还有一个方法 mappingCount(),JDK 的建议使用 mappingCount() 而不是size()mappingCount() 的代码如下:

public long mappingCount() {
    long n = sumCount();
    return (n < 0L) ? 0L : n; // ignore transient negative values
}

以上可以看出,无论是 size() 还是 mappingCount(), 计算大小的核心方法都是 sumCount()sumCount() 的代码如下:

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;
}

分析一下 sumCount() 代码。ConcurrentHashMap 提供了 baseCount、counterCells 两个辅助变量和一个 CounterCell 辅助内部类。sumCount() 就是迭代 counterCells 来统计 sum 的过程。 put 操作时,肯定会影响 size(),在 put() 方法最后会调用 addCount() 方法。

addCount() 代码如下:

  • 如果 counterCells == null, 则对 baseCount 做 CAS 自增操作。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1

  • 如果并发导致 baseCount CAS 失败了使用 counterCells。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1

  • 如果counterCells CAS 失败了,在 fullAddCount 方法中,会继续死循环操作,直到成功。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1

然后,CounterCell 这个类到底是什么?我们会发现它使用了 @sun.misc.Contended 标记的类,内部包含一个 volatile 变量。@sun.misc.Contended 这个注解标识着这个类防止需要防止 "伪共享"。那么,什么又是伪共享呢?

缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。

CounterCell 代码如下:

@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}    
总结

 

  • JDK1.7 和 JDK1.8 对 size 的计算是不一样的。 1.7 中是先不加锁计算三次,如果三次结果不一样在加锁。

  • JDK1.8 size 是通过对 baseCount 和 counterCell 进行 CAS 计算,最终通过 baseCount 和 遍历 CounterCell 数组得出 size。

  • JDK 8 推荐使用mappingCount 方法,因为这个方法的返回值是 long 类型,不会因为 size 方法是 int 类型限制最大值。

 

全文完

 

以下文章您可能也会感兴趣:

转载于:https://my.oschina.net/u/3134761/blog/1920579

<think>嗯,用户想了解ConcurrentHashMap的实现原理,特别是Java并发容器和源码解析方面的内容。首先,我需要回顾一下自己关于ConcurrentHashMap的知识,确保信息准确。ConcurrentHashMapJava中是一个非常重要的线程安全的哈希表实现,用于多线程环境下的高效操作。不过,用户提供的引用资料里提到JDK1.5和1.8的不同实现,需要区分这两个版本的差异,因为实现原理1.8之后有了很大的变化。 首先,用户提到的引用[4]提到,JDK1.5时期ConcurrentHashMap使用了分段锁(Segment),而JDK1.8之后改用了CAS和synchronized优化。这应该是关键点。因此,在回答时需要分版本讲解,但用户可能主要关注最新的实现,也就是1.8之后的版本,但可能需要对比说明。 接下来,我需要确保回答结构清晰,按照用户的要分步骤解析。根据用户的示例,回答应该包括原理、源码结构、成员变量、核心方法分析等部分。同时,需要引用用户提供的引用资料中的内容,比如引用[1]、[2]、[3]、[4],但要注意这些引用是否准确对应到内容中。 比如,引用[1]提到ConcurrentHashMap是线程安全的HashMap,这可能需要在介绍时引用。引用[2]说明ConcurrentHashMap继承了AbstractMap并实现了ConcurrentMap接口,这部分应该在类结构里提到。引用[3]提到Segment的数据结构,但需要注意这是在1.8之前的版本,所以需要说明版本差异。引用[4]详细描述了1.5和1.8的实现变化,特别是分段锁到CAS和synchronized的转变,这里需要重点展开,并可能作为优化部分的内容。 接下来,我需要组织内容的结构。可能分为: 1. 实现原理概述:分段锁(JDK1.7)和CAS+synchronized(JDK1.8)的区别,强调1.8的改进。 2. 类结构成员变量:继承关系、关键变量如Node数组、sizeCtl等。 3. 核心方法分析:如putVal、initTable、transfer(扩容)等,结合源码解析。 4. 线程安全机制:CAS、volatile变量、synchronized的使用。 5. 性能优化点:如链表转红黑树、扩容协助等。 需要注意使用正确的LaTeX格式,如行内公式用$...$,独立公式用$$...$$,但可能在这个问题中数学公式较少,不过如果有涉及计算或算法复杂度的地方需要正确使用。例如,提到时间复杂度时可能需要用$O(\log n)$这样的表达。 另外,用户要生成相关问题,可能需要根据内容提出相关问题,比如JDK1.8的改进、CAS机制在ConcurrentHashMap中的应用、红黑树的作用等。 需要检查引用的正确性,比如引用[4]提到JDK1.8使用Unsafe自旋操作,而ConcurrentHashMap1.8中确实摒弃了分段锁,改为每个桶节点独立加锁,结合CAS和synchronized。例如,put方法中通过CAS尝试无锁插入,失败后再synchronized锁定链表头或树节点。 在源码解析部分,可能需要摘录关键代码片段,比如putVal方法中的循环结构、CAS设置节点,以及initTable方法中的sizeCtl变量的使用。同时,注意代码块的格式,用三个反引号包围,并注明语言为Java。 另外,需要解释成员变量的作用,如sizeCtl的不同取值代表的不同状态(初始化、扩容中),以及如何通过volatile变量保证可见性。例如,Node数组用volatile修饰,确保扩容时其他线程能立即看到变化。 在性能分析部分,要比较分段锁和CAS+synchronized的优劣,比如减少锁粒度,降低竞争,提高并发度。同时,链表转红黑树(当链表长度超过8时)如何避免哈希碰撞导致的性能下降。 可能还需要提及并发扩容机制,即多个线程可以协助扩容,通过transfer方法分片迁移数据,提高效率。 最后,生成的相关问题需要围绕实现原理、源码细节、版本变化、性能优化等方面,确保问题具有针对性,帮助用户进一步深入学习。</think>### ConcurrentHashMap 实现原理分析 #### 一、核心设计思想 在JDK1.8中,ConcurrentHashMap摒弃了早期的**分段锁(Segment)**设计[^4],改为基于`CAS`+`synchronized`的细粒度锁机制。其核心数据结构HashMap类似,采用**数组+链表/红黑树**实现,通过以下方式保证线程安全: 1. 对链表头节点使用`synchronized`加锁 2. 通过`CAS`操作控制数组扩容、节点插入等并发操作 3. `volatile`修饰成员变量保证内存可见性 #### 二、关键源码结构 ```java public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable { transient volatile Node<K,V>[] table; // 哈希表数组 private transient volatile int sizeCtl; // 控制表初始化和扩容的标志 static class Node<K,V> implements Map.Entry<K,V> { // 基础节点 final int hash; final K key; volatile V val; volatile Node<K,V> next; } static final class TreeNode<K,V> extends Node<K,V> { // 红黑树节点 TreeNode<K,V> parent; TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; boolean red; } } ``` 类继承关系满足`AbstractMap -> ConcurrentMap`的规范[^2],`sizeCtl`的不同取值对应不同状态: - `-1`:表示正在初始化 - `-N`:表示有`N-1`个线程正在扩容 - 正数:表示扩容阈值或初始容量 #### 三、核心方法解析 ##### 1. putVal() 方法实现 ```java final V putVal(K key, V value, boolean onlyIfAbsent) { for (Node<K,V>[] tab;;) { // 通过CAS尝试无锁插入 if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value))) break; // CAS插入成功则退出循环 } else { synchronized (f) { // 对链表头加锁 // 处理链表/红黑树插入逻辑 if (tabAt(tab, i) == f) { if (fh >= 0) { // 链表插入逻辑 } else if (f instanceof TreeBin) { // 红黑树插入逻辑 } } } } } addCount(1L, binCount); return null; } ``` 通过`tabAt()`获取数组元素时使用`Unsafe.getObjectVolatile`保证可见性,`casTabAt()`使用`Unsafe.compareAndSwapObject`实现原子更新。 ##### 2. 初始化哈希表 ```java private final Node<K,V>[] initTable() { while ((tab = table) == null || tab.length == 0) { if ((sc = sizeCtl) < 0) Thread.yield(); // 其他线程正在初始化 else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { // 执行实际的数组初始化 } finally { sizeCtl = sc; } break; } } return tab; } ``` 通过`CAS`保证只有一个线程执行初始化操作。 #### 四、线程安全机制对比 | 版本 | 锁粒度 | 并发度 | 实现复杂度 | |------|--------|--------|------------| | JDK1.7 | 段锁(默认16段) | 段级并发 | 中等 | | JDK1.8 | 桶级锁(链表头节点) | 节点级并发 | 较高 | JDK1.8通过以下优化提升性能: 1. 链表长度超过8时转换为红黑树(时间复杂度从$O(n)$降为$O(\log n)$) 2. 多线程协同扩容(`transfer()`方法) 3. 使用`CounterCell`消除`size()`统计的竞争 #### 五、典型应用场景 1. 高并发缓存系统(如Guava Cache底层实现) 2. 实时计算中的状态存储 3. 多线程共享配置存储
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值