开发易忽视的问题:Java ConcurrentHashMap设计与实现

441 阅读14分钟

ConcurrentHashMap 是 Java 中用于实现线程安全且高性能的哈希表。它在多线程环境中可以比传统的 HashMap 提供更好的并发性能,同时避免同步的复杂性和开销。下面介绍 ConcurrentHashMap 的设计与实现。

设计思想

1. 分段锁机制(Java 7 及之前)

在 Java 7 及之前版本中,ConcurrentHashMap 采用了分段锁(Segment Locking)的技术,以解决多线程环境下的并发问题。

具体实现:

  • Segment:ConcurrentHashMap 将整个哈希表划分为多个 Segment,每个 Segment 拥有自己的锁和哈希桶。

    • 每个 Segment 就像是一个小型的独立哈希表,并且可以并行操作。
    • 通过限制每个 Segment 的锁范围,多个线程可以同时访问不同的 Segment,从而提高并发性能。
  • 锁的粒度:传统的同步容器(如 Hashtable)使用的是全表锁,这样虽然简单,但并发性能较差,因为任何对表的写操作都会阻塞其它操作。而 ConcurrentHashMap 通过分段锁将锁的粒度降低到 Segment 级别,减少了锁争用,提高了吞吐量。

2. CAS(Compare-And-Swap)操作(Java 8 及之后)

在 Java 8 中,ConcurrentHashMap 摒弃了 Segment 的设计,转而采用更细粒度的锁和 CAS 操作来保证并发安全。

具体实现:

  • CAS 操作:这是无锁算法的核心,通过乐观并发控制来实现原子性操作。

    • CAS 使用硬件指令(如 compareAndSwap),通过比较期望值和当前值,如果一致则更新为新值,否则重试,直到成功。
    • 这种方式避免了线程阻塞,不需要上下文切换,因此性能非常高。
  • 链表和红黑树:为了进一步优化,在处理哈希冲突时,ConcurrentHashMap 先使用链表存储节点,当链表长度超过一定阈值时(默认是8),链表会转换为更高效的红黑树,以优化查找性能。

    • 红黑树是一种自平衡二叉查找树,其查找、插入、删除操作的时间复杂度为 (O(\log n)),相比于链表的 (O(n)) 更加高效。

3. 更细粒度的并发控制

Java 8 的 ConcurrentHashMap 中,通过更细粒度的并发控制实现高效并发,包括以下几个方面:

  • Node:基本的链表节点,用于存储键值对。
  • TreeNode:当链表长度超过阈值时,链表节点会转换为 TreeNode,组成红黑树,以提高查询效率。
  • TreeBin:封装和管理红黑树的结构,负责 TreeNode 的插入、删除和查找等操作。

主要方法

  1. get(Object key)

    • 根据 key 的 hash 值找到对应的哈希桶,然后遍历桶中的链表或树结构来查找匹配的节点。
  2. put(K key, V value)

    • 计算 key 的 hash 值,定位到对应的哈希桶。
    • 使用 CAS 操作尝试插入新节点,如果失败则使用 synchronized 块进行插入。
    • 如果链表长度超过阈值,则转换为红黑树。
  3. remove(Object key)

    • 找到对应的哈希桶,使用 synchronized 块进行删除操作。
  4. resize()

    • 当哈希桶的负载因子超过设定值时,触发扩容操作。
    • 新建一个更大的数组,将旧数组中的数据重新散列到新数组中。

优点

  • 高并发性能:通过细粒度的锁和 CAS 操作,实现高效的并发访问。
  • 避免全局锁:相比于 Hashtable 的全局锁,ConcurrentHashMap 减少了锁的竞争。
  • 动态扩容:支持动态扩容,且扩容过程是渐进式的,不会导致长时间的停顿。

核心方法解析

ConcurrentHashMap 的主要方法包括 get, put, removeresize。下面详细解释这些方法的实现及其在并发环境下的处理策略。

Node

Node 类是 ConcurrentHashMap 的基本存储单元,每个桶(bucket)里存储的是一个链表或树形结构(在 Java 8 及以后)。Node 包含键值对对象,以及指向下一个 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;
    }

    // Getter methods and other relevant methods...
}

TreeNode 和 TreeBin

当链表长度超过阈值时,链表会转换为红黑树,以优化查找性能。TreeNode 是红黑树的节点,而 TreeBin 则是用于管理这棵树。

static final class TreeNode<K,V> extends Node<K,V> {
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;
    boolean red;

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

static final class TreeBin<K,V> {
    TreeNode<K,V> root;
    volatile TreeNode<K,V> first;
    volatile Thread waiter;
    volatile int lockState;
    
    // Methods to manipulate the tree...
}

1. get(Object key)

获取指定键对应的值,主要步骤如下:

  • 计算哈希:计算键的哈希值,定位到具体的桶位置。
  • 检查桶:如果该桶不为空,则遍历该桶的链表(或红黑树)查找对应的节点。
  • 返回值:找到则返回该节点的值,否则返回 null
public V get(Object key) {
    Node<K, V>[] tab; 
    Node<K, V> e, p; 
    int n, eh; 
    K ek;
    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;
        } 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;
}

2. put(K key, V value)

插入或更新键值对,主要步骤如下:

  • 参数校验:检查键值是否为 null

  • 计算哈希:计算键的哈希值。

  • 初始化表:如果哈希表还未初始化,则进行初始化。

  • 插入节点

    • 定位到具体的桶位置,通过 CAS 尝试插入新节点。
    • 如果桶中已有节点,使用 synchronized 锁进行插入。
    • 如果链表长度超过阈值,将链表转化成红黑树以提高性能。
  • 返回结果:返回旧值(如果存在)。

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();

        // 尝试通过 CAS 插入新节点到空桶中
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K, V>(hash, key, value, null)))
                break; // 成功插入新节点

        // 如果该位置正在扩容,则帮助扩容
        } else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);

        // 处理非空桶中的冲突情况
        else {
            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) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }

    addCount(1L, binCount);
    return null;
}

3. remove(Object key)

删除指定键对应的键值对,主要步骤如下:

  • 计算哈希:计算键的哈希值,定位到具体的桶位置。
  • 遍历节点:使用 synchronized 锁定桶,从链表或红黑树中查找并删除节点。
  • 返回结果:如果找到该节点,则删除并返回旧值,否则返回 null
public V remove(Object key) {
    return replaceNode(key, null, null);
}

final V replaceNode(Object key, V value, Object cv) {
    int hash = spread(key.hashCode()); // 计算键的哈希值,以确保哈希值分布均匀。
    for (Node<K, V>[] tab = table;;) { // 循环查找正确的桶。
        Node<K, V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0 || // 如果哈希表为空或尚未初始化。
            (f = tabAt(tab, i = (n - 1) & hash)) == null) // 定位到具体的桶位置,如果桶为空。
            return null; // 没有找到对应的节点,返回 null。

        else if ((fh = f.hash) == MOVED) // 检查桶是否正在进行扩容操作。
            tab = helpTransfer(tab, f); // 帮助完成扩容操作。

        else {
            V oldVal = null;
            boolean validated = false;
            synchronized (f) { // 锁定桶进行后续操作。
                if (tabAt(tab, i) == f) { // 确认第一个节点没有发生变化。
                    if (fh >= 0) { // 链表节点处理。
                        validated = true; // 验证成功。
                        for (Node<K, V> e = f, pred = null;;) { // 遍历链表节点。
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key || (ek != null && key.equals(ek)))) { // 找到匹配的节点。
                                V ev = e.val;
                                if (cv == null || cv == ev || (ev != null && cv.equals(ev))) { // 比较当前值是否匹配给定的比较值。
                                    oldVal = ev; // 保存旧值。
                                    if (value != null) // 如果提供了新的值。
                                        e.val = value; // 更新节点值。
                                    else if (pred != null) // 删除节点。
                                        pred.next = e.next; // 修改前驱节点的 next 指针。
                                    else
                                        setTabAt(tab, i, e.next); // 修改桶头节点。
                                }
                                break; // 结束循环。
                            }
                            pred = e; // 前驱节点向前移动。
                            if ((e = e.next) == null) // 移动到下一个节点。
                                break;
                        }
                    } else if (f instanceof TreeBin) { // 红黑树节点处理。
                        validated = true; // 验证成功。
                        TreeBin<K, V> t = (TreeBin<K, V>) f;
                        TreeNode<K, V> r, p;
                        if ((r = t.root) != null && // 查找红黑树中的匹配节点。
                            (p = r.findTreeNode(hash, key, null)) != null) {
                            V pv = p.val;
                            if (cv == null || cv == pv || (pv != null && cv.equals(pv))) { // 比较当前值是否匹配给定的比较值。
                                oldVal = pv; // 保存旧值。
                                if (value != null) // 如果提供了新的值。
                                    p.val = value; // 更新节点值。
                                else
                                    t.removeTreeNode(p); // 从红黑树中删除节点。
                            }
                        }
                    }
                }
            }

            if (validated) { // 如果验证成功。
                if (oldVal != null) { // 如果找到并处理了节点。
                    if (value == null) // 如果是删除操作。
                        addCount(-1L, -1); // 更新计数器。
                    return oldVal; // 返回旧值。
                }
                break; // 结束外层 for 循环。
            }
        }
    }
    return null; // 如果没有找到匹配的节点,返回 null。
}

扩容机制

扩容触发条件

当哈希表中的元素数量达到当前容量上限的某个阈值时(通常为容量的 0.75 倍),会触发扩容操作。这个阈值称为 resizeStamp。在 ConcurrentHashMap 中,扩容的触发由以下条件决定:

  • 当前负载因子超过阈值

扩容流程

  1. 标记扩容开始

    • 通过 CAS 操作设置扩容标识位,防止多个线程同时执行扩容操作。
  2. 创建新数组

    • 新数组的大小通常是原数组的两倍。
  3. 转移数据

    • 数据从旧数组转移到新数组。转移过程是分段进行的,以减少单次锁定时间。每个线程负责处理一部分数据。
  4. Forwarding Nodes

    • 在转移过程中,会用 Forwarding Node 标记已经处理过的桶。当其他线程访问这些桶时,会被引导到新数组中去。
  5. 调整索引位置

    • 在扩容过程中,需要重新计算每个元素的新索引位置,这通常是通过与新的数组大小取模运算 (hash % newCapacity) 来完成的。
  6. 清理旧数组

    • 当所有桶都被处理完毕后,旧数组会被废弃,所有读写操作都会转向新数组。

扩容过程中的并发控制

  • 在整个扩容过程中,通过 CAS 和自旋锁等机制确保线程安全。
  • 通过分段的方式,使得扩容操作可以被多个线程并行执行,从而提升性能。

核心方法:transfer

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;

    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false;
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        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;
            }
        }

        if (i < 0 || i >= n || i + n >= nextTab.length) {
            int sc;
            if (finishing) {
                nextTable = null;
                table = nextTab;
                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;
            }
        } else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED)
            advance = true;
        else {
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) {
                        // 链表节点处理逻辑在此
                        // ...
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    } else if (f instanceof TreeBin) {
                        // 红黑树节点处理逻辑在此
                        // ...
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}

详细解释

初始化部分

int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
    stride = MIN_TRANSFER_STRIDE;
  • 计算每个线程要处理的桶范围:根据 CPU 数量和当前数组长度 n 计算每个线程要处理的槽位范围(stride),并确保该范围不小于 MIN_TRANSFER_STRIDE
  • ForwardingNode:创建一个特殊的节点 fwd,用来标记已经迁移过的数据位置。

主循环和槽位分配

boolean advance = true;
boolean finishing = false;
for (int i = 0, bound = 0;;) {
    Node<K,V> f; int fh;
    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;
        }
    }
  • 槽位范围确定和更新

    • advance 用于控制是否进入下一步的分配逻辑。
    • 如果 i 超出当前线程负责的范围 (i >= bound),或如果正在完成最后的检查阶段 (finishing),则停止推进。
    • 使用 CAS 操作更新全局 transferIndex,确保多个线程不会重复处理相同的槽位范围。
    • 确定当前线程需要处理的新的槽位范围,并更新 i 以开始处理这些槽位。

边界检查和完成标志

    if (i < 0 || i >= n || i + n >= nextTab.length) {
        int sc;
        if (finishing) {
            nextTable = null;
            table = nextTab;
            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;  // 重新检查所有
        }
    }
  • 边界检查

    • 如果当前索引 i 超出有效范围,则进行一系列检查和操作,以决定是否完成迁移。
    • 如果 finishing 标志为真,则表示所有槽位的迁移已经完成,可以将 nextTable 设为 null 并更新当前 table
    • 否则,通过 CAS 操作减少 sizeCtl,并检查是否所有线程都完成了他们的任务。
    • 如果是最后一个线程完成任务,将 finishing 和 advance 设置为真,继续检查所有槽位。

数据迁移(链表和红黑树)

链表节点处理

                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) {
  • 链表节点处理

    • 如果当前桶中的节点是以链表形式存在的(fh >= 0),它会被拆分成两个部分 ln 和 hn
    • 使用 hash & n 检查每个节点属于低位或高位,并将其添加到 ln 或 hn 中。
    • 最后将处理后的 ln 和 hn 分别存储到新数组的对应位置,并在旧桶位置放置 ForwardingNode 标记。

红黑树节点处理

                    // 红黑树节点处理逻辑在此
                    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<>(lo) : null;
                    hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                          (lc != 0) ? new TreeBin<>(hi) : null;
                    setTabAt(nextTab, i, ln);
                    setTabAt(nextTab, i + n, hn);
                    setTabAt(tab, i, fwd);
                    advance = true;
  • 红黑树节点处理

    • 如果当前桶中的节点是红黑树形式的(f instanceof TreeBin),则需要更复杂的处理逻辑。
    • 红黑树中的节点也会根据 hash & n 拆分成两部分 lo 和 hi
    • 根据拆分后的节点数量,决定是否需要转回链表形式(当节点数少于或等于 UNTREEIFY_THRESHOLD)或者继续使用红黑树。
    • 最后,将处理后的 lo 和 hi 分别存储到新数组的相应位置,并在旧桶位置放置 ForwardingNode 标记。

总结

transfer 方法的核心逻辑可以总结如下:

  1. 初始化:计算每个线程要处理的槽位范围,并创建 ForwardingNode

  2. 槽位分配与处理

    • 多线程通过 CAS 操作分配各自负责的槽位范围。
    • 对每个槽位进行处理,如果槽位空闲则设置为 ForwardingNode
    • 如果槽位中有链表节点,则按照 hash & n 拆分为低位和高位链表,并分别存储到新表中。
    • 如果槽位中有红黑树节点,则根据 hash & n 拆分为低位和高位子树,并分别存储到新表中。
  3. 边界检查与完成

    • 检查是否所有槽位都已经处理完毕,如果处理完毕则更新 table 引用,释放临时表并返回。
    • 通过 CAS 操作减少 sizeCtl,确保多个线程协同工作。
  4. 同步与锁操作

    • 对每个非空槽位进行同步,防止多线程同时操作导致数据不一致。

这个方法保证了在多线程环境下,ConcurrentHashMap 的扩容操作能够安全且高效地进行。