ConcurrentHashMap详解:原理、实现与并发控制

该文章已生成可运行项目,

ConcurrentHashMap是Java并发包(java.util.concurrent)中提供的线程安全哈希表实现,它是对HashMap的线程安全版本,但比Hashtable或Collections.synchronizedMap()有更好的并发性能。本文将全面解析ConcurrentHashMap的设计原理、实现机制和最佳实践。

一、核心特性与设计演进

1. 基本特性

线程安全:支持多线程并发访问而不需要外部同步
高并发:通过分段锁或CAS操作实现高并发性能
弱一致性:迭代器反映的是创建时的状态,不保证反映所有更新
不允许null键/值:与HashMap不同,不允许null键或值

2. 版本演进

Java 7实现(分段锁):

将整个哈希表分成多个段(Segment),每个段是一个独立的哈希表
每个段有自己的锁,不同段可以并发操作
默认创建16个段,并发级别为16

Java 8实现(CAS + synchronized):

取消了分段锁设计
使用CAS(Compare-And-Swap) + synchronized实现更细粒度的锁
当发生哈希冲突时,只锁住冲突的链表或红黑树的头节点
引入红黑树处理哈希冲突,当链表长度超过阈值(默认为8)时转换为红黑树

二、核心数据结构与参数

1. 数据结构

ConcurrentHashMap在JDK8中的数据结构为数组+链表+红黑树:

默认数组大小:16,负载因子:0.75
链表长度 ≥ 8时转换为红黑树(树化)
树节点数 ≤ 6时退化为链表(反树化)

2. 重要参数

initialCapacity:初始容量,默认16
loadFactor:负载因子,默认0.75f
concurrencyLevel:并发级别(Java7),Java8中仅用于兼容性
TREEIFY_THRESHOLD:链表转红黑树的阈值,默认为8
UNTREEIFY_THRESHOLD:红黑树转链表的阈值,默认为6

三、并发控制机制

1. Java 8中的并发控制

CAS:用于无竞争情况下的快速操作
synchronized:当发生哈希冲突时,锁住链表或树的头节点
volatile:保证变量的可见性
sizeCtl:控制表初始化和扩容的特殊标志

2. 为什么使用synchronized而不是ReentrantLock

性能考量:Java 8优化后的synchronized在单bucket竞争时性能更好
内存占用:synchronized的JVM内置锁更节省内存
JIT优化:锁消除/锁膨胀等优化更成熟

四、关键操作实现原理

1. put操作实现 

put操作的核心逻辑如下:

计算key的hash值
如果表未初始化,则初始化表
如果定位到的桶为空,尝试CAS插入新节点
如果桶正在迁移(ForwardingNode),帮助迁移
否则对桶的头节点加synchronized锁:
如果是链表,遍历查找并插入/更新
如果是树,按照红黑树方式插入
判断是否需要树化或扩容

源码为:

/**
 * Maps the specified key to the specified value in this table.
 * Neither the key nor the value can be null.
 *
 * <p> The value can be retrieved by calling the <tt>get</tt> method
 * with a key that is equal to the original key.
 *
 * @param key key with which the specified value is to be associated
 * @param value value to be associated with the specified key
 * @return the previous value associated with <tt>key</tt>, or
 *         <tt>null</tt> if there was no mapping for <tt>key</tt>
 * @throws NullPointerException if the specified key or value is null
 */
public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    int hash = hash(key);
    // hash 值无符号右移 28位(初始化时获得),然后与 segmentMask=15 做与运算
    // 其实也就是把高4位与segmentMask(1111)做与运算
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
        // 如果查找到的 Segment 为空,初始化
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

/**
 * Returns the segment for the given index, creating it and
 * recording in segment table (via CAS) if not already present.
 *
 * @param k the index
 * @return the segment
 */
@SuppressWarnings("unchecked")
private Segment<K,V> ensureSegment(int k) {
    final Segment<K,V>[] ss = this.segments;
    long u = (k << SSHIFT) + SBASE; // raw offset
    Segment<K,V> seg;
    // 判断 u 位置的 Segment 是否为null
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
        Segment<K,V> proto = ss[0]; // use segment 0 as prototype
        // 获取0号 segment 里的 HashEntry<K,V> 初始化长度
        int cap = proto.table.length;
        // 获取0号 segment 里的 hash 表里的扩容负载因子,所有的 segment 的 loadFactor 是相同的
        float lf = proto.loadFactor;
        // 计算扩容阀值
        int threshold = (int)(cap * lf);
        // 创建一个 cap 容量的 HashEntry 数组
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck
            // 再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            // 自旋检查 u 位置的 Segment 是否为null
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                   == null) {
                // 使用CAS 赋值,只会成功一次
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

上面探究了获取 Segment 段和初始化 Segment 段的操作。最后一行的 Segment 的 put 方法还没有查看,继续分析:

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 获取 ReentrantLock 独占锁,获取不到,scanAndLockForPut 获取。
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        // 计算要put的数据位置
        int index = (tab.length - 1) & hash;
        // CAS 获取 index 坐标的值
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                // 检查是否 key 已经存在,如果存在,则遍历链表寻找位置,找到后替换 value
                K k;
                if ((k = e.key) == key ||
                    (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                e = e.next;
            }
            else {
                // first 有值没说明 index 位置已经有值了,有冲突,链表头插法。
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                // 容量大于扩容阀值,小于最大容量,进行扩容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);
                else
                    // index 位置赋值 node,node 可能是一个元素,也可能是一个链表的表头
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}

2. get操作实现

get操作的特点:

完全无锁,依赖volatile保证可见性
通过两次hash定位到具体元素
如果在桶上直接返回
如果是红黑树则按树方式查找
否则按链表遍历查找

源码为:

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    // 计算得到 key 的存放位置
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
             e != null; e = e.next) {
            // 如果是链表,遍历查找到相同 key 的 value。
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

3. 扩容机制 

ConcurrentHashMap的扩容机制非常精巧:

当元素数量超过容量*负载因子时触发扩容
扩容时创建新表(大小为原表2倍)
采用多线程协助扩容机制,提高扩容效率
扩容期间可以同时进行查询操作
通过ForwardingNode标记已迁移的桶

源码为:

private void rehash(HashEntry<K,V> node) {
    HashEntry<K,V>[] oldTable = table;
    // 老容量
    int oldCapacity = oldTable.length;
    // 新容量,扩大两倍
    int newCapacity = oldCapacity << 1;
    // 新的扩容阀值
    threshold = (int)(newCapacity * loadFactor);
    // 创建新的数组
    HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity];
    // 新的掩码,默认2扩容后是4,-1是3,二进制就是11。
    int sizeMask = newCapacity - 1;
    for (int i = 0; i < oldCapacity ; i++) {
        // 遍历老数组
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {
            HashEntry<K,V> next = e.next;
            // 计算新的位置,新的位置只可能是不变或者是老的位置+老的容量。
            int idx = e.hash & sizeMask;
            if (next == null)   //  Single node on list
                // 如果当前位置还不是链表,只是一个元素,直接赋值
                newTable[idx] = e;
            else { // Reuse consecutive sequence at same slot
                // 如果是链表了
                HashEntry<K,V> lastRun = e;
                int lastIdx = idx;
                // 新的位置只可能是不变或者是老的位置+老的容量。
                // 遍历结束后,lastRun 后面的元素位置都是相同的
                for (HashEntry<K,V> last = next; last != null; last = last.next) {
                    int k = last.hash & sizeMask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                // ,lastRun 后面的元素位置都是相同的,直接作为链表赋值到新位置。
                newTable[lastIdx] = lastRun;
                // Clone remaining nodes
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    // 遍历剩余元素,头插法到指定 k 位置。
                    V v = p.value;
                    int h = p.hash;
                    int k = h & sizeMask;
                    HashEntry<K,V> n = newTable[k];
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }
        }
    }
    // 头插法插入新的节点
    int nodeIndex = node.hash & sizeMask; // add the new node
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    table = newTable;
}

五、与其他Map的比较

1. 与HashMap、Hashtable的比较

特性HashMapHashtableConcurrentHashMap
线程安全
锁粒度无锁全表锁分段锁/桶锁
允许null键/值
迭代器弱一致性
性能最高最低

2. 线程安全保证的差异 

HashMap:在多线程环境下可能出现数据丢失或环形链表(JDK7)
Hashtable:全表锁导致性能低下
Collections.synchronizedMap:方法级别同步,性能较差
ConcurrentHashMap:细粒度锁,高并发性能

六、使用场景与注意事项

1. 适用场景 

高并发环境下的键值存储
需要线程安全且高性能的Map实现
读多写少的场景(因为读操作完全无锁)

2. 注意事项

复合操作问题 :

ConcurrentHashMap只能保证单个操作的原子性
复合操作(如"检查然后执行")需要额外同步

// 不安全的复合操作
if (!map.containsKey(key)) {
    map.put(key, value);
}

// 安全的替代方案
map.putIfAbsent(key, value);

size()的弱一致性:

size()方法返回的是近似值,不是精确值
精确计数需要全局锁,采用分片计数(baseCount + CounterCell[])牺牲精确性换取并发度

迭代器的弱一致性 :

迭代器反映的是创建时的状态
不会抛出ConcurrentModificationException

七、源码解析与设计亮点

1. 核心数据结构

// 主哈希表,volatile保证可见性
transient volatile Node<K,V>[] table;

// 扩容时用,代表扩容后的数组
private transient volatile Node<K,V>[] nextTable;

// 控制表初始化和扩容的字段
private transient volatile int sizeCtl;

2. 节点类型

基础节点(链表):

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;        // 不可变hash值
    final K key;           // 不可变key
    volatile V val;        // volatile保证可见性
    volatile Node<K,V> next;  // volatile保证可见性
}

树节点(红黑树):

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;
}

3. 设计亮点

锁粒度优化:仅锁单个bucket头节点(Java 7锁Segment)
无锁读路径:所有读操作都不加锁,依赖volatile和final保证可见性
协作式扩容:多线程共同完成数据迁移
状态机设计:通过MOVED、TREEBIN等特殊hash值实现状态转换
缓存友好:使用@Contended避免伪共享

八、常见问题解答

1. 为什么ConcurrentHashMap不允许null值?

ConcurrentHashMap不允许null值主要是因为并发环境下难以区分"键不存在"和"键对应的值为null"这两种情况。在非并发Map中,可以通过containsKey()来解决,但在并发环境下,这两个操作的原子性无法保证。

2. ConcurrentHashMap如何保证扩容期间的一致性?

ForwardingNode机制:已迁移的bucket会被标记,读操作自动跳转新表
写操作协作:遇到ForwardingNode的写入线程会先协助扩容

3. ConcurrentHashMap的size()为何不精确?

精确计数需要全局锁,这会严重影响并发性能。ConcurrentHashMap采用分片计数(baseCount + CounterCell[])的方式,牺牲精确性换取并发度。

九、最佳实践与性能优化

合理设置初始容量:避免频繁扩容
选择合适的并发级别:在Java 8中主要影响初始容量
利用原子性方法:如putIfAbsent、compute等
避免不必要的复合操作:使用内置的原子方法替代
读多写少场景性能最佳:因为读操作完全无锁

总结

ConcurrentHashMap是Java并发编程中最重要的并发容器之一,它通过精细的锁设计、CAS操作和volatile变量,在保证线程安全的同时提供了接近HashMap的性能。从Java 7的分段锁到Java 8的CAS+synchronized优化,ConcurrentHashMap的设计演进体现了Java并发编程技术的不断进步。理解其实现原理和适用场景,对于开发高性能并发应用至关重要。

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值