一、根本矛盾:为什么非要并发?

先想一个问题:如果单线程能跑得又快又好,为什么非要多线程并发?

答案:CPU 和 IO 的速度差了三个数量级。

1.1 一组触目惊心的数字

假设 CPU 执行一条指令用时 1 纳秒(相当于人说一个字 1 秒),按同比例放大:

操作 真实耗时 等比放大(1ns=1秒)
L1 缓存读取 0.5ns 0.5 秒
L2 缓存读取 7ns 7 秒
内存读取 100ns 100 秒(~1.5 分钟)
SSD 随机读 150000ns 150000 秒 = 41 小时
机械磁盘随机读 1000000ns 1000000 秒 = 11.5 天
网络发包(同机房) 500000ns 500000 秒 = 5.7 天
网络发包(跨洋) 150000000ns 150000000 秒 = 4.7 年

CPU 去内存取一次数据,等了 1.5 分钟。去机械磁盘读一次,等了 11 天半。去跨洋网络拿个数据,等了 4.7 年。

如果单线程同步执行,CPU 绝大多数时间都在"等待 IO",算力浪费在空转上。

1.2 并发 = 等 IO 的时候,CPU 去干别的活

这就是并发的根本动机:用多线程把 CPU 的"等待时间"利用起来。

单线程模型:
  CPU 发请求 → 等磁盘(11.5 天)→ 处理数据 → 再发请求 → 再等 11.5 天 → 处理...
  浪费:等待的 11.5 天里,CPU 完全闲置

多线程模型:
  线程1 发请求 → 阻塞等待磁盘
  线程2 发请求 → 阻塞等待磁盘
  线程3 发请求 → 阻塞等待磁盘
  ...
  操作系统:线程1 在等?切给线程3 用 CPU → 线程3 也等?切给线程5 → ...
  效果:CPU 被跑满了,11.5 天里同时处理了 100 个请求

1.3 并发的两种驱动力

驱动力 场景 目标
IO 密集型 Web 服务(请求/响应)、数据库查询、文件读写、远程调用 利用 CPU 等待 IO 的时间,让它不闲着
CPU 密集型 视频编码、机器学习训练、数据压缩、密码学运算 把多核 CPU 的全部算力同时用上(16 核理论上加速 16 倍)

今天的互联网应用(Spring Boot 服务)99% 是 IO 密集型,所以并发的主要矛盾是"利用等待 IO 的时间"。这也是虚拟线程(Project Loom)要解决的问题。


二、并发的三大根本问题

并发之所以难,不是因为多线程本身复杂,而是多线程带来了三个单线程不存在的问题。

2.1 问题一:上下文切换(Context Switch)

操作系统让 CPU 在多个线程之间轮换执行,每次切换要做三件事:
1. 保存当前线程的上下文(寄存器值、PC 程序计数器、栈指针)
2. 加载下一个线程的上下文
3. CPU 缓存(L1/L2)可能失效,要重新从内存加载

一次上下文切换耗时约 5000~10000 纳秒。这个数字单独看很小,但如果每秒切换几万次,总开销就能吃掉 CPU 10% 以上。

线程不是越多越好。 IO 密集型场景线程数大约是 CPU 核数的 2~10 倍(取决于阻塞比例);CPU 密集型线程数 = CPU 核数。线程多了反而切换开销拉满,整体更慢。

2.2 问题二:内存可见性(Visibility)

单线程下:线程 A 写了变量 x = 1,接下来再读 x 一定是 1。这是常识。

多线程下:线程 A 写了 x = 1,线程 B 可能永远读不到这个 1。

原因在于现代 CPU 的多级缓存:

CPU 0                        CPU 1
┌───────────────┐          ┌───────────────┐
│ 寄存器        │          │ 寄存器        │
│ L1 缓存       │          │ L1 缓存       │
│ L2 缓存       │          │ L2 缓存       │
└───────┬───────┘          └───────┬───────┘
        │ 共享 L3 缓存               │
        └──────────────┬─────────────┘
                       │
                    主内存(RAM)

每个 CPU 核都有自己的 L1/L2 缓存,变量值先被缓存到各自核里。线程 A 改了值,只是写到了 CPU 0 的 L1 缓存,还没刷回主内存;线程 B 读时从 CPU 1 的 L1 缓存拿旧值。

CPU 缓存是性能的功臣,也是并发可见性问题的罪魁祸首。

2.3 问题三:指令重排(Instruction Reordering)

为了让程序跑得更快,CPU 和编译器会不改变单线程语义的前提下,打乱指令的执行顺序。

比如这段代码:

a = 1;
b = 2;
// CPU 可能先执行 b=2,再执行 a=1。单线程结果一样,谁先谁后无所谓。

但多线程下重排就会出问题:

// 初始:a=0, b=0, flag=false
// 线程 1
a = 1;
flag = true;     // 写标记

// 线程 2
if (flag) {
    System.out.println(a);   // 期待输出 1,但实际可能输出 0!
}

原因:CPU 把 a=1flag=true 重排了,先写了 flag=true,再写 a=1。线程 2 看到 flag 已经 true 但 a 还没写完是 0。

单线程看起来一样的结果,在多线程下可能天差地别。


三、Java 内存模型(JMM):给混乱的世界定规矩

上面三大问题(缓存导致的可见性、编译/CPU 导致的重排),本质上是硬件为了性能做的优化,和多线程需要的正确性产生了矛盾

Java 给出的解决方案是 Java Memory Model(JMM)——一套语言层面的规范,定义了"在多线程环境下,一个线程的写入什么时候能被另一个线程看到"。

3.1 JMM 的核心武器:内存屏障(Memory Barrier)

JMM 在编译器层面,给你代码里某些特定位置插屏障指令,告诉 CPU:"这里不能乱重排、这里缓存要及时同步。"

四类屏障(简化理解):

屏障 作用
LoadLoad 此屏障前的读,一定在此屏障后的读之前完成
StoreStore 此屏障前的写,一定在此屏障后的写之前刷回内存
LoadStore 此屏障前的读,一定在此屏障后的写之前完成
StoreLoad 此屏障前的写,一定在此屏障后的读之前刷回内存(最强屏障,开销最大)

写屏障(Store 类)保证:屏障之前的写全部从 CPU 缓存刷回主内存。
读屏障(Load 类)保证:屏障之后的读全部从主内存重新加载,不看旧缓存。

JMM = 用内存屏障的代价,换取跨线程的内存可见性和顺序确定性。

3.2 happen-before 规则:8 条"可见性保证"

JMM 不用开发者手动插内存屏障,而是通过 8 条 happen-before 规则,定义"操作 A 的结果,保证对操作 B 可见"。

规则解读:如果 A happen-before B,那么 A 做的所有写入,对 B 都是可见的。

规则 说明 示例
1. 程序顺序规则 单线程内,前面的操作 happen-before 后面的操作 同一线程里,先写 a=1 再读 a → 一定读到 1
2. volatile 写规则 对 volatile 变量的写,happen-before 后续对它的读 线程 A 写 volatile x=1 → 线程 B 读 x 一定是 1
3. 管程锁定规则 对锁的解锁(unlock),happen-before 后续对同一锁的加锁(lock) 线程 A unlock 前写的内容 → 线程 B 加锁后全部可见
4. 线程启动规则 Thread.start() happen-before 此线程的任何操作 主线程 start() 前设置的字段 → 新线程里能看到
5. 线程终止规则 线程的所有操作 happen-before Thread.join() 成功返回 子线程改的字段 → join() 返回后主线程能看到
6. 线程中断规则 对线程 interrupt() 调用,happen-before 被中断线程检测到中断事件 interrupt() 发出的信号 → 被中断线程能捕获
7. 对象终结规则 对象构造方法结束,happen-before finalize() 方法开始 构造完的字段 → finalize 里能看到
8. 传递性规则 A hb B,B hb C → A hb C 规则组合推导

这 8 条规则是 JMM 的"法律条文"——判断并发可见性问题的唯一依据,靠"感觉"和"应该"是靠不住的。

3.3 JMM 的两大实现:volatile 和 synchronized

JMM 的 happen-before 规则落地到 Java 语法层面,主要是两个关键字:volatilesynchronizedfinal 字段也有特殊保证)。


四、volatile:最轻量的可见性与顺序保证

很多人把 volatile 理解成"轻量锁",这是不准确的。volatile 解决的是可见性和有序性问题,它不保证原子性

4.1 volatile 的双重语义

volatile boolean flag = false;

volatile 修饰的变量,JMM 会给它加两层内存屏障:

语义 实现(简化)
可见性 写 volatile 之后加 StoreLoad 屏障:把 CPU 缓存里的值强制刷回主内存
读 volatile 之前加 LoadLoad 屏障:不从缓存读,直接从主内存重新加载
有序性 禁止 volatile 写和它之前的读写重排
禁止 volatile 读和它之后的读写重排
即:volatile 是"重排的分界线"——之前的代码不能跑到后面来,之后的不能跑到前面去

4.2 典型用法 1:状态标记(DCL 单例)

public class Singleton {
    // 必须加 volatile!否则可能拿到半初始化对象
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                 // 第一次检查:无锁
            synchronized (Singleton.class) {    // 加锁
                if (instance == null) {         // 第二次检查:确认
                    instance = new Singleton(); // 关键:这行有 3 步,可能重排
                }
            }
        }
        return instance;
    }
}

instance = new Singleton() 看似一行,实际分三步:
1. 分配内存空间
2. 在空间里调用构造方法初始化字段
3. 把 instance 引用指向这块内存

没有 volatile 时,CPU 可能把 2 和 3 重排:先 3(引用指向了),后 2(字段还没初始化完)。其他线程进来拿到的 instance 不是 null,但字段都是 0/null——"半初始化对象"。volatile 禁止这种重排,保证返回时对象一定构造完整。

4.3 典型用法 2:CopyOnWriteArrayList 的底层

// CopyOnWriteArrayList 简化实现
public class CopyOnWriteArrayList<E> {
    private transient volatile Object[] array;   // volatile 数组

    public boolean add(E e) {
        synchronized (this) {
            Object[] es = this.array;            // 读 volatile
            int len = es.length;
            Object[] newArray = Arrays.copyOf(es, len + 1);  // 拷贝一份新数组
            newArray[len] = e;
            this.array = newArray;               // 写 volatile:全部数组元素一起发布
            return true;
        }
    }

    public E get(int index) {
        return (E) this.array[index];            // 读 volatile,拿到完整新数组
    }
}

写时复制:整个 add 过程在锁内,但 get 完全无锁。array 的 volatile 写充当了"一次性发布"的角色——this.array = newArray 一旦完成,get 线程里读取 array 再按 index 取元素,能看到新数组全部元素(volatile 写的可见性 + 传递性规则)。

4.4 volatile 的边界:不保证原子性

volatile int count = 0;

// 1000 个线程各加 1
// count 最后结果大概率 < 1000

count++;  // 实际是 3 步:读 count → +1 → 写回去
          // 多线程下 A 读了旧值,B 也读了旧值,都 +1 写回去,覆盖了对方的写入

count++ 不是原子操作,volatile 保证不了它。原子计数要用 AtomicInteger(CAS + volatile 组合)或者加锁。

volatile 保证"一次写"可见,保证不了"读-改-写"三步是不可分割的。


五、synchronized:JVM 内置锁

synchronized 是 Java 最古老也是最可靠的锁机制。底层由 JVM 通过 monitorenter/monitorexit 字节码指令实现。

5.1 锁到底锁在了哪里?

public synchronized void method1() { ... }      // 锁 this 对象
public static synchronized void method2() { ... } // 锁 类对象(Xxx.class)
synchronized (obj) { ... }                       // 锁指定的 obj 对象

synchronized 永远锁的是"对象",不是代码块。 每个 Java 对象都有一个对象头(Mark Word),锁信息就存在对象头里。

5.2 Mark Word 与锁升级机制(JDK 6+)

JDK 6 以前,synchronized 是重量级锁(每次加锁直接挂起 OS 线程),性能很差。JDK 6 引入了锁升级机制:同一个锁,按竞争激烈程度,从无锁 → 偏向锁 → 轻量级锁 → 重量级锁,逐级升级。

竞争程度:低───────────────────────────────→高
无锁 → 偏向锁 → 轻量级锁 → 重量级锁

偏向锁(Biased Lock)

场景:锁始终只有同一个线程用(90% 的实际情况)。

做法:第一个线程获取锁时,用 CAS 把 Mark Word 里的线程 ID 改成自己的。之后这个线程再进入这个锁,只要线程 ID 对得上,不用任何 CAS,零开销。

JDK 15 起偏向锁默认关闭(JEP 374),因为复杂且收益下降。

轻量级锁(Lightweight Lock)

场景:多个线程交替使用锁,没有同一时刻争用。

做法:竞争线程在自己的栈里创建一个 Lock Record,用 CAS 把对象的 Mark Word 改成指向 Lock Record 的指针。CAS 成功则加锁成功,失败则升级。

重量级锁(Heavyweight / Inflated)

场景:多个线程同时竞争同一把锁。

做法:CAS 失败的线程不再空转,调用操作系统把线程挂起(park),进入等待队列。解锁时唤醒一个等待线程。这是传统意义上的"真锁"。

锁升级的代价是"不能降级"。 一旦升级到重量级锁,哪怕之后竞争消失,也只能等 GC 回收 Monitor 才能回退。

5.3 synchronized 的语义

synchronized 同时保证三件事:

保证 说明
原子性 synchronized 块内的代码,执行过程中不会被其他线程打断(同一把锁的 synchronized 块互斥)
可见性 unlock 前的所有写入,对下一个 lock 进入的线程全部可见(happen-before 管程规则)
有序性 synchronized 块内的代码可以互相重排(不影响单线程语义),但不会重排到 synchronized 块外面。块内的写在解锁时一起刷回内存。

synchronized 是"三重保障",volatile 是"双重保障(可见+有序,无原子)"。所以 synchronized 能做的事,volatile 不一定能做。


六、AQS:JUC 同步工具的地基

java.util.concurrent(简称 JUC)包里的 ReentrantLockSemaphoreCountDownLatchCyclicBarrierReentrantReadWriteLock,底层都继承了同一个抽象类:AbstractQueuedSynchronizer(AQS,抽象队列同步器)

6.1 AQS 的核心思想:队列 + CAS

AQS 用一个 volatile int state 表示同步状态,维护一个 CLH 队列(Craig-Landin-Hagersten)存等待线程。

state(volatile int)
   0 = 未被占用
   >0 = 被占用(数值大小代表重入次数)

等待队列:
  head ──→ Node(Thread A) ──→ Node(Thread B) ──→ Node(Thread C) ← tail
              持有锁              排队等锁            排队等锁

加锁(acquire)流程:
1. 用 CAS 试把 state 从 0 改成 1 → 成功,直接加锁(快速路径)
2. CAS 失败,把当前线程包装成 Node 加入等待队列尾部
3. 前驱节点是 head?再试一次 CAS → 成功就占位
4. 否则调用 LockSupport.park() 挂起线程,等前驱节点释放锁时 unpark 唤醒

解锁(release)流程:
1. state -1 → 如果减到 0,说明完全释放
2. 找后继节点,LockSupport.unpark() 唤醒它

AQS 是"模板方法模式"的经典应用:子类只需实现 5 个 protected 方法(tryAcquire/tryRelease 等),队列和 CAS 的脏活累活全在 AQS 父类里做好了。

6.2 AQS 的独占模式 vs 共享模式

模式 子类例子 特点
独占模式 ReentrantLock 同一时刻只能一个线程持有锁(state=0/1 或重入数)
共享模式 SemaphoreCountDownLatch 同一时刻可以多个线程通过(state=剩余许可数)

Semaphore:state 初始是 N 个许可,acquire() 扣 1 个,release() 加 1 个——多个线程可以同时通过,许可为 0 则等待。

CountDownLatch:state 初始是 N,每次 countDown() 减 1,await() 等 state 变成 0——"一组线程都做完了才放行"。


七、CAS 与原子类

7.1 CAS:Compare-And-Swap

CAS 是一种 CPU 原语(Unsafe 类里的 compareAndSwapInt/compareAndSwapLong/compareAndSwapObject),语义是:

boolean compareAndSwapInt(Object o, long offset, int expected, int newValue)
  // 读取对象 o 的 offset 偏移处的 int 值
  // 如果它 == expected,就改成 newValue,返回 true
  // 否则什么都不做,返回 false
  // 整个操作由 CPU 保证原子性,不可被中断

用 CAS 实现"原子加 1":

// AtomicInteger 的 getAndIncrement 简化实现
public final int getAndIncrement() {
    int v;
    do {
        v = getIntVolatile(obj, offset);   // 读当前值
    } while (!compareAndSwapInt(obj, offset, v, v + 1));  // 预期 v → 试改成 v+1
    return v;
}

如果并发期间别人已经把值改了(CAS 返回 false),就再读一遍最新值、再试——这叫自旋(Spin)

7.2 ABA 问题

CAS 的经典缺陷:值是 A → 被改成 B → 又被改回 A。CAS 检查的时候发现还是 A,以为没变过,其实中间已经被改过一轮了。

线程1:读 value=A,准备 CAS(A→C),还没执行 CAS 就被切走了
线程2:value A→B,然后 B→A
线程1:切回来执行 CAS,看到 value 还是 A,成功改成 C
       (但实际上 value 已经经历了 A→B→A 的变化)

解决方案:加版本号。AtomicStampedReference<V> 存的是 Pair<V, Integer>(值 + 版本戳),每次修改版本号 +1。CAS 不仅对比值,还对比版本号。

原子类 适用场景
AtomicInteger/AtomicLong/AtomicBoolean 基础类型原子计数/标记
AtomicReference<V> 对象引用的原子替换
AtomicStampedReference<V> 解决 ABA 问题(带版本号)
AtomicIntegerArray/AtomicLongArray 数组元素的原子更新
LongAdder/DoubleAdder JDK 8+,高并发下的计数(比 AtomicLong 更快,分段 CAS 后汇总)

LongAdder 是高并发场景(比如 QPS 统计计数器)的正确选择。线程竞争时,每个线程在自己的 Cell 里累加,读的时候才 sum 汇总——用精度换吞吐量。


八、死锁

8.1 死锁的四大必要条件(Coffman Conditions)

四个条件同时满足才会死锁,缺一不可。破坏任意一个就能解除死锁。

条件 含义 怎么破坏
互斥 资源同一时刻只能被一个线程持有 换成无锁算法(CAS),或读多写少用读写锁
持有并等待 持有的同时申请新资源 一次性申请全部资源;或者申请不到就释放已持有的(tryLock + 超时)
不可剥夺 资源只能主动释放,不能被强抢走 tryLock(timeout),超时自动放弃;或者 LockSupport 中断
循环等待 A 等 B、B 等 C、C 等 A,形成环 统一资源加锁顺序(所有线程先锁 A 再锁 B,不可能形成环)

8.2 经典死锁示例

// 两个线程,两个锁,加锁顺序相反
Object lockA = new Object();
Object lockB = new Object();

// 线程 1:先 A 后 B
synchronized (lockA) {
    Thread.sleep(100);
    synchronized (lockB) { ... }
}

// 线程 2:先 B 后 A
synchronized (lockB) {
    Thread.sleep(100);
    synchronized (lockA) { ... }
}

解决方法(按 Coffman 条件):
1. 统一顺序:两个线程都先锁 A,再锁 B → 破坏循环等待
2. tryLock 超时:拿不到 B 就释放 A → 破坏持有并等待
3. 用一把大锁代替两把 → 破坏循环等待

8.3 怎么发现死锁?

  • jstack / jconsole:自动检测,输出 Found one Java-level deadlock,列出死锁线程、各自持有和等待的锁
  • jcmd Thread.print:等价于 jstack
  • JFR/JMC:录制过程中的死锁事件

九、线程池(Executor Framework)

Java 线程是重量级资源(每个 OS 线程栈默认 1M),不能每来一个请求就 new 一个 Thread。线程池的存在理由:
1. 复用线程,减少创建/销毁开销
2. 控制并发数,防止资源耗尽
3. 统一管理(监控、队列、拒绝策略)

9.1 七大参数(ThreadPoolExecutor 核心)

new ThreadPoolExecutor(
    int corePoolSize,           // 核心线程数:长期保留,不会回收(除非 allowCoreThreadTimeOut)
    int maximumPoolSize,        // 最大线程数:核心+救急,救急线程用完会回收
    long keepAliveTime,         // 救急线程的空闲存活时间
    TimeUnit unit,              // 时间单位
    BlockingQueue<Runnable> workQueue,  // 任务队列(核心满了就进队列)
    ThreadFactory threadFactory,        // 线程工厂(给线程起名、设 daemon)
    RejectedExecutionHandler handler    // 拒绝策略(队列和最大池都满了怎么办)
);

9.2 提交流程

提交一个任务
    │
    ├─ 正在运行的线程数 < corePoolSize?
    │   └─ 是:new 一个核心线程执行任务
    │
    └─ 否:任务进 workQueue?
        ├─ 是:入队排队
        │
        └─ 否(队列已满):线程数 < maxPoolSize?
            ├─ 是:new 一个救急线程(非核心)执行
            │
            └─ 否:触发拒绝策略

9.3 四种工作队列

队列类型 特性 适用场景
LinkedBlockingQueue(无界) 默认容量 Integer.MAX_VALUE,队列几乎不可能满 单机任务有限,避免 OOM 情况下用。Executors.newFixedThreadPool 用的它(但无界队列意味着 maxPoolSize 参数等于没用)
ArrayBlockingQueue(有界) 定长数组,必须指定容量 生产环境推荐,可控、防 OOM
SynchronousQueue(不存储) 入队必须有另一个线程同时出队,否则阻塞 Executors.newCachedThreadPool 用的它。任务多则立刻建救急线程,适合短任务
DelayedWorkQueue(延迟) 按延迟时间排序,到期才出队 ScheduledThreadPoolExecutor 用,做定时任务

9.4 四种拒绝策略

策略 行为
AbortPolicy(默认) 抛 RejectedExecutionException
CallerRunsPolicy 谁提交谁执行(提交者线程自己跑),反压上游
DiscardPolicy 直接丢弃任务,不抛异常
DiscardOldestPolicy 丢弃队列头最老的任务,重试提交当前任务

9.5 Executors 工厂方法的陷阱

Executors.newFixedThreadPool / newCachedThreadPool / newSingleThreadExecutor 都是便捷方法,但有两个致命问题,生产环境不推荐直接用

工厂方法
newFixedThreadPool(n) 工作队列是无界 LinkedBlockingQueue,任务堆积会 OOM
newCachedThreadPool maxPoolSize=Integer.MAX_VALUE,请求太多会创建几十万线程 OOM
newSingleThreadExecutor 同 FixedThreadPool,无界队列 OOM 风险

生产环境一律手动 new ThreadPoolExecutor,指定有界队列容量 + 合理的最大线程数 + 自定义拒绝策略,把风险可控在手里。


十、线程安全:从根本上怎么做到?

多线程安全有三种根本策略,按推荐度排序:

10.1 线程封闭(Thread Confinement)

根本思想:数据不共享,每个线程只操作自己的数据。没有共享 → 没有竞争 → 没有任何问题。

做法:
- 栈封闭:只用局部变量,不用共享字段。局部变量在栈上,每个线程有自己的栈,互不干扰。
- ThreadLocal:每个线程持有一份副本。典型应用:存请求上下文、用户信息、事务 Connection。

线程封闭是并发编程的"银弹"——简单、安全、无锁、零开销。能封闭就不要共享。

10.2 不可变对象(Immutability)

根本思想:对象一旦构造完成就再也不能改。读任意次都不需要同步,写只能 new 新对象。

JDK 自带的不可变类:
- String
- 基本类型包装类(Integer/Long 等)
- BigInteger/BigDecimal
- Java 9+ 的 List.ofSet.ofMap.of(返回的都是不可变副本)

不可变对象是并发里的"黄金子弹"——免费的线程安全,任何场合都能安全共享。写比 String 还简单的场景都应该优先用。

10.3 正确的同步

当数据既不能封闭也不能不可变时,才靠锁和原子类:

方案 适用场景
synchronized 简单互斥、无需高级功能、代码少。JDK 6+ 锁升级后性能不错
ReentrantLock 需要 tryLock 超时、公平锁、多条件 Condition。比 synchronized 灵活
ReadWriteLock(ReentrantReadWriteLock / StampedLock) 读多写少。读不互斥,写互斥。读线程多时显著提升吞吐
原子类(AtomicInteger、LongAdder 等) 单个变量的原子更新、计数器
并发集合(ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue) 场景化的线程安全数据结构,比 Collections.synchronizedXxx 好得多
Semaphore 控制同时访问资源的线程数量(限流)
CountDownLatch 一个线程等 N 个线程做完再继续
CyclicBarrier N 个线程互相等,到齐了一起继续(可重复用)

十一、常见误解澄清

11.1 误解 1:"线程越多越快"

错误。线程数超过 CPU 核数的合理倍数后,上下文切换开销占比越来越大,反而整体变慢。线程数要和任务类型匹配
- CPU 密集型:线程数 = CPU 核数
- IO 密集型:线程数 = 核数 * 阻塞系数的倒数(大致 2核数 ~ 10核数)
- Java 21+ IO 密集型:用虚拟线程,不用纠结线程数(百万级都可以)

11.2 误解 2:"加了 synchronized 就一定线程安全"

不对。锁加的地方不对,一样有问题:

// 反例:锁错对象了,两把锁互斥不了
Integer i = 0;
// 线程 A
synchronized (i) { i++; }    // i++ 会返回新的 Integer 对象,锁错了
// 线程 B
synchronized (i) { i++; }    // 各自锁各自的 Integer,完全没互斥

还要注意:锁的粒度不能太大(整个方法加锁,吞吐极差),也不能太小(被重排拆成多步)。

11.3 误解 3:"volatile 就是轻量级 synchronized"

错误。volatile 只保证可见性+有序性,不保证原子性。volatile int count + count++ 照样线程不安全。能替代 synchronized 的场景非常有限(单一的 volatile 写 + 多个 volatile 读)。

11.4 误解 4:"ConcurrentHashMap 所有操作都线程安全,可以不用管"

对单个操作是,对复合操作不是

ConcurrentHashMap<String, Integer> map = ...;
Integer v = map.get("a");
if (v != null) {
    map.put("a", v + 1);  // get 和 put 之间,可能被别的线程改了!
}

get-if-modify 是复合操作,线程不安全。要用 compute/merge/putIfAbsent 等原子方法。

11.5 误解 5:"sleep 会释放锁,wait 不会"

搞反了
- Thread.sleep():只让出 CPU,不释放锁,继续持有着
- Object.wait():释放锁,进入等待队列,等 notify/notifyAll 唤醒后重新竞争锁


十二、学习路径建议

阶段 内容 掌握标准
第一阶段:基础 Thread/Runnable/线程池/同步关键字 能写出正确的多线程代码,熟悉 7 大线程池参数
第二阶段:内存模型 JMM、happen-before、volatile、synchronized 语义 不靠"感觉",能按规则判断可见性
第三阶段:底层原理 Mark Word 锁升级、AQS 源码思想、CAS + 原子类 看懂 JUC 工具的底层实现机制,不靠死记
第四阶段:问题诊断 jstack 看死锁、jstat 看 GC、jmap 看对象、jconsole/JFR 监控 线上 CPU 100% / 死锁 / 内存泄漏能独立定位
第五阶段:高级并发 虚拟线程、StampedLock、ForkJoinPool、Disruptor、零拷贝 能按场景选最合适的并发模型,做压测调优

十三、总结

并发编程的根本动机,是利用 CPU 和 IO 之间巨大的速度差——等 IO 的时候,让 CPU 去干别的活,而不是空转。

但并发也带来了三大新问题:上下文切换(切换成本)、缓存导致的可见性、CPU/编译器的指令重排。Java 用 JMM 统一解决:用内存屏障(store/load barrier)的代价,换来了 happen-before 8 条规则的确定性。

JMM 在语法层面的两大实现:
- volatile 给单个变量提供"可见性 + 有序性"(无原子性)
- synchronized 给一段代码提供"原子性 + 可见性 + 有序性",并通过 Mark Word 的锁升级机制(偏向→轻量→重量)平衡性能和安全

JUC 的工具(ReentrantLock/Semaphore/CountDownLatch)全部建立在 AQS 模板方法之上:volatile state 字段 + CAS + CLH 等待队列。原子类则基于 CAS 自旋解决单个变量的原子更新问题,LongAdder 在高并发计数场景性能更好,AtomicStampedReference 解决 ABA。

但解决并发最好的方式,是从源头上消灭共享
1. 线程封闭(ThreadLocal/局部变量)——不共享数据 → 零开销 → 最安全
2. 不可变对象(String/包装类/不可变集合)——共享但不可改 → 不用同步 → 仍安全
3. 只有前两者都做不到时,才用正确的同步机制(锁、原子类、并发集合)

一句话记忆:并发的出发点是利用等待时间,落脚点是控制共享。能不共享就不共享,不能不共享就用正确的同步。