引言:为什么需要ConcurrentHashMap?
在电商平台的秒杀系统中,某次促销活动遭遇了库存数据不一致的严重事故。技术团队排查发现,问题根源在于使用普通HashMap处理库存扣减时,多个线程同时修改数据导致脏读。这个真实案例揭示了并发环境下数据结构选择的重要性——ConcurrentHashMap正是为解决这类问题而生的并发容器。
一、线程安全Map的演进之路
1.1 HashMap的线程安全问题
// 线程不安全的HashMap使用示例
Map<String, Integer> map = new HashMap<>();
// 线程1
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put("key", map.getOrDefault("key", 0) + 1);
}
}).start();
// 线程2
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put("key", map.getOrDefault("key", 0) + 1);
}
}).start();
// 最终结果大概率不是2000
问题分析:当多个线程同时执行put操作时,可能导致:
-
哈希碰撞引发链表成环
-
数据覆盖导致更新丢失
-
size计数不准确
1.2 传统解决方案的局限性
-
Hashtable:全表锁导致性能瓶颈
-
Collections.synchronizedMap:与Hashtable类似,方法级同步锁
-
读写锁ReentrantReadWriteLock:写锁会阻塞所有读操作
1.3 ConcurrentHashMap的诞生
JDK1.5引入的ConcurrentHashMap采用分段锁设计,将数据划分为多个Segment(默认为16个),每个Segment独立加锁,实现真正的并发读写。
二、ConcurrentHashMap核心实现原理
2.1 Java 7的分段锁设计
如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。
Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:
// Segment内部类核心结构
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
// 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
/**
* HashEntry跟HashMap差不多的,但是不同点是,他使用volatile去修饰了他的数据Value还有下一个节点next。
* volatile的特性:
* 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
* 禁止进行指令重排序。(实现有序性)
* volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。
*/
transient volatile HashEntry<K,V>[] table;
transient int count;
// 快速失败(fail—fast)
transient int modCount;
// 大小
transient int threshold;
// 负载因子
final float loadFactor;
}
设计特点:
-
每个Segment相当于一个独立的HashMap
-
put操作只需锁定当前Segment
-
get操作完全无锁(volatile保证可见性)
原理上来说,ConcurrentHashMap 采用了分段锁技术(类似于行锁和表锁),其中 Segment 继承于 ReentrantLock。
不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。
每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。
1.7虽然可以支持每个Segment并发访问,但是还是存在一些问题,因为基本上还是数组加链表的方式,我们去查询的时候,还得遍历链表,会导致效率很低,这个跟jdk1.7的HashMap是存在的一样问题,所以他在jdk1.8完全优化了。
2.2 Java 8的重大革新
JDK1.8之后抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。
跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。
-
Node+CAS+synchronized:替代Segment分段锁
-
红黑树优化:链表长度>8时转为红黑树
-
扩容优化:多线程协同扩容
2.3 并发控制三剑客
-
CAS(Compare And Swap)
CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。
CAS 操作的流程如下图所示,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。
是一种乐观策略,认为并发操作并不总会发生。
// 典型CAS操作示例
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
就比如我现在要修改数据库的一条数据,修改之前我先拿到他原来的值,然后在SQL里面还会加个判断,原来的值和我手上拿到的他的原来的值是否一样,一样我们就可以去修改了,不一样就证明被别的线程修改了你就return错误就好了。
SQL代码大概如下:
update a set value = newValue where value = #{oldValue}
//oldValue就是我们执行前查询出来的值
CAS中最常见的问题就是ABA问题:
就是说来了一个线程把值改回了B,又来了一个线程把值又改回了A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没有被人改过,其实很多场景如果只追求最后结果正确,这是没关系的。
但是实际过程中还是需要记录修改过程的,比如资金修改什么的,你每次修改的都应该有记录,方便回溯。
ABA问题的解决:
用版本号去保证就好了,就比如说,我在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。
update a set value = newValue ,vision = vision + 1 where value = #{oldValue} and vision = #{vision}
// 判断原来的值和版本号是否匹配,中间有别的线程修改,值可能相等,但是版本号100%不一样
其实有很多方式,比如时间戳也可以,查询的时候把时间戳一起查出来,对的上才修改并且更新值的时候一起修改更新时间,这样也能保证,方法很多但是跟版本号都是异曲同工之妙,看场景大家想怎么设计吧
CAS性能很高,synchronized性能不好,为啥jdk1.8升级之后反而多了synchronized?
synchronized之前一直都是重量级的锁,但是后来java官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。
针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的。
-
volatile变量
transient volatile Node<K,V>[] table; // 保证数组引用的可见性
-
synchronized同步块
// JDK8的putVal方法核心片段
synchronized (f) {
if (tabAt(tab, i) == f) {
// 处理链表或红黑树插入
}
}
三、关键源码深度解析
3.1 初始化过程
public ConcurrentHashMap(int initialCapacity) {
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
// 计算实际容量,保证是2的幂次方
this.sizeCtl = cap; // 控制初始化的状态标记
}
初始化策略:
-
延迟初始化:首次put时创建table
-
sizeCtl字段的多重作用(初始化大小、扩容阈值、状态标志)
3.2 put操作流程
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
- 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
/*
* 当添加一对键值对的时候,首先会去判断保存这些键值对的数组是不是初始化了,
* 如果没有的话就初始化数组
* 然后通过计算hash值来确定放在数组的哪个位置
* 如果这个位置为空则直接添加,如果不为空的话,则取出这个节点来
* 如果取出来的节点的hash值是MOVED(-1)的话,则表示当前正在对这个数组进行扩容,复制到新的数组,则当前线程也去帮助复制
* 最后一种情况就是,如果这个节点,不为空,也不在扩容,则通过synchronized来加锁,进行添加操作
* 然后判断当前取出的节点位置存放的是链表还是树
* 如果是链表的话,则遍历整个链表,直到取出来的节点的key来个要放的key进行比较,如果key相等,并且key的hash值也相等的话,
* 则说明是同一个key,则覆盖掉value,否则的话则添加到链表的末尾
* 如果是树的话,则调用putTreeVal方法把这个元素添加到树中去
* 最后在添加完成之后,会判断在该节点处共有多少个节点(注意是添加前的个数),如果达到8个以上了的话,
* 则调用treeifyBin方法来尝试将处的链表转为树,或者扩容数组
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();//K,V都不能为空,否则的话跑出异常
int hash = spread(key.hashCode()); //取得key的hash值
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(); //第一次put的时候table没有初始化,则初始化table
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { //通过哈希计算出一个表中的位置因为n是数组的长度,所以(n-1)&hash肯定不会出现数组越界
if (casTabAt(tab, i, null, //如果这个位置没有元素的话,则通过cas的方式尝试添加,注意这个时候是没有加锁的
new Node<K,V>(hash, key, value, null))) //创建一个Node添加到数组中区,null表示的是下一个节点为空
break; // no lock when adding to empty bin
}
/*
* 如果检测到某个节点的hash值是MOVED,则表示正在进行数组扩张的数据复制阶段,
* 则当前线程也会参与去复制,通过允许多线程复制的功能,一次来减少数组的复制所带来的性能损失
*/
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
/*
* 如果在这个位置有元素的话,就采用synchronized的方式加锁,
* 如果是链表的话(hash大于0),就对这个链表的所有元素进行遍历,
* 如果找到了key和key的hash值都一样的节点,则把它的值替换到
* 如果没找到的话,则添加在链表的最后面
* 否则,是树的话,则调用putTreeVal方法添加到树中去
*
* 在添加完之后,会对该节点上关联的的数目进行判断,
* 如果在8个以上的话,则会调用treeifyBin方法,来尝试转化为树,或者是扩容
*/
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) { //再次取出要存储的位置的元素,跟前面取出来的比较
if (fh >= 0) { //取出来的元素的hash值大于0,当转换为树之后,hash值为-2
binCount = 1;
for (Node<K,V> e = f;; ++binCount) { //遍历这个链表
K ek;
if (e.hash == hash && //要存的元素的hash,key跟要存储的位置的节点的相同的时候,替换掉该节点的value即可
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent) //当使用putIfAbsent的时候,只有在这个key没有设置值得时候才设置
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) { //如果不是同样的hash,同样的key的时候,则判断该节点的下一个节点是否为空,
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, //调用putTreeVal方法,将该元素添加到树中去
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD) //当在同一个节点的数目达到8个的时候,则扩张数组或将给节点的数据转为tree
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount); //计数
return null;
}
里面的关键点就是两个点,首次是用自旋锁去插入数据,失败后才采用了重量级锁synchronized
3.3 get流程
get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。
由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。
/*
* 相比put方法,get就很单纯了,支持并发操作,
* 当key为null的时候回抛出NullPointerException的异常
* get操作通过首先计算key的hash值来确定该元素放在数组的哪个位置
* 然后遍历该位置的所有节点
* 如果不存在的话返回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;
}
3.4 ConcurrentHashMap几个重要方法
在ConcurrentHashMap中使用了unSafe方法,通过直接操作内存的方式来保证并发处理的安全性,使用的是硬件的安全机制。
/*
* 用来返回节点数组的指定位置的节点的原子操作
*/
@SuppressWarnings("unchecked")
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
/*
* cas原子操作,在指定位置设定值
*/
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
/*
* 原子操作,在指定位置设定值
*/
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
3.5 扩容机制(transfer)
扩容触发条件:
-
元素数量超过sizeCtl阈值
-
链表长度超过TREEIFY_THRESHOLD(默认8)
-
调用putAll等批量操作
并行扩容策略:
-
每个线程处理固定步长的桶位
-
使用ForwardingNode标记迁移完成
-
迁移时保持旧table可用
四、性能优化与最佳实践
4.1 参数调优指南
// 创建调优后的ConcurrentHashMap
Map<String, Object> configMap = new ConcurrentHashMap<>(
64, // 初始容量
0.75f, // 负载因子
16 // 并发级别(JDK8已弃用,但影响初始大小)
);
调优建议:
-
预估最大容量避免频繁扩容
-
根据并发线程数设置合理并发级别
-
监控节点分布是否均匀
4.2 使用场景分析
适用场景:
-
高频读写的缓存系统(如商品库存)
-
实时统计系统(如UV计数器)
-
分布式计算的本地聚合
不适用场景:
-
需要强一致性的金融交易系统
-
需要范围查询的有序集合
-
元素数量极少的简单映射
4.3 常见陷阱与规避
问题案例:
// 错误的使用方式:非原子性操作
if (!map.containsKey(key)) {
map.put(key, value); // 存在竞态条件
}
// 正确方式:使用putIfAbsent
map.putIfAbsent(key, value);
其他注意事项:
-
不要依赖size()方法做精确统计
-
迭代器的弱一致性特征
-
自定义对象必须正确实现hashCode和equals
五、实战案例:构建高并发缓存系统
5.1 需求分析
某社交平台需要实现:
-
支持每秒10万+的查询请求
-
数据变更实时生效
-
缓存自动过期和刷新
5.2 架构设计
public class ConcurrentCache<K,V> {
private final ConcurrentHashMap<K, CacheEntry<V>> map;
private final ScheduledExecutorService cleaner;
// 缓存条目(带过期时间)
static class CacheEntry<V> {
V value;
long expireTime;
boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
public V get(K key) {
CacheEntry<V> entry = map.get(key);
if (entry != null && !entry.isExpired()) {
return entry.value;
}
map.remove(key);
return null;
}
public void put(K key, V value, long ttl) {
CacheEntry<V> entry = new CacheEntry<>();
entry.value = value;
entry.expireTime = System.currentTimeMillis() + ttl;
map.put(key, entry);
}
// 定期清理线程
private void startCleaner() {
cleaner.scheduleAtFixedRate(() -> {
map.forEach((k, v) -> {
if (v.isExpired()) {
map.remove(k);
}
});
}, 1, 1, TimeUnit.MINUTES);
}
}
5.3 性能测试对比
方案 | QPS(读) | QPS(写) | 内存占用 |
---|---|---|---|
HashMap+同步锁 | 12,000 | 8,500 | 低 |
ConcurrentHashMap | 98,000 | 76,000 | 中等 |
Redis集群 | 150,000+ | 120,000+ | 高 |
结论:ConcurrentHashMap在单机场景下表现出优异的读写性能,适合作为本地缓存方案。
六、未来演进与替代方案
6.1 Java内存模型的影响
随着JEP 188引入新的内存访问模式,ConcurrentHashMap可能采用:
-
VarHandle替代Unsafe
-
更细粒度的内存屏障
-
与虚拟线程的深度整合
6.2 替代方案对比
方案 | 优点 | 缺点 |
---|---|---|
ConcurrentHashMap | 内置实现、高性能 | 单机限制 |
Redis | 分布式、持久化 | 网络开销 |
Caffeine | 更丰富的缓存策略 | 第三方依赖 |
Ehcache | 企业级特性 | 配置复杂 |
结语
ConcurrentHashMap作为Java并发容器的集大成者,其设计哲学体现了以下编程智慧:
-
空间换时间:通过分段/分桶降低锁竞争
-
乐观锁优先:CAS操作减少同步开销
-
渐进式优化:动态适应不同并发场景
在分布式架构盛行的今天,ConcurrentHashMap仍然是单机高并发场景的首选解决方案。理解其实现原理不仅能帮助开发者编写更高效的代码,更能培养并发编程的底层思维。