Java基础-容器Map(下)

64 阅读12分钟

1 实现原理

1.1 JDK 1.7

JDK1.7 中的 ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即 ConcurrentHashMap 把哈希桶数组切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。

如下图所示,首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,实现了真正的并发访问。

image.png

1.1.1 Segment

Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:

image.png Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。Segment 默认为 16,也就是并发度为 16。

1.1.2 HashEntry

存放元素的 HashEntry,也是一个静态内部类,主要的组成如下: image.png 其中,用 volatile 修饰了 HashEntry 的数据 value 和 下一个节点 next,保证了多线程环境下数据获取时的可见性

1.2 JDK 1.8

在数据结构上, JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的Node数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用 CAS + synchronized实现更加细粒度的锁。

将锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度。

image.png

2 Put

2.1 JDK 1.7

image.png 先定位到相应的 Segment ,然后再进行 put 操作。

image.png

首先会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。

  1. 尝试自旋获取锁。
  2. 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。

获取锁成功后的put流程:

  1. 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
  2. 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value;为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
  3. 最后会解除获取当前 Segment 的锁。

2.2 JDK 1.8

  1. 根据 key 计算出 hashcode 。
  2. 判断是否需要进行初始化。
  3. f 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  5. 如果都不满足,则利用 synchronized 锁写入数据。
  6. 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

image.png

3 GET

3.1 JDK 1.7

首先,根据 key 计算出 hash 值定位到具体的 Segment ,再根据 hash 值获取定位 HashEntry 对象,并对 HashEntry 对象进行链表遍历,找到对应元素。

由于 HashEntry 涉及到的共享变量都使用 volatile 修饰,volatile 可以保证内存可见性,所以每次获取时都是最新值。 image.png

3.2 JDK 1.8

大致可以分为以下步骤:

  1. 根据 key 计算出 hash 值,判断数组是否为空;
  2. 如果是首节点,就直接返回;
  3. 如果是红黑树结构,就从红黑树里面查询;
  4. 如果是链表结构,循环遍历判断。

image.png

4 扩容

4.1 扩容时机

  • put() 添加元素完毕后,通过 addCount() 检查元素总量 size 是否超过阈值 sizeCtl
  • putAll() 添加大量元素之前,通过 tryPresize() 检查是否需要扩容。
  • treeifyBin() 桶中元素由链表转成树结构之前,如果数组容量小于 64(MIN_TREEIFY_CAPACITY),放弃转换红黑树,通过 tryPresize() 检查是否需要扩容。
  • put()computeIfAbsent()computeIfPresent() 等方法操作 HashMap 元素时,发现元素节点类型为 ForwardingNode,则通过 helpTransfer() 检查当前线程是否加入扩容。

4.2 扩容控制 sizeCtl

sizeCtl 是 ConcurrentHashMap 中的一个重要的变量。

/**
 * 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. 初始化前
  • 等于0:未指定初始容量
  • 大于0:由指定的初始容量计算而来,再找最近的2的幂次方。比如传入6,计算公式为6+6/2+1=10,最近的2的幂次方为16,所以sizeCtl就为16。具体见 tableSizeFor 函数。
  1. 初始化中
  • 等于-1 //table正在初始化
  • 等于-N //N是int类型,分为两部分,高15位是指定容量标识,低16位表示并行扩容线程数+1,具体见 resizeStamp 函数。
  1. 初始化后
  • 等于n - (n >>> 2) = table.length * 0.75 //扩容阈值,为table容量大小的0.75倍

4.3 扩容检查

addCount()tryPresize()helpTransfer() 都包含了相似的扩容检查逻辑。
这里以 addCount() 为例作分析。

4.3.1 addCount() 源码

扩容检查流程:

  • 计算元素总量 size,若 CAS 冲突严重则放弃扩容。
  • 若 size 计算成功,有新元素加入,且检测到元素总量大于阈值 size > sizeCtl。
    • 如果检查到当前已有线程在进行扩容。
      • 扩容已经接近完成或足够多的线程参与到扩容中了,当前线程直接返回。
      • 否则当前线程参与扩容。
    • 如果没有其他线程在进行扩容,则修改 sizeCtl 标识,进行扩容。
// 参数 x 表示键值对个数的变化值,如果为正,表示新增了元素,如果为负,表示删除了元素
private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 如果 counterCells 为空,则直接尝试通过 CAS 将 x 累加到 baseCount 中
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // counterCells 非空
        // 或 counterCells 为空,但 CAS baseCount 失败都会来到这里
        CounterCell a; long v; int m;
        boolean uncontended = true;
        // 如果当前线程探针哈希到的数组元素非空,则尝试将 x 累加到对应数组元素
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            // counterCells 为空,或其长度小于1
            // 或当前线程探针哈希到的数组元素为空
            // 或当前线程探针哈希到的数组元素非空,但 CAS 数组元素失败
            // 都会调用 fullAddCount 方法来完成 x 的写入  
            fullAddCount(x, uncontended);
            // 如果调用过 fullAddCount,则当前线程一定不会协助扩容
            return;
        }
        if (check <= 1)
            return;
        s = sumCount(); // s表示加入新元素后的size大小,即元素总量
    }
    if (check >= 0) { // check值为桶上节点数量,有新元素加入成功才检查是否要扩容
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) { // size大于sizeCtl阈值,及其他边界条件符合,则扩容
            int rs = resizeStamp(n); // 高16位置0,第16位为1,低15位存放当前容量n扩容标识,用于表示是对n的扩容。
            if (sc < 0) { // sizeCtl<0表示已经有线程在进行扩容工作
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0) // 条件1校验容量n扩容标识,条件2和3校验sc的边界(这里有BUG!),条件4和5校验扩容逻辑是否完成
                    break; // 跳出循环,表示当前线程无需参与扩容
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) // 当前线程参与扩容,sizeCtl加1
                    transfer(tab, nt);
            }
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2)) // 没有其他线程在进行扩容,则修改sizeCtl的值,将其高15位存放容量n扩容标识,低16位存放并行扩容线程数+1
                transfer(tab, null);
            s = sumCount();
        }
    }
}

上述扩容检查流程,有两个关键的技术点:

  • ConcurrentHashMap 如何计算元素总量 size ?
  • resizeStamp 函数如何生成 sizeCtrl 以控制扩容过程?

计算总量size

ConcurrentHashMap 依靠 baseCount 和 counterCells 来计算元素总量 size,定义如下:

private transient volatile long baseCount;

private transient volatile CounterCell[] counterCells;

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

addCount() 中分为以下三种情况来处理 size:

  1. baseCount CAS 成功。得到 size = baseCount + x。
  2. baseCount CAS 失败,counterCells CAS 成功。 通过 size = sumCount() 来计算容量。
// 累加 baseCount 和与所有 counterCells 数组的非空元素的和
    final long sumCount() {
        CounterCell[] as = counterCells; CounterCell a;
        long sum = baseCount;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }

3.baseCount CAS 失败,counterCells CAS 。通过 fullAddCount() 将 x 的写入 baseCount 或 counterCells,不会计算 size。

扩容过程 sizeCtrl 的计算

在扩容过程中,sizeCtrl 值为负数

  • 高 15 位是指定容量标识。高 15 位是指定容量标识。即存储扩容之前数组的大小 table.length,用于标识是对该大小的扩容。
  • 低 16 位表示并行扩容线程数 + 1。用于记录当前参与扩容的线程数量,用于控制参与扩容的线程数。

resizeStamp() 函数和 addCount()tryPresize()helpTransfer() 扩容检查逻辑都参与对 sizeCtrl 负数值的计算。

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

4.4 扩容流程

扩容的核心逻辑在 transfer() 方法中。看源码之前,提前梳理一下扩容过程:

  1. 创建 nextTable,新容量是旧容量的 2 倍。
  2. 将原 table 的所有桶逆序分配给多个线程,每个线程每次最小分配 16 个桶,防止资源竞争导致的效率下降。指定范围的桶可能分配给多个线程同时处理。
  3. 扩容时遇到空的桶,采用 CAS 设置为 ForwardingNode 节点,表示该桶扩容完成。
  4. 扩容时遇到 ForwardingNode 节点,表示该桶已扩容过了,直接跳过。
  5. 单个桶内元素的迁移是加锁的,将旧 table 的 i 位置上所有元素拆分成高低两部分,并迁移到 nextTable 上,低位索引是 i,高位索引是 i + n,其中 n 为扩容前的容量。
  6. 最后将旧 table 的 i 位置设置为 ForwardingNode 节点。
  7. 所有桶扩容完毕,将 table 指向 nextTable,设置 sizeCtl 为新容量 0.75 倍
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) // 每核处理的桶的数目,最小为16
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    if (nextTab == null) {            // initiating
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; // 构建nextTable,其容量为原来容量的两倍
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n; // 迁移总进度,值范围为[0,n],表示从table的第n-1位开始处理直到第0位。
    }
    int nextn = nextTab.length;
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); // 扩容时的特殊节点,hash固定为-1,标明此节点正在进行迁移。扩容期间的元素查找要调用其find方法在nextTable中查找元素
    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) { // 此循环的作用是 1.确定当前线程要迁移的桶的范围;2.通过更新i的值确定当前范围内下一个要处理的节点
            int nextIndex, nextBound;
            if (--i >= bound || finishing) // 每次循环都检查结束条件:i自减没有超过下界,finishing标识为true时,跳出while循环
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) { // 迁移总进度<=0,表示所有桶都已迁移完成
                i = -1;
                advance = false;
            }
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) { // CAS执行transferIndex=transferIndex-stride,即transferIndex减去已分配出去的桶,得到边界,这里为下界
                bound = nextBound; // 当前线程需要处理的桶下标的下界
                i = nextIndex - 1; // 当前线程需要处理的桶下标
                advance = false;
            }
        }
        if (i < 0 || i >= n || i + n >= nextn) { // 当前线程自己的活已经做完或所有线程的活都已做完
            int sc;
            if (finishing) { // 已经完成所有节点复制了。所有线程已干完活,最后才走这里
                nextTable = null;
                table = nextTab; // table指向nextTable
                sizeCtl = (n << 1) - (n >>> 1); // 设置sizeCtl为新容量0.75倍
                return;
            }
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { // 当前线程已结束扩容,sizeCtl-1表示参与扩容线程数-1
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) // 相等时说明没有线程在参与扩容了,置finishing=advance=true,为保险让i=n再检查一次
                    return;
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        else if ((f = tabAt(tab, i)) == null) // 遍历到i位置为null,则放入ForwardingNode节点,标志该桶扩容完成。
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED) // f.hash == -1 表示遍历到了ForwardingNode节点,意味着该节点已经处理过了
            advance = true; // already processed
        else {
            synchronized (f) { // 桶内元素迁移需要加锁
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) { // 链表节点。非链表节点hash值小于0
                        int runBit = fh & n; // 根据 hash&n 的结果,将所有结点分为两部分
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n; // 遍历链表的每个节点,依次计算 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); // hash&n为0,索引位置不变,作低位链表
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn); // hash&n不为0,索引变成“原索引+oldCap”,作高位链表
                        }
                        setTabAt(nextTab, i, ln); // 低位链表放在i处
                        setTabAt(nextTab, i + n, hn); // 高位链表放在i+n处
                        setTabAt(tab, i, fwd); // 在原table的i位置设置ForwardingNode节点,以提示该桶扩容完成
                        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;
                    }
                }
            }
        }
    }
}

FAQ

1 JDK1.8 中为什么使用内置锁 synchronized替换 可重入锁 ReentrantLock?

  • 在 JDK1.6 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
  • 减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。

2 ConcurrentHashMap 的 get 方法是否要加锁,为什么?

get 方法不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。

这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 效率高的原因之一。

3 JDK1.7 与 JDK1.8 中ConcurrentHashMap 的区别?★★★★★

  • 数据结构:取消了 Segment 分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
  • 保证线程安全机制:JDK1.7 采用 Segment 的分段锁机制实现线程安全,其中 Segment 继承自 ReentrantLock 。JDK1.8 采用CAS+synchronized 保证线程安全。
  • 锁的粒度:JDK1.7 是对需要进行数据操作的 Segment 加锁,JDK1.8 调整为对每个数组元素加锁(Node)。
  • 链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于 8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储。
  • 查询时间复杂度:从 JDK1.7的遍历链表O(n), JDK1.8 变成遍历红黑树O(logN)。

4 ConcurrentHashMap 和 Hashtable 的效率哪个更高?为什么?

ConcurrentHashMap 的效率要高于 Hashtable,因为 Hashtable 给整个哈希表加了一把大锁从而实现线程安全。而ConcurrentHashMap 的锁粒度更低,在 JDK1.7 中采用分段锁实现线程安全,在 JDK1.8 中采用CAS+synchronized实现线程安全。

5 具体说一下Hashtable的锁机制

Hashtable 是使用 synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差!

image.png

6 Collections.synchronizedMap

image.png 如果传入的是 HashMap 对象,其实也是对 HashMap 做的方法做了一层包装,里面使用对象锁来保证多线程场景下,线程安全,本质也是对 HashMap 进行全表锁。在竞争激烈的多线程环境下性能依然也非常差,不推荐使用!

7 ConcurrentHashMap 与 HashMap (基于JDK 1.8)

红黑树结构略有不同。

  1. HashMap 的红黑树中的节点叫做 TreeNode,TreeNode 不仅仅有属性,还维护着红黑树的结构,比如说查找,新增等等;
  2. ConcurrentHashMap 中红黑树被拆分

image.png

  • TreeNode 仅仅维护的属性和查找功能
  • 新增了 TreeBin,来维护红黑树结构,并负责根节点的加锁和解锁;
  • 新增 ForwardingNode (转移)节点,扩容的时候会使用到,通过使用该节点,来保证扩容时的线程安全。

8 ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?★★★

我们先来说value 为什么不能为 null。因为 ConcurrentHashMap 是用于多线程的 ,如果ConcurrentHashMap.get(key)得到了 null ,这就无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,就有了二义性。

而用于单线程状态的 HashMap 却可以用containsKey(key) 去判断到底是否包含了这个 null 。

ConcurrentHashMap 的并发度是什么?★★

并发度可以理解为程序运行时能够同时更新 ConccurentHashMap且不产生锁竞争的最大线程数。在JDK1.7中,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度,默认是16,这个值可以在构造函数中设置。

如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32。

如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。

在JDK1.8中,已经摒弃了Segment的概念,选择了Node数组+链表+红黑树结构,并发度大小依赖于数组的大小。