ConcurrentHashMap源码分析

文章简称ConcurrentHashMap为map

文章深入分析了get函数,put函数和remove函数,以及并发扩容过程。ConcurrentHashMap的一半精华在于并发扩容,多个线程是如何协作加速迁移哈希槽中的元素。


sizeCtl是map的重要控制变量

通过几种值来传递当前容器处于什么状态。-1是map正在初始化中。-2~-n是有abs(sizeCtl)个线程帮助扩容中。正常的正整数是map下一次扩容的阈值,当前capacity*0.75。

/**
 * Table initialization and resizing control.  When negative the
 * table is being initialized or resized: -1 for initialization
 * else -(1 + the number of active resizing threads).  Otherwise
 * when table is null holds the initial table size to use upon
 * creation or 0 for default. After initialization holds the
 * next element count value upon which to resize the table.
 */
private transient volatile int sizeCtl;

​​​​​​​哈希槽中节点hash值含义,链表节点hash值是>=0的正整数,树根节点时-2,-1是正在扩容的哈希槽虚节点。

static final int MOVED     = -1; // hash for forwarding nodes

static final int TREEBIN   = -2; // hash for roots of trees

static final int RESERVED  = -3; // hash for transient reservations

initTable()函数

初始化操作很简单,数组初始化的长度是由sizeCtl控制的,sizeCtl 是被 volatile 修饰的变量,保证在并发环境下的内存可见性。

sizeCtl 有几种值的含义:

负数:-1 正在初始化。-N 表示有 N-1个线程正在扩容。

正数:初始化表大小的默认值,或者下一次扩容时的阈值。

所以不必担心在并发时有多个线程多次初始化表格,因为当 sizeCtl < 0 时,线程就 yield() 了(放弃CPU执行权)。而且前面提到过 sizeCtl 是 volatile 的。

如果是正数就用 CAS 把 sizeCtl 设置为 -1 表示正在初始化(这里的CAS可理解为类似乐观锁作用)。new了数组后,就是 n - n/4 的阈值给了 sizeCtl。即 75% 的扩容阈值。

put()函数

大致和HashMap的put操作差不多,区别在于,会检查是否正在扩容,且会帮助扩容。hash计算的函数和HashMap算法差不多,将Key的哈希值高16位与16位异或后跟table.length-1相与(对于2的n次幂-1后做与运算等价于对table.length取模,原理是什么?很简单想想取模后的余数二进制是不是在table.length-1范围内),得到index。对于寻址得到的bucket若是空结点,会用CAS直接创建新节点。(以下bucket称为哈希槽)

如果当前哈希槽的节点的hash值时-1,代表整个哈希表正在扩容,进入helpTransfer()帮助扩容。

步骤:

1.首先检查k v,key不能为空,HashMap可以为空 此时key的hash为0

2.计算key hash

3.若表需要初始化则初始化

4.寻址哈希槽,当前哈希槽为空直接CAS插入新节点

5.哈希槽非空,判断当前节点hash是否-1代表正在扩容,则帮助扩容

6.非空,非扩容,走正常的加锁节点,链表插入或红黑树插入节点

bucket有节点则会以该节点上锁,锁粒度还是控制在 bucket 级别。进去临界区后再检查一次当前槽位的节点有没被改动过(锁双重检查),OK,线程安全了,开始添加操作。之后就是判断链表还是树形,链表会插入到尾部,红黑树则是红黑树的节点插入算法。

从这里可以看出ConcurrentHashMap是尾插法。

链表添加的过程会涉及一个binCount,这个变量代表当前哈希槽中链表的长度,每遍历一个节点binCount++,当遍历了8个节点以上才将当前元素加入链表后,在putVal()最后会判断binCount > 8 && table.length >= 64就将链表转红黑树。

最后在 addCount() 中将binCount加1,由于涉及并发,这个计数逻辑也变得比较复杂。

来看看树化函数

private final void treeifyBin(Node<KV>[] tab int index) {
    Node<KV> b; int n sc;
    if (tab != null) {
        // 前面提过当一个链表中超过8个节点就会尝试树化,但是不一定会执行
        // 还要看哈希表长度是否>64,如果<64会将哈希表扩容2倍
        // 因为哈希表太小而某个槽的元素过多,代表哈希冲突比较严重,需要扩大槽位降低冲突
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            tryPresize(n << 1);
        else if ((b = tabAt(tab index)) != null && b.hash >= 0) {
            // 锁住当前槽位的第一个节点
            synchronized (b) {
                if (tabAt(tab index) == b) {
                    TreeNode<KV> hd = null tl = null;
                    // 从第一个节点遍历链表,将每个节点转成树形节点,并且这个树形节点还保持原链表的前后节点指针。hd是头结点,tl尾结点
                    // 这里只是将节点转树形节点,真正的链表转红黑树逻辑在new TreeBin()
                    for (Node<KV> e = b; e != null; e = e.next) {
                        TreeNode<KV> p =
                            new TreeNode<KV>(e.hash e.key e.val
                                              null null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    // new TreeBin(hd) 是构造一颗红黑树,TreeBin顾名思义树形槽,里面维护一个root的TreeNode,并且树形槽节点hash=-2
                    // forwardingNode hash = -1
                    // 构造红黑树后,将这个树形槽设置在哈希表的槽位上,至此某个槽位的树化过程完毕
                    setTabAt(tab index new TreeBin<KV>(hd));
                }
            }
        }
    }
}

get()函数

get函数和普通hashMap差别不大,其中需要关注的是如果map在并发扩容中该怎么查找对应的key? 如果一个哈希槽是迁移中的它的首节点肯定是被替换成了forwdingNode,然后迁移线程开始迁移链表,所以访问线程只需要到新哈希表去找对应的entry就行。

如果同时有另个线程在put元素,因为table是volatile修饰的,保证数组元素发生写操作后立即对所有线程可见。所以只要put线程先行插入元素到链表尾部,get线程可立即读到新元素。所以get函数没有加锁访问。

public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        // 计算key的哈希值
        int h = spread(key.hashCode());
        // 哈希值 % table.length
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            // 比较key
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            // 如果哈希槽的entry是forwdingNode,它的哈希值固定位-1
            else if (eh < 0)
                // 在新的哈希表中查找entry
                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;
            }
        }
        // 哈希槽是空的,或链表没有找到entry
        return null;
    }

remove()函数

具体实现是 replaceNode(key, null, null) 函数。通过寻址后定位key所在某个槽,哈希表是空或未初始化返回null。正常的第一步判断槽节点是否 ForwardingNode 它的hash=-1,是的话会进入扩容环节。

没有扩容的话,仍然是以节点为锁 synchronized(f),如果节点hash >= 0 代表是普通链表节点。链表的替换逻辑,其中 传进来的 cv value 和 当前节点的旧值 ev 的比较不是很明白,为什么需要一个 cv 和 ev的比较?

可以看到找到目标节点后直接覆盖了旧值,如果传进来的 value 是空直接将上一个节点指向下一个节点,将目标节点剔除链表(没有任何指向)。如果没找到节点就跳出循环。

 TreeBin 的hash = -2,红黑树的替换逻辑貌似就简单很多(实际内部应该比较复杂,又是涉及红黑树平衡算法)找到节点后,若value非空直接替换旧值,若为空代表要删除树节点,剩下的就是红黑树的删除算法。

transfer()

关于 transfer() 操作,如果下面的解析看不懂,还是建议看开头的文章,比较好理解。在每次put()操作后,会调用addCount(),更新baseCount和检查是否需要扩容。

扩容操作,是很复杂的一个函数。借助解析文章和源码看了下,Doug Lee 写这个扩容函数很吊的地方在于扩容操作允许多线程并发扩容,提高性能,还保证了安全性。

transfer()主要由2部分:扩容时确定每个线程负责的哈希槽范围。对该范围内每个节点数据的迁移动作。

扩容时哈希槽分配算法:

while(advance) 循环主要是给当前参加扩容的线程分配一个哈希槽区间,由bound和i 2个变量组成区间。循环体中每次都读取成员变量 volatile transferIndex,以它为本次分配的边界,假设现在是第一个扩容的线程,transferIndex=old table length=64

stride是步幅 每个线程默认处理的哈希槽区间范围 16,nextIndex - stride = 64 - 16 =48,如果剩下的待分配区间不够一个stride步幅,那么这个线程就包揽剩下全部哈希槽(小于16个)。用CAS替换内存中transferIndex地址,把旧值64替换成48,成功就代表这个线程抢到了这部分哈希槽区间,失败就重新读transferIndex,重新计算区间。所以经过这个循环,线程就得到 bound = 48 边界,起点 i = 63,从后往前迁移每个哈希槽的数据。

这里得到待转移的槽区间,下面逻辑完成某个槽的数据迁移后,会将advance=true 重新回到while(advance),目的是检查有没有完成区间内全部槽位的数据迁移。if(--i >= bound || finishing) 代表当前线程还在边界内 或 全局finishing标志位=true代表扩容已完成,也意味着该线程不需要再领取槽区间,离开 while 循环继续去下面的迁移逻辑。

while (advance) {
    // 首先bound是本次线程领取迁移范围的左边界,i是右边界。迁移是从右->左顺序。
    int nextIndex nextBound;
    // --i >= bound还没迁移完毕当前领取的范围。finishing=true扩容已经完成了
    if (--i >= bound || finishing)
        advance = false;
    // transferIndex可以看做是迁移的起始位置,每个线程从起始位置领取一段区间去迁移。起始位置<=0时扩容完毕了。
    else if ((nextIndex = transferIndex) <= 0) {
        i = -1;
        advance = false;
    }
    // 从transferIndex起始位置开始领取一段stride范围的区间,CAS缩减起始位置,i从后往前迁移区间内的节点
    else if (U.compareAndSwapInt
             (this TRANSFERINDEX nextIndex
              nextBound = (nextIndex > stride ?
                           nextIndex - stride : 0))) {
        bound = nextBound;
        i = nextIndex - 1;
        advance = false;
    }
}
// 迁移完毕了,更新新的哈希表和sizeCtl
if (i < 0 || i >= n || i + n >= nextn) {
    int sc;
    if (finishing) {
        nextTable = null;
        table = nextTab;
        sizeCtl = (n << 1) - (n >>> 1);
        return;
    }
    if (U.compareAndSwapInt(this SIZECTL sc = sizeCtl sc - 1)) {
        if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
            return;
        finishing = advance = true;
        i = n; // recheck before commit
    }
}

在上面扩容线程分配一个负责的哈希槽区间后,这里的逻辑就是开始处理每个哈希槽位的数据。如果当前哈希槽是空的,就插入一个ForwardingNode节点,它的hash是固定的-1标识这个槽位的数据正在被转移。

casTabAt(tab, i, null, fwd) 就是用CAS提供的原子操作在指定内存地址替换成fwd节点,通知其它线程遍历到这个节点后就跳过。

else if ((fh = f.hash) == MOVED) 判断就是表达这个逻辑 advance置为true,重新回到while中判断当前线程是否处理完分配的哈希槽位。

如果当前线程处理完闭而全局的哈希槽还没处理完(finishing=false),就再次分配。

节点迁移算法:

上面判断完,一个槽位非空又非ForwardingNode,那么就是一个正常的Node,锁住它。开始前需要先讲下大致逻辑:整个槽位的链表会被划分成2条新的链表,低位链表和高位链表,低位链表会被放置在原index位置上 高位链表被放到 index + old table.length 位置上

synchronized (f) {
    // 在锁里面再检查一次,免得被别人改变了
    if (tabAt(tab i) == f) {
        // 低位节点,高位节点。这个概念比较重要,低位节点组成低位链表,高位节点同理。学过HashMap扩容原理应该知道,
        // 因为数组总是以2的幂次方增长,所以一个节点被rehash后要么在原位置上要么会被移动到 index + old table.length的位置上
        // 原本 [0~old table.length) 的范围称为低位,[old table.length ~ new table.length) 是高位
        Node<KV> ln hn;
        // 链表节点hash >= 0,树形节点 hash = -2
        if (fh >= 0) {
            // 根据扩容后的1位看节点hash是否会被移动,参看 HashMap 源码学习(JDK 1.8)
            int runBit = fh & n;
            Node<KV> lastRun = f;
            // 从头到尾遍历链表,判断每个节点新增的1位是否为1,这里的目的没有别的就是让 lastRun 指针指向最后一个runBit有差异的节点
            // 这个 lastRun 节点后的所有节点的 runBit 位都和它相同,而且它们本身已经有链条关系,直接将以 lastRun开头的这个剩余K个节点的链表
            // 放到对应高位或低位链表中就OK了。说那么多这个小算法就是想优化效率
            for (Node<KV> p = f.next; p != null; p = p.next) {
                int b = p.hash & n;
                if (b != runBit) {
                    runBit = b;
                    lastRun = p;
                }
            }
            // 经过上面计算,lastRun指向的链表是低位链表还是高位链表已经得知
            if (runBit == 0) {
                ln = lastRun;
                hn = null;
            }
            else {
                hn = lastRun;
                ln = null;
            }
            // 这个循环才是真正移动每个节点到新的位置同时构造新的链表
            // 从头开始到 lastRun 指向的链表之前,将每个节点要么放进低位链表要么放进高位链表
            // 这里用的是 新节点 -> 老节点,最新的节点被当做头结点放在哈希槽中,头插法。
            for (Node<KV> p = f; p != lastRun; p = p.next) {
                int ph = p.hash; K pk = p.key; V pv = p.val;
                if ((ph & n) == 0)
                    ln = new Node<KV>(ph pk pv ln);
                else
                    hn = new Node<KV>(ph pk pv hn);
            }
            // 在新数组中CAS设置低位链表原来 index 中
            setTabAt(nextTab i ln);
            // 在新数组中CAS设置高位链表在 index + old table.length 中
            setTabAt(nextTab i + n hn);
            // 该哈希槽数据被处理完毕,将旧数组的这个哈希槽节点替换为ForwardingNode,通知其它线程跳过此槽
            setTabAt(tab i fwd);
            // advance置为true,重新回到while中前进一个哈希槽或重新分配一个范围的哈希槽继续迁移数据
            advance = true;
        }
        else if (f instanceof TreeBin) {
            TreeBin<KV> t = (TreeBin<KV>)f;
            TreeNode<KV> lo = null loTail = null;
            TreeNode<KV> hi = null hiTail = null;
            int lc = 0 hc = 0;
            for (Node<KV> e = t.first; e != null; e = e.next) {
                int h = e.hash;
                TreeNode<KV> p = new TreeNode<KV>
                    (h e.key e.val null null);
                if ((h & n) == 0) {
                    if ((p.prev = loTail) == null)
                        lo = p;
                    else
                        loTail.next = p;
                    loTail = p;
                    ++lc;
                }
                else {
                    if ((p.prev = hiTail) == null)
                        hi = p;
                    else
                        hiTail.next = p;
                    hiTail = p;
                    ++hc;
                }
            }
            ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                (hc != 0) ? new TreeBin<KV>(lo) : t;
            hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                (lc != 0) ? new TreeBin<KV>(hi) : t;
            setTabAt(nextTab i ln);
            setTabAt(nextTab i + n hn);
            setTabAt(tab i fwd);
            advance = true;
        }
    }
}

支持并发扩容,实现方式是,将表拆分,让每个线程处理自己的区间。下面几张图来描述上面的过程,黑色节点为低位节点,白色节点是高位节点。一开始在原本链表中是这样的

中间计算过程,构造2条链表,可以看到 lastRun指向的 9、10链表被加入到低位链表,表现形式也是头插法。

扩容后低位链表被留在了原位置,高位被放到了 index + old table.length上,这里的old table.length = 16

参考文章:

并发编程——ConcurrentHashMap#transfer() 扩容逐行分析 - 简书并发编程——ConcurrentHashMap#transfer() 扩容逐行分析

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值