一、为什么需要ConcurrentHashMap?从“大食堂打饭”说起
想象一下,我们有一个普通的HashMap,它就像一个大食堂里唯一的一个打饭窗口。中午饭点,所有同学(线程)都涌向这个窗口。为了保证打饭阿姨(HashMap内部结构)不会因为同时被多人操作而记错账、给错菜(数据错乱),管理员(JVM)规定,一次只能有一个同学在窗口前操作,其他同学必须排队等待。
这种方式就是“锁住整个食堂”,专业上叫全局锁或悲观锁。在Collections.synchronizedMap(new HashMap<>())里就是这么干的。它确实安全,但效率太低了。大部分时间,同学们只是在不同的餐盘(HashMap的不同位置)取菜,彼此并不冲突,却依然要排队。
那么,有没有办法让食堂开多个打饭窗口呢?让去A窗口打红烧肉的同学和去B窗口打青菜的同学可以同时进行?这就是ConcurrentHashMap的核心思想——分段锁(在JDK 1.7及之前)或更先进的CAS+ synchronized(在JDK 1.8及之后)。今天,我们主要剖析应用更广、思想更具代表性的JDK 1.7版本的分段锁实现,理解了它,就能更好地领会JDK 1.8优化的精妙之处。
二、庖丁解牛:ConcurrentHashMap的分段锁结构
在JDK 1.7的ConcurrentHashMap中,其内部不再是一个大的数组,而是由一个叫做Segment的数组组成。你可以把每个Segment想象成食堂里的一个独立的打饭窗口。
每个Segment本身就是一个小的、线程安全的HashMap(继承自ReentrantLock)。ConcurrentHashMap默认有16个Segment(这个值可以设置,但一旦创建就不可更改)。当我们向整个ConcurrentHashMap存一个键值对时,它首先会根据键(Key)的哈希值,决定这个键值对应该去哪个Segment窗口办理业务。
核心逻辑是:
- 定位Segment: 用键的高位哈希值对
Segment数组长度取模,找到对应的Segment。 - Segment内部操作: 在找到的
Segment内部,再进行一次哈希,决定键值对在这个小HashMap数组中的位置。 - 分段加锁: 当线程要操作某个
Segment时,只需要锁住这个Segment对象本身即可。其他线程仍然可以并发地访问和操作其他Segment。
这样一来,理论上,最多可以有16个线程同时进行写操作(如果它们恰好操作的是不同的Segment),并发能力大大提升。这就是“分段锁”名字的由来——将一把大锁,拆分成多把小锁,各自管理数据的一部分。
三、动手实践:看看ConcurrentHashMap如何工作
让我们通过一个具体的代码示例,来感受一下ConcurrentHashMap(以JDK 1.8的API为例,其线程安全特性和分段思想一脉相承,但API更友好)在实际并发场景下的威力。我们会模拟一个热门商品库存扣减的场景。
技术栈:Java
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 模拟并发场景下的商品库存扣减
* 使用ConcurrentHashMap来保证线程安全
*/
public class ConcurrentHashMapDemo {
// 使用ConcurrentHashMap存储商品ID和库存数量
private static final ConcurrentHashMap<String, Integer> productStock = new ConcurrentHashMap<>();
public static void main(String[] args) throws InterruptedException {
// 初始化商品库存,比如热门商品“phone_001”有1000件库存
productStock.put("phone_001", 1000);
// 模拟1000个用户同时发起抢购请求
int userCount = 1000;
// CountDownLatch用于等待所有用户线程执行完毕
CountDownLatch latch = new CountDownLatch(userCount);
// 使用线程池模拟并发用户
ExecutorService executorService = Executors.newFixedThreadPool(50);
System.out.println("抢购开始前,商品 phone_001 库存为: " + productStock.get("phone_001"));
for (int i = 0; i < userCount; i++) {
final String userId = "User_" + i;
executorService.submit(() -> {
try {
// 每个用户尝试购买1件商品
buyProduct("phone_001", 1, userId);
} finally {
// 每个线程完成后,计数器减1
latch.countDown();
}
});
}
// 等待所有抢购线程结束
latch.await();
executorService.shutdown();
System.out.println("抢购结束后,商品 phone_001 库存为: " + productStock.get("phone_001"));
}
/**
* 购买商品方法
* @param productId 商品ID
* @param quantity 购买数量
* @param userId 用户ID
*/
private static void buyProduct(String productId, int quantity, String userId) {
// 关键部分:使用ConcurrentHashMap的compute方法进行原子性操作
// compute方法会保证对同一个key的操作为原子性,类似在Segment内加锁
productStock.compute(productId, (key, currentStock) -> {
if (currentStock == null) {
System.out.println("商品不存在!");
return 0;
}
if (currentStock >= quantity) {
// 库存充足,扣减库存
int newStock = currentStock - quantity;
System.out.println(userId + " 购买成功。库存从 " + currentStock + " 减至 " + newStock);
return newStock;
} else {
// 库存不足
System.out.println(userId + " 购买失败,库存不足。当前库存: " + currentStock);
return currentStock; // 返回原值,不改变库存
}
});
// 注意:compute方法的整个lambda表达式执行是原子的,不会被其他线程打断。
// 在JDK1.7中,这相当于在对应的Segment上进行了synchronized操作。
}
}
代码注释说明:
ConcurrentHashMap<String, Integer> productStock: 声明线程安全的库存Map。CountDownLatch: 用于协调主线程等待所有抢购子线程完成。executorService: 线程池,模拟高并发用户请求。buyProduct方法中的compute: 这是JDK 1.8引入的强大方法。它接收一个键和一个BiFunction函数。这个函数会原子性地执行:根据当前键productId找到对应的值currentStock,进行计算,然后回写新值。这个原子操作的保证,在底层就依赖于对特定哈希桶(在JDK1.8中)或Segment(在JDK1.7中)的同步控制。 这完美解决了“判断库存”和“扣减库存”两个操作组合时的竞态条件问题。
运行这段代码,最终库存很可能会正确地变为0,而不会出现超卖(库存变成负数)的情况。这就是ConcurrentHashMap提供的线程安全保证。
四、关联技术:CAS与volatile——高并发的基石
在深入ConcurrentHashMap(尤其是JDK 1.8版本)之前,有必要了解两个底层关键技术:CAS和volatile。它们是现代无锁或细粒度锁算法的基础。
volatile(易变的): 当一个变量被声明为
volatile后,它就具备了两种特性:- 可见性: 任何线程对该变量的修改,都会立即刷新到主内存;其他线程读取时,会直接去主内存读取最新值。这就防止了线程因使用自己工作内存中的缓存副本而导致的数据不一致。
- 禁止指令重排序: 保证代码执行顺序与程序顺序一致。
在
ConcurrentHashMap中,许多用于计数的变量(如sizeCtl)都被声明为volatile,确保所有线程能立刻感知到其变化。CAS(Compare-And-Swap,比较并交换): 你可以把它理解为一个乐观的原子操作。它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。当且仅当V的值等于A时,处理器才会用B更新V的值,否则不执行更新。无论是否更新,都会返回V的旧值。这个过程是原子的。
通俗比喻: 这就像你去更新一个共享的公告板。你去看的时候,上面写着“任务未开始”(A)。你想把它改成“任务进行中”(B)。CAS操作就是:你带着“任务未开始”的预期去修改。如果在你修改的瞬间,公告板还是“任务未开始”,你就成功改为“任务进行中”。如果已经被别人改成了“任务进行中”,你的这次修改就失败,你可以选择重试或其他处理。这避免了使用锁带来的开销。
JDK 1.8的
ConcurrentHashMap在实现put等操作时,大量使用了CAS来无锁地定位数组头节点,只有在发生哈希冲突(链表头节点已存在)时,才针对这个具体的链表头或红黑树根节点使用synchronized加锁,锁的粒度从Segment(一组桶)缩小到了单个桶的头节点,并发度更高。
五、应用场景与选型考量
典型应用场景:
- 高并发缓存: 这是最经典的用途。例如,在Web服务器中,用
ConcurrentHashMap缓存用户会话、热点数据等。多个请求线程可以安全地并发读写。 - 实时计数器: 统计网站点击量、广告曝光量等,
ConcurrentHashMap的原子性方法(如compute、merge)能保证计数的准确性。 - 线程安全的共享配置存储: 在应用运行时,存储一些需要被多个线程访问的动态配置。
- 替代
Hashtable和synchronizedMap: 在任何需要线程安全Map且对性能有要求的场景,它都是首选。
技术优缺点:
- 优点:
- 高并发性: 通过分段或桶级锁,实现了远超全局锁的并发吞吐量。
- 线程安全: 保证了多线程环境下数据的一致性。
- API强大: JDK 1.8提供了丰富的原子性复合操作方法(如
putIfAbsent,compute,merge),让并发编程更简洁。
- 缺点:
- 弱一致性迭代器:
ConcurrentHashMap的迭代器是“弱一致性”的。它反映的是创建迭代器那一刻或之后某个时刻的Map状态,但不会抛出ConcurrentModificationException。这意味着在迭代过程中,可能读到其他线程刚插入但还未完全链接的数据,也可能读不到已被其他线程删除的数据。这并非bug,而是为性能做出的权衡。 - JDK 1.7中size()/isEmpty()不精确: 为了不锁住所有Segment,这些方法返回的是一个估算值,在并发更新激烈时可能不准确。JDK 1.8通过复杂的计数机制进行了优化。
- 内存消耗: 其内部结构(Segment数组、Node节点等)比
HashMap更复杂,会占用稍多内存。
- 弱一致性迭代器:
注意事项:
- 理解“线程安全”的范围:
ConcurrentHashMap保证的是其自身内部数据结构的线程安全。如果你通过get(k)取出一个List,然后对这个List进行修改,这个修改过程不是线程安全的!你需要额外同步或使用线程安全的集合。 - 合理预估并发量设置初始参数: 在JDK 1.7中,
Segment数量在构造时确定,后期无法扩容。如果初始设置过小,会导致锁竞争加剧。在JDK 1.8中,虽然锁粒度更细,但合理的初始容量和负载因子仍有助于减少扩容次数。 - 优先使用JDK 1.8+: 除非有特殊兼容性要求,否则应使用JDK 1.8及以上的版本。其“CAS + synchronized”的实现比分段锁更优,锁粒度更小,并且在链表长度超过阈值(默认为8)时,会将链表转换为红黑树,防止哈希冲突极端化导致性能退化。
六、总结
ConcurrentHashMap是Java并发编程工具箱中的一颗明珠。它从JDK 1.7的“分段锁”思想,进化到JDK 1.8的“CAS + synchronized”桶级别锁,其设计始终围绕着同一个目标:在保证线程安全的前提下,最大限度地提升并发性能。
理解它的关键在于理解其如何缩小锁的粒度——从锁整个Map,到锁一个Segment(一批桶),再到只锁一个桶的头节点。这种设计哲学,结合volatile和CAS这些底层并发原语,为我们提供了高性能的线程安全容器。
在实际开发中,面对高并发下的共享数据存储需求,ConcurrentHashMap通常是你的第一选择。但请记住,要正确使用它,必须清晰了解其提供的保证(如原子性方法)和其不提供的保证(如迭代器的强一致性),并始终关注你操作的对象的完整生命周期是否都处于线程安全的状态。掌握了ConcurrentHashMap,你就在征服Java高并发世界的道路上,迈出了坚实的一步。
评论