保姆级ConcurrentHashMap源码分析

912 阅读12分钟

前言

在JDK8中ConcurrentHashMap的结构和HashMap类似,数组+链表+红黑树+Node节点,但是ConcurrentHashMap是线程安全的,在需要保证线程安全的情况下使用很多,本文分析了ConcurrentHashMap的部分源码,解析了并发扩容等并发操作的原理,如有不对的地方,还请指出。

clipboard.png

一些重要概念

sizeCtl属性

并发map中定义了一个sizeCtl属性,通过这个属性的值来判断并发map的状态

  • sizeCtl为0时,代表数组未初始化,且数组的初始容量为16。
  • sizeCtl为正数时,如果数组没有初始化,那么其记录的是数组的初始容量,如果数组已经初始化,那么代表的是数组的扩容阈值。
  • sizeCtl为-1时,表示数组正在进行初始化。
  • sizeCtl小于0,且不是-1时,表示数组正在扩容,-(1+n),表示此时共有n个线程正在共同完成数组的扩容操作。

transferIndex属性

扩容索引,各个线程协助扩容时需要从该索引处开始迁移,协调多个线程并发扩容,一开始在数组最右边,值为数组的长度-1,当transferIndex的值为0时代表所有位桶的迁移都已经分配出去,当前线程无需再进行协助扩容。
扩容时各个线程通过 自旋+CAS 来修改 transferIndex(左移),transferIndex=transferIndex-stride(要迁移hash桶的个数)
每个线程都最少需要迁移16个hash桶 ,要迁移的桶位个数根据cpu的个数进行计算

5种类型节点

并发map中一共有5种类型的数据节点,Node、TreeBin、TreeNode、Forwarding、Reservation。

  • TreeBin:代理节点,指向红黑树的根节点,hash值为-2
  • TreeNode:红黑树节点
  • Reservation:类型的节点根据它的注释判断出它是起到一个类似占位的作用
  • Forwarding:一个特殊的Node节点,hash值为-1,其中存储nextTable的引用。作为一个占位符放在table中表示当前节点为null或则已经被移动,有以下两个作用:
    • 标记作用,表示其它线程正在扩容,并且该节点数据已被迁徙,扩容期间可以通过find方法找到已经迁移到新数组的节点。
    • 关联作用,关联了nextTable,扩容期间可以通过find方法找到已经迁移到新数组的节点

不同类型的节点都实现了find()方法。
Reservation Node属于占位节点,所以如果最后经过路由寻址之后找到的是这种节点,证明集合中没有要找的节点,直接返回null。

Node<K,V> find(int h, Object k) {
    return null;
}

ForwardingNode的find方法如下

Node<K,V> find(int h, Object k) {
        outer: for (Node<K,V>[] tab = nextTable;;) {
            Node<K,V> e; int n;
            // 键为空,table 为空,键对应的槽位空都是直接返回
            if (k == null || tab == null || (n = tab.length) == 0 ||
                (e = tabAt(tab, (n - 1) & h)) == null)
                return null;
            for (;;) {
                int eh; K ek;
                if ((eh = e.hash) == h &&
                    ((ek = e.key) == k || (ek != null && k.equals(ek))))
                    return e;
                if (eh < 0) {
                    // 如果 e 节点还是 ForwardingNode 节点,那么就去新的用于迁移数据的 table 找
                    if (e instanceof ForwardingNode) {
                        tab = ((ForwardingNode<K,V>)e).nextTable;
                        // 从最外部循环再次开始
                        continue outer;
                    }
                    else
                        // 这个方法就是调用 TreeBin 节点的 find 方法
                        return e.find(h, k);
                }
                // 查到最后了还没有找到那就直接返回 null 
                if ((e = e.next) == null)
                    return null;
            }
        }
    }
}

putVal()方法

源码分析

final V putVal(K key, V value, boolean onlyIfAbsent) {
    //如果有空值或者空键,直接抛异常
    if (key == null || value == null) throw new NullPointerException();
    //基于key计算hash值,并进行一定的扰动
    int hash = spread(key.hashCode());
    //记录某个桶上元素的个数,如果超过8个,会转成红黑树
    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();
	    //如果hash计算得到的桶位置没有元素,利用cas将元素添加
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            //cas+自旋(和外侧的for构成自旋循环),保证元素添加安全
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        //如果hash计算得到的桶位置元素的hash值为MOVED,证明正在扩容,那么协助扩容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            //hash计算的桶位置元素不为空,且当前没有处于扩容操作,进行元素添加
            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) {
                //链表长度大于/等于8,将链表转成红黑树
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                //如果是重复键,直接将旧值返回
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    //添加的是新元素,维护集合长度,并判断是否要进行扩容操作
    addCount(1L, binCount);
    return null;
}

从putVal()方法源码可以看出并发map和HashMap的一个区别,HashMap允许以null为key和value,而并发map不允许。
同时可以看出,在添加时,只会锁住经过路由寻址算法之后找到的数组位桶,不会锁位桶的其它位置,大大地提升了性能。

流程概括

  • 1、首先会对传进来的参数进行校验,只要有一个参数为空,都会抛出异常
  • 2、之后调用hashcode方法和spread方法,来计算key的散列值
  • 3、循环位桶数组,如果没有初始化,会执行初始化功能
  • 4、如果已经初始化,并且经过路由寻址找到的数组位置为空,会通过 CAS+自旋(和外侧的for构成自旋循环),保证元素添加安全
  • 5、如果找到的位桶数组的第一个元素的hash值为MOVED,证明集合正在扩容,尝试帮助扩容
  • 6、如果经过比较,找到的位置上的元素的key值相同,但是onlyIfAbsent为true,返回旧value值,不做任何操作
  • 7、再经过以上几种情况,接下来就是,链表元素的插入,或者超过链表长度,插入到红黑树,又或者是集合需要扩容,进行数据迁徙
  • 8、在7所说的情况下,会使用synchronized锁住路由寻址找到的头节点,并之后会再进行判断该节点是否还是头节点,双重判断,保证数据的安全
    • 8.1、在头元素的hash值大于0的情况下,证明在数组的这个位置上是一个链表,或者说有可能只有一个node节点的链表,循环该链表,并使用binCount来记录链表的长度,之后比较该链表上是否有相同的key,如果是,进行替换操作,如果不是,进入下一轮循环,最终要么进行替换,要么进行尾插入。
    • 8.2、如果该位置的头元素的hash值小于0,那么有两种情况,一种是该位置上是一个TreeBin元素(红黑树代理节点)或者是ReservationNode元素,如果是ReservationNode元素,证明该节点只是一个占位元素,直接抛出异常。如果是TreeBin,进行红黑树的插入操作,通过TreeBin的putTreeVal(hash, key, value)方法来插入,如果红黑树中已有相同key,进行替换操作。
  • 9、同步代码块结束后,会进行判断binCount的值是否大于等于8,如果是将链表进行树化操作(treeifyBin),在进行树化操作前,会判断数组的大小是否大于64,如果大于才进行树化,否则扩容数组(同hashMap)。
  • 10、之后判断oldVal是否为空,如果不为空,证明是个替换操作,直接return,不进行计数操作 。
  • 11、循环借宿,调用 addCount(1L, binCount);添加的是新元素,维护集合长度,并判断是否要进行扩容操作。

get()方法

源码分析

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // 计算 hash 值
    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;
        }
        // 从红黑树/ForwardingNode/ReservationNode开始找
        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方法并没有进行任何的加锁操作,那么get操作有线程安全问题吗?答案是没有,因为Node节点的next和val属性都是用volatile进行修饰,一旦经过修改就会被立即写到主存,具有可见性。
node数组使用volatile修饰,是为了在并发扩容时使数组具有可见性。

流程概括

  • get方法会根据传进来的key,计算hash和spread之后的key值,之后再根据路由寻址算法,找到对应的桶位
  • 如果找到位桶数组后,第一个元素就是要找的节点,直接返回
  • 如果找到的位桶位置中的第一个节点的hash值小于0的话,代表不是普通的node节点,调用该节点的find()方法,每一种节点都有重写find()方法,对应不同节点应该有的查找方法
  • 如果前面两种情况都不是,那找到的位桶位置上是一个链表,进行循环查找

transfer()扩容方法

扩容触发时间

  • 新增结点之后,addCount统计元素个数大于扩容阈值
  • 新增节点,链表长度>=8,进行树化时判断,但是数组长度小于64的
  • 在putVal 时,路由寻址找到的数组位置上是Forwarding Node节点,帮助进行扩容

源码分析

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    //如果是多cpu,那么每个线程划分任务,最小任务量是16个桶位的迁移
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    //如果是扩容线程,此时新数组为null
    if (nextTab == null) {            // initiating
        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 = n;
    }
    //记录新数组的末尾
    int nextn = nextTab.length;
    //已经迁移的桶位,会用这个节点占位(这个节点的hash值为-1--MOVED)
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false; // to ensure sweep before committing nextTab
   
   //i记录当前正在迁移桶位的索引值
    //bound记录下一次任务迁移的开始桶位,这一次任务的停止边界
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        while (advance) {
            //nextIndex-1为下一次开始索引,nextbound为下一次任务边界
            int nextIndex, nextBound; 
            
            //--i >= bound 成立表示当前线程分配的迁移任务还没有完成
            if (--i >= bound || finishing)
                advance = false;
            //没有元素需要迁移,迁移任务分配完 -- 后续会去将扩容线程数减1,并判断扩容是否完成
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            //计算下一次任务迁移的开始桶位,并将这个值赋值给transferIndex
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        //如果没有更多的需要迁移的桶位,就进入该if
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            //扩容结束后,保存新数组,并重新计算扩容阈值,赋值给sizeCtl
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
		   //扩容任务线程数减1
            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
            }
        }
        //当前迁移的桶位没有元素,直接在该位置添加一个fwd节点
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        //当前节点已经被迁移
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            //当前节点需要迁移,加锁迁移,保证多线程安全,过程和hashMap类似
              synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) {
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        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(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            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;
                            }
                        }
                        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(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}

流程概括

1、可以看到在创建了一个2倍老数组大小的新数组之后,有一个while循环,这个while循环就是在为每一个线程分配迁移任务,有三个判断,三种情况:

  • if (--i >= bound || finishing) ,i为当前线程正在迁移的桶位数组索引,bound为当前线程迁移任务的边界,if (--i >= bound || finishing) 这行代码做了两个操作,一是将i减一(一轮循环之后迁移完一个桶位),二是判断当前线程是否已经完成了分配给它的迁移任务。如果满足证明还未完成,让advance=false退出while循环,否则进入下面两种情况。
  • else if ((nextIndex = transferIndex) <= 0),nextIndex为下一个迁移任务的开始索引+1,这一行代码也有两个操作,一是将nextIndex赋值为transferIndex,二是判断transferIndex是否已经<= 0。如果满足这种情况,证明所有位桶数组的迁移都已经分配完,将i赋值为-1,退出while循环,否则进入下一种情况。
  • else if (U.compareAndSetInt (this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0))) 到了这一层,证明当前线程已经完成了分配的迁移任务,且仍然有位桶数组在等待被迁移,这一行代码计算了,下一个迁移任务的结束边界nextBound,并将这个值赋值给transferIndex。下一个迁移任务的开始索引即为i = nextIndex - 1;

2、在经过上述三种情况之后,会根据i的值进行一次判断,看当前线程是否已经完成迁移任务,满足判断的证明已经完成分配的任务且没有任务可以再分配,之后会将扩容线程数减一(操作sizeCtl),判断是否所有线程都已完成任务,如果是,最后会计算扩容阈值,并赋值给sizeCtl。

3、真正进行数据迁移的操作比较简单,实现逻辑和HashMap类似,可以直接观看源代码。

再简单地概括起来就是下面这段话:
通过transferIndex扩容索引来协调多个线程实现并发扩容,一开始在数组的最右边,之后每个线程会计算自己需要迁移的桶位,之后通过cas+自旋的方式设置transferIndex的值,当transferIndex的值为0时表示已将所有位桶的迁移分配出去,新建的数组大小为原来的2倍(左移一位),之后通过synchronized锁住头元素,将如果是链表和红黑树的话,都分成两份,链表和HashMap种一样分为高位链表和低位链表,之后迁移到新数组,位置分别是i和i+n,i为原来在老数组中的索引位置,n为老数组的长度,迁移后的桶位设置为一个forwarding node节点,可以通过这个节点找到新数组中对应的节点。

initTable方法

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSetInt(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;
}

1、首先while循环,以数组为空或者长度为0为条件
2、然后判断sizeCtl是否小于0,如果小于0,代表有线程正在做初始化工作,线程礼让 3、如果大于0的话,是数组的初始化长度,将初始化长度保存,会通过CAS的方式sizeCtl设置为-1
4、然后会再进行一次判断(二次判断数组是否初始化),如果没有初始化,创建一个sc大小或者是默认大小的Node数组
5、之后不管创建是否成功都会执行sizeCtl = sc; 将sizeCtl设置为扩容阈值或将sizeCtl设置为初始化容量初始化成功的话设置为扩容阈值,失败的话恢复为初始化容量
6、初始化成功后break;

结语

其实ConcurrentHashMap的扩容和HashMap的扩容都是通过创建一个新的数组,然后将数据从老数组种迁移到新数组中,只不过ConcurrentHashMap是线程安全的,通过多个线程来协助扩容。
除了ConcurrentHashMap是线程安全外还有一个集合HashTable也是线程安全的,HashTable实现线程安全的话比较简单,就是在涉及到线程安全的方法前加上synchronized关键字,对比起来ConcurrentHashMap的效率要高很多,因为虽然也使用了synchronized关键字,但是加锁的也只是位桶数组中的其中一个元素,还有就是通过CAS+自旋来确保数据的线程安全。