ConcurrentHashMap源码分析(基于jdk1.8)

前言

看标题就知道本篇文章主角是ConcurrentHashMap,在讲解它之前有几个问题有必要弄清楚。(本文都是基于jdk1.8进行分析)

什么是ConcurrentHashMap?

如果看过我之前写的关于HashMap的文章,应该知道它是基于key-value形式存储数据的一种hash存储表,ConcurrentHashMap的存储结构和HashMap是一样的。

ConcurrentHashMap和HashMap的不同点

虽然都可以用来进行key-value对存储,但是HashMap是线程不安全的,但是ConcurrentHashMap是线程安全的。这也意味着前者在多线程环境可能会出现数据丢失的情况,后者则可以保证读取数据的安全性。

ConcurrentHashMap继承图

GWtdEV.png

为了方便简洁,之后ConcurrentHashMap都简称为CHM

案例

在分析具体的代码之前,让我们先看一个CHM的使用案例

public class Bootstrap {
    private static ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
//  private static HashMap<String, String> map = new HashMap<>();
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 400; i++) {
                map.put(String.valueOf(i), String.valueOf(i));
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 200; i++) {
                map.put(String.valueOf(i), String.valueOf(i));
            }
        });
        Thread thread3 = new Thread(() -> {
            for (int i = 0; i < 400; i++) {
                map.remove(String.valueOf(i));
            }
        });

        thread1.start();
        thread2.start();
        thread3.start();
    }
}

这个案例很简单,定义了三个线程同时对CHM进行数据的增加和删除操作,对于CHM来说可以保证数据不会丢失,确保多线程下数据安全性问题。同样如果用HashMap来代替的话是可能出现数据被覆盖的风险的(关于HashMap可以看我之前的文章),本文就通过案例来进入具体源码的讲解

源码解析

对于CHM源码的解析,我主要通过三个方向进行阐述:分别是CHM的初始化、添加元素的操作(put)、扩容机制。通过这三个方面就可以比较清楚CHM是进行数据保存已经如何确保线程安全的。

初始化

首先从初始化开始,先从我们案例的初始化开始:

    public ConcurrentHashMap() {
    }

可以看见,如果你没有指定任何参数,初始化的时候CHM是不会做任何操作,当然你也可以提前指定好一些参数:

    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;
    }
    
    
    //涉及到的常量:
    //MAXIMUM_CAPACITY是给定的可能的最大容量值
    private static final int MAXIMUM_CAPACITY = 1 << 30;

   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,通过initialCapacity可以计算出对应的容量:判断它是否比MAXIMUM_CAPACITY的一半还大,如果是的话直接将容量设置为MAXIMUM_CAPACITY,否则通过tableSizeFor函数将它设置为离c最近的2的幂次方的数(此函数我在HashMap源码解析中有具体的说明),最后将计算好的值赋值给sizeCtl(这个变量是CHM的关键变量,之后会有详细讲解)。到这边CHM的初始化就结束了。

put方法

初始化完成后,就可以调用put方法来存放值了,具体的代码如下:

    public V put(K key, V value) {
        return putVal(key, value, false);
    }

    final V putVal(K key, V value, boolean onlyIfAbsent) {
        //需要注意的是CHM的key和value都是不能为空的
        if (key == null || value == null) throw new NullPointerException();
    #1  int hash = spread(key.hashCode());
        //该值用来计算链表的节点数
        int binCount = 0;
        //进入自旋操作
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
    #2      if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //如果数组不为空则需要去查找对应的节点,通过CAS的方式去定
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    #3      if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
    #4      else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                //通过synchronized将节点进行锁定
    #5          synchronized (f) {
                    //判断在锁定之前节点有没有被修改,防止其他线程的操作
                    if (tabAt(tab, i) == f) {
                        //判断节点hash是否大于0,大于0则表示是链表
                        if (fh >= 0) {
                           //binCount的值设置为1,binCount用来计算链表结点的个数
                            binCount = 1;
                            //进行循环遍历,如果key和hash都是相等的,更新值 
                            //注意binCount的值计算的是新节点添加的之前的链表的个数,后面会在新节点到来的时候去判断,如果加入新的节点,链表长度已经达到了8,就会进行树的转换,
                            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;
                            }
                        }
                    }
                }
        #6      if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
    #7  addCount(1L, binCount);
        return null;
    }
    
    涉及到的常量:
    //CHM存放数据的表
    transient volatile Node<K,V>[] table;
    //转换成树的临界点
    static final int TREEIFY_THRESHOLD = 8;
    

这一块我主要是针对数据存放来讲解,涉及到扩容会放到下一节统一分析。首先我们可以思考一个问题:CHM存放数据和HashMap比有什么挑战?很显然,是需要考虑在多线程环境如何让数据安全的存放到指定的节点,我对put的主要步骤会进行编号,下面我们来一一分析:

#1:第一步很显然,我们需要对key进行hash值的计算,但是它的计算方法和HashMap有点诧异我们看一下代码:

    static final int spread(int h) {
        return (h ^ (h >>> 16)) & HASH_BITS;
    }
    
    //涉及常量:
    static final int HASH_BITS = 0x7fffffff;

可以看见总体上是和HashMap类似,但是多了一个HASH_BITS,它是一个16进制的数,转换成二进制数的话除了最高位是0其余都是1,通过和它取余,那么获取到的hash值永远是大于0的。至于为何要确保hash值大于0,是因为CHM存在hash的负数的其他节点用来标识一些特别的功能。

#2:这一步是在table为空情况下,需要进行初始化操作,这边先用一张图来解释多线程下如何确保安全初始化:

JtRJPK.png

具体代码如下:

    private final Node<K,V>[] initTable() {
        //定义一个临时节点变量和一个int类型变量sc
        Node<K,V>[] tab; int sc;
        //当table为空或者长度等于0的情况下进行循环
        while ((tab = table) == null || tab.length == 0) {
            //这里判断sizeCtl的值是否小于0,这个值在初始化的时候提到过,这边用它来判断是否需要进行初始化
            //小于0是因为已经有别的线程正在初始化table数组,需要暂停线程
            if ((sc = sizeCtl) < 0)
                Thread.yield(); // lost initialization race; just spin
            //否则通过CAS的方式直接操作内存将sizeCtl设置为-1,表示当前线程正在初始化
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    //此时第二次判断table是否为空,来防止其他线程已经初始化了
                    if ((tab = table) == null || tab.length == 0) {
                        //设置sizeCtl的值,如果>0直接用否则设置成默认值16
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        //初始化节点数组赋值给table
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        //设置新的sizeCtl用来判断下一次扩容的临界点
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

    //涉及常量:
    //默认的表容量
    private static final int DEFAULT_CAPACITY = 16;

#3:这一步是数组存在,但是定位到的节点不存在,那么直接通过CAS的方式操作内存,来对节点进行创建

#4:这一步定位到的节点的hash值是MOVED,表示别的线程正在对该节点进行扩容操作,那么就需要去加入扩容。这边不详细讲扩容

#5: 这步是真正的添加新的数据,主要步骤我在代码中进行注释说明。

#6:在数据添加完成后,通过binCount判断链表的长度是否大于了8,如果是的话需要将链表转换成红黑树

#7:最后一步是发生在添加数据完成之后,主要有两个功能:添加计数以及判断是否需要扩容,具体代码如下:

 private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
        //如果counterCells数组不为空或者CAS方式设置baseCount失败的话,表示多个线程修改了table
        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();
        }
        
        //...省略扩容相关
    }

CHM中计数涉及到baseCount和CounterCell两个概念,一种情况是没有竞争关系,那么直接通过CAS的方式添加baseCount即可,如果是有竞争的话,就用CounterCell数组去计数,因为没有线程都有唯一的Probe值通过该值每一个线程都会对应到CounterCell数组的一个位置然后进行数的叠加。如果在叠加过程还是有冲突,则需要对CounterCell数组的操作也进行自旋操作直到数添加成功。

CHM通过这种方式来提高多线程环境下累加count的效率,类似分而治之的思想。

扩容

对于扩容,主要有两处地方涉及到:

新增元素后

这是在put完成后会判断当前元素容量时候需要进行扩容,相关代码如下:

 private final void addCount(long x, int check) {
   .....
   
   //如果链表之前的节点数大于等于0,就要去判断一次,添加了新元素后情况如何
      if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            //首先是要满足条件:
            //1. 当前元素个数比sizeCtl大,达到扩容的阈值
            //2. table表不能为空并且table的容量小于MAXIMUM_CAPACITY
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
                //
                int rs = resizeStamp(n);
                //如果sc小于0表示有线程正在进行扩容
                if (sc < 0) {
                    //如果满足以下几个条件的任意一种,那么就不在需要扩容
                    //1. (sc >>> RESIZE_STAMP_SHIFT) != rs 当sizeCtl无符号右移16位后不等rs
                    //2. sc==rs+1
                    //3. sc==rs+MAX_RESIZERS
                    //4. (nt = nextTable) == null
                    //5. transferIndex<=0
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    //如果上面的条件都不满足,那么通过CAS的方式去设置sc,将它+1,如果成功的话表示当前线程需要进行扩容    
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        //进行扩容操作
                        transfer(tab, nt);
                }
                //如果sc>0,表示还没有线程正在进行扩容,将sc设置为 (rs << RESIZE_STAMP_SHIFT) + 2,它是一个负数
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    //进行扩容
                    transfer(tab, null);
                s = sumCount();
            }
    
    }

resizeStamp方法:计算出扩容的标志位

   static final int resizeStamp(int n) {
        return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
    }
    

    0000 0000 0000 0000 0000 0000 0000 1000
    0000 0000 0000 0000 1000 0000 0000 0000
    ---------------------------------------
    0000 0000 0000 0000 1000 0000 0000 1000

具体扩容方法:

    private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        //定义两个变量,n表示tab的长度,stride(步的意思)变量
        int n = tab.length, stride;
        //这边去计算cpu核数,如果大于1那么就将stride赋值为(n >>> 3) / NCPU,否则就赋值为tab的长度
        //然后判断stride是否比MIN_TRANSFER_STRIDE(16)小,如果比它小则将stride赋值为16
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // subdivide range
        //如果nextTab等于空,需要做如下操作
        if (nextTab == null) {            // initiating
            try {
                @SuppressWarnings("unchecked")
                //去初始化新的节点数组,该数组用来存放扩容后的数据,并且length是原来的两倍
                Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
                //然后将nt辅助给nextTable
                nextTab = nt;
            } catch (Throwable ex) {      // try to cope with OOME
                //如果操作错误,则将sizeCtl设置为Integer.MAX_VALUE,并且停止扩容
                sizeCtl = Integer.MAX_VALUE;
                return;
            }
            //做完后此时的nextTable就是初始化的新的数组
            nextTable = nextTab;
            //transferIndex设置为16,表示转移数据从原来的最后一个槽开始
            transferIndex = n;
        }
        
        //如果nextTable已经初始化过了,那么获取新数组的长度
        int nextn = nextTab.length;
        //并且将nextTab封装为ForwardingNode节点
        ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
        //定义两个标志位,advance和finishing
        boolean advance = true;
        boolean finishing = false; // to ensure sweep before committing nextTab
        //进入循环,并且初始化i和bound为0
        for (int i = 0, bound = 0;;) {
            //定义一个节点变量f和一个int变量fh
            Node<K,V> f; int fh;
            //进入循环,当advance为true的时候
            while (advance) {
                //这边又定义了两个变量nextIndex和nextBound
                int nextIndex, nextBound;
                //如果满足将i减1后大于bound或者finishing的值为true的时候,就把advance设置为false
                if (--i >= bound || finishing)
                    advance = false;
                    
                //如果nextIndex也就是transefer<=0的时候将i-1并且把advance设置为false    
                else if ((nextIndex = transferIndex) <= 0) {
                    i = -1;
                    advance = false;
                }
                //除此之外,将transfer设置为nextbound,也就是判断nextIndex是否>16,如果是的话设置为 nextIndex - stride
                else if (U.compareAndSwapInt
                         (this, TRANSFERINDEX, nextIndex,
                          nextBound = (nextIndex > stride ?
                                       nextIndex - stride : 0))) {
                    //将区间下标设置为nextBound
                    bound = nextBound;
                    //如果是16则i=15,这是对i的第一次赋值
                    i = nextIndex - 1;
                    //然后将推进标识设置为false
                    advance = false;
                }
            }
            //在循环结束后,就有如下几种情况
            //如果i<0或者i>=数组长度或者i+n的值大于新数组的长度
            if (i < 0 || i >= n || i + n >= nextn) {
                int sc;
                //就去判断finishing是否为true,第一次进来肯定为false
                if (finishing) {
                    //是的话将nextTable设置为null
                    //nextTab设置为talbe的值
                    //设置新的sizeCtl的值
                    //最后返回标识扩容结束
                    nextTable = null;
                    table = nextTab;
                    sizeCtl = (n << 1) - (n >>> 1);
                    return;
                }
                //如果finishing不为true,第一次会进入这个条件
                //将sizeCtl的值-1
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                    如果sc-2不等于那么表示没有结束帮助扩容 
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    //如果表示帮助扩容结束那么就将finishing和advance都设置为true
                    finishing = advance = true;
                    i = n; // recheck before commit
                }
            }
            //第二种情况是根据i,这边i是15获取对应的节点,如果为空
            else if ((f = tabAt(tab, i)) == null)
                //那么就去将fwd的节点设置进去,用去占位表示正在扩容
                advance = casTabAt(tab, i, null, fwd);
           
           //如果节点不是空的并且已经是fwd节点了
            else if ((fh = f.hash) == MOVED)
                //那么就继续推进,区别的节点进行扩容
                advance = true; // already processed
            else {
                //如果是正常的节点,那么就要进行扩容操作
                //通过synchronized将节点进行同步
                synchronized (f) {
                    //在此之前,再次判断节点是否变化,没变的话正式进行扩容
                    if (tabAt(tab, i) == f) {
                        定义ln和hn两个节点变量
                        Node<K,V> ln, hn;
                        如果hash>=0,表示是正常的节点
                        if (fh >= 0) {
                            //通过fh & n去计算节点在扩容后是否在原来的位置
                            int runBit = fh & n;
                            Node<K,V> lastRun = f;
                            for (Node<K,V> p = f.next; p != null; p = p.next) {
                                遍历链表,判断是否和runBit相等,如果不是则将runBit设置为新的值,并且将lastRun设置为当前节点
                                int b = p.hash & n;
                                if (b != runBit) {
                                    runBit = b;
                                    lastRun = p;
                                }
                            }
                            //0代表还在原来的位置
                            if (runBit == 0) {
                                ln = lastRun;
                                hn = null;
                            }
                            //代表在n+原来的位置 
                            else {
                                hn = lastRun;
                                ln = null;
                            }
                            //去遍历链表,然后创建新的节点,根据时候在原来的位置分别进行赋值,这个HashMap扩容机制类似
                            //需要注意的是新数组中的链表数据的顺序可能和原来的是不一样的
                            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(nextTab, i, ln);
                            setTabAt(nextTab, i + n, hn);
                            setTabAt(tab, i, fwd);
                            advance = true;
                        }
                        else if (f instanceof TreeBin) {
                           ...由于代码过长,这边省略对树的扩容
                        }
                    }
                }
            }
        }
    }

新增元素时

在添加新的元素时,可能会遇到节点正在进行扩容的情况,那么当前线程就要去协助扩容,具体代码如下:

   final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
        Node<K,V>[] nextTab; int sc;
         //如果当前节点属于FowWardingNode并且table不为空,并且nextTable也不为空
        if (tab != null && (f instanceof ForwardingNode) &&
            (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
            int rs = resizeStamp(tab.length);
            while (nextTab == nextTable && table == tab &&
                   (sc = sizeCtl) < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || transferIndex <= 0)
                    break;
                //去进行扩容操作,将sc+1    
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                    transfer(tab, nextTab);
                    break;
                }
            }
            return nextTab;
        }
        return table;
    }

总结

本文主要讲了CHM如何进行数据的存放已经如何进行扩容,并且解析了它是如何在多线程环境保证数据的安全性的

转载请注明出处!链接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章