Java8 ConcurrentHashMap源码分析

本文详细解读了JavaConcurrentHashMap的内部结构、初始化过程、put和get操作,以及负载因子的选择,展示了其在多线程环境中的高效并发性能。
摘要由CSDN通过智能技术生成

在这里插入图片描述

ConcurrentHashMap是Java并发包中的一个线程安全的哈希表实现,它提供了高效的并发访问性能。本文将对ConcurrentHashMap的源码进行分析,以帮助大家更好地理解其内部实现原理。

整体结构

在这里插入图片描述

ConcurrentHashMap的主要组成部分有以下几个:

  • Node数组: 在 Java 1.8 版本中,ConcurrentHashMap 放弃了分段锁的设计,改为使用 CAS(Compare and Swap)操作和一系列辅助数据结构来保证并发操作的安全性。它不再有 Segment 结构,而是直接使用类似 HashMap 中的 Node 数组(也称为桶(bucket)数组),Node 类同样用于存储键值对。

  • 链表/红黑树: 当桶中的元素数量达到一定阈值时,链表会转换为红黑树以优化查找性能。

  • CAS操作和相关原子类: JDK 1.8 的 ConcurrentHashMap 使用了 AtomicInteger 等原子类以及 Unsafe 提供的 CAS 操作,来实现在无锁或更细粒度的锁控制下完成插入、删除和查询等操作。

初始化

在这里插入图片描述

ConcurrentHashMap的初始化过程主要包括以下几个步骤:

  • 构造函数调用: 当创建一个新的 ConcurrentHashMap 实例时,可以通过构造函数指定初始容量(initial capacity)、负载因子(load factor)和并发级别(concurrency level)。但在 Java 1.8 中,实际并未使用并发级别参数,因为取消了分段锁的设计,转而采用更加精细的粒度控制。
ConcurrentHashMap<K, V> map = new ConcurrentHashMap<>(initialCapacity, loadFactor);
  • 容量调整: 初始化过程中,会将传入的初始容量向上调整到最接近的2的幂次方,因为它的内部数组长度必须是2的倍数,这样可以利用位运算快速定位槽位。

  • 初始化table数组: 初始化时,首先检查 table 是否已存在(即是否已被其他线程初始化)。如果不存在,则会尝试初始化 table 数组。这个数组就是用来存放键值对节点(Node)的桶(bucket)。

  • 同步控制: 使用一个名为 sizeCtl 的变量来协调初始化过程的并发控制。sizeCtl 的初始值代表不同的状态含义,例如负值表示正在进行初始化或扩容。

// 非静态内部类 Node<K, V>
transient volatile Node<K, V>[] table;
  • 初始化操作: 线程尝试进行初始化时,会先CAS更新 sizeCtl 值,确保只有一个线程能够成功执行初始化。成功获得初始化权限的线程会根据给定的初始容量创建 table 数组,并将其长度设置为合适的值。

  • 填充table: 初始化完成后,table 数组会被填充为空节点,以准备接收后续的插入操作。

put操作

final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            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, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

ConcurrentHashMap的put操作主要包括以下几个步骤:

  • 校验输入:

    • 检查传入的key和value是否为null,两者都不能为null,否则抛出NullPointerException。
  • 计算hash值:

    • 调用hash()函数计算key的哈希值。
  • 定位槽位:

    • 根据计算出的哈希值找到table数组中的索引位置。
  • 初始化或扩容:

    • 如果table尚未初始化或者发现容量不足(负载因子超过阈值),则可能触发一次扩容操作(resize)。扩容时会创建新的table数组,然后重新分配所有的键值对到新的数组中,同时调整相应的索引位置。
  • 插入元素:

    • 在目标槽位处,通过CAS(Compare And Swap)操作尝试将新节点插入到链表头部(或红黑树的根节点位置,如果已经转化为树结构)。
      • 如果槽位为空,则直接尝试CAS插入新节点。
      • 如果已经有节点存在,进入链表或树的插入逻辑:
        • 遍历链表或树,寻找合适的位置(相同hash值和equals相等的key存在的位置)。
        • 若找到已有键值对,替换原有值(根据onlyIfAbsent参数决定是否替换)。
        • 若未找到相同键值对,则使用CAS或synchronized添加新节点至链表或树中。
  • 更新大小:

    • 插入成功后,如果之前桶内没有该键值对,则更新整体映射的大小。

get操作

 public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

ConcurrentHashMap的get操作主要包括以下几个步骤:

  • 计算hash值:

    • 调用散列函数(通常通过 spread() 方法)计算 key 的哈希值。
	static final int spread(int h) {
        return (h ^ (h >>> 16)) & HASH_BITS;
    }
  • 校验输入:

    • 检查传入的 key 是否为 null,若为 null,则返回 null,因为 ConcurrentHashMap 不允许 null 键。
  • 定位槽位:

    • 将计算出的哈希值与 table 数组的长度进行按位与操作,确定要访问的数组槽位(桶)索引。
  • 遍历槽位中的元素:

    • 访问对应索引位置的桶,此时可能是单个节点、链表节点或红黑树节点:
      • 如果桶为空,则直接返回 null,表示键不存在于映射中。
      • 如果桶中只有一个节点(或链表长度为1),直接检查该节点的键是否与传入的 key 相等,如果相等则返回对应的值。
      • 如果桶中包含链表结构,从头开始遍历链表,直到找到键值相等的节点,返回其值;若遍历完链表未找到匹配项,则返回 null。
      • 如果桶中存储的是红黑树结构,则按照红黑树的查找算法遍历树节点,找到与 key 相匹配的节点并返回其值。
  • 内存可见性保证:

    • 在 Java 1.8 中,虽然 get 操作不需要加锁,但由于 ConcurrentHashMap 中的节点(如链表或树节点的 value 字段)被声明为 volatile,因此可以保证当其他线程对映射进行修改时,读取操作能够看到最新的值,实现了线程间的内存可见性。

扩容操作

    private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
        if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
            CounterCell a; long v; int m;
            boolean uncontended = true;
            if (as == null || (m = as.length - 1) < 0 ||
                (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
                !(uncontended =
                  U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
                fullAddCount(x, uncontended);
                return;
            }
            if (check <= 1)
                return;
            s = sumCount();
        }
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
                int rs = resizeStamp(n);
                if (sc < 0) {
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
                s = sumCount();
            }
        }
    }

当ConcurrentHashMap中的元素数量超过阈值时,会触发扩容操作。扩容操作主要包括以下几个步骤:

  • 初始化或更新扩容标志: 设置一个内部标志(如sizeCtl字段)来指示正在进行扩容,或者增加正在参与扩容的线程计数器。

  • 创建新的table: 创建一个新的更大的Node数组,即新的table。新数组的大小通常是原数组的两倍。

  • 迁移数据:

    • 转移元素:遍历旧table的所有槽位,对于每个槽位内的链表或红黑树结构,将其中的元素重新计算哈希值,并根据新的容量重新定位到新的table中。
    • 并发迁移:多个线程可以参与到扩容过程中,每个线程负责一部分槽位的数据迁移工作。迁移过程中,使用CAS操作和其他并发控制手段确保数据迁移的正确性和线程安全性。
  • 清理旧table: 当所有元素都迁移完毕后,将旧的table引用替换为新的table。这个替换操作需要保证原子性。

  • 同步控制: 在迁移过程中,对于正在进行扩容的槽位,其他线程试图对该槽位进行写操作时会被阻塞,等待扩容完成。同时,扩容过程结束后,释放相关的锁,让其他线程可以继续进行读写操作。

  • 更新阈值: 根据新的容量计算新的扩容阈值,确保未来在元素数量再次增长到一定程度时才会触发下一轮扩容。

负载因子

ConcurrentHashMap 在 Java 1.8 及更高版本中的负载因子(load factor)之所以默认设置为 0.75,主要是基于以下原因:

  • 空间和时间成本平衡: 负载因子决定了哈希表何时应该扩容。设置为 0.75 表示当哈希表中的元素数量达到了容量的 75% 时,将会触发扩容操作。这样设计是因为如果负载因子过大,哈希表会趋向于更长时间才扩容,这意味着更多的元素会在哈希表填满的状态下进行操作,增加哈希冲突的概率,进而影响 get、put 等操作的性能。相反,如果负载因子较小,虽然哈希冲突减少,但扩容操作会更加频繁,浪费更多内存空间。0.75 是一个经过实践检验的折衷点,能够在空间消耗和哈希冲突之间取得较好的平衡。

  • 经验值: 在众多哈希表实现中,0.75 已经成为一个广泛接受的经验值。它在多种负载情况下表现良好,既能防止过早扩容造成的空间浪费,又能及时扩容避免因负载过高导致的性能问题。

  • 二进制关系: 由于 ConcurrentHashMap 的容量总是2的幂,选择0.75作为一个负载因子,使得扩容时的新容量恰好是原容量的两倍,这样可以方便地通过位运算来重新定位元素,同时也简化了扩容操作的逻辑。

总结

ConcurrentHashMap是Java中一种线程安全的哈希表实现,尤其适用于多线程环境。在Java 1.7版本中采用了分段锁(Segment)技术来实现并发控制,而在1.8及更高版本中改用CAS(Compare and Swap)操作和原子类以减少锁竞争,提高并发效率。内部结构主要包括一个可动态扩容的数组,数组中的每个元素指向链表或红黑树(在适当条件下转换),存储键值对。put操作通过计算键的哈希值定位数组桶,借助CAS或轻量级锁插入新节点,而get操作则快速定位桶并通过无锁机制检索值。ConcurrentHashMap充分利用并发处理的优势,兼顾了高并发读写性能与数据一致性,是多线程编程中实现共享数据存储的理想选择。

  • 45
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

吴代庄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值