深度拆解ConcurrentHashMap核心源码,彻底搞懂扩容机制

120 阅读15分钟

ConcurrentHashMap

请先掌握HashMap,本文ConcurrentHashMap源码基于1.8

ConcurrentHashMap是一个线程安全的HashMap,通过CAS + synchronized 来保证并发安全,数据结构仍然是数组+链表+红黑树。读操作get是不会加锁的。

核心字段

    // table数组
    transient volatile Node<K,V>[] table;
    // rehash时用到的数组
    private transient volatile Node<K,V>[] nextTable;
    // sizeCtl
    private transient volatile int sizeCtl;
    // rehash时用到
    private transient volatile int transferIndex;
    // 下面这三个看名字就很眼熟,甚至Striped64有个一模一样的字段名,cellsBusy
    // 这是统计元素个数用的,显然这是把LongAdder思想借用过来了,我们后面再细说
    private transient volatile long baseCount;
    private transient volatile int cellsBusy;
    private transient volatile CounterCell[] counterCells;

sizeCtl:控制逻辑

sizeCtl是一个用于控制Map行为的关键字段,Ctl是Control的缩写

// 源码注解如下
    /**
     * 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;

sizeCtl用于控制「初始化」和「扩容」。

简单翻译一下大概是下面这个意思:

  • 负数代表:正在被初始化或者是扩容

    • -1:代表正在初始化
    • -X ( X > 1 ):代表正在扩容,扩容的线程数为 X-1(真的是这样么)
  • 正数代表:数组初始化的大小或是触发扩容的阈值

    • 未进行初始化,代表初始化时数组的大小,默认为0
    • 已经初始化完毕,代表触发扩容的阈值

看一个构造方法佐证一下:

public ConcurrentHashMap(int initialCapacity) {
 int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
            MAXIMUM_CAPACITY :
            tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
 this.sizeCtl = cap;
}

设置的是sizeCtl的值,而非直接初始化数组,实际上大多数集合类都有这样的懒加载的细节,可以参考:

Java集合答疑解惑之ArrayList:无参构造的数组为空?

核心方法

put(k,v)详细流程

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // hash值
    int hash = spread(key.hashCode());
    // 链表长度
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        // 1. 数组为空 初始化数组
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        // 2. 数组的位置为null,CAS的方式直接放上去
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 若CAS失败再来一次循环 成功就直接退出 put成功
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;             
        }
        // 3. 发现是fwd 帮助扩容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        // 4. 一般逻辑 下面再分析
        else {
          // ...
        }
    }
    // 计数
    addCount(1L, binCount);
    return null;
}

再来看看一般逻辑:

            // 就是 数组的那个位置有节点,首先锁住头节点(可能是链表头,也可能是红黑树的根)
            V oldVal = null;
            synchronized (f) {
                // 1. 链表
                   if (fh >= 0) {
                       // 遍历链表 判断equals,如果key都不同,就尾插
                    }
                // 2, 红黑树
                    else if (f instanceof TreeBin) {
                       // 插入红黑树
                    }
                }
            // 如果尾插链表后达到阈值8
            // 如果数组小于64,扩容;否则转化为红黑树
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }

initTable方法如何初始化数组?

initTable会尝试CAS的方式将sizeCtl设置为-1,发现其他线程正在初始化,会yield(尽量等到别的线程初始化数组完毕,该线程再继续执行,并不是陷入阻塞状态,而是进入可运行状态)

     if ((sc = sizeCtl) < 0)
            Thread.yield();

最后返回tab。

其实就是用CAS保证只有一个线程会做初始化

大致流程已经理清了,现在还有几个黑盒我们后面解决:

  • treeifyBin内的tryPresize如何扩容
  • addCount(1L, binCount)方法干了啥

get(k)详细流程

get方法是没有加锁的。

public V get(Object key) {
    // hash
    int h = spread(key.hashCode());
    // 这下面一大堆判断 如果数组节点为空 就返回空 有就继续
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 1. 头节点就是目标节点
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // 2. 红黑树 或是 正在扩容
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        // 3. 链表遍历
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

如何统计元素个数

在HashMap中,我们用一个transient int size;统计容器内的元素个数,但ConcurrentHashMap不能这么做

put方法锁的是hash对应的链表头节点/红黑树根节点,但容器内的元素个数是数组所有元素共享的,那这样维护元素个数就需要锁住整个Map,性能非常低。在上篇文章CAS与锁的应用之:原子类、LongAdder、阻塞队列详解学习了LongAdder以后,这不就是将LongAdder应用到实战的一个很好的机会吗?实际上,ConcurrentHashMap也确实是以类似的方式做的:

private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;

cell眼熟吧,longAdder的数组不也是cells数组吗

@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

同样用Contended注解解决伪共享问题

再看size方法

public int size() {
    long n = sumCount();
    return n; // 一般情况就return n 省略特殊情况的判断
}
final long sumCount() {
    long sum = baseCount;
     for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
     }
    return sum;
}

发现和LongAdder的思想几乎完全一样。

addCount:修改元素个数

最后来看一下addCount方法。虽然名字叫add,实际上在删除节点是也是调用这个方法来维护count的

// x代表对count + x 
// check = -1 代表删除操作
private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 如果数组为空,对BASECOUNT做CAS
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // 数组不为空 或者CAS失败 进入这个if分支
        CounterCell a; long v; int m;
        boolean uncontended = true;
        // 根据ThreadLocalRandom再去CAS 这个和LongAdder的行为非常像
        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 这个方法上面的注释是:
            // See LongAdder version for explanation 
            // 是的,这个方法和LongAdder的longAccumulate几乎一样
            // 详解请参考:https://juejin.cn/post/7280747221510930486
            // 总之通过自旋CAS,一定能保证成功加上这个x
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    // 检查是否需要扩容
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        // 发现可以扩容
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            // resizeStamp RESIZE_STAMP_SHIFT
            int rs = resizeStamp(n);
            if (sc < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

resize_stamp:rs

这个东西初看会让人摸不着头脑,我们举个例子来分析:

private static int RESIZE_STAMP_BITS = 16;
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;//16
static final int resizeStamp(int n) {// n是原数组table的长度
    // n的前导0的个数 | (1<<15)
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

假设n为8,则resizeStamp的返回值,即rs的值,为:

28 | (0000 0000 0000 0000 1000 0000 0000 0000)
rs = (0000 0000 0000 0000 1000 0000 0001 1100)

在第一次触发扩容时,会将sizeCtl修改为:

U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))    
sizeCtl = 1000 0000 0001 1100 0000 0000 0000 0010

因此,在扩容期间,sizeCtl一定是负数,且不是-1,可以用于扩容期间的标识;前16位的sizeCtl存储了扩容的原数组的n的信息;后16位存储了当前在扩容的线程的数量+1,也就是源码注解提到的意思。

那这个rs有啥用呢?我们就看addCount方法:

if ((sc >>> RESIZE_STAMP_SHIFT) != rs)

可以利用rs来判断此次扩容是否是当前容量n触发的,避免两次触发扩容导致的冲突

扩容机制🚩

其实ConcurrentHashMap最复杂的最难理解的点就是扩容,换个角度思考:如果让你来设计ConcurrentHashMap,你会怎么做呢?最简单的方法就是:所有方法加个synchronized,但复杂度太高了;由于每次get/put都只会对数组的某个位置进行操作,因此我们希望细化锁粒度,只锁数组上的这个节点,这也好办。但困难之处就在于:如何扩容?扩容时,get/put的线程该怎么做?

addCount方法内通过transfer来扩容,但treeifyBin(转红黑树的逻辑)内又用tryPresize方法,这名字不是一看就是扩容吗?那到底哪个是扩容的方法?我们发现tryPresize内调用了transfer,那可以先猜测:它们都能扩容。我们之后再分析源码来验证看看这个猜想是否正确。先看看它们的调用时机。

扩容时机

  • transfer有三个方法调用,分别是helpTransfer,addCount和tryPresize
  • tryPresize有两处调用,分别是treeifyBin和putAll

因此,扩容时机为:

  1. put最后的addCount方法会检查是否需要扩容,sizeCtl超过3/4了
  2. 链表元素超过8个,要转换成红黑树前,会判断数组大小是否大于等于64,小于64会扩容
  3. 一下子放很多元素,可能导致预先的扩容

再加上helpTransfer,扩容期间对桶做修改的线程会加入参与扩容

因为tryPresize内调用了transfer,我们当然先看transfer

真正的扩容:transfer

这个方法非常长,正式开始前,我们需要先了解一下字段中transferIndex的意思。

transferIndex的意义

transferIndex在触发扩容时,会被设置为旧数组的长度,在线程参与到transfer方法进行扩容时,会被分配一部分任务(需要迁移的桶),那怎么来分配呢?就是利用transferIndex,在执行任务前,会不断CAS将transferIndex - stride(每个线程负责迁移的桶的数量),于是这部分任务就交给了这个线程

一个线程不是只会被分配一次任务的,如果一次任务执行完,仍未finishing,advance仍为true,还会被再分配一次任务,直到迁移工作不需要该线程了,才会从transfer返回

接下来开始transfer源码的漫漫长路:

1.计算每个线程负责迁移多少,最小为16

int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
    stride = MIN_TRANSFER_STRIDE; // 16

2.如果目标数组为空,初始化一下(第一次触发扩容时nextTab为空)

if (nextTab == null) {            // initiating
    try {
        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;
}

3.准备工作,下面要进入一个非常长的循环

int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;    // 是否继续分配任务
boolean finishing = false; // 是否所有table的桶都已经迁移完毕

4.开始循环,比较长,还是拆开来讲

for (int i = 0, bound = 0;;) {
    Node<K,V> f; int fh;
    // ....
}

4.1、分配任务,这就是前文提到的transferIndex起到的作用

while (advance) {
    int nextIndex, nextBound;
    if (--i >= bound || finishing)
        advance = false;
    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;
    }
}

4.2、任务都已经分配完了,那不需要我继续扩容了

if (i < 0 || i >= n || i + n >= nextn) {
    int sc;
    // 任务全部完成 退出
    if (finishing) {
        nextTable = null;
        table = nextTab;
        // sizeCtl仍然是 3/4 cap
        sizeCtl = (n << 1) - (n >>> 1);
        return;
    }
    // 任务虽然全部被分派,但还没执行完成,维护一下sizeCtl的值
    if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
        // 如果我不是最后一个线程了 直接退出就好了
        if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
            return;
        // 如果是参与扩容的最后一个线程 还需要recheck一遍
        // 会再遍历一遍所有节点看是否有所遗漏
        finishing = advance = true;
        i = n; // recheck before commit
    }
}

4.3、如果这个位置为空,直接CAS改成fwd即可

else if ((f = tabAt(tab, i)) == null)
    advance = casTabAt(tab, i, null, fwd);

4.4、如果已经是fwd了,已经被处理完了,或者是正在被别的线程处理,不用管,跳过即可

else if ((fh = f.hash) == MOVED)
    advance = true; // already processed

4.5、这个桶真的需要我去迁移了:

else {
    synchronized (f) {
        if (tabAt(tab, i) == f) {
            // 准备好两个链表,迁移时因为hashcode只多一位
            // 所以一个旧桶的元素,最多只会被分配到两个新桶,先自己串成两个链表
            Node<K,V> ln, hn;
            // 如果是链表
            if (fh >= 0) {
                // 这里是找到最后一个新一位hashcode的不一样的节点
                // 从lastRun到链表尾,这些元素都可以保留,不必new Node
                // 算是个小优化 不影响理解流程
                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;
                }
                // 遍历整个链表 根据新一位的Hashcode决定插入哪个链表
                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) {
              // 红黑树就省略了
            }
        }
    }
transfer方法小结

真正需要我去扩容时,我会锁住原table的头节点,然后生成两个链表,插入到新table后,再设置为fwd节点释放锁。transfer的过程是持有锁的。

至此,transfer源码就分析完毕了,再看tryPresize

private final void tryPresize(int size) {
    // 保证2的幂
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
        tableSizeFor(size + (size >>> 1) + 1);
    int sc;
    while ((sc = sizeCtl) >= 0) {
        Node<K,V>[] tab = table; int n;
        // 数组为空,初始化数组
        if (tab == null || (n = tab.length) == 0) {
            n = (sc > c) ? sc : c;
            // CAS 修改sizeCtl为-1,表示table数组正在初始化
            if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if (table == tab) {
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = nt;
                        // 设置sizeCtl为 3/4的cap
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
            }
        }
        // 不需要继续扩容的情况
        else if (c <= sc || n >= MAXIMUM_CAPACITY)
            break;
        else if (tab == table) {
            int rs = resizeStamp(n);
            // 正在被初始化或者扩容
            if (sc < 0) {
                Node<K,V>[] nt;
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            // CAS尝试触发扩容
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
        }
    }
}

在扩容时读写操作如何进行

其实get/put方法我们已经介绍过,但介绍了扩容后,我们才能补充与扩容有关的细节

put

在put方法中,有两处会影响扩容:

  • hashcode找到数组下标,不是fwd,至少这个位置还没被扩容,正常put
  • 发现数组对应的下标的节点是个fwd,就helpTransfer帮助扩容,等待扩容完毕再继续put
  • addCount,treeifyBin可能会主动触发扩容,这个前文已经详细介绍过了
get

在get方法中,看这个之前被我们忽视的2分支

     // 2. 红黑树 或是 正在扩容
    else if (eh < 0)
        return (p = e.find(h, key)) != null ? p.val : null;

ForwardingNode重写了find方法,我们来看看这个方法

Node<K,V> find(int h, Object k) {
    // loop to avoid arbitrarily deep recursion on forwarding nodes
    outer: for (Node<K,V>[] tab = nextTable;;) {
        Node<K,V> e; int n;
        // nexttab没节点
        if (k == null || tab == null || (n = tab.length) == 0 ||
            (e = tabAt(tab, (n - 1) & h)) == null)
            return null;
        for (;;) {
            int eh; K ek;
            // 新table的首节点就是
            if ((eh = e.hash) == h &&
                ((ek = e.key) == k || (ek != null && k.equals(ek))))
                return e;
            // 又被扩容 或是 红黑树
            if (eh < 0) {
                // 1 扩容 重来一次
                if (e instanceof ForwardingNode) {
                    tab = ((ForwardingNode<K,V>)e).nextTable;
                    continue outer;
                }
                // 2 红黑树的find
                else
                    return e.find(h, k);
            }
            // 遍历链表
            if ((e = e.next) == null)
                return null;
        }
    }
}
小结

因为迁移过程中,迁移完毕后才会将节点设置为fwd,因此:

  • 如果put时,该处的桶正在扩容,put也需要获取锁,因此会阻塞
  • 如果put时,该处的桶已经扩容完,即是个fwd节点,会帮助扩容
  • 如果get时,该处的桶正在扩容,没关系,直接在原table上去get就行
  • 如果get时,该处的桶已经扩容完,即是个fwd节点,那就去新table上去get

所以扩容几乎不会影响get的性能,这是concurrentHashMap设计的非常巧妙的一点。

get不会帮助扩容,put是会的

最后非常推荐ConcurrentHashMap底层详解(图解扩容)(JDK1.8)的流程图,比直接啃源码会更清晰一些

常见面试题

能不能放入null

HashMap允许key和value为null,但ConcurrentHashMap不允许,会直接报错。

HashMap如果key为null,hash值0

key/value放入null值本身就是一件有二义性的事情,即get时拿到值为null,我们无法确定这到底是不存在该键值对,还是value就是null。

如果非要用null,可以用一个空Object代替null

public static final Object NULL = new Object();

为啥HashMap可以?忘了从哪听来的说法了,HashMap在设计时关于该不该允许放入null争论了很久。一般情况下也不会放null,非要放建议用上面的方案。

与HashMap的不同

利用CAS+synchronized保证线程安全,由于锁粒度细化,在扩容和计数上都有区别。计数是LongAdder的思想,而扩容非常复杂。以及put/get的详细流程,加锁与CAS的使用细节。

简述扩容机制

时机:元素数量超过3/4,链表元素个数超过8个且数组<64,addAll放很多元素会触发扩容

行为:transfer方法,分配任务,如何迁移(两链表),加锁与fwd的细节

别的get/put线程的行为:put可能帮忙?get直接返回?四种情况讨论

细节:rs有啥用?transferindex?sizeCtl的含义?这些都说出来面试官就能get到你是看过源码的

由于concurrentHashMap源码比较复杂,难免有错误,请在评论区指正,感激不尽

参考文档

ConcurrentHashMap底层详解(图解扩容)(JDK1.8)