HashMap 的线程安全版:ConcurrentHashMap

ConcurrentHashMap 是 Java 并发包中的线程安全集合类,是对 HashMap 的优化版本,主要用于多线程环境下的高效访问。它解决了 HashMap 在多线程下可能出现的 数据丢失、死循环、异常等问题


一、基本特性

  • 线程安全:支持高并发读写。
  • 效率高:在保证线程安全的同时,尽可能提高并发性能。
  • 支持并发读、部分写操作

二、JDK 不同版本实现对比

JDK 7 采用的是分段锁,整个 Map 会被分为若干段,每个段都可以独立加锁。不同的线程可以同时操作不同的段,从而实现并发。
在这里插入图片描述

JDK 8 使用了⼀种更加细粒度的锁——桶锁,再配合 CAS + synchronized 代码块控制并发写入,以最大程度减少锁的竞争。

CAS(Compare and Swap)是一种原子操作,通过比较内存中的值与预期值是否相等来决定是否更新该值,用于实现高效的无锁并发控制。

在这里插入图片描述

版本结构并发控制主要优点主要缺点
JDK 1.7Segment + HashEntry分段锁(ReentrantLock)并发度高于 HashTable,但锁粒度仍较大结构复杂,占用额外空间
JDK 1.8Node + CAS + Synchronized + 红黑树CAS+Synchronized(更细粒度锁)更高效,减少锁竞争,减少内存开销需要依赖 CAS 操作,存在 ABA 问题风险

ABA 问题:只关心结果正确与否,忽略了中间的状态变化,可能会导致中间的状态变化信息丢失,导致数据不一致。


三、底层结构

JDK 1.7

JDK 7 的 ConcurrentHashMap 采用的是分段锁,整个 Map 会被分为若干段,每个段都可以独立加锁,每个段类似⼀个 Hashtable。
在这里插入图片描述

每个段维护一个键值对数组 HashEntry<K, V>[] table , HashEntry 是⼀个单项链表。

static final class HashEntry<K,V> {
	final int hash;   // 当前 key 的哈希值
	final K key;      // 存储的键(key),不可变
	volatile V value; // 存储的值(value),需要支持并发修改,所以用 volatile 保证可见性
	final HashEntry<K,V> next; // 指向下一个 HashEntry(链表结构)
}

段继承了 ReentrantLock,所以每个段都是一个可重入锁,不同的线程可以同时操作不同的段,从而实现并发。

static final class Segment<K,V> extends ReentrantLock { 
	transient volatile HashEntry<K,V>[] table; 
	transient int count;
}

JDK 1.8

  • 内部结构为:
    Node<K,V>[] table;
    
    • 每个 Node 是一个链表节点(或树节点)。
    • 采用 数组 + 链表 + 红黑树结构
  • 采用 CAS + synchronized 保证线程安全
    • putcomputeIfAbsent 等方法中使用了乐观锁(CAS)+ 锁机制。

四、核心方法流程

JDK 1.7 put(key, value) 流程:

  1. 计算 key 的 hash,定位到段,段如果是空就先初始化;

  2. 使用 ReentrantLock 进行加锁,如果加锁失败就自旋,自旋超过次数就阻塞,保证一定能获取到锁;

自旋是一种锁机制,线程通过不断检查锁的状态来等待锁的释放,适用于短时间内竞争的情况。

  1. 遍历段中的键值对 HashEntry, key 相同直接替换, key 不存在就插入。

  2. 释放锁。

在这里插入图片描述

JDK 1.7 get(key) 流程:

先计算 key 的 hash 找到段,再遍历段中的键值对,找到就直接返回 value。

get 不用加锁,因为是 value 是 volatile 的,所以线程读取 value 时不会出现可见性问题。

JDK 1.8 put(key, value) 流程:

  1. 计算 hash 值。
  2. 定位桶位 index,如果数组为空,采用 CAS 的方式初始化,以确保只有一个线程在初始化数组。
// 计算 hash
int hash = spread(key.hashCode());

// 初始化数组
if (tab == null || (n = tab.length) == 0)
	tab = initTable();

// 按位与计算桶的位置 而不用取余 性能更快
int i = (n - 1) & hash;
  1. 如果该位置为空,用 CAS 插入。
  2. 如果不为空,使用 synchronized 锁住当前桶进行链表/红黑树插入,如果 CAS 操作失败,会退化为 synchronized 代码块来插入节点。。
// CAS 插入节点
if (tabAt(tab, i) == null) {
    if (casTabAt(tab, i, null, new Node<K, V>(hash, key, value, null))) {
        break;
    }
} 
// 否则,使用 synchronized 代码块插入节点
else {
    synchronized (f) { // **只锁当前桶**
        if (tabAt(tab, i) == f) { // 确保未被其他线程修改
            if (f.hash >= 0) { // 链表处理
                for (Node<K, V> e = f; ; e = e.next) {
                    K ek;
                    if (e.hash == hash && ((ek = e.key) == key || (key != null && key.equals(ek)))) {
                        e.val = value;
                        break;
                    }
                }
            } else if (f instanceof TreeBin) { // **红黑树处理**
                ((TreeBin<K, V>) f).putTreeVal(hash, key, value);
            }
        }
    }
}

插入的过程中会判断桶的哈希是否小于 0( f.hash >= 0 ),小于 0 说明是红黑树,大于等于 0 说明是链表。

这里补充⼀点:在 ConcurrentHashMap 的实现中,红黑树节点 TreeBin 的 hash 值固定为 -2。
在这里插入图片描述

  1. 如果链表长度超过阈值(默认8),转为红黑树
if (binCount >= TREEIFY_THRESHOLD)
	treeifyBin(tab, i);
  1. 在插入新节点后,会调用 addCount() 方法检查是否需要扩容。
addCount(1L, binCount);

在这里插入图片描述

JDK 1.8 get(key) 流程:

get 也是通过 key 的 hash 进行定位,如果该位置节点的哈希匹配且键相等,则直接返回值。
在这里插入图片描述
如果节点的哈希为负数,说明是个特殊节点,比如说如树节点或者正在迁移的节点,就调用 find 方法查找。

否则遍历链表查找匹配的键。如果都没找到,返回 null。

版本结构put 流程get 流程
JDK 7分段锁先通过 key 计算出 hash,定位到段,如果段为空则初始化;使用 ReentrantLock 加锁,如果获取锁失败就自旋,自旋超过次数就阻塞,保证一定加锁成功;遍历 hashEntry 如果 key 相同则替代,否则就插入;最后操作完释放锁直接通过 key 计算得到 hash 找到对应的段,在此段进行键值对的遍历找到对应的 key 返回 value 即可。
JDK 8CAS + synchronized先通过 key 计算出 hash,得到在数组中的索引位置,如果位置为空,则使用 CAS 进行初始化;如果桶的位置为空,直接使用 CAS 插入桶,否则使用 synchronized 将结点插入链表,如果 CAS 失败则使用 synchronized 实现插入操作,计算桶的 hash,如果大于 0 为链表,小于 0 为红黑树;如果链表的长度大于 8 则转换成红黑树;通过 addCount () 方法判断当下是否需要扩容。先通过 key 计算得到 hash,如果hash<0 为红黑树,需要使用 find () 方法查找;如果 hash>0 为链表,则直接遍历找到对应的 key 返回 value 即可。

五、HashMap vs ConcurrentHashMap

特性HashMapConcurrentHashMap
线程安全❌ 非线程安全✅ 线程安全
并发控制❌ 无同步机制,多线程下可能死锁或丢数据✅ JDK 1.7: 分段锁,JDK 1.8: CAS + synchronized
null 处理✅ 允许 null 键和值❌ 不允许 null
读性能❌ 多线程下可能不一致✅ 读操作无锁,提高效率
扩容安全❌ JDK 1.7 可能死循环✅ JDK 1.8 版本使用 synchronized 保护,避免死锁

ConcurrentHashMap 的 key 和 value 都不能设置为 null:因为高并发场景中 map.get(key) 时返回 null 导致歧义:value = null 或 value 不存在;并且存储 null 可能会引发额外的同步开销和异常情况。


六、ReentrantLock vs synchronized

JDK 1.7 中的 ConcurrentHashMap 使用了分段锁机制,每个 Segment 都继承了 ReentrantLock,这样可以保证每个 Segment 都可以独立地加锁。

而在 JDK 1.8 中, ConcurrentHashMap 取消了 Segment 分段锁,采用了更加精细化的锁——桶锁,以及 CAS 无锁算法,每个桶都可以独立地加锁,只有在 CAS 失败时才会使用 synchronized 代码块加锁,这样可以减少锁的竞争,提高并发性能

总结:
  • JDK 1.7: 使用 ReentrantLock 实现分段锁(Segment Locking),多个线程可以并发操作不同的段,但同一段内的操作需要加锁。
  • JDK 1.8: 使用桶级锁,每个桶上的操作通过 synchronized 保证线程安全,优化了性能并减少了锁的粒度,同时减少了锁竞争。synchronized 的效率和内存开销更低,适合 ConcurrentHashMap 的场景。

简而言之,JDK 1.8 中改用 synchronized 主要是为了提高性能、简化代码,并且 synchronized 在这种场景下足够满足需求。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值