前言
本文基于 JDK 8u 版本的ConcurrentHashMap源码进行分析:
- 源码路径:
jdk/src/share/classes/java/util/concurrent/ConcurrentHashMap.java - 作者:Doug Lea
第1章:基础概念
1.1 为什么需要线程安全的HashMap?
HashMap 完全没有做任何同步,任何并发修改都可能出问题,不能在多线程环境下使用。
| 场景 | 具体会发生什么问题 | 典型后果 |
|---|---|---|
| 1. 多个线程同时 put | 同时触发 resize() 扩容 | JDK7:形成环形链表 → CPU 100% 死循环 JDK8:数据覆盖丢失 |
| 2. 一个线程 put,一个线程 get | get 读到正在扩容过程中的"半初始化"节点 | 返回 null / 抛异常 |
| 3. 多个线程同时 put 新键 | 计算同一个 bucket 位置时,链表/红黑树节点被覆盖 | 数据永久丢失(没人知道) |
| 4. 多个线程同时 put 相同键 | 最后一个覆盖前面的,但中间过程可能出现临时不一致 | 业务逻辑错乱 |
| 5. size() 不准确 | 多线程 put 时 size++ 不是原子操作 | 返回的值比实际小或大 |
1.2 现有解决方案对比
| 解决方案 | 线程安全性 | 读性能 | 写性能 | 适用场景 |
|---|---|---|---|---|
| HashMap | 不安全 | 优秀 | 优秀 | 单线程环境 |
| Hashtable | 安全 | 差 | 差 | 低并发场景 |
| Collections.synchronizedMap | 安全 | 差 | 差 | 临时解决方案 |
| ConcurrentHashMap | 安全 | 优秀 | 良好 | 高并发场景 |
Hashtable的问题
为什么Hashtable性能这么差? Hashtable虽然解决了线程安全问题,但它的解决方式过于简单粗暴。它在每个方法上都加了synchronized关键字,这意味着整个表都被锁住了。
想象一下,Hashtable就像一个只有一把钥匙的大仓库,不管你是要存东西(put)还是取东西(get),不管你操作的是第1个位置还是第100个位置 ,同一时间只能有一个人进入仓库,这种"一刀切"的锁定方式导致了极差的并发性能。
java
// Hashtable的synchronized方法
public synchronized V put(K key, V value) {
// 整个方法都被锁定,其他所有线程都要等待
// 即使操作不同的数据位置也要排队
// ...
}
public synchronized V get(Object key) {
// 连读操作都需要加锁,读和读之间也不能并发
// 这在读多写少的场景下性能损失巨大
// ...
}
Hashtable的问题总结:
- 读写互斥:读操作会阻塞写操作,写操作会阻塞读操作
- 读读互斥:多个读操作之间也不能并发执行
- 粗粒度锁:锁定整个表,而不是具体的数据区域
Collections.synchronizedMap的问题
什么是Collections.synchronizedMap? 这是Java提供的一个工具方法,它可以把任何Map包装成线程安全的版本。听起来不错,但实际上它有严重的局限性。
组合操作不安全 虽然每个单独的方法调用都是同步的,但多个方法调用组成的复合操作却不是原子的。这就像你在银行ATM机前:
- 查询余额(操作1)
- 根据余额决定是否取钱(操作2)
虽然每个操作单独看都是安全的,但在操作1和操作2之间,别人可能已经取走了钱,你看到的余额就不准确了。
java
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());
// 虽然单个操作是安全的,但组合操作不安全
if (!syncMap.containsKey("key")) { // 操作1:检查key是否存在
syncMap.put("key", "value"); // 操作2:如果不存在就插入
// 问题:在操作1和操作2之间,其他线程可能已经插入了这个key!
}
更严重的问题:迭代时的并发修改
java
// 这段代码在并发环境下会抛出ConcurrentModificationException异常
for (String key : syncMap.keySet()) {
if (someCondition(key)) {
syncMap.remove(key); // 在迭代过程中修改Map,非常危险!
}
}
1.3 ConcurrentHashMap的设计目标
ConcurrentHashMap是如何解决前面提到的问题的? Doug Lea(ConcurrentHashMap的作者)在设计这个类时,有着非常明确的目标。来看看源码中的设计说明:
java
/*
* 这个哈希表的首要设计目标是维持并发的可读性
* (主要是get()方法,同时也包括迭代器和相关方法)
* 同时最小化更新操作的竞争。
* 次要目标是保持空间消耗与java.util.HashMap相当或更好
*/
什么叫"并发可读性"? 这意味着多个线程可以同时进行读取操作,不会相互阻塞。想象一下图书馆:多个人可以同时阅读不同的书,甚至可以同时阅读同一本书的不同章节,这就是"并发可读"。
什么叫"最小化更新竞争"? 这意味着在写入数据时,尽量减少线程之间的等待时间。ConcurrentHashMap通过精巧的设计,让不同位置的写入操作可以并行进行,只有在操作同一个位置时才需要等待。
核心目标
-
并发可读性
- get()操作完全无锁
- 迭代器支持并发访问
-
最小化更新竞争
- 细粒度锁定策略
- CAS无锁操作
-
空间效率
- 内存占用不超过HashMap
- 延迟初始化
1.4 ConcurrentHashMap的核心特性
特性1:分离读写操作
为什么读操作可以完全无锁? ConcurrentHashMap使用了"volatile"Java关键字来保证数据的可见性。当一个线程修改了数据,其他线程能立即看到这个修改,而不需要加锁等待。
这就像在一个透明的玻璃柜子里放东西,放东西的人需要打开柜门(加锁),看东西的人只需要透过玻璃看(无锁读取),看的人不会妨碍放东西的人,放东西的人也不会妨碍看的人。
java
// 读操作:完全无锁
public V get(Object key) {
// 使用volatile读取,保证能看到最新的数据
// 多个线程可以同时调用get方法,不会相互阻塞
// 详细实现见第4章
}
// 写操作:精细化锁定
public V put(K key, V value) {
// 只对特定的数据桶加锁,不影响其他桶的读写操作
// 这样可以实现真正的并行写入
// 详细实现见第4章
}
读写分离带来的好处:
- 读操作永远不会被阻塞
- 多个读操作可以并行进行
- 读操作不会阻塞写操作(在不同位置时)
特性2:happens-before保证
什么是happens-before? 这是Java内存模型中的一个重要概念,简单来说就是"发生在...之前"。如果操作A happens-before 操作B,那么A的结果对B是可见的。
让我们看看源码中的说明:
java
/*
* 检索操作(包括get方法)通常不会阻塞,
* 所以可能与更新操作(包括put和remove)重叠执行。
* 检索操作能反映出最近完成的更新操作的结果。
* (更正式地说,对给定key的更新操作与
* 任何读取该key更新值的非空检索操作之间
* 存在happens-before关系)
*/
用生活中的例子来理解: 想象你在看一个电子告示板,有人刚刚更新了告示板内容(put操作),你现在去看告示板(get操作) ,happens-before保证你一定能看到最新的内容,不会看到旧的或者乱码。
技术含义解释:
- get操作能看到最近完成的put操作结果:当你读取一个key时,你看到的一定是最新写入的值
- 通过happens-before关系保证内存可见性:这是JVM级别的保证,不需要程序员额外处理
- 无需显式同步就能保证数据一致性:你不需要在get和put之间加锁,JVM帮你处理了同步问题
特性3:null值限制
为什么不允许null值? 这是一个经常让初学者困惑的设计决定。让我们看看源码中的说明:
java
/*
* 像Hashtable一样,但不像HashMap,
* 这个类不允许null用作key或value
*/
为什么要这样设计?主要有三个原因:
-
null作为"缺失"状态的可靠指示器 在并发环境下,当get()方法返回null时,我们需要能明确知道这意味着什么:
javaV value = map.get("key"); if (value == null) { // 在ConcurrentHashMap中,这明确表示key不存在 // 如果允许null值,我们就无法区分"key不存在"和"key存在但值为null" } -
简化并发控制逻辑 如果允许null值,很多内部算法就会变得复杂。比如,在判断一个位置是否为空时,就需要额外的标记来区分"真的空"和"值为null"。
-
避免歧义(null是真的值还是未找到?)
java// 如果允许null值,这种情况就很困惑 map.put("key", null); // 假设这是合法的 V result = map.get("key"); // result为null,但我们不知道是因为key不存在,还是因为值本来就是null
如果你尝试放入null值,会得到NullPointerException。
1.5 性能对比实验
本次测试采用JMH框架,针对JDK17中四种主要Map实现(HashMap、Hashtable、Collections.synchronizedMap和ConcurrentHashMap)进行了全面的性能对比,测试场景涵盖读取、写入、混合操作和迭代等关键操作,通过多线程环境下不同数据规模(100、10,000和100,000元素)的吞吐量测。
java
//启动类
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
public class BenchmarkRunner {
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(MapBenchmark.class.getSimpleName())
.shouldDoGC(true)
.shouldFailOnError(true)
.build();
new Runner(options).run();
}
}
//JMH测试类
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
@Threads(4) // 测试多线程性能
public class MapBenchmark {
@Param({"100", "10000", "100000"})
private int size;
private HashMap<Integer, String> hashMap;
private Hashtable<Integer, String> hashtable;
private Map<Integer, String> synchronizedMap;
private ConcurrentHashMap<Integer, String> concurrentHashMap;
private final Random random = new Random();
@Setup
public void setup() {
hashMap = new HashMap<>();
hashtable = new Hashtable<>();
synchronizedMap = Collections.synchronizedMap(new HashMap<>());
concurrentHashMap = new ConcurrentHashMap<>();
// 初始化数据
for (int i = 0; i < size; i++) {
hashMap.put(i, "value" + i);
hashtable.put(i, "value" + i);
synchronizedMap.put(i, "value" + i);
concurrentHashMap.put(i, "value" + i);
}
}
// 测试写入性能
@Benchmark
public void testHashMapPut() {
int key = random.nextInt(size);
hashMap.put(key, "newValue" + key);
}
@Benchmark
public void testHashtablePut() {
int key = random.nextInt(size);
hashtable.put(key, "newValue" + key);
}
@Benchmark
public void testSynchronizedMapPut() {
int key = random.nextInt(size);
synchronizedMap.put(key, "newValue" + key);
}
@Benchmark
public void testConcurrentHashMapPut() {
int key = random.nextInt(size);
concurrentHashMap.put(key, "newValue" + key);
}
// 测试读取性能
@Benchmark
public void testHashMapGet(Blackhole blackhole) {
int key = random.nextInt(size);
String value = hashMap.get(key);
blackhole.consume(value);
}
@Benchmark
public void testHashtableGet(Blackhole blackhole) {
int key = random.nextInt(size);
String value = hashtable.get(key);
blackhole.consume(value);
}
@Benchmark
public void testSynchronizedMapGet(Blackhole blackhole) {
int key = random.nextInt(size);
String value = synchronizedMap.get(key);
blackhole.consume(value);
}
@Benchmark
public void testConcurrentHashMapGet(Blackhole blackhole) {
int key = random.nextInt(size);
String value = concurrentHashMap.get(key);
blackhole.consume(value);
}
// 测试并发迭代性能
@Benchmark
public void testHashMapIteration(Blackhole blackhole) {
synchronized (hashMap) {
for (Map.Entry<Integer, String> entry : hashMap.entrySet()) {
blackhole.consume(entry.getKey());
blackhole.consume(entry.getValue());
}
}
}
@Benchmark
public void testHashtableIteration(Blackhole blackhole) {
for (Map.Entry<Integer, String> entry : hashtable.entrySet()) {
blackhole.consume(entry.getKey());
blackhole.consume(entry.getValue());
}
}
@Benchmark
public void testSynchronizedMapIteration(Blackhole blackhole) {
synchronized (synchronizedMap) {
for (Map.Entry<Integer, String> entry : synchronizedMap.entrySet()) {
blackhole.consume(entry.getKey());
blackhole.consume(entry.getValue());
}
}
}
@Benchmark
public void testConcurrentHashMapIteration(Blackhole blackhole) {
for (Map.Entry<Integer, String> entry : concurrentHashMap.entrySet()) {
blackhole.consume(entry.getKey());
blackhole.consume(entry.getValue());
}
}
// 测试混合操作
@Benchmark
public void testHashMapMixed(Blackhole blackhole) {
int key = random.nextInt(size);
if (random.nextBoolean()) {
hashMap.put(key, "mixed" + key);
} else {
String value = hashMap.get(key);
blackhole.consume(value);
}
}
@Benchmark
public void testHashtableMixed(Blackhole blackhole) {
int key = random.nextInt(size);
if (random.nextBoolean()) {
hashtable.put(key, "mixed" + key);
} else {
String value = hashtable.get(key);
blackhole.consume(value);
}
}
@Benchmark
public void testSynchronizedMapMixed(Blackhole blackhole) {
int key = random.nextInt(size);
if (random.nextBoolean()) {
synchronizedMap.put(key, "mixed" + key);
} else {
String value = synchronizedMap.get(key);
blackhole.consume(value);
}
}
@Benchmark
public void testConcurrentHashMapMixed(Blackhole blackhole) {
int key = random.nextInt(size);
if (random.nextBoolean()) {
concurrentHashMap.put(key, "mixed" + key);
} else {
String value = concurrentHashMap.get(key);
blackhole.consume(value);
}
}
}
运行结果:
bash
Benchmark (size) Mode Cnt Score Error Units
MapBenchmark.testConcurrentHashMapGet 100 thrpt 10 14680186.174 ± 2135314.361 ops/s
MapBenchmark.testConcurrentHashMapGet 10000 thrpt 10 12615730.908 ± 749653.788 ops/s
MapBenchmark.testConcurrentHashMapGet 100000 thrpt 10 12574805.271 ± 388677.101 ops/s
MapBenchmark.testConcurrentHashMapIteration 100 thrpt 10 4012326.388 ± 209943.638 ops/s
MapBenchmark.testConcurrentHashMapIteration 10000 thrpt 10 50250.488 ± 175.431 ops/s
MapBenchmark.testConcurrentHashMapIteration 100000 thrpt 10 3916.331 ± 44.880 ops/s
MapBenchmark.testConcurrentHashMapMixed 100 thrpt 10 7989582.880 ± 2766511.545 ops/s
MapBenchmark.testConcurrentHashMapMixed 10000 thrpt 10 7102592.534 ± 608168.259 ops/s
MapBenchmark.testConcurrentHashMapMixed 100000 thrpt 10 6232547.863 ± 776152.116 ops/s
MapBenchmark.testConcurrentHashMapPut 100 thrpt 10 8838457.246 ± 1443709.023 ops/s
MapBenchmark.testConcurrentHashMapPut 10000 thrpt 10 10565768.477 ± 1299170.284 ops/s
MapBenchmark.testConcurrentHashMapPut 100000 thrpt 10 10925571.325 ± 742299.710 ops/s
MapBenchmark.testHashMapGet 100 thrpt 10 17153827.554 ± 3876638.038 ops/s
MapBenchmark.testHashMapGet 10000 thrpt 10 14575098.162 ± 153492.917 ops/s
MapBenchmark.testHashMapGet 100000 thrpt 10 8631041.182 ± 3938144.949 ops/s
MapBenchmark.testHashMapIteration 100 thrpt 10 684468.179 ± 28076.889 ops/s
MapBenchmark.testHashMapIteration 10000 thrpt 10 7912.429 ± 730.822 ops/s
MapBenchmark.testHashMapIteration 100000 thrpt 10 393.185 ± 66.278 ops/s
MapBenchmark.testHashMapMixed 100 thrpt 10 2981737.302 ± 420464.655 ops/s
MapBenchmark.testHashMapMixed 10000 thrpt 10 10427545.760 ± 994008.195 ops/s
MapBenchmark.testHashMapMixed 100000 thrpt 10 3062373.816 ± 676201.458 ops/s
MapBenchmark.testHashMapPut 100 thrpt 10 6546795.161 ± 999954.731 ops/s
MapBenchmark.testHashMapPut 10000 thrpt 10 5536944.449 ± 324849.431 ops/s
MapBenchmark.testHashMapPut 100000 thrpt 10 6578787.885 ± 495482.528 ops/s
MapBenchmark.testHashtableGet 100 thrpt 10 5366336.511 ± 311402.539 ops/s
MapBenchmark.testHashtableGet 10000 thrpt 10 4762498.946 ± 926920.475 ops/s
MapBenchmark.testHashtableGet 100000 thrpt 10 4182685.645 ± 177766.721 ops/s
MapBenchmark.testHashtableIteration 100 thrpt 10 12197283.441 ± 6094883.198 ops/s
MapBenchmark.testHashtableIteration 10000 thrpt 10 18868.746 ± 607.101 ops/s
MapBenchmark.testHashtableIteration 100000 thrpt 10 1778.286 ± 135.194 ops/s
MapBenchmark.testHashtableMixed 100 thrpt 10 3102870.406 ± 443605.931 ops/s
MapBenchmark.testHashtableMixed 10000 thrpt 10 2870457.262 ± 92317.536 ops/s
MapBenchmark.testHashtableMixed 100000 thrpt 10 2627790.459 ± 106966.508 ops/s
MapBenchmark.testHashtablePut 100 thrpt 10 3777555.211 ± 307156.057 ops/s
MapBenchmark.testHashtablePut 10000 thrpt 10 4406290.167 ± 79557.023 ops/s
MapBenchmark.testHashtablePut 100000 thrpt 10 3861202.557 ± 319101.648 ops/s
MapBenchmark.testSynchronizedMapGet 100 thrpt 10 8359246.289 ± 542481.045 ops/s
MapBenchmark.testSynchronizedMapGet 10000 thrpt 10 5902427.697 ± 541149.161 ops/s
MapBenchmark.testSynchronizedMapGet 100000 thrpt 10 4819444.895 ± 86682.476 ops/s
MapBenchmark.testSynchronizedMapIteration 100 thrpt 10 1070851.606 ± 115956.081 ops/s
MapBenchmark.testSynchronizedMapIteration 10000 thrpt 10 11860.412 ± 5542.719 ops/s
MapBenchmark.testSynchronizedMapIteration 100000 thrpt 10 824.730 ± 121.804 ops/s
MapBenchmark.testSynchronizedMapMixed 100 thrpt 10 5305081.408 ± 659259.487 ops/s
MapBenchmark.testSynchronizedMapMixed 10000 thrpt 10 4757111.190 ± 526398.515 ops/s
MapBenchmark.testSynchronizedMapMixed 100000 thrpt 10 3539548.918 ± 558929.229 ops/s
MapBenchmark.testSynchronizedMapPut 100 thrpt 10 6446693.996 ± 880919.217 ops/s
MapBenchmark.testSynchronizedMapPut 10000 thrpt 10 4992778.336 ± 431744.321 ops/s
MapBenchmark.testSynchronizedMapPut 100000 thrpt 10 4087226.735 ± 444760.269 ops/s
整理之后,结果如下:
读取操作 (Get) 性能对比
| 实现方式 | size=100 | size=10,000 | size=100,000 | 性能排名 |
|---|---|---|---|---|
| HashMap | 17,153,827 | 14,575,098 | 8,631,041 | 第1名 |
| ConcurrentHashMap | 14,680,186 | 12,615,730 | 12,574,805 | 第2名 |
| SynchronizedMap | 8,359,246 | 5,902,427 | 4,819,444 | 第3名 |
| Hashtable | 5,366,336 | 4,762,498 | 4,182,685 | 第4名 |
写入操作 (Put) 性能对比
| 实现方式 | size=100 | size=10,000 | size=100,000 | 性能排名 |
|---|---|---|---|---|
| ConcurrentHashMap | 8,838,457 | 10,565,768 | 10,925,571 | 第1名 |
| HashMap | 6,546,795 | 5,536,944 | 6,578,787 | 第2名 |
| SynchronizedMap | 6,446,693 | 4,992,778 | 4,087,226 | 第3名 |
| Hashtable | 3,777,555 | 4,406,290 | 3,861,202 | 第4名 |
混合操作性能对比
| 实现方式 | size=100 | size=10,000 | size=100,000 | 性能排名 |
|---|---|---|---|---|
| ConcurrentHashMap | 7,989,582 | 7,102,592 | 6,232,547 | 第1名 |
| HashMap | 2,981,737 | 10,427,545 | 3,062,373 | 第2名 |
| SynchronizedMap | 5,305,081 | 4,757,111 | 3,539,548 | 第3名 |
| Hashtable | 3,102,870 | 2,870,457 | 2,627,790 | 第4名 |
迭代操作性能对比
| 实现方式 | size=100 | size=10,000 | size=100,000 | 性能排名 |
|---|---|---|---|---|
| ConcurrentHashMap | 4,012,326 | 50,250 | 3,916 | 第1名 |
| Hashtable | 12,197,283 | 18,868 | 1,778 | 第2名 |
| SynchronizedMap | 1,070,851 | 11,860 | 824 | 第3名 |
| HashMap | 684,468 | 7,912 | 393 | 第4名 |
第2章:深入理解内部结构
2.1 整体架构概览
ConcurrentHashMap的UML图:
ConcurrentHashMap采用数组 + 链表/红黑树的混合数据结构:
核心设计理念
根据源码注释:
java
/*
* 这个Map通常作为一个分桶(bucketed)的哈希表。每个
* key-value映射都保存在一个Node中。大多数节点都是基本
* Node类的实例,包含hash、key、value和next字段。
* 但是,存在各种子类:TreeNode被安排在平衡树中,而不是链表中。
* TreeBin保存TreeNode集合的根节点。ForwardingNode在扩容
* 期间被放置在桶的头部。ReservationNode在computeIfAbsent
* 和相关方法中建立值时用作占位符。
*/
2.2 JDK 1.7 vs JDK 1.8 数据结构对比
JDK 1.7架构的根本缺陷
缺陷1:固化的并发模型
java
// JDK 1.7的固化设计
public class ConcurrentHashMap<K, V> {
static final int DEFAULT_CONCURRENCY_LEVEL = 16; // 硬编码!
final Segment<K,V>[] segments = new Segment[16]; // 无法动态调整
// 问题:无论你的应用是4核还是64核,都只能用16个并发度
// 就像一个16车道的收费站,无论有多少辆车都只能开16个通道
}
缺陷2:内存浪费严重
java
// JDK 1.7的内存结构
ConcurrentHashMap map = new ConcurrentHashMap();
// 即使只存储1个元素,也要创建:
// - 16个Segment对象
// - 16个ReentrantLock对象
// - 16个HashEntry数组
// - 各种阈值、计数器等辅助对象
// 内存浪费可达 300% ~ 500%!
缺陷3:锁粒度不合理
java
// JDK 1.7:一个Segment锁定多个桶
Segment segment = segments[hash >>> segmentShift];
segment.lock(); // 锁定整个Segment
try {
// 即使只操作一个桶,也要锁定整个Segment的所有桶
// 就像为了修理一间房子,把整层楼都封锁了
} finally {
segment.unlock();
}
// JDK 1.8:精确到桶级别的锁定
synchronized (bucketHead) { // 只锁定一个桶
// 精确制导,影响最小
}
JDK 1.8架构的革命性创新
创新1:动态并发度
java
// 并发度 = 数组长度,可以动态增长
Node<K,V>[] table = new Node[16]; // 初始16个桶
// 扩容后:32个桶 → 64个桶 → ... → 数万个桶
// 并发度随着数据增长而增长,完美适应负载变化
创新2:智能的数据结构
java
// 根据冲突程度自动选择最优结构
if (链表长度 < 8) {
使用链表; // 简单快速
} else if (数组容量 >= 64 && 链表长度 >= 8) {
转换为红黑树; // 高效查找
} else {
扩容数组; // 减少冲突
}
让我们看看具体的变化:
| 对比维度 | JDK 1.7 | JDK 1.8 | 改进效果 |
|---|---|---|---|
| 核心结构 | Segment数组 + HashEntry链表 | Node数组 + 链表/红黑树 | 减少内存开销,提高性能 |
| 并发度 | Segment数量固定(默认16) | 桶级别并发(数组长度) | 并发度大幅提升 |
| 锁机制 | ReentrantLock分段锁 | synchronized + CAS | JVM优化更好 |
| hash冲突 | 只有链表 | 链表 → 红黑树 | 最坏情况O(n)→O(log n) |
| 扩容方式 | 单个Segment独立扩容 | 全表协作式扩容 | 并发扩容,性能更好 |
JDK 1.7 数据结构详解
java
// JDK 1.7的核心结构(简化版)
public class ConcurrentHashMap<K, V> {
// Segment数组,每个Segment是一个独立的小哈希表
final Segment<K,V>[] segments;
static final class Segment<K,V> extends ReentrantLock {
// 每个Segment内部的哈希表
transient volatile HashEntry<K,V>[] table;
transient int count; // 当前Segment中的元素数量
transient int threshold; // 扩容阈值
static final class HashEntry<K,V> {
final int hash; // 哈希值
final K key; // 键
volatile V value; // 值(volatile保证可见性)
volatile HashEntry<K,V> next; // 链表指针
}
}
}
JDK 1.7的问题:
- 固定并发度:默认16个Segment,无法根据实际需求调整
- 内存浪费:每个Segment都需要维护独立的数据结构
- 锁粒度问题:一个Segment内的所有桶共享一把锁
- 哈希冲突严重:只能使用链表,性能下降明显
JDK 1.8 数据结构详解
java
// JDK 1.8的核心结构(简化版)
public class ConcurrentHashMap<K, V> {
// 直接使用Node数组,每个位置可以是链表或红黑树
transient volatile Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 哈希值
final K key; // 键
volatile V val; // 值(volatile保证可见性)
volatile Node<K,V> next; // 链表指针
}
// 红黑树节点
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)
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root; // 红黑树根节点
volatile TreeNode<K,V> first; // 链表式遍历的起点
volatile Thread waiter; // 等待写锁的线程
volatile int lockState; // 读写锁状态
}
}
JDK 1.8的优势:
- 动态并发度:桶级别的锁,并发度等于数组长度
- 内存效率:去掉了Segment层,减少内存开销
- 红黑树优化:链表长度≥8时转换为红黑树,查找效率O(log n)
- 协作式扩容:多线程协作扩容,提高扩容效率
2.3 红黑树优化
树化的触发条件
为什么需要红黑树? 在理想情况下,哈希表中的每个位置最多只有一个元素。但在现实中,由于哈希冲突,某些位置可能会形成很长的链表。当链表太长时,查找效率就会下降到O(n),这时候就需要红黑树来优化。
什么时候会把链表转换成红黑树? 让我们看看源码中定义的阈值:
java
/**
* 使用树而不是链表的桶计数阈值。
* 当向一个至少有这么多节点的桶添加元素时,桶会被转换为树。
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 在调整大小操作期间,将(分割的)桶去树化的桶计数阈值。
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 桶可以被树化的最小表容量。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
树化需要同时满足两个条件:
-
链表长度达到8个节点
- 为什么是8?这是通过统计学分析得出的最优值
- 根据泊松分布,在正常情况下,链表长度达到8的概率非常小(约0.00000006)
- 如果真的出现了长度为8的链表,说明可能存在大量哈希冲突,需要优化
-
数组容量至少64
- 如果数组还很小(容量小于64),说明可能只是因为容量不够导致的冲突
- 这种情况下,扩容比树化更有效
- 只有当容量足够大,还出现长链表时,才说明真的需要树化
为什么退化阈值是6而不是7?
- 这是为了避免频繁的树化和退化
- 如果阈值都是8,那么在7-8-7-8之间震荡时,会频繁进行树化和退化操作
- 设置2个节点的缓冲区间,可以避免这种不稳定的状态
树化条件总结
| 条件 | 阈值 | 说明 |
|---|---|---|
| 链表长度 | ≥ 8 | 触发树化考虑 |
| 数组容量 | ≥ 64 | 真正执行树化 |
| 退化条件 | ≤ 6 | 红黑树退化为链表 |
TreeBin的设计
什么是TreeBin? TreeBin是红黑树的"容器"或"管理器"。你可以把它想象成一个智能的树管家,它不仅管理着红黑树的结构,还负责协调多个线程对树的访问。
为什么需要TreeBin而不是直接使用TreeNode? 如果直接把TreeNode放在数组中,多线程访问会很复杂。TreeBin作为一个中介,提供了统一的并发控制机制。
java
// TreeBin的核心功能(简化版)
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root; // 红黑树的根节点
volatile TreeNode<K,V> first; // 用于链表式遍历的起点(保持链表兼容性)
volatile Thread waiter; // 当前等待获取写锁的线程
volatile int lockState; // 锁状态控制字段
// 读写锁的状态常量
static final int WRITER = 1; // 写锁状态(独占)
static final int WAITER = 2; // 有线程在等待状态
static final int READER = 4; // 读锁基数(可以有多个读者)
}
TreeBin的巧妙设计:
- 保持链表兼容性 :通过
first指针,即使转成了树,依然可以用链表的方式遍历 - 读写锁机制:允许多个线程同时读,但写操作是独占的
- 等待队列 :通过
waiter字段管理等待的线程,避免无意义的自旋
红黑树的并发控制策略
为什么选择红黑树,而不是 AVL 树、跳表、B-Tree?
| 结构 | 最差查询复杂度 | 插入/删除平均旋转次数 | Java 是否已有成熟实现 | 适合场景 |
|---|---|---|---|---|
| 链表 | O(n) | 0 | 有 | 极少冲突 |
| AVL树 | O(log n) | O(log n)(严格平衡) | 无 | 查询极多、插入极少 |
| 红黑树 | O(log n) | 最多 3 次旋转 | TreeMap 已非常成熟 | 增删查都频繁(HashMap 正好) |
| 跳表 | 期望 O(log n) | 无旋转 | 无 | Redis zset 用得多 |
| B-Tree | O(log n) | 复杂分裂合并 | 无 | 磁盘数据库 |
JDK 官方 + 社区结论:
- 链表长度 ≤ 6 时:链表更快(红黑树常量大)
- 链表长度 = 8 时:两者差不多
- 链表长度 ≥ 32 时:红黑树完胜
- 红黑树在大量随机插入/删除时的吞吐量比 AVL 高 20%~50%
为什么红黑树胜出?
- 插入/删除最多只旋转 2~3 次,AVL 可能要旋转 log n 次
- TreeMap 的红黑树实现已经经过 20+ 年实战打磨,极其稳定
- TreeNode 只需要比普通 Node 多 5 个引用 + 1 个 boolean,空间开销可接受
2.4 特殊节点类型
ForwardingNode - 扩容标记节点
什么是ForwardingNode? ForwardingNode是扩容过程中的"路标",它告诉其他线程:"这个位置的数据已经搬到新地址了,请到那里去找"。
java
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable; // 指向新的哈希表数组
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null); // hash = MOVED = -1,特殊标记
this.nextTable = tab; // 保存新表的引用
}
}
ForwardingNode的重要作用:
-
标记该桶已经迁移到新表:
- 当一个桶的数据迁移完成后,会在旧表的这个位置放一个ForwardingNode
- 其hash值为-1(MOVED),这是一个特殊标记,表示"数据已迁移"
-
重定向查找请求到新表:
- 当其他线程尝试在旧表中查找数据时,发现是ForwardingNode,就会自动跳转到新表继续查找
- 这保证了在扩容过程中,查找操作依然能正确进行
-
协调多线程扩容:
- 多个线程可以同时参与扩容,ForwardingNode帮助它们知道哪些桶已经处理完毕
- 避免重复迁移同一个桶的数据
ReservationNode - 占位节点
什么是ReservationNode? ReservationNode是一个"占座"的节点,就像在电影院里用包包占座位一样,它防止其他线程在同一个位置插入数据。
java
static final class ReservationNode<K,V> extends Node<K,V> {
ReservationNode() {
super(RESERVED, null, null, null); // hash = RESERVED = -3,占位标记
}
}
ReservationNode的使用场景:
-
computeIfAbsent等方法的占位符:
java// 当调用computeIfAbsent时的内部流程: // 1. 检查key是否存在 // 2. 如果不存在,先放一个ReservationNode占位 // 3. 然后调用计算函数 // 4. 最后用计算结果替换ReservationNode -
防止并发插入相同key:
- 假设两个线程同时调用
computeIfAbsent("key", func) - 第一个线程发现key不存在,放入ReservationNode占位
- 第二个线程发现已有ReservationNode,知道有人在处理这个key,就会等待
- 这避免了重复计算和数据覆盖
- 假设两个线程同时调用
为什么需要这些特殊节点?
- 它们的hash值都是负数(-1, -2, -3),与正常节点的正数hash值区分开
- 这种设计让ConcurrentHashMap能在一个统一的框架内处理各种特殊情况
- 提高了并发操作的安全性和效率
2.5 哈希计算与分布
hash值的计算
为什么需要特殊的哈希计算? Java原生的hashCode()可能分布不均匀,特别是当数组长度较小时,很容易产生哈希冲突。ConcurrentHashMap使用spread方法来优化哈希分布。
java
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS; // HASH_BITS = 0x7fffffff
}
spread方法的设计原理:
-
高位扩散 (
h ^ (h >>> 16)):java// 例子:假设原始hash值为 0x12345678 int h = 0x12345678; // 原始hash: 00010010001101000101011001111000 int high16 = h >>> 16; // 右移16位: 00000000000000000001001000110100 int result = h ^ high16; // 异或操作: 00010010001101000100010001001100 // 这样低16位就包含了原始hash高16位的信息,提高了分布均匀性 -
消除符号位 (
& HASH_BITS):java// HASH_BITS = 0x7fffffff = 01111111111111111111111111111111 // 通过与操作确保结果永远为正数,避免负数hash值 -
减少冲突:通过混合高低位信息,即使在小数组中也能获得较好的分布
索引计算
如何从hash值计算数组索引?
java
// 数组索引计算公式(JDK 1.8优化版本)
int index = (table.length - 1) & hash;
// 为什么不用取模运算?
// int index = hash % table.length; // 较慢的方式
// 因为当table.length是2的幂次方时:
// hash % table.length 等价于 hash & (table.length - 1)
// 但位运算比取模运算快得多
JDK 1.7 vs 1.8 哈希计算对比
java
// JDK 1.7的哈希计算
static final int hash(Object k) {
int h = 0;
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12); // 多次位运算
return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8的哈希计算(更简洁)
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS; // 一次位运算即可
}
2.6 内存布局分析
对象大小估算
java
// Node对象的内存占用分析(64位JVM,压缩指针开启)
class Node<K,V> {
// 对象头:12字节(Mark Word 8字节 + Class Pointer 4字节)
final int hash; // 4字节(int类型)
final K key; // 4字节(压缩指针)
volatile V val; // 4字节(压缩指针)
volatile Node<K,V> next; // 4字节(压缩指针)
// 对齐填充:4字节(JVM要求对象大小为8的倍数)
// 总计:32字节
}
// TreeNode对象的内存占用(继承自Node)
class TreeNode<K,V> extends Node<K,V> {
// Node的字段:32字节
TreeNode<K,V> parent; // 4字节(压缩指针)
TreeNode<K,V> left; // 4字节(压缩指针)
TreeNode<K,V> right; // 4字节(压缩指针)
TreeNode<K,V> prev; // 4字节(压缩指针)
boolean red; // 1字节,但对齐后占4字节
// 对齐填充:3字节
// 总计:56字节
}
空间效率对比
| 数据结构 | 每个Entry开销 | 额外特性 | 使用场景 |
|---|---|---|---|
| HashMap.Entry | 24字节 | 无并发安全 | 单线程环境 |
| ConcurrentHashMap.Node | 32字节 | volatile字段 | 并发环境 |
| TreeNode | 56字节 | 红黑树指针 + 链表维护 | 长链表优化 |
| TreeBin | 40字节 | 读写锁控制 | 红黑树管理 |
内存使用的权衡:
- Node vs Entry:多8字节换取线程安全
- TreeNode vs Node:多24字节换取O(log n)查找
- 合理的代价:在高并发和性能面前,内存开销是值得的
2.7 数据结构演进过程
链表 → 红黑树转换
红黑树 → 链表退化
java
// 扩容时的退化逻辑(简化)
if (TreeBin.count <= UNTREEIFY_THRESHOLD) {
// 转换回链表结构
return untreeify(TreeBin.first);
}
2.8 与HashMap的结构对比
| 特性 | HashMap | ConcurrentHashMap |
|---|---|---|
| 节点类型 | Entry | Node (volatile字段) |
| 线程安全 | 否 | 是 |
| 红黑树 | 支持 | 支持 + 并发控制 |
| 空值支持 | key/value可为null | 都不可为null |
| 扩容方式 | 单线程重建 | 多线程协助 |
| 内存开销 | 较小 | 略大(并发控制开销) |
第3章:并发控制机制 - 分段锁与CAS
3.1 并发控制策略概览
ConcurrentHashMap采用多层次的并发控制机制来实现高性能的线程安全:
3.2 CAS无锁操作
3.2.1 CAS的基本原理
CAS是一种无锁的原子操作,包含三个参数:
- V:内存位置的值
- E:期望的旧值
- N:要设置的新值
操作逻辑:如果V == E,则将V设置为N,否则不做任何操作。
3.2.2 CAS在ConcurrentHashMap中的应用
应用1:数组元素的原子更新
什么是casTabAt方法? 这是ConcurrentHashMap中用于原子性更新数组元素的关键方法。它使用了底层的Unsafe类来进行CAS操作。
java
// 使用Unsafe类进行CAS操作
@SuppressWarnings("unchecked")
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
// tab: 目标数组
// i: 数组索引
// c: 期望的当前值(compare)
// v: 要设置的新值(value)
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
这个方法的工作原理:
- 计算数组中第i个元素的内存地址:
((long)i << ASHIFT) + ABASE - 检查该位置的当前值是否等于期望值c
- 如果相等,就将该位置的值更新为v;如果不等,操作失败
- 整个过程是原子的,不会被其他线程干扰
实际使用场景:
java
// 在空桶中插入第一个节点
Node<K,V> f = tabAt(tab, i); // 读取当前桶的头节点
if (f == null) { // 如果桶是空的
// 尝试用CAS将新节点设为头节点
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // CAS成功,插入完成,跳出循环
// CAS失败,说明有其他线程抢先插入了节点,需要重试
}
为什么要用CAS而不是直接赋值?
- 直接赋值
tab[i] = newNode不是线程安全的 - 可能出现多个线程同时写入,导致数据丢失
- CAS保证只有一个线程能成功更新,其他线程会知道操作失败,可以重试
应用2:sizeCtl字段的并发控制
sizeCtl是什么? sizeCtl是ConcurrentHashMap中的一个神奇的字段,它像一个"状态指示器",通过不同的数值来表示哈希表当前处于什么状态。
java
// sizeCtl的不同状态含义
private transient volatile int sizeCtl;
/**
* sizeCtl的语义(这是一个巧妙的编码设计):
* - 负数:表示正在初始化或扩容
* - -1:表示正在初始化
* - -(1 + 扩容线程数):表示正在扩容,后面的数字表示有多少个线程在帮忙
* - 0:使用默认容量
* - 正数:下一次扩容的阈值(通常是 容量 * 0.75)
*/
为什么要用一个字段表示这么多状态?
- 节省内存空间,避免使用多个字段
- 可以用单个CAS操作原子性地改变状态
- 状态转换清晰,易于理解和维护
初始化时的CAS竞争过程:
java
// 简化的初始化逻辑
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // 发现其他线程正在初始化,主动让出CPU时间片
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// CAS成功!我获得了初始化的权限
try {
// 双重检查:获得锁后再次确认表确实需要初始化
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY; // 确定初始容量
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; // 创建新数组
table = nt; // 设置为新表
sc = n - (n >>> 2); // 计算下次扩容阈值:n * 0.75
}
} finally {
sizeCtl = sc; // 恢复sizeCtl为正数,表示初始化完成
}
break;
}
// CAS失败,说明竞争激烈,继续循环重试
}
return tab;
}
这个过程就像抢购商品:
- 多个线程同时发现需要初始化表
- 它们同时尝试将sizeCtl从正数改为-1
- 只有一个线程成功,其他线程发现失败后就等待
- 成功的线程完成初始化,将sizeCtl改为扩容阈值
- 等待的线程发现表已经初始化好了,直接使用
3.2.3 CAS的优势与局限
| 优势 | 局限性 |
|---|---|
| 无锁,性能高 | ABA问题(可用版本号解决) |
| 无线程阻塞 | 自旋可能浪费CPU |
| 无死锁风险 | 只能保证单个变量原子性 |
| 响应性好 | 竞争激烈时效率下降 |
3.3 Synchronized分段锁机制
3.3.1 锁的粒度控制
什么是分段锁? ConcurrentHashMap不使用全局锁,而是采用"分段锁"的策略,对每个桶(bin)的头节点加锁。这就像一个大型停车场被分成很多小区域,每个区域有自己的管理员,互不干扰。
java
// put操作的锁定策略(详细注释版)
final V putVal(K key, V value, boolean onlyIfAbsent) {
// ... 省略前置检查和CAS尝试
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 情况1:桶为空,尝试CAS无锁插入
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 使用CAS操作,避免加锁,这是最快的路径
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // 无锁插入成功,直接退出
}
// 情况2:遇到ForwardingNode,说明正在扩容,去协助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 帮助扩容,然后继续在新表上操作
// 情况3:桶不为空且不在扩容,需要加锁处理冲突
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;
// 查找是否已存在相同的key
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent) // 如果允许覆盖
e.val = value; // 更新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;
}
}
}
}
3.3.2 锁定范围分析
关键特性:
- 细粒度锁:只锁定单个桶,不影响其他桶的并发访问
- 锁对象选择:使用头节点作为锁对象,节省内存
- 双重检查:加锁后再次确认头节点未变化
3.3.3 为什么选择synchronized而不是ReentrantLock?
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 内存开销 | 无额外对象 | 每个锁需要对象 |
| 性能 | JVM优化好 | 略逊于synchronized |
| 可中断性 | 不可中断 | 可中断 |
| 公平性 | 非公平 | 可配置公平/非公平 |
| 适用场景 | 细粒度短暂锁定 | 复杂锁定逻辑 |
Doug Lea选择synchronized的原因:内存效率,避免为每个桶创建锁对象。
3.4设计实现
3.4.1 volatile字段的作用
为什么Node中的字段要用volatile? 在多线程环境下,每个线程都有自己的工作内存(CPU缓存),如果不用volatile,一个线程的修改可能对其他线程不可见。volatile关键字确保了内存可见性和有序性。
java
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 不需要volatile,因为是final的
final K key; // 不需要volatile,因为是final的
volatile V val; // 必须用volatile!保证value更新的可见性
volatile Node<K,V> next; // 必须用volatile!保证链表结构变化的可见性
}
volatile在这里解决什么问题?
-
内存可见性问题:
java// 没有volatile的危险情况: // 线程1:node.val = "新值" -> 可能只更新了线程1的缓存 // 线程2:String v = node.val -> 可能还读到旧值 // 有了volatile的安全情况: // 线程1:node.val = "新值" -> 立即刷新到主内存,并通知其他线程 // 线程2:String v = node.val -> 强制从主内存读取,必定读到新值 -
指令重排序问题:
java// 插入新节点的操作序列 Node newNode = new Node(hash, key, value, null); // 操作1 newNode.val = value; // 操作2 oldNode.next = newNode; // 操作3 // 如果next不是volatile,编译器可能重排序为: // oldNode.next = newNode; (操作3提前) // newNode.val = value; (操作2延后) // 这样其他线程可能看到next指向了一个val还未设置的节点! // volatile的next确保了正确的顺序
3.4.2 final字段的不可变性
java
final int hash; // hash值计算后不再改变,避免并发修改
final K key; // key不可变,保证哈希桶位置稳定
find方法是做什么的? 这个方法负责在链表中查找指定的key。它使用了一种优化的查找策略,通过三层比较来快速定位目标。
java
Node<K,V> find(int h, Object k) {
Node<K,V> e = this; // 从当前节点开始查找
if (k != null) { // 确保查找的key不为null
do {
K ek;
// 三层比较策略:hash -> 引用 -> equals
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e; // 找到了,返回这个节点
} while ((e = e.next) != null); // 继续查找链表中的下一个节点
}
return null; // 没找到,返回null
}
为什么要三层比较?这是一种性能优化策略:
-
第一层:比较hash值 (
e.hash == h)- 这是最快的比较,因为hash是int类型
- 如果hash都不相等,那key肯定不相等,可以快速跳过
- 这能过滤掉大部分不匹配的节点
-
第二层:比较引用 (
ek = e.key) == k)- 如果两个变量指向同一个对象,它们肯定相等
- 引用比较比调用equals方法快得多
- 在很多情况下(比如字符串常量池中的字符串),这能直接确定相等性
-
第三层:调用equals方法 (
k.equals(ek))- 只有前两层都不能确定的情况下,才调用这个最慢但最准确的方法
- equals方法会比较对象的实际内容
这种设计的好处:
- 大部分情况下只需要进行快速的hash比较
- 避免了不必要的equals方法调用,提高了查找性能
- 保证了查找结果的正确性
3.4.3 Happens-Before关系
根据JMM (Java Memory Model),volatile写操作happens-before后续的volatile读操作:
3.4.4 无锁读取的实现
java
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; // volatile读取,无需加锁
}
// 特殊节点(如ForwardingNode)的处理
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; // volatile读取
}
}
return null;
}
关键点:
- 全程无锁操作
- 依赖volatile保证内存可见性
- 可能读到中间状态,但保证最终一致性
3.5 TreeBin的读写锁机制
3.5.1 TreeBin的锁状态
java
static final class TreeBin<K,V> extends Node<K,V> {
volatile int lockState;
// 锁状态常量
static final int WRITER = 1; // 写锁
static final int WAITER = 2; // 等待状态
static final int READER = 4; // 读锁基数
}
3.5.2 读写锁的实现逻辑
java
// 获取写锁(简化版)
private final void lockRoot() {
if (!U.compareAndSwapInt(this, LOCKSTATE, 0, WRITER))
contendedLock(); // 竞争时的处理
}
// 获取读锁(简化版)
private final void contendedLock() {
boolean waiting = false;
for (int s;;) {
if (((s = lockState) & ~WAITER) == 0) {
if (U.compareAndSwapInt(this, LOCKSTATE, s, WRITER)) {
if (waiting)
waiter = null;
return;
}
} else if ((s & WAITER) == 0) {
if (U.compareAndSwapInt(this, LOCKSTATE, s, s | WAITER)) {
waiting = true;
waiter = Thread.currentThread();
}
} else if (waiting)
LockSupport.park(this);
}
}
3.5.3 读操作的降级策略
设计优势:
- 读操作永不阻塞:最多降级为链表遍历
- 写操作独占:确保红黑树结构的一致性
- 性能平衡:大多数情况下享受O(log n)性能
3.6 扩容过程的并发控制
3.6.1 多线程协助扩容
java
// 扩容标志计算
private static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
// 扩容状态编码在sizeCtl中
// 高16位:扩容标识戳
// 低16位:(扩容线程数 + 1)
3.6.2 扩容过程的同步
3.7 内存屏障与可见性保证
3.7.1 关键内存屏障
java
// tabAt方法使用volatile语义读取
@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);
}
// setTabAt方法使用volatile语义写入
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.7.2 可见性保证机制
| 操作类型 | 可见性保证 | 实现方式 |
|---|---|---|
| 数组访问 | 立即可见 | Unsafe volatile方法 |
| Node字段 | 立即可见 | volatile关键字 |
| 锁操作 | 同步可见 | synchronized内存语义 |
| CAS操作 | 原子可见 | Unsafe CAS方法 |
3.8 并发控制性能分析
3.8.1 不同场景的性能特征
| 操作场景 | 并发控制方式 | 性能特征 | 冲突概率 |
|---|---|---|---|
| get操作 | 无锁 | 优秀 | 无冲突 |
| 空桶插入 | CAS | 优秀 | 极低 |
| 链表插入 | synchronized | 良好 | 1/(8×元素数) |
| 树操作 | TreeBin锁 | 良好 | 较低 |
| 扩容 | 多线程协作 | 良好 | 周期性 |
3.8.2 锁竞争概率计算
竞争概率公式:P = 1 / (8 × 元素总数)
实例计算:
- 10,000元素:P = 1/80,000 = 0.00125%
- 100,000元素:P = 1/800,000 = 0.000125%
第4章:核心方法实现 - 源码深度剖析
4.1 put方法 - 插入操作的完整流程
4.1.1 方法入口与参数检查
java
public V put(K key, V value) {
return putVal(key, value, false);
}
java
// putVal方法的核心逻辑(简化版)
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException(); // 不允许null值
int hash = spread(key.hashCode()); // 计算经过优化的hash值
int binCount = 0; // 记录桶中节点数量,用于判断是否需要树化
for (Node<K,V>[] tab = table;;) { // 无限循环,直到插入成功
Node<K,V> f; int n, i, fh;
// 第1步:表未初始化,需要先初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 初始化表,其他线程会等待
// 第2步:目标桶为空,尝试用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; // CAS成功,插入完成,退出循环
// CAS失败,说明有其他线程抢先插入,继续循环重试
}
// 第3步:发现ForwardingNode,表示正在扩容,去帮助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 帮助扩容,然后使用新表继续操作
// 第4步:桶不为空且不在扩容,需要加锁处理冲突
else {
V oldVal = null;
synchronized (f) { // 锁定桶的头节点,细粒度锁
// 在锁内进行链表或红黑树的操作...
// 详细实现见下方
}
}
}
// 第5步:更新元素计数,可能触发扩容
addCount(1L, binCount);
return null;
}
put方法的整个过程就像去餐厅排队点餐:
- 检查餐厅是否开门(表是否初始化)- 没开门就等开门
- 找个空桌子直接坐(空桶CAS插入)- 最快的情况
- 发现在装修(扩容中)- 帮忙搬桌子(协助扩容)
- 桌子有人但还有位置(桶有数据)- 排队等待(加锁插入)
- 登记用餐人数(更新计数)- 人太多可能要扩建餐厅(触发扩容)
4.1.2 Put操作流程图
4.1.3 关键步骤详细分析
步骤1:表初始化 (initTable)
java
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // 让出CPU,等待其他线程完成初始化
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2); // 0.75 * n,下次扩容阈值
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
设计亮点:
- CAS竞争:只有一个线程能获得初始化权限
- 双重检查:获得锁后再次检查table状态
- Thread.yield():失败线程主动让出CPU时间片
步骤2:空桶CAS插入
java
// 桶为空时的无锁插入
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // 成功插入,跳出循环
}
性能优势:
- 无锁操作:避免synchronized开销
- 高概率成功:在负载因子0.75下,约60%的桶为空
- 失败重试:CAS失败后自动重新尝试
步骤3:锁内链表操作
java
synchronized (f) {
if (tabAt(tab, i) == f) { // 双重检查
if (fh >= 0) { // 普通链表节点
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 找到相同key,更新value
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) { // 红黑树节点
// 树插入逻辑...
}
}
}
4.2 get方法 - 高效的无锁读取
4.2.1 get方法的完整实现
get方法为什么这么快? get方法是ConcurrentHashMap的性能明星,它完全不需要加锁,却能安全地在并发环境中工作。让我们看看它是怎么做到的:
java
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode()); // 计算hash值(和put方法用同样的算法)
// 检查表是否存在,且目标桶不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 快路径:检查桶中的第一个节点(最常见的情况)
if ((eh = e.hash) == h) { // 先比较hash值
if ((ek = e.key) == key || (ek != null && key.equals(ek))) // 再比较key
return e.val; // 找到了!直接返回value(volatile读取)
}
// 特殊节点处理:ForwardingNode(扩容中)、TreeBin(红黑树)等
else if (eh < 0) // hash值为负数说明是特殊节点
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; // 没找到,返回null
}
get方法的三条查找路径:
- 快路径:目标就在桶的第一个位置 - 最快,大约30%的情况
- 特殊路径:遇到ForwardingNode或TreeBin - 需要特殊处理,但仍然很快
- 慢路径:需要遍历链表 - 相对较慢,但在良好的hash分布下很少发生
为什么完全不需要加锁?
- 所有的读取操作都是基于volatile字段
- volatile保证了内存可见性,能读到最新的值
- 即使在读取过程中有写操作,最多读到中间状态,但不会读到错误数据
- 这就是无锁编程的魅力!
4.2.2 get操作的性能优化
get方法的优化策略分析:
性能特征详细分析:
| 场景 | 时间复杂度 | 说明 |
|---|---|---|
| 桶为空 | O(1) | 负载因子0.75下,大部分桶为空 |
| 头节点匹配 | O(1) | 最理想情况,一次就找到 |
| 链表查找 | O(k) | k为链表长度,平均k<2 |
| 红黑树查找 | O(log n) | 只在链表很长时才会出现 |
| 扩容期间跳转 | O(k) | 需要跳转到新表继续查找 |
为什么get操作如此高效?
- 最常见的情况最快:大部分情况下是O(1)操作
- 无锁设计:完全不需要等待锁,永不阻塞
- CPU缓存友好:连续的内存访问模式
- 分支预测友好:最常见的分支被CPU预测器优化
性能特征分析:
| 场景 | 时间复杂度 | 说明 |
|---|---|---|
| 桶为空 | O(1) | 直接返回null |
| 头节点匹配 | O(1) | 最快路径,约30%的情况 |
| 链表查找 | O(k) | k为链表长度,平均k<2 |
| 红黑树查找 | O(log n) | n为树节点数量 |
| 扩容期间 | O(k) | 可能需要跳转到新表 |
4.2.3 无锁读取的正确性保证
volatile语义的关键作用
java
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; // 确保value读取的可见性
volatile Node<K,V> next; // 确保链表结构的可见性
}
读写并发的一致性
4.3 resize方法 - 多线程协作扩容
4.3.1 扩容触发条件
什么时候需要扩容? 当ConcurrentHashMap中的元素数量超过阈值时,就需要扩容以保持良好的性能。扩容的核心逻辑在addCount方法中:
java
// 扩容阈值检查(添加中文注释)
private final void addCount(long x, int check) {
// ... 计数更新逻辑,统计当前元素总数
if (check >= 0) { // 需要检查是否扩容
Node<K,V>[] tab, nt; int n, sc;
// 当元素数量超过阈值,且表未达到最大容量时,进行扩容
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n); // 生成扩容标识戳
if (sc < 0) {
// sizeCtl为负数,说明已有其他线程在扩容,尝试协助扩容
// 以下情况不能协助扩容:
// 1. 扩容标识不匹配
// 2. 扩容即将完成
// 3. 扩容线程数已达上限
// 4. 新表还未创建
// 5. 没有更多桶需要迁移
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// 尝试加入扩容大军
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt); // 协助扩容
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null); // 我是第一个发起扩容的线程
s = sumCount(); // 重新统计元素数量
}
}
}
扩容过程就像搬家:
- 发现房子太挤了(元素数量超过阈值)
- 第一个人开始找新房(发起扩容线程)
- 其他人看到了也来帮忙(协助扩容)
- 大家分工合作搬东西(并行迁移数据)
- 搬完后更新地址(切换到新表)
4.3.2 扩容状态编码
java
// sizeCtl在扩容期间的编码格式
// 高16位:resizeStamp(扩容标识)
// 低16位:扩容线程数 + 1
private static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
4.3.3 transfer方法 - 数据迁移核心
transfer方法是扩容的核心,它负责将数据从旧表迁移到新表:
java
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 计算每个线程处理的桶数量(工作分配)
// CPU越多,每个线程分担的工作越少,提高并行效率
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // 最少处理16个桶
// 创建新表(只有第一个扩容线程才需要创建)
if (nextTab == null) {
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; // 容量翻倍
nextTab = nt;
} catch (Throwable ex) {
sizeCtl = Integer.MAX_VALUE; // 扩容失败,设置最大值阻止后续扩容
return;
}
nextTable = nextTab; // 保存新表引用
transferIndex = n; // 设置迁移索引,从后往前迁移
}
int nextn = nextTab.length;
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))) {
// CAS成功获取一个工作范围
bound = nextBound; // 范围下界
i = nextIndex - 1; // 范围上界
advance = false;
}
}
// 处理单个桶的迁移
if ((f = tabAt(tab, i)) == null)
// 空桶:直接放置ForwardingNode标记
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) { // 普通链表节点
// 将链表分成两部分:一部分留在原位置,一部分移到新位置
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) {
// 红黑树的迁移逻辑(类似链表,但更复杂)
// ... 树迁移代码
}
}
}
}
}
}
为什么要从后往前迁移?
- Iterator一致性:保证迭代器能正确遍历,不会遗漏或重复
- 内存局部性:后面的桶通常在CPU缓存中,访问更快
- 负载均衡:避免所有线程都从同一个位置开始工作
4.3.4 多线程扩容协作图
4.4 其他重要方法
4.4.1 computeIfAbsent - 原子计算
java
public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {
if (key == null || mappingFunction == null)
throw new NullPointerException();
int h = spread(key.hashCode());
V val = null;
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();
else if ((f = tabAt(tab, i = (n - 1) & h)) == null) {
// 使用ReservationNode占位
Node<K,V> r = new ReservationNode<K,V>();
synchronized (r) {
if (casTabAt(tab, i, null, r)) {
binCount = 1;
Node<K,V> node = null;
try {
if ((val = mappingFunction.apply(key)) != null)
node = new Node<K,V>(h, key, val, null);
} finally {
setTabAt(tab, i, node);
}
}
}
if (binCount != 0)
break;
}
// ... 其他情况处理
}
if (val != null)
addCount(1L, binCount);
return val;
}
ReservationNode的作用:
- 占位保护:防止并发线程插入相同key
- 原子性保证:确保计算函数只执行一次
- 异常安全:异常时能正确清理占位节点
4.4.2 size方法 - 计数器实现
java
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
分段计数器设计:
- baseCount:基础计数器
- CounterCell[]:分段计数器数组
- 减少竞争:多线程更新不同的CounterCell
- 最终一致性:读取时汇总所有计数器
4.5 方法性能对比分析
4.5.1 操作复杂度对比
| 方法 | 平均情况 | 最坏情况 | 并发特征 |
|---|---|---|---|
| get | O(1) | O(log n) | 完全无锁 |
| put | O(1) | O(log n) | CAS + 细粒度锁 |
| remove | O(1) | O(log n) | 细粒度锁 |
| size | O(分段数) | O(分段数) | 弱一致性 |
| computeIfAbsent | O(1) | O(log n) | 占位 + 锁 |
4.5.2 并发性能特征
总结
又是没有大厂约面日子,小编还在找实习的路上,这篇文章也是我的笔记汇总整理,让自己对ConcurrentHashMap相关知识温故知新。