ConcurrentHashMap 深入解析:从0到1彻底掌握(1.3万字)

232 阅读46分钟

前言

本文基于 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,一个线程 getget 读到正在扩容过程中的“半初始化”节点返回 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个位置 ,同一时间只能有一个人进入仓库,这种"一刀切"的锁定方式导致了极差的并发性能。

// Hashtable的synchronized方法
public synchronized V put(K key, V value) {
    // 整个方法都被锁定,其他所有线程都要等待
    // 即使操作不同的数据位置也要排队
    // ...
}

public synchronized V get(Object key) {
    // 连读操作都需要加锁,读和读之间也不能并发
    // 这在读多写少的场景下性能损失巨大
    // ...
}

Hashtable的问题总结:

  1. 读写互斥:读操作会阻塞写操作,写操作会阻塞读操作
  2. 读读互斥:多个读操作之间也不能并发执行
  3. 粗粒度锁:锁定整个表,而不是具体的数据区域

Collections.synchronizedMap的问题

什么是Collections.synchronizedMap? 这是Java提供的一个工具方法,它可以把任何Map包装成线程安全的版本。听起来不错,但实际上它有严重的局限性。

组合操作不安全 虽然每个单独的方法调用都是同步的,但多个方法调用组成的复合操作却不是原子的。这就像你在银行ATM机前:

  1. 查询余额(操作1)
  2. 根据余额决定是否取钱(操作2)

虽然每个操作单独看都是安全的,但在操作1和操作2之间,别人可能已经取走了钱,你看到的余额就不准确了。

Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());

// 虽然单个操作是安全的,但组合操作不安全
if (!syncMap.containsKey("key")) {     // 操作1:检查key是否存在
    syncMap.put("key", "value");       // 操作2:如果不存在就插入
    // 问题:在操作1和操作2之间,其他线程可能已经插入了这个key!
}

更严重的问题:迭代时的并发修改

// 这段代码在并发环境下会抛出ConcurrentModificationException异常
for (String key : syncMap.keySet()) {
    if (someCondition(key)) {
        syncMap.remove(key);  // 在迭代过程中修改Map,非常危险!
    }
}

1.3 ConcurrentHashMap的设计目标

ConcurrentHashMap是如何解决前面提到的问题的? Doug Lea(ConcurrentHashMap的作者)在设计这个类时,有着非常明确的目标。来看看源码中的设计说明:

/*
 * 这个哈希表的首要设计目标是维持并发的可读性
 * (主要是get()方法,同时也包括迭代器和相关方法)
 * 同时最小化更新操作的竞争。
 * 次要目标是保持空间消耗与java.util.HashMap相当或更好
 */

什么叫"并发可读性"? 这意味着多个线程可以同时进行读取操作,不会相互阻塞。想象一下图书馆:多个人可以同时阅读不同的书,甚至可以同时阅读同一本书的不同章节,这就是"并发可读"。

什么叫"最小化更新竞争"? 这意味着在写入数据时,尽量减少线程之间的等待时间。ConcurrentHashMap通过精巧的设计,让不同位置的写入操作可以并行进行,只有在操作同一个位置时才需要等待。

核心目标

  1. 并发可读性

    • get()操作完全无锁
    • 迭代器支持并发访问
  2. 最小化更新竞争

    • 细粒度锁定策略
    • CAS无锁操作
  3. 空间效率

    • 内存占用不超过HashMap
    • 延迟初始化

1.4 ConcurrentHashMap的核心特性

特性1:分离读写操作

为什么读操作可以完全无锁? ConcurrentHashMap使用了"volatile"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是可见的。

让我们看看源码中的说明:

/*
 * 检索操作(包括get方法)通常不会阻塞,
 * 所以可能与更新操作(包括put和remove)重叠执行。
 * 检索操作能反映出最近完成的更新操作的结果。
 * (更正式地说,对给定key的更新操作与
 * 任何读取该key更新值的非空检索操作之间
 * 存在happens-before关系)
 */

用生活中的例子来理解: 想象你在看一个电子告示板,有人刚刚更新了告示板内容(put操作),你现在去看告示板(get操作) ,happens-before保证你一定能看到最新的内容,不会看到旧的或者乱码。

技术含义解释

  • get操作能看到最近完成的put操作结果:当你读取一个key时,你看到的一定是最新写入的值
  • 通过happens-before关系保证内存可见性:这是JVM级别的保证,不需要程序员额外处理
  • 无需显式同步就能保证数据一致性:你不需要在get和put之间加锁,JVM帮你处理了同步问题

特性3:null值限制

为什么不允许null值? 这是一个经常让初学者困惑的设计决定。让我们看看源码中的说明:

/*
 * 像Hashtable一样,但不像HashMap,
 * 这个类不允许null用作key或value
 */

为什么要这样设计?主要有三个原因:

  1. null作为"缺失"状态的可靠指示器 在并发环境下,当get()方法返回null时,我们需要能明确知道这意味着什么:

    V value = map.get("key");
    if (value == null) {
        // 在ConcurrentHashMap中,这明确表示key不存在
        // 如果允许null值,我们就无法区分"key不存在"和"key存在但值为null"
    }
    
  2. 简化并发控制逻辑 如果允许null值,很多内部算法就会变得复杂。比如,在判断一个位置是否为空时,就需要额外的标记来区分"真的空"和"值为null"。

  3. 避免歧义(null是真的值还是未找到?)

    // 如果允许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元素)的吞吐量测。

//启动类
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);
        }
    }
}

运行结果:

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=100size=10,000size=100,000性能排名
HashMap17,153,82714,575,0988,631,041第1名
ConcurrentHashMap14,680,18612,615,73012,574,805第2名
SynchronizedMap8,359,2465,902,4274,819,444第3名
Hashtable5,366,3364,762,4984,182,685第4名

写入操作 (Put) 性能对比

实现方式size=100size=10,000size=100,000性能排名
ConcurrentHashMap8,838,45710,565,76810,925,571第1名
HashMap6,546,7955,536,9446,578,787第2名
SynchronizedMap6,446,6934,992,7784,087,226第3名
Hashtable3,777,5554,406,2903,861,202第4名

混合操作性能对比

实现方式size=100size=10,000size=100,000性能排名
ConcurrentHashMap7,989,5827,102,5926,232,547第1名
HashMap2,981,73710,427,5453,062,373第2名
SynchronizedMap5,305,0814,757,1113,539,548第3名
Hashtable3,102,8702,870,4572,627,790第4名

迭代操作性能对比

实现方式size=100size=10,000size=100,000性能排名
ConcurrentHashMap4,012,32650,2503,916第1名
Hashtable12,197,28318,8681,778第2名
SynchronizedMap1,070,85111,860824第3名
HashMap684,4687,912393第4名

第2章:深入理解内部结构

2.1 整体架构概览

ConcurrentHashMap的UML图:

01.png ConcurrentHashMap采用数组 + 链表/红黑树的混合数据结构:

graph TD
    A[ConcurrentHashMap] --> B[Node数组 table]
    B --> C[索引0: Node链表/TreeBin红黑树]
    B --> D[索引1: Node链表/TreeBin红黑树] 
    B --> E[索引2: Node链表/TreeBin红黑树]
    B --> F[... 其他索引]
    
    C --> G[Node1] --> H[Node2] --> I[Node3]
    D --> J[TreeBin根节点] --> K[红黑树结构]

核心设计理念

根据源码注释:

/*
 * 这个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:固化的并发模型
// 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:内存浪费严重
// JDK 1.7的内存结构
ConcurrentHashMap map = new ConcurrentHashMap();
// 即使只存储1个元素,也要创建:
// - 16个Segment对象
// - 16个ReentrantLock对象  
// - 16个HashEntry数组
// - 各种阈值、计数器等辅助对象

// 内存浪费可达 300% ~ 500%!
缺陷3:锁粒度不合理
// 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:动态并发度
// 并发度 = 数组长度,可以动态增长
Node<K,V>[] table = new Node[16];    // 初始16个桶
// 扩容后:32个桶 → 64个桶 → ... → 数万个桶
// 并发度随着数据增长而增长,完美适应负载变化
创新2:智能的数据结构
// 根据冲突程度自动选择最优结构
if (链表长度 < 8) {
    使用链表;  // 简单快速
} else if (数组容量 >= 64 && 链表长度 >= 8) {
    转换为红黑树;  // 高效查找
} else {
    扩容数组;  // 减少冲突
}

让我们看看具体的变化:

对比维度JDK 1.7JDK 1.8改进效果
核心结构Segment数组 + HashEntry链表Node数组 + 链表/红黑树减少内存开销,提高性能
并发度Segment数量固定(默认16)桶级别并发(数组长度)并发度大幅提升
锁机制ReentrantLock分段锁synchronized + CASJVM优化更好
hash冲突只有链表链表 → 红黑树最坏情况O(n)→O(log n)
扩容方式单个Segment独立扩容全表协作式扩容并发扩容,性能更好

JDK 1.7 数据结构详解

// 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的问题:

  1. 固定并发度:默认16个Segment,无法根据实际需求调整
  2. 内存浪费:每个Segment都需要维护独立的数据结构
  3. 锁粒度问题:一个Segment内的所有桶共享一把锁
  4. 哈希冲突严重:只能使用链表,性能下降明显

JDK 1.8 数据结构详解

// 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的优势:

  1. 动态并发度:桶级别的锁,并发度等于数组长度
  2. 内存效率:去掉了Segment层,减少内存开销
  3. 红黑树优化:链表长度≥8时转换为红黑树,查找效率O(log n)
  4. 协作式扩容:多线程协作扩容,提高扩容效率

2.3 红黑树优化

树化的触发条件

为什么需要红黑树? 在理想情况下,哈希表中的每个位置最多只有一个元素。但在现实中,由于哈希冲突,某些位置可能会形成很长的链表。当链表太长时,查找效率就会下降到O(n),这时候就需要红黑树来优化。

什么时候会把链表转换成红黑树? 让我们看看源码中定义的阈值:

/**
 * 使用树而不是链表的桶计数阈值。
 * 当向一个至少有这么多节点的桶添加元素时,桶会被转换为树。
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 在调整大小操作期间,将(分割的)桶去树化的桶计数阈值。
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 桶可以被树化的最小表容量。
 */
static final int MIN_TREEIFY_CAPACITY = 64;

树化需要同时满足两个条件:

  1. 链表长度达到8个节点

    • 为什么是8?这是通过统计学分析得出的最优值
    • 根据泊松分布,在正常情况下,链表长度达到8的概率非常小(约0.00000006)
    • 如果真的出现了长度为8的链表,说明可能存在大量哈希冲突,需要优化
  2. 数组容量至少64

    • 如果数组还很小(容量小于64),说明可能只是因为容量不够导致的冲突
    • 这种情况下,扩容比树化更有效
    • 只有当容量足够大,还出现长链表时,才说明真的需要树化

为什么退化阈值是6而不是7?

  • 这是为了避免频繁的树化和退化
  • 如果阈值都是8,那么在7-8-7-8之间震荡时,会频繁进行树化和退化操作
  • 设置2个节点的缓冲区间,可以避免这种不稳定的状态

树化条件总结

条件阈值说明
链表长度≥ 8触发树化考虑
数组容量≥ 64真正执行树化
退化条件≤ 6红黑树退化为链表

TreeBin的设计

什么是TreeBin? TreeBin是红黑树的"容器"或"管理器"。你可以把它想象成一个智能的树管家,它不仅管理着红黑树的结构,还负责协调多个线程对树的访问。

为什么需要TreeBin而不是直接使用TreeNode? 如果直接把TreeNode放在数组中,多线程访问会很复杂。TreeBin作为一个中介,提供了统一的并发控制机制。

// 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的巧妙设计:

  1. 保持链表兼容性:通过first指针,即使转成了树,依然可以用链表的方式遍历
  2. 读写锁机制:允许多个线程同时读,但写操作是独占的
  3. 等待队列:通过waiter字段管理等待的线程,避免无意义的自旋

红黑树的并发控制策略

graph LR
    A[读操作] --> B{检查锁状态}
    B -->|无写锁| C[直接遍历红黑树]
    B -->|有写锁| D[降级为链表遍历]
    
    E[写操作] --> F[获取写锁]
    F --> G[修改红黑树结构]
    G --> H[释放写锁]

为什么选择红黑树,而不是 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-TreeO(log n)复杂分裂合并磁盘数据库

JDK 官方 + 社区结论:

  • 链表长度 ≤ 6 时:链表更快(红黑树常量大)
  • 链表长度 = 8 时:两者差不多
  • 链表长度 ≥ 32 时:红黑树完胜
  • 红黑树在大量随机插入/删除时的吞吐量比 AVL 高 20%~50%

为什么红黑树胜出?

  1. 插入/删除最多只旋转 2~3 次,AVL 可能要旋转 log n 次
  2. TreeMap 的红黑树实现已经经过 20+ 年实战打磨,极其稳定
  3. TreeNode 只需要比普通 Node 多 5 个引用 + 1 个 boolean,空间开销可接受

2.4 特殊节点类型

ForwardingNode - 扩容标记节点

什么是ForwardingNode? ForwardingNode是扩容过程中的"路标",它告诉其他线程:"这个位置的数据已经搬到新地址了,请到那里去找"。

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的重要作用:

  1. 标记该桶已经迁移到新表

    • 当一个桶的数据迁移完成后,会在旧表的这个位置放一个ForwardingNode
    • 其hash值为-1(MOVED),这是一个特殊标记,表示"数据已迁移"
  2. 重定向查找请求到新表

    • 当其他线程尝试在旧表中查找数据时,发现是ForwardingNode,就会自动跳转到新表继续查找
    • 这保证了在扩容过程中,查找操作依然能正确进行
  3. 协调多线程扩容

    • 多个线程可以同时参与扩容,ForwardingNode帮助它们知道哪些桶已经处理完毕
    • 避免重复迁移同一个桶的数据

ReservationNode - 占位节点

什么是ReservationNode? ReservationNode是一个"占座"的节点,就像在电影院里用包包占座位一样,它防止其他线程在同一个位置插入数据。

static final class ReservationNode<K,V> extends Node<K,V> {
    ReservationNode() {
        super(RESERVED, null, null, null);  // hash = RESERVED = -3,占位标记
    }
}

ReservationNode的使用场景:

  1. computeIfAbsent等方法的占位符

    // 当调用computeIfAbsent时的内部流程:
    // 1. 检查key是否存在
    // 2. 如果不存在,先放一个ReservationNode占位
    // 3. 然后调用计算函数
    // 4. 最后用计算结果替换ReservationNode
    
  2. 防止并发插入相同key

    • 假设两个线程同时调用computeIfAbsent("key", func)
    • 第一个线程发现key不存在,放入ReservationNode占位
    • 第二个线程发现已有ReservationNode,知道有人在处理这个key,就会等待
    • 这避免了重复计算和数据覆盖

为什么需要这些特殊节点?

  • 它们的hash值都是负数(-1, -2, -3),与正常节点的正数hash值区分开
  • 这种设计让ConcurrentHashMap能在一个统一的框架内处理各种特殊情况
  • 提高了并发操作的安全性和效率

2.5 哈希计算与分布

hash值的计算

为什么需要特殊的哈希计算? Java原生的hashCode()可能分布不均匀,特别是当数组长度较小时,很容易产生哈希冲突。ConcurrentHashMap使用spread方法来优化哈希分布。

static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS;  // HASH_BITS = 0x7fffffff
}

spread方法的设计原理:

  1. 高位扩散 (h ^ (h >>> 16)):

    // 例子:假设原始hash值为 0x12345678
    int h = 0x12345678;           // 原始hash:     00010010001101000101011001111000
    int high16 = h >>> 16;        // 右移16位:     00000000000000000001001000110100
    int result = h ^ high16;      // 异或操作:     00010010001101000100010001001100
    
    // 这样低16位就包含了原始hash高16位的信息,提高了分布均匀性
    
  2. 消除符号位 (& HASH_BITS):

    // HASH_BITS = 0x7fffffff = 01111111111111111111111111111111
    // 通过与操作确保结果永远为正数,避免负数hash值
    
  3. 减少冲突:通过混合高低位信息,即使在小数组中也能获得较好的分布

索引计算

如何从hash值计算数组索引?

// 数组索引计算公式(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 哈希计算对比

// 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 内存布局分析

对象大小估算

// 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.Entry24字节无并发安全单线程环境
ConcurrentHashMap.Node32字节volatile字段并发环境
TreeNode56字节红黑树指针 + 链表维护长链表优化
TreeBin40字节读写锁控制红黑树管理

内存使用的权衡:

  1. Node vs Entry:多8字节换取线程安全
  2. TreeNode vs Node:多24字节换取O(log n)查找
  3. 合理的代价:在高并发和性能面前,内存开销是值得的

2.7 数据结构演进过程

链表 → 红黑树转换

sequenceDiagram
    participant C as Client
    participant M as ConcurrentHashMap
    participant B as Bucket[i]
    
    C->>M: put(key, value)
    M->>B: 检查链表长度
    
    alt 链表长度 < 8
        B->>B: 链表插入
    else 链表长度 ≥ 8 且 数组容量 ≥ 64
        B->>B: 转换为红黑树
        Note over B: TreeBin + TreeNode
    else 链表长度 ≥ 8 但 数组容量 < 64
        M->>M: 扩容数组
    end

红黑树 → 链表退化

// 扩容时的退化逻辑(简化)
if (TreeBin.count <= UNTREEIFY_THRESHOLD) {
    // 转换回链表结构
    return untreeify(TreeBin.first);
}

2.8 与HashMap的结构对比

特性HashMapConcurrentHashMap
节点类型EntryNode (volatile字段)
线程安全
红黑树支持支持 + 并发控制
空值支持key/value可为null都不可为null
扩容方式单线程重建多线程协助
内存开销较小略大(并发控制开销)

第3章:并发控制机制 - 分段锁与CAS

3.1 并发控制策略概览

ConcurrentHashMap采用多层次的并发控制机制来实现高性能的线程安全:

graph TD
    A[ConcurrentHashMap并发控制] --> B[无锁读取]
    A --> C[CAS操作]
    A --> D[分段锁定]
    A --> E[volatile内存模型]
    
    B --> B1[get操作无锁]
    B --> B2[迭代器无锁]
    
    C --> C1[数组初始化]
    C --> C2[链表头节点插入]
    C --> C3[计数器更新]
    
    D --> D1[synchronized锁桶头节点]
    D --> D2[TreeBin读写锁]
    
    E --> E1[Node.val volatile]
    E --> E2[Node.next volatile]
    E --> E3[数组volatile访问]

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操作。

// 使用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);
}

这个方法的工作原理:

  1. 计算数组中第i个元素的内存地址:((long)i << ASHIFT) + ABASE
  2. 检查该位置的当前值是否等于期望值c
  3. 如果相等,就将该位置的值更新为v;如果不等,操作失败
  4. 整个过程是原子的,不会被其他线程干扰

实际使用场景:

// 在空桶中插入第一个节点
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中的一个神奇的字段,它像一个"状态指示器",通过不同的数值来表示哈希表当前处于什么状态。

// sizeCtl的不同状态含义
private transient volatile int sizeCtl;

/**
 * sizeCtl的语义(这是一个巧妙的编码设计):
 * - 负数:表示正在初始化或扩容
 *   - -1:表示正在初始化
 *   - -(1 + 扩容线程数):表示正在扩容,后面的数字表示有多少个线程在帮忙
 * - 0:使用默认容量
 * - 正数:下一次扩容的阈值(通常是 容量 * 0.75)
 */

为什么要用一个字段表示这么多状态?

  • 节省内存空间,避免使用多个字段
  • 可以用单个CAS操作原子性地改变状态
  • 状态转换清晰,易于理解和维护

初始化时的CAS竞争过程:

// 简化的初始化逻辑
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;
}

这个过程就像抢购商品:

  1. 多个线程同时发现需要初始化表
  2. 它们同时尝试将sizeCtl从正数改为-1
  3. 只有一个线程成功,其他线程发现失败后就等待
  4. 成功的线程完成初始化,将sizeCtl改为扩容阈值
  5. 等待的线程发现表已经初始化好了,直接使用

3.2.3 CAS的优势与局限

优势局限性
无锁,性能高ABA问题(可用版本号解决)
无线程阻塞自旋可能浪费CPU
无死锁风险只能保证单个变量原子性
响应性好竞争激烈时效率下降

3.3 Synchronized分段锁机制

3.3.1 锁的粒度控制

什么是分段锁? ConcurrentHashMap不使用全局锁,而是采用"分段锁"的策略,对每个桶(bin)的头节点加锁。这就像一个大型停车场被分成很多小区域,每个区域有自己的管理员,互不干扰。

// 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 锁定范围分析

graph TB
    subgraph "ConcurrentHashMap数组"
        A[桶0] --> A1[Node1] --> A2[Node2]
        B[桶1] --> B1[Node3] 
        C[桶2] --> C1[Node4] --> C2[Node5] --> C3[Node6]
        D[桶3] --> D1["🔒 synchronized(头节点)"]
    end
    
    E[线程1] -.-> D1
    F[线程2] -.-> A1
    G[线程3] -.-> B1
    
    style D1 fill:#ff9999
    style E fill:#99ccff
    style F fill:#99ff99  
    style G fill:#ffcc99

关键特性

  • 细粒度锁:只锁定单个桶,不影响其他桶的并发访问
  • 锁对象选择:使用头节点作为锁对象,节省内存
  • 双重检查:加锁后再次确认头节点未变化

3.3.3 为什么选择synchronized而不是ReentrantLock?

对比项synchronizedReentrantLock
内存开销无额外对象每个锁需要对象
性能JVM优化好略逊于synchronized
可中断性不可中断可中断
公平性非公平可配置公平/非公平
适用场景细粒度短暂锁定复杂锁定逻辑

Doug Lea选择synchronized的原因:内存效率,避免为每个桶创建锁对象。

3.4设计实现

3.4.1 volatile字段的作用

为什么Node中的字段要用volatile? 在多线程环境下,每个线程都有自己的工作内存(CPU缓存),如果不用volatile,一个线程的修改可能对其他线程不可见。volatile关键字确保了内存可见性和有序性。

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在这里解决什么问题?

  1. 内存可见性问题

    // 没有volatile的危险情况:
    // 线程1:node.val = "新值"     -> 可能只更新了线程1的缓存
    // 线程2:String v = node.val  -> 可能还读到旧值
    
    // 有了volatile的安全情况:
    // 线程1:node.val = "新值"     -> 立即刷新到主内存,并通知其他线程
    // 线程2:String v = node.val  -> 强制从主内存读取,必定读到新值
    
  2. 指令重排序问题

    // 插入新节点的操作序列
    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字段的不可变性

final int hash;  // hash值计算后不再改变,避免并发修改
final K key;     // key不可变,保证哈希桶位置稳定

find方法是做什么的? 这个方法负责在链表中查找指定的key。它使用了一种优化的查找策略,通过三层比较来快速定位目标。

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
}

为什么要三层比较?这是一种性能优化策略:

  1. 第一层:比较hash值 (e.hash == h)

    • 这是最快的比较,因为hash是int类型
    • 如果hash都不相等,那key肯定不相等,可以快速跳过
    • 这能过滤掉大部分不匹配的节点
  2. 第二层:比较引用 (ek = e.key) == k)

    • 如果两个变量指向同一个对象,它们肯定相等
    • 引用比较比调用equals方法快得多
    • 在很多情况下(比如字符串常量池中的字符串),这能直接确定相等性
  3. 第三层:调用equals方法 (k.equals(ek))

    • 只有前两层都不能确定的情况下,才调用这个最慢但最准确的方法
    • equals方法会比较对象的实际内容

这种设计的好处:

  • 大部分情况下只需要进行快速的hash比较
  • 避免了不必要的equals方法调用,提高了查找性能
  • 保证了查找结果的正确性

3.4.3 Happens-Before关系

根据JMM (Java Memory Model),volatile写操作happens-before后续的volatile读操作:

sequenceDiagram
    participant T1 as 线程1 (写)
    participant M as 主内存
    participant T2 as 线程2 (读)
    
    T1->>M: volatile写 node.val = newValue
    Note over M: happens-before关系建立
    M->>T2: volatile读 value = node.val
    Note over T2: 必定能看到最新值

3.4.4 无锁读取的实现

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的锁状态

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 读写锁的实现逻辑

// 获取写锁(简化版)
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 读操作的降级策略

flowchart TD
    A[TreeBin读操作] --> B{检查lockState}
    B -->|无写锁| C[直接遍历红黑树]
    B -->|有写锁| D[降级为链表遍历]
    
    C --> E[Olog n 性能]
    D --> F[On 性能但无阻塞]
    
    style C fill:#99ff99
    style D fill:#ffcc99
    style E fill:#e6f3ff
    style F fill:#ffe6e6

设计优势

  • 读操作永不阻塞:最多降级为链表遍历
  • 写操作独占:确保红黑树结构的一致性
  • 性能平衡:大多数情况下享受O(log n)性能

3.6 扩容过程的并发控制

3.6.1 多线程协助扩容

// 扩容标志计算
private static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

// 扩容状态编码在sizeCtl中
// 高16位:扩容标识戳
// 低16位:(扩容线程数 + 1)

3.6.2 扩容过程的同步

sequenceDiagram
    participant T1 as 发起线程
    participant SC as sizeCtl
    participant T2 as 协助线程1
    participant T3 as 协助线程2
    
    T1->>SC: CAS设置扩容标志
    T1->>T1: 开始transfer
    
    T2->>SC: 检测到扩容标志
    T2->>SC: CAS增加线程计数
    T2->>T2: 协助transfer
    
    T3->>SC: 检测到扩容标志  
    T3->>SC: CAS增加线程计数
    T3->>T3: 协助transfer
    
    Note over T1,T3: 并行处理不同的桶范围
    
    T2->>SC: 完成后CAS减少计数
    T1->>SC: 最后完成者清理扩容标志

3.7 内存屏障与可见性保证

3.7.1 关键内存屏障

// 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 方法入口与参数检查

public V put(K key, V value) {
    return putVal(key, value, false);
}
// 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方法的整个过程就像去餐厅排队点餐:

  1. 检查餐厅是否开门(表是否初始化)- 没开门就等开门
  2. 找个空桌子直接坐(空桶CAS插入)- 最快的情况
  3. 发现在装修(扩容中)- 帮忙搬桌子(协助扩容)
  4. 桌子有人但还有位置(桶有数据)- 排队等待(加锁插入)
  5. 登记用餐人数(更新计数)- 人太多可能要扩建餐厅(触发扩容)

4.1.2 Put操作流程图

flowchart TD
    A[put方法调用] --> B[参数null检查]
    B --> C[计算hash值]
    C --> D{表是否初始化?}
    
    D -->|否| E[initTable 初始化]
    E --> F[重新获取表引用]
    F --> G
    
    D -->|是| G{目标桶是否为空?}
    
    G -->|是| H[CAS插入新节点]
    H --> I{CAS成功?}
    I -->|是| J[addCount 更新计数]
    I -->|否| K[重试]
    
    G -->|否| L{是否为ForwardingNode?}
    L -->|是| M[helpTransfer 协助扩容]
    M --> K
    
    L -->|否| N[synchronized锁定头节点]
    N --> O{是链表还是树?}
    
    O -->|链表| P[链表插入/更新逻辑]
    O -->|树| Q[树插入/更新逻辑]
    
    P --> R{链表长度>=8?}
    R -->|是| S[treeifyBin 转换为树]
    R -->|否| T[结束锁定]
    
    Q --> T
    S --> T
    T --> J
    J --> U[返回结果]
    
    K --> D
    
    style H fill:#99ff99
    style N fill:#ff9999
    style J fill:#99ccff

4.1.3 关键步骤详细分析

步骤1:表初始化 (initTable)
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插入
// 桶为空时的无锁插入
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:锁内链表操作
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的性能明星,它完全不需要加锁,却能安全地在并发环境中工作。让我们看看它是怎么做到的:

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方法的三条查找路径:

  1. 快路径:目标就在桶的第一个位置 - 最快,大约30%的情况
  2. 特殊路径:遇到ForwardingNode或TreeBin - 需要特殊处理,但仍然很快
  3. 慢路径:需要遍历链表 - 相对较慢,但在良好的hash分布下很少发生

为什么完全不需要加锁?

  • 所有的读取操作都是基于volatile字段
  • volatile保证了内存可见性,能读到最新的值
  • 即使在读取过程中有写操作,最多读到中间状态,但不会读到错误数据
  • 这就是无锁编程的魅力!

4.2.2 get操作的性能优化

get方法的优化策略分析:

graph LR
    A[get调用] --> B[计算hash值]
    B --> C[volatile读取数组]
    C --> D{桶是否为空?}
    
    D -->|是| E[返回null - 最快路径]
    D -->|否| F[检查头节点]
    
    F --> G{hash值匹配?}
    G -->|是| H{key匹配?}
    H -->|是| I[返回value - 快路径]
    H -->|否| J[检查特殊节点]
    
    G -->|否| J
    J --> K{hash<0?}
    K -->|是| L[调用特殊节点的find方法]
    K -->|否| M[遍历链表 - 慢路径]
    
    L --> N[返回结果]
    M --> N
    
    style C fill:#99ff99
    style I fill:#99ff99
    style E fill:#ffcc99

性能特征详细分析:

场景时间复杂度说明
桶为空O(1)负载因子0.75下,大部分桶为空
头节点匹配O(1)最理想情况,一次就找到
链表查找O(k)k为链表长度,平均k<2
红黑树查找O(log n)只在链表很长时才会出现
扩容期间跳转O(k)需要跳转到新表继续查找

为什么get操作如此高效?

  1. 最常见的情况最快:大部分情况下是O(1)操作
  2. 无锁设计:完全不需要等待锁,永不阻塞
  3. CPU缓存友好:连续的内存访问模式
  4. 分支预测友好:最常见的分支被CPU预测器优化

性能特征分析

场景时间复杂度说明
桶为空O(1)直接返回null
头节点匹配O(1)最快路径,约30%的情况
链表查找O(k)k为链表长度,平均k<2
红黑树查找O(log n)n为树节点数量
扩容期间O(k)可能需要跳转到新表

4.2.3 无锁读取的正确性保证

volatile语义的关键作用
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; // 确保链表结构的可见性
}
读写并发的一致性
sequenceDiagram
    participant R as Reader线程
    participant M as 主内存
    participant W as Writer线程
    
    Note over W: put操作开始
    W->>M: 1. 创建新Node
    W->>M: 2. 设置node.val (volatile写)
    W->>M: 3. 设置node.next (volatile写)
    W->>M: 4. CAS更新数组引用
    
    Note over R: get操作开始
    R->>M: volatile读取数组
    R->>M: volatile读取node.next
    R->>M: volatile读取node.val
    
    Note over R,W: happens-before保证reader看到完整的writer操作

4.3 resize方法 - 多线程协作扩容

4.3.1 扩容触发条件

什么时候需要扩容? 当ConcurrentHashMap中的元素数量超过阈值时,就需要扩容以保持良好的性能。扩容的核心逻辑在addCount方法中:

// 扩容阈值检查(添加中文注释)
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();  // 重新统计元素数量
        }
    }
}

扩容过程就像搬家:

  1. 发现房子太挤了(元素数量超过阈值)
  2. 第一个人开始找新房(发起扩容线程)
  3. 其他人看到了也来帮忙(协助扩容)
  4. 大家分工合作搬东西(并行迁移数据)
  5. 搬完后更新地址(切换到新表)

4.3.2 扩容状态编码

// 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方法是扩容的核心,它负责将数据从旧表迁移到新表:

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) {
                        // 红黑树的迁移逻辑(类似链表,但更复杂)
                        // ... 树迁移代码
                    }
                }
            }
        }
    }
}

为什么要从后往前迁移?

  1. Iterator一致性:保证迭代器能正确遍历,不会遗漏或重复
  2. 内存局部性:后面的桶通常在CPU缓存中,访问更快
  3. 负载均衡:避免所有线程都从同一个位置开始工作

4.3.4 多线程扩容协作图

gantt
    title 多线程扩容时间线
    dateFormat X
    axisFormat %s
    
    section 线程1
    发起扩容        :t1, 0, 2
    处理桶0-7       :t1-1, 2, 4
    处理桶8-15      :t1-2, 6, 2
    
    section 线程2  
    加入扩容        :t2, 1, 1
    处理桶16-23     :t2-1, 2, 4
    处理桶24-31     :t2-2, 6, 2
    
    section 线程3
    加入扩容        :t3, 3, 1  
    处理桶32-39     :t3-1, 4, 4
    
    section 清理
    最终清理        :cleanup, 8, 1

4.4 其他重要方法

4.4.1 computeIfAbsent - 原子计算

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方法 - 计数器实现

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 操作复杂度对比

方法平均情况最坏情况并发特征
getO(1)O(log n)完全无锁
putO(1)O(log n)CAS + 细粒度锁
removeO(1)O(log n)细粒度锁
sizeO(分段数)O(分段数)弱一致性
computeIfAbsentO(1)O(log n)占位 + 锁

4.5.2 并发性能特征

graph TD
    A[ConcurrentHashMap操作] --> B[读操作]
    A --> C[写操作]
    
    B --> B1[get: 无锁无竞争]
    B --> B2[containsKey: 无锁无竞争]  
    B --> B3[size: 弱一致性读取]
    
    C --> C1[put: CAS优先]
    C --> C2[remove: 细粒度锁]
    C --> C3[computeIfAbsent: 占位保护]
    
    style B1 fill:#99ff99
    style B2 fill:#99ff99  
    style C1 fill:#ffcc99
    style C2 fill:#ff9999

总结

又是没有大厂约面日子,小编还在找实习的路上,这篇文章也是我的笔记汇总整理,让自己对ConcurrentHashMap相关知识温故知新。