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

前言

本文基于 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的问题总结:

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

Collections.synchronizedMap的问题

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

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

  1. 查询余额(操作1)
  2. 根据余额决定是否取钱(操作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通过精巧的设计,让不同位置的写入操作可以并行进行,只有在操作同一个位置时才需要等待。

核心目标

  1. 并发可读性

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

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

    • 内存占用不超过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
 */

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

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

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

  3. 避免歧义(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采用数组 + 链表/红黑树的混合数据结构:

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[红黑树结构]

核心设计理念

根据源码注释:

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

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

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

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

2.3 红黑树优化

树化的触发条件

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

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

java 复制代码
/**
 * 使用树而不是链表的桶计数阈值。
 * 当向一个至少有这么多节点的桶添加元素时,桶会被转换为树。
 */
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作为一个中介,提供了统一的并发控制机制。

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

  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-Tree O(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是扩容过程中的"路标",它告诉其他线程:"这个位置的数据已经搬到新地址了,请到那里去找"。

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

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

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

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

    • 多个线程可以同时参与扩容,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的使用场景:

  1. computeIfAbsent等方法的占位符

    java 复制代码
    // 当调用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方法来优化哈希分布。

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

spread方法的设计原理:

  1. 高位扩散 (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位的信息,提高了分布均匀性
  2. 消除符号位 (& HASH_BITS):

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

索引计算

如何从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字节 读写锁控制 红黑树管理

内存使用的权衡:

  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

红黑树 → 链表退化

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采用多层次的并发控制机制来实现高性能的线程安全:

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

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);
}

这个方法的工作原理:

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

实际使用场景:

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;
}

这个过程就像抢购商品:

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

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

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?

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

  1. 内存可见性问题

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

    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
}

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

  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 无锁读取的实现

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 读操作的降级策略

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 多线程协助扩容

java 复制代码
// 扩容标志计算
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 关键内存屏障

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方法的整个过程就像去餐厅排队点餐:

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

  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语义的关键作用
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; // 确保链表结构的可见性
}
读写并发的一致性
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方法中:

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();  // 重新统计元素数量
        }
    }
}

扩容过程就像搬家:

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

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

为什么要从后往前迁移?

  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 - 原子计算

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 并发性能特征

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相关知识温故知新。

相关推荐
极客Bob20 分钟前
Java 集合操作完整清单(Java 8+ Stream API)
java
雨中飘荡的记忆21 分钟前
Javassist实战指南
java
uhakadotcom21 分钟前
Loguru 全面教程:常用 API 串联与实战指南
后端·面试·github
Knight_AL29 分钟前
JWT 无状态认证深度解析:原理、优势
java·jwt
JuiceFS32 分钟前
JuiceFS sync 原理解析与性能优化,企业级数据同步利器
运维·后端
寒山李白1 小时前
IDEA中如何配置Java类注释(Java类注释信息配置,如作者、备注、时间等)
java
我要添砖java1 小时前
<JAVAEE> 多线程4-wait和notify方法
android·java·java-ee
Rysxt_1 小时前
Spring Boot SPI 教程
java·数据库·sql
海边夕阳20061 小时前
主流定时任务框架对比:Spring Task/Quartz/XXL-Job怎么选?
java·后端·spring·xxl-job·定时任务·job