(三) 并发容器之 ConcurrentHashMap

HashMap 是线程不安全的集合, 相信只要是参加过面试的同学都会被面试官怼一句 : HashMap 线程安全吗? 为什么?

HashMap 的源码就不讲了, 直接看他的孙子 : ConcurrentHashMap (越年轻越厉害) 现在为你揭开 ConcurrentHashMap 的神秘面纱 (本章节参照的是 JDK 8 的源码, 不讨论 JDK 7 的实现)

ConcurrentHashMap

JDK8 的 ConcurrentHashMap放弃了分段锁, 并且添加了红黑树, 因为定位到节点需要进行两次运算, 效率较低, 而是采用了 Node 锁, 即每一个节点上使用 CAS+ synchronized 加锁, 并使用大量的 volatile型 变量, 锁的粒度更低了, 并发效率也更好了

有一个重要的参数sizeCtl,用于控制数组的初始化和扩容

HashMap扩容在1.8中由头插改为尾插(为了避免死循环问题),ConcurrentHashmap也是

为什么JDK8放弃了分段锁? 有什么问题?

根据官方的文档 :

  1. 加入多个分段锁浪费内存空间。
  2. 生产环境中, map 在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待。
  3. 为了提高 GC 的效率

使用了synchronized而不是Reentrantlock, 会导致性能有所下降.

在这里插入图片描述
它的内部类多的一批, 主要有三大结构 : 数组 + 链表 + 红黑树, 数组的每个元素存储的都是链表的头结点 (以下将数组元素成为 : 桶)

属性

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable {
    
    // Map 可以存储的最大桶个数 ( 2^30 )
    private static final int MAXIMUM_CAPACITY = 1 << 30;

    // 桶的初始容量, 必定为 2 的指数次幂
    private static final int DEFAULT_CAPACITY = 16;

    // 数组的最大长度
    static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

    // 并发级别
    private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

    // 加载因此, 用于判断是否需要扩容
    private static final float LOAD_FACTOR = 0.75f;

    // 数组转红黑树的阈值
    static final int TREEIFY_THRESHOLD = 8;

    // 与上面相反, 不需要转红黑树阈值
    static final int UNTREEIFY_THRESHOLD = 6;

    // 最小的红黑树容量, 最少为 TREEIFY_THRESHOLD 的四倍
    static final int MIN_TREEIFY_CAPACITY = 64;

    //
    private static final int MIN_TRANSFER_STRIDE = 16;

    // sizeCtl中用于生成标记的位数
    private static int RESIZE_STAMP_BITS = 16;

    // 可以帮助扩容的最大线程数
    private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;

    // 在sizeCtl中记录大小标记的位移位
    private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
    ...
    // 用于控制数组的初始化和扩容
    private volatile int sizeCtl;
    ...
}

put(K, V)

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 如果 K-V 为 null, 直接抛出异常
    if (key == null || value == null) throw new NullPointerException();
    // Key 的 hash 码
    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();
        // 根据 Key 的 hash 值, 定位桶的下标 (i) 并返回桶的第一个元素
        // 如果桶没有存元素, 第一次插入, 则返回 null
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 使用 CAS 将创建出的 Node 对象放入桶中
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;       
        }
        // 检查桶中第一个节点的 hash 值, 如果为 -1
        // 说明有线程正在扩容数组, 该线程加入扩容工作, 返回扩容后的数组
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            // 如果上面添加都不符合, 就要追加链表了
            // JDK 采用节点锁, 并非分段锁
            synchronized (f) {
                // 再次判断, 防止多线程出现问题 (类似于 DCL 单例模式的双重检查)
                if (tabAt(tab, i) == f) { // f 为该桶中的第一个节点
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;// 桶中当前节点的 key
                            // 再次判断 key 的 hash 码是否相同, 如果 hash 码相同且 key 相等
                            // 覆盖当前节点的 value
                            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) {
                                // 如果后继节点为 null, 创建对象放到当前节点的后继位置
                                // 可以看到 JDK 8 使用的是尾插法,并非 JDK 7 的头插法
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    // 如果桶中放的不是链表节点, 而是红黑树节点 
                    // 如果是红黑树节点, 他的 hash 标志应小于 0
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        // 先转换成红黑树节点, p 为该桶中红黑树的根节点
                        // 将节点最佳到红黑树中, 同样检查, 如果存在 key 相同的节点, 覆盖旧值
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                              value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                // 判断数组的长度是否大于阈值
                // 如果数组的长度不超过 64 时则优先扩容数组, 否者转换成红黑树
                if (binCount >= TREEIFY_THRESHOLD)
                    // 将链表转成红黑树
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // 计数, 在里面考虑是否需要扩容
    // 跟 HashMap 类似, 当数组超过阈值时, 扩容
    // 阈值 = 桶的数量 * 负载因子
    addCount(1L, binCount);
    return null;
}

get(Object)

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) {
        // 观测第一个节点的 hash 值
        if ((eh = e.hash) == h) {
            // 再次判断传进来的参数与该桶中的一个节点的 key 是否相等
            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;
        // 遍历这个链表, 返回对应的 value
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

为了保证高效性, ConcurrentHashMap 使用了大量的 volatile 型变量, 数组的引用使用了 volatile,同时一个 Node 节点中的 val 和 next 属性也为 volatile, 由于 volatile 的可见性, 所以 get 操作不需要加锁

迁移

理想情况下 talbe 数组元素的大小就是其支持并发的最大个数, 可以多个线程同时扩容 (牛不牛逼),在一个线程发起扩容的时候,就会改变 sizeCt l这个值,其含义如下:

sizeCtl :默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来。

  • -1 代表 table正在初始化
  • -N 表示有 N-1个线程正在进行扩容操作

其余情况:
1、如果 table 未初始化,表示 table 需要初始化的大小。
2、如果 table 初始化完成,表示 table 的容量,默认是 table 大小的 0.75 倍

第一个得到分片的线程首先将数组扩容, 长度为原来的两倍, 然后遍历分到的每个桶, 进行元素迁移, 通过观测桶中的第一个元素的 hash 值, 如果为 -1 则说明该桶已经被线程处理了 (正在扩容)

如果桶中为链表或者红黑树结构,则需要获取桶锁,防止其他线程对该桶进行 put 操作,然后处理方式同 HashMap 的处理方式一样, 将元素分为两部分, 当前桶的元素, 需要迁移到新的桶的元素 (通过位运算判断), 迁移从尾部开始,迁移完毕后, 旧数组中该桶就可以设置成 ForwardingNode 了


//调用该扩容方法的地方有:
//java.util.concurrent.ConcurrentHashMap#addCount        向集合中插入新数据后更新容量计数时发现到达扩容阈值而触发的扩容
//java.util.concurrent.ConcurrentHashMap#helpTransfer    扩容状态下其他线程对集合进行插入、修改、删除、合并、compute 等操作时遇到 ForwardingNode 节点时触发的扩容
//java.util.concurrent.ConcurrentHashMap#tryPresize      putAll批量插入或者插入后发现链表长度达到8个或以上,但数组长度为64以下时触发的扩容
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    //计算每条线程处理的桶个数,每条线程处理的桶数量一样,如果CPU为单核,则使用一条线程处理所有桶
    //每条线程至少处理16个桶,如果计算出来的结果少于16,则一条线程处理16个桶
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    if (nextTab == null) {            // 初始化新数组(原数组长度的2倍)
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        //将 transferIndex 指向最右边的桶,也就是数组索引下标最大的位置
        transferIndex = n;
    }
    int nextn = nextTab.length;
    //新建一个占位对象,该占位对象的 hash 值为 -1 该占位对象存在时表示集合正在扩容状态,key、value、next 属性均为 null ,nextTable 属性指向扩容后的数组
    //该占位对象主要有两个用途:
    //   1、占位作用,用于标识数组该位置的桶已经迁移完毕,处于扩容中的状态。
    //   2、作为一个转发的作用,扩容期间(已迁移完毕)如果遇到读操作,遇到转发节点,会把该读操作转发到新的数组上去,不会阻塞读操作。
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    //该标识用于控制是否继续处理下一个桶,为 true 则表示已经处理完当前桶,可以继续迁移下一个桶的数据
    boolean advance = true;
    //该标识用于控制扩容何时结束,该标识还有一个用途是最后一个扩容线程会负责重新检查一遍数组查看是否有遗漏的桶
    boolean finishing = false; // to ensure sweep before committing nextTab
    //这个循环用于处理一个 stride 长度的任务,i 后面会被赋值为该 stride 内最大的下标,而 bound 后面会被赋值为该 stride 内最小的下标
    //通过循环不断减小 i 的值,从右往左依次迁移桶上面的数据,直到 i 小于 bound 时结束该次长度为 stride 的迁移任务
    //结束这次的任务后会通过外层 addCount、helpTransfer、tryPresize 方法的 while 循环达到继续领取其他任务的效果
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        while (advance) {
            int nextIndex, nextBound;
            //每处理完一个hash桶就将 bound 进行减 1 操作
            if (--i >= bound || finishing)
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) {
                //transferIndex <= 0 说明数组的hash桶已被线程分配完毕,没有了待分配的hash桶,将 i 设置为 -1 ,后面的代码根据这个数值退出当前线的扩容操作
                i = -1;
                advance = false;
            }
            //只有首次进入for循环才会进入这个判断里面去,设置 bound 和 i 的值,也就是领取到的迁移任务的数组区间
            else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            //扩容结束后做后续工作,将 nextTable 设置为 null,表示扩容已结束,将 table 指向新数组,sizeCtl 设置为扩容阈值
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            //每当一条线程扩容结束就会更新一次 sizeCtl 的值,进行减 1 操作
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                //(sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT 成立,说明该线程不是扩容大军里面的最后一条线程,直接return回到上层while循环
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                //(sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT 说明这条线程是最后一条扩容线程
                //之所以能用这个来判断是否是最后一条线程,因为第一条扩容线程进行了如下操作:
                //    U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
                //除了修改结束标识之外,还得设置 i = n; 以便重新检查一遍数组,防止有遗漏未成功迁移的桶
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        else if ((f = tabAt(tab, i)) == null)
            //遇到数组上空的位置直接放置一个占位对象,以便查询操作的转发和标识当前处于扩容状态
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED)
            //数组上遇到hash值为MOVED,也就是 -1 的位置,说明该位置已经被其他线程迁移过了,将 advance 设置为 true ,以便继续往下一个桶检查并进行迁移操作
            advance = true; // already processed
        else {
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    //该节点为链表结构
                    if (fh >= 0) {
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        //遍历整条链表,找出 lastRun 节点
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        //根据 lastRun 节点的高位标识(0 或 1),首先将 lastRun设置为 ln 或者 hn 链的末尾部分节点,后续的节点使用头插法拼接
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        //使用高位和低位两条链表进行迁移,使用头插法拼接链表
                        for (Node<K,V> 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<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        //setTabAt方法调用的是 Unsafe 类的 putObjectVolatile 方法
                        //使用 volatile 方式的 putObjectVolatile 方法,能够将数据直接更新回主内存,并使得其他线程工作内存的对应变量失效,达到各线程数据及时同步的效果
                        //使用 volatile 的方式将 ln 链设置到新数组下标为 i 的位置上
                        setTabAt(nextTab, i, ln);
                        //使用 volatile 的方式将 hn 链设置到新数组下标为 i + n(n为原数组长度) 的位置上
                        setTabAt(nextTab, i + n, hn);
                        //迁移完成后使用 volatile 的方式将占位对象设置到该 hash 桶上,该占位对象的用途是标识该hash桶已被处理过,以及查询请求的转发作用
                        setTabAt(tab, i, fwd);
                        //advance 设置为 true 表示当前 hash 桶已处理完,可以继续处理下一个 hash 桶
                        advance = true;
                    }
                    //该节点为红黑树结构
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        //lo 为低位链表头结点,loTail 为低位链表尾结点,hi 和 hiTail 为高位链表头尾结点
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        //同样也是使用高位和低位两条链表进行迁移
                        //使用for循环以链表方式遍历整棵红黑树,使用尾插法拼接 ln 和 hn 链表
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            //这里面形成的是以 TreeNode 为节点的链表
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (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;
                            }
                        }
                        //形成中间链表后会先判断是否需要转换为红黑树:
                        //1、如果符合条件则直接将 TreeNode 链表转为红黑树,再设置到新数组中去
                        //2、如果不符合条件则将 TreeNode 转换为普通的 Node 节点,再将该普通链表设置到新数组中去
                        //(hc != 0) ? new TreeBin<K,V>(lo) : t 这行代码的用意在于,如果原来的红黑树没有被拆分成两份,那么迁移后它依旧是红黑树,可以直接使用原来的 TreeBin 对象
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                        (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                        (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        //setTabAt方法调用的是 Unsafe 类的 putObjectVolatile 方法
                        //使用 volatile 方式的 putObjectVolatile 方法,能够将数据直接更新回主内存,并使得其他线程工作内存的对应变量失效,达到各线程数据及时同步的效果
                        //使用 volatile 的方式将 ln 链设置到新数组下标为 i 的位置上
                        setTabAt(nextTab, i, ln);
                        //使用 volatile 的方式将 hn 链设置到新数组下标为 i + n(n为原数组长度) 的位置上
                        setTabAt(nextTab, i + n, hn);
                        //迁移完成后使用 volatile 的方式将占位对象设置到该 hash 桶上,该占位对象的用途是标识该hash桶已被处理过,以及查询请求的转发作用
                        setTabAt(tab, i, fwd);
                        //advance 设置为 true 表示当前 hash 桶已处理完,可以继续处理下一个 hash 桶
                        advance = true;
                    }
                }
            }
        }
    }

由于迁移的代码逻辑太复杂了, 请参考参考 : ConcurrentHashMap 的迁移过程

任务分片

按照计算机的CPU核心数进行分片

if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
       stride = MIN_TRANSFER_STRIDE; // subdivide range

当前线程如何感知其他线程也在参与迁移工作?

靠观测一个 volatile 型边变量 sizeCtl , 它默认是一个负数, 每当一个线程参与进来执行迁移工作,则该值进行 CAS自增,线程的任务执行完毕要退出时对该值进行 CAS 自减操作,所以当 sizeCtl 的值不等于初值说明有其他线程正在执行迁移任务

如何记录已分配的任务?

通过一个 volatile 型变量 transferIndex, 初值等于数组的长度, 当有一个任务分配成功时, 则用 CAS 将该值减去分片的大小

size

1.8 中的 size 实现比 1.7 简单多,因为元素个数保存 baseCount 中,部分元素的变化个数保存在 CounterCell 数组中, 通过累加 baseCount 和 CounterCell 数组中的数量,即可得到元素的总个数

JDK 7 ConcurrentHashMap 的弱一致性

对数组的引用是volatile来修饰的,但是数组中的元素并不是。即读取数组的引用总是能读取到最新的值,但是读取数组中某一个元素的时候并不一定能读到最新的值。所以说是弱一致性的。

只需要稍微改动下就可以实现强一致性:

  • 对于新加的key,通过写入到链表的末尾即可。因为一个元素的next属性是volatile的,可以保证写入后立马看的到,1.8的方式
  • 或者对数组中元素的更新采用volatile写的方式,1.8直接将新加入的元素写入next属性(含有volatile修饰)中而不是修改桶中的第一个元素。

与HashTable区别

HashTable类是线程安全的,它使用synchronize来做线程安全,全局只有一把锁,在线程竞争比较激烈的情况下hashtable的效率是比较低下的。因为当一个线程访问hashtable的同步方法时,其他线程再次尝试访问的时候,会进入阻塞或者轮询状态,比如当线程1使用put进行元素添加的时候,线程2不但不能使用put来添加元素,而且不能使用get获取元素。

猜你喜欢

转载自blog.csdn.net/Gp_2512212842/article/details/107692347