ConcurrentHashMap 怎么样去保证线程安全的, 读操作为什么不需要加锁

前言

最近在看公众号 看到一个问题:为什么 ConcurrentHashMap 的读操作不需要加锁?

第一次看到这个问题的时候 我也确实比较懵逼 我虽然知道ConCurrentHashMap 是怎么在put的时候 去保证线程安全的,但是真的没关注过 get取的时候 怎么去保证线程安全的 今天我们就从这个问题切入 来看下 是怎么做到的

分析

首先 我们应该清楚下ConcurrentHashMap 怎么去保证 线程安全的,我会分别从初始化的时候 和新增元素的时候 用了那些手段去保证了线程安全

初识ConcurrentHashMap

首先 我们还是要先了解下ConcurrentHashMap 是什么 在那种场景下使用。

看名字 我们知道 首先 这个类应该是 在java.util.concurrent包中(JUC)这个包很重要,我们常见的 线程安全的集合 都会在这个包里面,再看后面的是HashMap.

数据结构对比

HashMap我们知道 哈希表,是一个双列集合。是一个能根据key值 能快速定位的value的数据结构。 既然聊到这个这里,那我们应该清楚 为什么会存在这样的数据结构,存在即合理是吧,那一定是有需求 才会产生这样的数据结构,那就对比下常见的数据结构 在新增 和删除 时间复杂度的表现

  1. 数组:数组是才用一段连续的地址空间来存储元素,可以指定下标 来 快速的找到数值,时间复杂度是O(1),但是通过给定的值来查找的时候,时间复杂度是O(n),因为要遍历整个数组才能去做匹配,如果是有序数组的话 我们可以通过二分法等手段 缩减查找时间,此时的复杂度是O(long n),但是对于一般的插入,删除操作,这个时刻就可能需要 移动数组元素 这个是平均时间复杂度是O(n)。
  2. 链表:链表的存储结构 是用一个头尾节点 去做关联的,所以这个时候不需要一段连续的地址空间和数组有所区别,这边还是把数组和链表做下对比,数组存储的时候需要开辟一段连续的地址空间去存储数据,但是对于一些大的对象 就要分配一段连续的地址空间,如果这个时间空间很紧张 就难以分配 ,但是对于链表这样的存储结构,就是可以的 因为他不需要开辟连续的地址空间,只要一块一块的就可以了,用头尾节点做连接即可。但是链表这样结构也存在一定的缺陷,就是要维护头尾节点多占空间,而且也会容易产生空间碎片。对于这样结构的优化,我们可以从Redis中quicklist 的到一些想法,有兴趣的 可以自己去看看,强大的Redis 是怎么处理底层数据的存储的。后面有时间了再写博文 聊聊。链表由于其存储结构的关系 在插入和删除的时候 就比较快了 时间复杂度是O(1),但是在查询的时候 就要从头开始遍历了复杂度是O(n)。
  3. 二叉树:如果是对于一个平衡二叉树的话 插入 删除 和查询的 平均时间复杂度是O(long n),HashMap和ConcurrentHashMap 在存储hash冲突的列表的时候 就是采取了链表和红黑树,当链表长度大于8的时间,就会转变为红黑树,当红黑树的元素小于6的时候 就会转变了链表,我相信看过源码的小伙伴 应该能知道,具体 为什么是8,6 这一定是均衡了插入和查询的 效率考虑的~
  4. 哈希表:对比了 以上几种数据结构,哈希表 在这方便 是有优势的 在插入 删除和查询方法 都是O(1)的 ,当然是不考虑hash冲突的情况下 hash冲突了 还是是从冲突的列表里面去对比查询的。所以这个时候计算存哈希表中卡槽位置的hash函数 是由于的重要,这个函数的是否优秀 直接决定了 hash冲突的概率。


我们常见的 几个哈希表中 是怎么计算卡槽位置的

  • HashTable: 【(key.hashCode() & 0x7FFFFFFF) % tab.length】; // 0x7FFFFFFF 的意思是int的最大值 二进制标识的话 全部都是高位 都是1 第一为是0 是符号位, 和hash值做了位与运算 就是保证了第一为一定是0 的到了一个正数 具体 为什么要这样做 可以去网上查查 主要是为了 范围的问题。
  • HashMap: hash值的算法:【(h = key.hashCode()) ^ (h >>> 16)】,计算index: 【(n - 1) & hash】,h为什么要右移16位 注释上面说 是为了尽量使得高位掩码的的影响向下,减少hash冲突,反正注释上是这么说的,具体原因 哈哈 本人没研究明白,以后待补充。
  • ConcurrentHashMap: 【( (h=key.hashCode() ^ (h >>> 16) ) & 0x7fffffff】计算index:【(n - 1) & hash)】

HashMap和ConcurrentHashMap

首先 这2个类 都是实现了Map接口 和继承了AbstractMap抽象类 所以我们从HashMap 切换成ConcurrentHashMap的时候 几乎不用担心 因为方法都是差不多的。
使用ConcurrentHashMap的原因 一定是因为HashMap 在多线程下 不安全,其实这就是废话,哈哈,不安全的地方表现在,如果是多个线程同时访问HashMap的时候,比如这个时候发生了扩容,扩容的时候 卡槽的里面的值 是要重新计算位置的 重新分配的,但是多线程的情况下 可能你计算好位置 当如卡槽的时候 这个时候 已经发生了扩容 就会导致 你存放的位置有问题,在新的扩容后数组下 再去根据key 可能就找不道对应的value了,当然我这边只是说的一种情况,还有很多这样的情况。这个时候 我们就要使用JUC里面的ConcurrentHashMap。

ConcurrentHashMap 就是相当于 线程安全的HashMap. 那下面就看下ConcurrentHashMap是怎么保证线程安全的。

ConcurrentHashMap

initTable初始化

我在上篇博文说到过HashMap 容量设置的时候 说起过 HashMap 的数组初始化 其实在是第一次put时候发生的,那我们看下 ConcurrentHashMap 是什么样子的,先看下代码

    private static final int MAXIMUM_CAPACITY = 1 << 30;
    
     /**
     * Creates a new, empty map with the default initial table size (16).
     */
    public ConcurrentHashMap() {
    }
    
    public ConcurrentHashMap(int initialCapacity) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException();
        int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
                   MAXIMUM_CAPACITY :
                   tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
        this.sizeCtl = cap;
    }
    
    private static final int tableSizeFor(int c) {
        int n = c - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

正常如果 我们设置了initialCapacity的话 这个时候就会算出当前的cap 这边 如果initialCapacity是10的话,initialCapacity >>> 1的意思就是右移1位 10的二进制是:1010 右移1位是:0101 十进制值是:5 那 tableSizeFor里面的参数就是 10+5+1=16, tableSizeFor 这个方法意思 我在上篇博文中也提到过 就是算出 大于传入的值 最小的二的幂的值 如果传入7 那记过就是8 如果传入17 那就是32.

我们知道 数组初始化都是在第一次新增元素的时候做的,那我们就看下put方法中 初始化方法

    /**
     * Initializes table, using the size recorded in sizeCtl.
     */
    private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {//自旋处理 保证了一定初始化成功
            if ((sc = sizeCtl) < 0)//小于0 说明 有线程正在进行初始化  就让出CPU资源
                Thread.yield(); // lost initialization race; just spin
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

首先 我们看到 这边用了一个While 判断tab是否完成初始化,也就是这个一个自旋处理,保证一定能初始化成功。

后面判断了一下sizeCtl 如果小于0,其实就是等于-1,说明了此时有线程真在进行初始化,为什么要这样判断呢,是因为看下后面的那个CAS 操作,就是修改SIZECTL为 -1,如果能修改成功,就执行下面的初始化操作,如果修改失败,说明
SIZECTL的值已经被修改,和预期的不符,就进行下次询换,这个时候 可能tab已经初始化完成 就退出了循环,如果tab还没有初始化完成,此时这个条件sc小于0的条件又成立了 再次让出CPU资源 直到初始化完成。

继续说下数组初始化,一进入初始化还是判断了table是否是初始化应该有的状态,这边是一个双重Check.

一种情况sc大于0 就是我们初始化的时候传入了initialCapacity,计算得到了sizeCtl 此时就用这个值,初始化数组大小。

另外的是使用的是默认的构造函数 没有传入initialCapacity,此时就使用默认的值DEFAULT_CAPACITY,这个默认值是16.

看到这里 简单的一段代码,看人家写的多严禁,自旋 不是空自旋 还做了及时让出CPU资源,看源码的好处 是学习优秀的代码!!!

就这样初始化完成了,我们简单的总结下 ConcurrentHashMap是用什么保证线程安全的

  • 自旋 保证一定初始化完成
  • CAS 保证同一时间 只有一个初始化数组线程
  • 二次check保证数组初始化的时候 table状态的对的

put的线程安全

好的说完了初始化的方法 我们来看下核心放入元素的方法。

先看下代码:

    /** Implementation for put and putIfAbsent */
    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) {
             <!--这边也是一个CAS 操作 只有 的当前节点值是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)//MOVED 是一个固定的值 是为了标识 当前正在扩容,此节点已被转移,不能操作 需要等待扩容后 才能操作,此时是自悬等待
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                synchronized (f) { //这边使用了synchronized锁哈
                    //Hash冲突后处理
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)//如果链表的长度大于等8 就要转变为红黑树
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);// 这边新增数量大小,然后判断是否要进行扩容
        return null;
    }

一些具体的操作 我都加了注释 ,这边也是才用和初始化table的时候 一样的手段来保证多线程下的安全

  • 自旋 自旋保证了 元素一定能被放入成功
  • CSA CAS 操作 保证了 放入数据的时候 当前节点一定是null的 如果 在这期间 有别的线程也来操作这个卡槽的值,那CAS 中table中i 位置的值 就一定不是null了,和预期值不符,那就CAS 执行失败,进入下次循环 下次循环的时候 发现此槽位已经有值了 就会走到Hash碰撞后的Synchronized的方法块里面,这样保证了 在新的卡槽 新增第一个元素的是线程安全的
  • synchronized 锁 保证执行的线程安全

transfer 扩容时的线程安全

ConcurrentHashMap 和HashMap 在操作扩容的时机都是一样的,都是在新增原数后 判断是否达到临界值 如果达到 就进行扩容 都是2倍的扩容原大小
那我们就看下transfer 代码

 /**
     * Moves and/or copies the nodes in each bin to new table. See
     * above for explanation.
     */
    private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        int n = tab.length, stride;
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // 这边是设置CPU 可操作数组的node步长 最低 是16
        <!--tab是新的数组  这边的nextTab 就是扩容后的数组 -->    
        if (nextTab == null) {            // initiating
            try {
                @SuppressWarnings("unchecked")
                Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];//扩容的时候 是n << 1 结果相当于n*2
                nextTab = nt;
            } catch (Throwable ex) {      // try to cope with OOME
                sizeCtl = Integer.MAX_VALUE;
                return;
            }
            nextTable = nextTab;//当在扩容过程中的时候 一个过渡的表
            transferIndex = n;// 这边transferIndex 是扩容的时候 第一个转移的卡槽的节点,对原数组的操作是倒叙的。
        }
        int nextn = nextTab.length;
        
         /**
         * ForwardingNode是继承了Node的 但是他所有节点的hash值都是设置成了MOVED,标识着当前节点在扩容当中
         * above for explanation.
         */
        ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
        boolean advance = true;
        boolean finishing = false; // to ensure sweep before committing nextTab
        for (int i = 0, bound = 0;;) {
            Node<K,V> f; int fh;
            while (advance) {
                <!--nextIndex 是开始拷贝的槽点位置 是从尾部开始 每次-1-->
                int nextIndex, nextBound;
                <!-- --i >= bound这个说明此次的循环结束 bound是每次执行到最后的那个值  finishing说明这个整个拷贝结束-->
                if (--i >= bound || finishing)
                    advance = false;
               <!--说明整个数组都拷贝结束了 index 已经是 到最开始的处  倒叙拷贝 的所以是判断小于等0 是算结束-->
                else if ((nextIndex = transferIndex) <= 0) {
                    i = -1;
                    advance = false;
                }
                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;
                if (finishing) {
                    nextTable = null;//临时表 赋值为null
                    table = nextTab;//结束后 新的table 赋值
                    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
                }
            }
            else if ((f = tabAt(tab, i)) == null)
                advance = casTabAt(tab, i, null, fwd);
            else if ((fh = f.hash) == MOVED)
                advance = true; // already processed
            else {
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        Node<K,V> ln, hn;
                        if (fh >= 0) {
                           //是链表的时候处理
                        }
                        else if (f instanceof TreeBin) {
                           //红黑树的时候处理
                        }
                    }
                }
            }
        }
    }

扩容的代码 有点冗长,需要一定的时间 去理解
大体的思路是这样的:

  1. 首先从原数组的队尾开始进行拷贝
  2. 拷贝数组的时候 会把原数组的槽点的锁住使用的是synchronized,这样 原数组里面的数据就没法被修改,保证了线程安全,成功拷贝到新数组后,把原数组的槽点设置为转移节点move。
  3. 如果这个时候有数据的put 当前槽点状态是转移节点也就是move,就会一直等待,这个讲put方法的时候也聊到过
  4. 直到原数组所有的节点被复制到新数组里面,然后再把新数组赋值给数组容器,完成拷贝

总结一下,在数组扩容的时候,主要是利用Synchronized锁去锁住槽点,不让别的线程去操作,槽点复制成功后,会标识为转移节点,这样新的put操作过来,看的槽点状态是move,就会一直等待扩容完成后才会再进行put操作。

get

最后我们来看下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());//获取hash值
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            <!--根据hash值 找到槽点  然后看槽点的第一个节点 是否是要找到值 如果是直接返回-->
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            <!--节点的hash值小于0 说明是转移节点 或者是红黑树-->
            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;
    }

看完了 整个get 方法 我们发现 这个获取方法 和我们的HashMap中的get 方法 并没有什么区别,没有任何的锁 CAS 等等,那是怎么做到的呢

继续看下代码中

**
     * The array of bins. Lazily initialized upon first insertion.
     * Size is always a power of two. Accessed directly by iterators.
     */
    transient volatile Node<K,V>[] table;

    /**
     * The next table to use; non-null only while resizing.
     */
    private transient volatile Node<K,V>[] nextTable;

    /**
     * Base counter value, used mainly when there is no contention,
     * but also as a fallback during table initialization
     * races. Updated via CAS.
     */
    private transient volatile long baseCount;

    /**
     * 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;

    /**
     * The next table index (plus one) to split while resizing.
     */
    private transient volatile int transferIndex;

    /**
     * Spinlock (locked via CAS) used when resizing and/or creating CounterCells.
     */
    private transient volatile int cellsBusy;

    /**
     * Table of counter cells. When non-null, size is a power of 2.
     */
    private transient volatile CounterCell[] counterCells;

大家有没有发现 这些 类的成员变量 都是加了特殊的关键字 volatile,这个关键字 我想大家都知道什么意思 就是保证了多线程之间 内存的可见性,也就是A线程修改了变量的值 B线程能立马知道值的变化,并且获取到最新的,具体是怎么实现的 这个主要使用是内存屏障,缓存一致性等技术实现,不是本文的重点,以后慢慢聊。

但是 我们知道volatile 修饰的关键字,如果是基础值类型 保证了多线程修改后值类型的可见性 但是这边修饰了 数组容器table 是一个数组的对象 是一个引用类型 这样虽然保证了数组对象的引用可见性,但是数组对象 里面的元素修改了 是没法知道的呀,那我们再去看下 数组容器里面的Node节点

   static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;

        Node(int hash, K key, V val, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.val = val;
            this.next = next;
        }
    }

当看到这里的时候 我差不多知道了 这里的node节点里面的val节点值,和下一个节点next都是加了volatitle关键字的,也就是说 我们在修改 节点值的时候 和插入节点值的时候 都是线程之间可见的,这样我们在使用get方法的时候 就能在不加任何锁的情况下 得到最新的值,也就完成了多线程下的同步,保证了多线程下的安全。

volatitle 关键字 真香呀,而且性能很高,今天还看了本书 书上说volatitle利用的得当话,在并发情况下 完美的替代锁,来提高多线程下的性能。

总结

ConcurrentHashMap 主要使用的是 CAS+自旋+synchronized+多重check 来保证在初始化,新增,和扩容的时候线程安全,读取数据的时候则使用了 volatitle 让元素节点 在多线程之间 可见,从而达到获取最新的值!

又完成了一篇 哇偶!!!! 继续加油吧

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值