深度理解手册 · 以问题为主线

Java 多线程
并发编程

所有的关键字、锁、容器,本质上都在解决同样的三个问题——原子性、可见性、有序性。抓住这条主线,零散的工具就会连成一张网。

21 章 · 一条主线 从硬件根源讲到工程实践 更新于 2026·05

原子性

Atomicity

一组操作要么全做完不被打断,要么不做。count++ 偷偷拆成三步,就坏在这里。

解药 · synchronized / Lock / 原子类

可见性

Visibility

一个线程改了变量,别的线程能立刻看见。CPU 缓存让"看不见"成为常态。

解药 · volatile / synchronized / final

有序性

Ordering

代码的执行顺序符合逻辑顺序。编译器和 CPU 的指令重排会打乱它。

解药 · volatile / synchronized
01

为什么需要并发

从一个比喻说起
🍳
想象你开了家餐厅,只有一个厨师(单线程)。客人 A 点了锅要炖 40 分钟的汤,那这 40 分钟里厨师只能干等,B、C、D 全饿着。可厨师明明能在炖汤的间隙去炒别的菜——这就是并发的直觉

CPU 也一样。程序运行时经常要等磁盘、等网络、等数据库返回,这些"等待"里 CPU 是空闲的。并发的本质,就是让 CPU 在等待 I/O 的间隙去干别的活,把昂贵的计算资源榨干。

并发主要解决两类问题:

  • 提升吞吐量(throughput):等 I/O 时切去处理别的任务,单位时间做更多事——对 Web 服务器、数据库这类 I/O 密集型场景至关重要。
  • 缩短响应时间(latency):把大任务拆成多块丢给多个核心同时算(并行排序、MapReduce),让用户更快拿到结果。
天下没有免费午餐并发带来三类全新麻烦:线程安全问题(数据被多个线程同时改坏)、上下文切换成本(CPU 切换本身有开销)、以及死锁 / 活锁等协调问题。本手册的大部分篇幅,就是在讲怎么对付这些麻烦。
02

进程、线程与 CPU 调度

地基一

2.1进程 vs 线程

维度进程 Process线程 Thread
定义操作系统资源分配的基本单位CPU 调度执行的基本单位
内存拥有独立的内存地址空间共享所属进程的内存(堆、方法区)
独占资源整套地址空间、文件句柄等只独占程序计数器、虚拟机栈、本地方法栈
通信较重(管道、信号、共享内存、Socket)轻(直接读写共享变量即可)
崩溃影响一个崩溃通常不影响其他进程一个崩溃可能拖垮整个进程
🏠
进程是一套房子,线程是住在里面的人。房子有独立的水电门牌(内存空间),住客共用客厅厨房(堆内存、共享变量),但各有私人物品(栈、局部变量、程序计数器)。正因共享内存,线程通信方便;也正因共享,才会出现"两个人同时改一个账本"的线程安全问题。
线程为什么"轻"创建进程要分配独立地址空间(开销大),创建线程只需在已有进程里分一个栈。线程切换也比进程切换便宜——不用切换地址空间(不刷新页表 / TLB)。

2.2上下文切换:并发的隐藏成本

CPU 同一时刻其实只能跑一个线程。所谓"同时运行多个线程",是 CPU 给每个线程分配一小段时间片(通常几十毫秒),到点就保存当前线程状态(寄存器、程序计数器)、加载下一个线程状态。这个"保存—加载"过程叫上下文切换(Context Switch)

切换本身不产生任何业务价值,纯属开销。所以并不是线程越多越好——开到几千个,CPU 可能大部分时间都耗在切换上。这也是后面线程池要严格控制线程数量的根本原因。

2.3并发 vs 并行

CONCURRENCY
并发

宏观上"同时"处理多任务,微观上 CPU 快速轮换交替执行。单核也能并发,强调"结构上能处理多任务"。

看起来同时
PARALLELISM
并行

微观上真有多个任务在多个核心上同一时刻执行。必须多核才能并行,强调"物理上真同时跑"。

真的同时

一个并发程序在单核上就是交替执行,放到多核上就可能变成并行执行。

03

Java 内存模型 JMM

地基二 · 一切并发问题的根源
如果只读一章,就读这章这是整份文档最重要的部分。80% 的并发 bug 都能用 JMM 来解释。

3.1为什么会有 JMM

现代 CPU 比内存快得多(差好几个数量级)。若 CPU 每次读写变量都直接访问主内存,大部分时间都在干等。为弥合这个速度差,硬件在 CPU 和主内存之间加了多级缓存(L1/L2/L3 Cache)

于是问题来了:每个核心都有自己的缓存。线程 A 在核心 1 上把 x 改成 5,这个 5 可能还待在核心 1 的缓存里没刷回主内存;线程 B 在核心 2 上读 x,读到的还是缓存里的旧值。这就是可见性问题的硬件根源。

Java 为了"一次编写、到处运行",不能让程序员操心每种 CPU 的缓存细节,于是抽象出一套规则——Java 内存模型(JMM),规定线程之间如何通过内存交互、一个线程的修改在什么条件下对另一个线程可见。

3.2JMM 的抽象结构

线程 A 工作内存(副本) x = 0 线程 B 工作内存(副本) x = 0 主内存 Main Memory x = 0 read / write read / write
线程只能操作自己工作内存里的副本,改完再同步回主内存——同步不及时,别人就看不到

线程 A 改了 x,若没有正确的同步机制,修改可能只停在 A 的工作内存里,B 永远看不到。volatilesynchronizedfinal 的作用,本质上就是在告诉 JMM:"这里需要强制同步,别让我读到过期数据"。

3.3happens-before:JMM 的"承诺"

JMM 太底层,直接面对它太痛苦。于是它对外提供了一套更易理解的规则——happens-before(先行发生)。它的含义不是"时间上先发生",而是:如果操作 A happens-before 操作 B,那么 A 的结果对 B 一定可见,且在 B 看来 A 排在前面。

几条最重要的规则:

  1. 程序顺序规则:同一线程内,写在前面的操作 happens-before 后面的操作。
  2. 锁规则:对一个锁的解锁 happens-before 后续对它的加锁。
  3. volatile 规则:对 volatile 变量的写 happens-before 后续对它的读。
  4. 传递性:A→B、B→C,则 A→C。
  5. 线程启动规则Thread.start() happens-before 该线程内的任何操作。
  6. 线程终止规则:线程内所有操作 happens-before 其他线程检测到它已终止(join() 返回)。
happens-before 的最大价值它让你不必去想 CPU 缓存、指令重排这些底层细节——只要确认两个操作之间存在 happens-before 关系,就能放心认为前者的结果对后者可见。
04

核心框架:并发的三大问题

把它刻进脑子,后面所有工具都是解药

遇到任何并发 bug,先问自己:是哪个问题坏了?

4.1原子性 Atomicity

定义:一个或多个操作,要么全部执行完且中间不被打断,要么就不执行。最经典的反例是 count++——看起来一行,底层却是三步:

STEP 1
read

读取 count 的值到工作内存

STEP 2
add

把值加 1

STEP 3
write

把结果写回主内存

两个线程都读到 0,各自 +1 写回 1 → 两次自增只涨了 1,原子性被破坏
解药synchronizedLock、原子类(AtomicInteger 等)。

4.2可见性 Visibility

定义:一个线程修改了共享变量,其他线程能立刻看到。根源就是第 3 章讲的 CPU 缓存。一个典型的"死循环 bug":

VisibilityBug.java
boolean running = true;   // 注意:没有 volatile

// 线程 A
while (running) { /* 干活 */ }

// 线程 B
running = false;          // 想让 A 停下来

线程 B 把 running 改成了 false,但 A 可能一直在自己缓存里读到 true,永远停不下来

解药volatile(最轻量)、synchronizedfinal

4.3有序性 Ordering

定义:程序执行顺序要符合代码逻辑顺序。但编译器和 CPU 为优化性能,会在不影响单线程结果的前提下对指令重排序。单线程没问题,多线程下重排可能出大事。最著名的例子是双重检查锁(DCL)单例:

Reorder.java
instance = new Singleton();
// 这一行实际拆成三步:
//  1. 分配内存
//  2. 初始化对象
//  3. 把 instance 指向内存地址
// 若 2、3 被重排成 1 → 3 → 2,
// 另一个线程可能拿到"还没初始化完"的半成品对象!
解药volatile(禁止重排序)、synchronized

4.4三大问题与解药对照表

问题通俗解释硬件 / 编译器根源主要解药
原子性操作做一半被打断一条语句对应多条 CPU 指令synchronized、Lock、原子类
可见性改了别人看不见CPU 多级缓存volatile、synchronized、final
有序性执行顺序被打乱指令重排序优化volatile、synchronized
选型基础volatile 能解决可见性和有序性,但不能解决原子性synchronized 三个都能解决,但更重。这是后面所有选型的出发点。
05

线程的生命周期与基本操作

六种状态,别再画错

5.1Java 线程的六种状态

很多资料把状态画成"新建→就绪→运行→阻塞",那是操作系统视角。Java 的 Thread.State 枚举只有 6 种状态,并没有单独的"运行中"(RUNNABLE 同时涵盖了"就绪"和"运行中")。

状态含义怎么进入
NEW新建,还没 startnew Thread() 之后
RUNNABLE可运行(含就绪和运行中)调用 start()
BLOCKED阻塞,等待获取 synchronized 锁进不去 synchronized 块
WAITING无限期等待,需被显式唤醒wait()join()park()
TIMED_WAITING限期等待,超时自动返回sleep(n)wait(n)join(n)
TERMINATED终止,run 执行完正常结束或抛异常退出
NEW RUNNABLE TERMINATED BLOCKED WAITING /TIMED_WAITING start() run() 完毕 抢锁失败 获取到锁 wait()/join() notify/超时
实线 = 离开 RUNNABLE,虚线 = 重新回到 RUNNABLE 去抢 CPU
易错点 · BLOCKED 与 WAITING 不一样BLOCKED 是抢 synchronized 锁失败时被动等待(锁一释放就自动去抢);WAITING主动调用 wait() 之类的方法,必须有其他线程显式 notify 才能醒。

5.2几个容易混淆的方法

方法释放锁吗所属类说明
sleep(n)不释放Thread(静态)抱着锁睡觉,时间到自己醒
wait()释放Object必须在 synchronized 里调用,等别人 notify
yield()不释放Thread(静态)提示"我可以让一让",但不保证真让
join()释放调用者持有的该对象锁Thread等另一线程跑完再继续
两个为什么wait/notify 为什么在 Object 而不在 Thread?因为它们操作的是"对象的监视器锁",任何对象都能当锁,所以天然属于 Object。
wait() 为什么必须配 synchronized?调用前必须先持有该对象的锁,wait() 会释放锁并挂起,被唤醒后重新竞争锁;不在同步块里调用会直接抛 IllegalMonitorStateException
06

创建线程的几种方式

本质只有一种:new Thread + start

Java 创建线程本质只有一种——new Thread()start()。区别只在于给线程"喂"什么任务

6.1继承 Thread 类

extends Thread
public class MyThread extends Thread {
    @Override public void run() {
        System.out.println("线程跑起来了:" + Thread.currentThread().getName());
    }
}
new MyThread().start();
缺点Java 单继承,继承了 Thread 就不能再继承别的类,灵活性差。

6.2实现 Runnable 接口(推荐)

implements Runnable
Runnable task = () -> System.out.println("任务执行中");
new Thread(task).start();   // 把"任务"和"线程"解耦

为什么推荐 Runnable:

  • 解耦——Runnable 描述"做什么",Thread 负责"怎么跑",符合面向对象设计原则。
  • 不占用唯一的继承名额,类还能继承别的东西。
  • 同一个 Runnable 实例可交给多个线程,方便共享数据。
  • 线程池只接受 Runnable / Callable,不接受继承 Thread 的对象。
关键区别直接调用 run() 不会开新线程,只是普通方法调用,在当前线程同步执行。必须调用 start() 才会真正创建并启动新线程。

6.3实现 Callable 接口(需要返回值时)

Runnable 的 run() 没返回值、不能抛检查异常。需要返回结果就用 Callable

Callable + FutureTask
Callable<Integer> task = () -> { Thread.sleep(1000); return 42; };
FutureTask<Integer> future = new FutureTask<>(task);
new Thread(future).start();
Integer result = future.get();   // 阻塞等待结果,拿到 42

6.4三者对比

方式有返回值能抛检查异常受单继承限制推荐度
继承 Thread
实现 Runnable★★★
实现 Callable★★★ 需返回值时
生产环境的正确姿势上面三种是"造线程"的方式,但实际项目几乎不手动 new Thread,而是用线程池统一管理(见第 12 章)。手动创建不可控、不可复用,高并发下容易耗尽系统资源。
07

synchronized:从用法到锁升级

一次解决原子性、可见性、有序性

7.1三种用法,锁的是不同的东西

用法锁的对象范围
修饰实例方法当前实例 this同一对象的所有同步实例方法互斥
修饰静态方法当前类的 Class 对象整个类级别互斥(所有实例共享)
修饰代码块括号里指定的对象锁粒度最细,最灵活
踩坑提醒锁实例方法和锁静态方法是两把不同的锁(一个锁对象、一个锁 Class),它们之间不互斥。新手常以为加了 synchronized 就万事大吉,结果锁错了对象。

7.2底层原理:对象头与 Monitor

每个 Java 对象在内存里都有对象头(Object Header),其中一块叫 Mark Word,存着锁状态信息(锁标志位、指向锁记录的指针等)。

  • 同步代码块编译后生成 monitorentermonitorexit 两条字节码,表示"获取 / 释放监视器锁"。
  • 同步方法则在方法访问标志上加 ACC_SYNCHRONIZED,JVM 进入时自动加锁、退出时自动解锁(含异常退出)。

监视器(Monitor)可理解成每个对象自带的一把"门锁",同一时刻只能一个线程拿锁进临界区,其他线程在门口排队(进入 BLOCKED)。

7.3锁升级:synchronized 为什么不再"重"

早期 synchronized 每次加锁都要向操作系统申请互斥量(重量级锁),涉及用户态到内核态切换,非常慢。JDK 1.6 引入锁升级(锁膨胀)机制:锁随竞争激烈程度从轻到重逐级膨胀,且只能升级不能降级

LEVEL 0
无锁

没有任何竞争

LEVEL 1
偏向锁

一个线程反复获取,只在 Mark Word 记线程 ID,连 CAS 都省

LEVEL 2
轻量级锁

轻度竞争,CAS 改对象头 + 自旋空转重试

LEVEL 3
重量级锁

激烈竞争,线程挂起进阻塞队列,交给操作系统

几乎无开销 → CAS + 自旋 → 操作系统互斥量,代价逐级升高
版本变化偏向锁在 JDK 15 默认禁用、JDK 18 彻底移除,因现代应用维护它的收益不如开销。新版链路简化为"无锁 → 轻量级锁 → 重量级锁"。
核心思想用空间换时间、用乐观假设换性能。大多数同步其实没有真正的并发冲突,那就用最轻的方式处理;只有真撞上了,才付出重的代价。

7.4自旋锁与适应性自旋

膨胀到重量级锁前的"自旋",是用 while 空转代替线程挂起。挂起 / 唤醒要切换内核态,开销大;若锁很快释放,自旋几圈拿到反而更划算。JDK 还做了适应性自旋——根据上次自旋是否成功,动态调整这次的自旋次数,越来越聪明。

08

volatile:轻量级的可见性保证

只做两件事

volatilesynchronized 轻得多,但能力也小得多。它只做两件事:保证可见性 + 禁止指令重排序

8.1保证可见性

volatile 修饰的变量,写操作立即刷回主内存,读操作强制从主内存读(不走工作内存旧副本)。这就解决了第 4.2 节的 running 死循环 bug:

fixed.java
volatile boolean running = true;   // 加上 volatile
while (running) { /* ... */ }      // B 改了之后 A 能立刻看到,正常退出

底层通过 CPU 的缓存一致性协议(如 MESI)内存屏障实现:写 volatile 后插一道屏障,强制刷缓存并让其他核心的缓存失效。

8.2禁止指令重排序

这正是 DCL 单例必须给 instance 加 volatile 的原因——防止"对象还没初始化完,引用就先被赋值"的半成品问题:

Singleton.java · DCL
public class Singleton {
    private static volatile Singleton instance;   // volatile 不可省

    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查(不加锁,提性能)
            synchronized (Singleton.class) {
                if (instance == null) {            // 第二次检查(加锁后再确认)
                    instance = new Singleton();    // 这里若重排会出半成品对象
                }
            }
        }
        return instance;
    }
}

8.3致命局限:不保证原子性

最常被误用之处volatile int count; count++; 依然线程不安全!因为 count++ 是"读—改—写"三步,volatile 只保证每步读到最新值,不保证三步连起来不被打断

所以 volatile 只适合一个线程写、多个线程读的场景(典型如状态标志位)。需要原子的自增自减,请用 AtomicInteger

8.4volatile vs synchronized 一句话总结

对比项volatilesynchronized
原子性✗ 不保证✓ 保证
可见性
有序性
阻塞不阻塞线程会阻塞
适用状态标志、一写多读复合操作、临界区
开销很小相对大(虽已优化)
09

CAS 与原子类:无锁编程的基石

不锁门,改之前先看东西有没有被动过

加锁(悲观锁)的思路是"先把门锁上,谁也别想进"。但锁有开销,竞争不激烈时显得浪费。CAS 提供了另一条路:不锁门,改之前先看看东西有没有被人动过。

9.1CAS 是什么

CAS = Compare And Swap(比较并交换),一条 CPU 原子指令,接收三个值:

V
当前值

内存里变量当前的值

A
预期旧值

我预期它应该是的旧值

B
新值

我想把它改成的新值

逻辑:如果 V == A(没人动过),就把 V 改成 B;否则什么都不做,返回失败。整个"比较 + 交换"由硬件保证原子,中间不会被打断。失败了通常配合循环不断重试,这叫自旋 CAS

spin-cas.java
do {
    int oldValue = value;            // 读旧值
    int newValue = oldValue + 1;     // 算新值
} while (!compareAndSwap(oldValue, newValue));   // 失败就重来

9.2CAS 的两个经典问题

问题一 · ABA线程 1 读到值是 A 准备 CAS,期间线程 2 把它改成 B 又改回 A。线程 1 的 CAS 一看"还是 A 嘛"就成功了,但其实中间已被动过。对单纯数值通常无害,但涉及引用、栈 / 队列结构时可能出错。
解药:AtomicStampedReference 加版本号,CAS 时连版本号一起比,A→B→A 后版本号变了就能识别。
问题二 · 自旋开销竞争激烈时大量线程 CAS 失败、反复自旋,白白烧 CPU。
解药:限制自旋次数后退化为阻塞,或用分段思想分散竞争(见 LongAdder)。

9.3原子类家族

类别代表类用途
基本类型AtomicIntegerAtomicLongAtomicBoolean原子的数值 / 布尔操作
数组AtomicIntegerArray原子地更新数组某元素
引用AtomicReferenceAtomicStampedReference(带版本)原子地更新对象引用
字段更新器AtomicIntegerFieldUpdater原子地更新某对象的指定 volatile 字段

9.4Unsafe:CAS 背后的"魔法"

原子类的 CAS 最终调用 sun.misc.Unsafe。顾名思义它"不安全"——能直接操作内存、绕过 JVM 安全检查,提供硬件级原子操作。它是 JUC 整套工具的底层支撑,但普通开发者不应直接使用

9.5LongAdder:高并发下的性能升级

AtomicLong 在高并发下有痛点:成千上万线程抢着对同一个 value 做 CAS,绝大多数失败、自旋重试,形成恶性循环。LongAdder(JDK 1.8)的思路是分而治之

  • 内部不是一个 value,而是一个 Cell 数组(多个 value)。
  • 并发低时直接 CAS 更新基础值 base
  • 并发高、CAS 撞车时,把不同线程的累加分散到数组不同槽位,各加各的互不干扰。
  • 取总和时把 base 和所有 Cell 相加。
🛣️
用空间换并发度:把"千军万马挤一座独木桥"变成"开了一百条车道"。代价是 sum() 非绝对实时精确,所以适合高并发计数 / 统计(如监控指标),需要精确实时值仍用 AtomicLong
进阶 · 伪共享 False SharingCPU 缓存以"缓存行"(通常 64 字节)为单位加载。若多个 Cell 挤在同一缓存行,一个核心改自己的 Cell 会导致其他核心整行缓存失效,反而变慢。所以 Cell 用 @Contended 注解做缓存行填充,让每个 Cell 独占一行。
10

AQS 与 Lock 体系

JUC 同步组件的共同地基

CAS 是无锁的"原子单点操作",而要构建真正的锁、信号量、闭锁这些复杂同步工具,就需要一个统一的底层框架——AQSJUC 里绝大多数同步组件都基于它搭出来。

10.1AQS 是什么

AQS = AbstractQueuedSynchronizer(抽象队列同步器),把"管理锁 / 同步状态"抽象成两个核心部分:

  1. 一个 volatile int 的 state:表示同步状态,含义由子类定义——ReentrantLock 里 0 没人持锁 / 1 被持有 / N 重入 N 次;Semaphore 里 state 是剩余许可数;CountDownLatch 里 state 是还需 countDown 几次。
  2. 一个 FIFO 等待队列(CLH 变体):抢不到锁的线程被包装成 Node 排进双向链表挂起,轮到自己时被唤醒去抢。
state = 1 exclusiveOwnerThread → 线程 A(持锁中) FIFO 等待队列(被 LockSupport.park 挂起): head Node·B Node·C Node·D tail
一个 state + 一条 排队队列,就撑起了整个 JUC 的锁世界

10.2AQS 的两种工作模式

模式含义代表
独占 Exclusive同一时刻只有一个线程能拿到同步状态ReentrantLock、写锁
共享 Shared同一时刻允许多个线程拿到同步状态Semaphore、CountDownLatch、读锁

AQS 用模板方法模式:把"怎么排队、怎么挂起唤醒"写死,把"怎么判定能不能拿到锁"(tryAcquire / tryRelease)留给子类。构建新同步器只需重写几个方法、定义好 state 含义。

10.3ReentrantLock:可重入的显式锁

ReentrantLock.java
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();   // 必须在 finally 里手动释放!
}

它比 synchronized 多出的能力:可中断(lockInterruptibly())、可超时(tryLock(timeout),能避免死锁)、公平 / 非公平可选、一个锁可创建多个 Condition 实现精细的分组等待 / 唤醒。

代价必须手动 unlock() 且放在 finally 里,否则临界区一旦抛异常,锁永远不释放,造成死锁。synchronized 由 JVM 自动释放,更省心。

10.4ReentrantLock vs synchronized

对比项synchronizedReentrantLock
实现层面JVM 关键字JDK 类库(基于 AQS)
锁释放自动手动(finally unlock)
可中断
超时获取
公平锁仅非公平可选公平 / 非公平
条件变量只有一个等待队列可有多个 Condition
选型建议能用 synchronized 就用它(简单、自动释放、JVM 持续优化);只有需要"可中断、可超时、公平锁、多条件"这些高级特性时才上 ReentrantLock

10.5读写锁与 StampedLock

  • ReentrantReadWriteLock:把锁拆成读锁和写锁。读读共享、读写互斥、写写互斥,适合读多写少(如缓存),多个读线程能同时进。
  • StampedLock(JDK 1.8):读写锁升级版,增加乐观读模式——读时先不加锁,读完再校验期间有没有被写过,没写过就直接用。彻底避免读线程阻塞写线程,性能更高,但不可重入、用法更复杂。
11

锁的各种"形容词"

从不同角度给同一把锁贴的标签

并发里关于锁的术语特别多,很多其实是从不同角度给同一把锁贴的标签。理清角度,名词就不再混乱。

11.1乐观锁 vs 悲观锁(看待冲突的态度)

PESSIMISTIC
悲观锁

假设"每次访问都会冲突",先加锁再操作。代表:synchronized、Lock。适合写多、冲突频繁。

宁可错杀
OPTIMISTIC
乐观锁

假设"大概率不冲突",不加锁,更新时才检查有没有被改过。代表:CAS、数据库版本号。适合读多写少。

事后验明

11.2公平锁 vs 非公平锁(排队规则)

  • 公平锁:严格按申请顺序排队,先到先得,不会饿死。但维护队列有开销、吞吐量较低。
  • 非公平锁:新来的线程可"插队"直接抢,抢不到再排队。吞吐量更高,但可能有线程长期抢不到(饿死)。

synchronized 是非公平的;ReentrantLock 默认非公平,可选公平。

11.3可重入锁 vs 不可重入锁(能否重复获取)

可重入指:一个线程已持有某把锁,再次请求同一把锁时能直接拿到,不会把自己锁死。

reentrant.java
synchronized void a() { b(); }   // a 持有 this 锁
synchronized void b() { }        // b 也要 this 锁 —— 可重入,不会死锁

实现上靠一个计数器:同一线程每重入一次 +1、释放一次 -1,减到 0 才真正释放。若锁不可重入,上面 a()b() 就会把自己锁死。

11.4共享锁 vs 独占锁 / 自旋锁 vs 阻塞锁

维度类型 A类型 B
能否多人持有独占锁:同一时刻只一个线程(写锁、ReentrantLock)共享锁:可多个线程同时持有(读锁、Semaphore)
抢不到时怎么办自旋锁:空转重试不让出 CPU,适合持锁极短阻塞锁:挂起线程让出 CPU,适合持锁长

synchronized 在锁升级中先自旋后阻塞,是两者的结合。

12

线程池:为什么以及怎么用

核心思想 = 复用

12.1为什么不能手动 new Thread

  • 创建 / 销毁开销大:每个线程都要分配栈内存、向操作系统申请资源。
  • 数量失控:来一个请求 new 一个线程,高并发下瞬间几万个,内存爆掉 + 上下文切换拖垮 CPU。
  • 难以管理:无法统一监控、限流、复用。

线程池的核心思想是复用:预先创建一批线程,任务来了丢给空闲线程跑,跑完不销毁、回池等下一个任务。

12.2七大核心参数

ThreadPoolExecutor.java
new ThreadPoolExecutor(
    corePoolSize,      // 1. 核心线程数:常驻员工,即使空闲也不裁
    maximumPoolSize,   // 2. 最大线程数:核心 + 临时工的上限
    keepAliveTime,     // 3. 空闲存活时间:临时工闲多久就裁掉
    unit,              // 4. 时间单位
    workQueue,         // 5. 任务阻塞队列:忙不过来时的"待办清单"
    threadFactory,     // 6. 线程工厂:怎么创建线程(可自定义线程名)
    handler            // 7. 拒绝策略:实在扛不住了怎么办
);

12.3任务来了,线程池的处理流程

记住一个反直觉的顺序先塞队列,再开临时工。
核心线程满了吗?否 ↘
创建核心线程执行
是 ↓
任务队列满了吗?否 ↘
放进队列排队等待
是 ↓
线程数达到 max 了吗?否 ↘
创建临时(非核心)线程执行
是 ↓
执行拒绝策略
常见误区很多人以为"核心满了就立刻开到最大线程数"。其实是核心满了先往队列塞,队列也满了才开临时工。这也解释了为什么用无界队列(如不指定容量的 LinkedBlockingQueue)时,maximumPoolSize 永远不生效——队列永远塞得下。

12.4四种拒绝策略

默认
AbortPolicy

直接抛 RejectedExecutionException 异常

推荐
CallerRunsPolicy

提交任务的线程自己执行,变相降速、给池子喘息

危险
DiscardPolicy

默默丢弃新任务,不报错(任务悄无声息没了)

替换
DiscardOldestPolicy

丢弃队列里最老的任务,腾位置给新任务

实践CallerRunsPolicy 很有用——它形成一种背压,提交太快时让提交方自己干活,自然把速度压下来,且不丢任务。

12.5为什么阿里规约禁用 Executors

方法特点隐患
newFixedThreadPool固定线程数无界队列,任务堆积可能 OOM
newSingleThreadExecutor单线程,保证顺序同样无界队列,OOM 风险
newCachedThreadPool线程数可无限增长上限 Integer.MAX_VALUE,可能海量线程 OOM
newScheduledThreadPool支持定时 / 周期同样有队列隐患
《阿里巴巴 Java 开发手册》明确规定不允许用 Executors 创建线程池,必须手动 new ThreadPoolExecutor——无界队列和无上限线程数会在流量突增时把内存撑爆。手动创建能强制你设定有界队列和合理拒绝策略。

12.6怎么设置线程池大小

CPU 密集型
核数 + 1

大量计算、少 I/O。线程太多只会增加无谓的上下文切换。

I/O 密集型
核数 × (1 + 等待/计算)

大量等待网络 / 磁盘。线程大部分时间在等 I/O,可多开几个填满 CPU 空闲。

以上为经验法则,实际需结合压测确定。

13

并发容器:HashMap 到 ConcurrentHashMap

在安全与高并发之间找平衡

13.1HashMap 底层原理

HashMap 的核心是数组 + 链表 / 红黑树:用 key 的 hashCode() 经扰动定位到数组某个桶;不同 key 算到同一桶发生哈希冲突,用链表串起来(拉链法);JDK 1.8 起链表长度 > 8 且数组容量 ≥ 64 时链表转红黑树,查找从 O(n) 优化到 O(log n)。

核心参数:默认初始容量 16,负载因子 0.75(元素数超过 容量×0.75 就扩容翻倍)。

维度JDK 1.7JDK 1.8
结构数组 + 链表数组 + 链表 + 红黑树
插入链表头插法尾插法
扩容并发问题多线程下可能形成环形链表,导致死循环改用尾插法,消除环形链表
著名"血案"1.7 头插法在多线程扩容时链表可能首尾相连成环,之后 get() 不存在的 key 会陷入死循环,CPU 飙到 100%。但注意:1.8 解决的只是死循环,HashMap 多线程下依然不安全(仍可能丢数据、读脏值),多线程必须用 ConcurrentHashMap。

13.2ConcurrentHashMap 的演进

JDK 1.7
分段锁 Segment

整张表分成默认 16 个 Segment,每个一把独立锁。操作哪段只锁哪段,理论支持 16 线程同时写不同段。

JDK 1.8
CAS + synchronized

放弃分段锁,锁粒度细化到每个桶:CAS 处理空桶首次插入(无锁),synchronized 锁桶头节点处理冲突链表 / 树。

为什么 1.8 放弃分段锁?① Segment 数组占额外内存且每个是较重的 ReentrantLock;② 锁粒度仍太粗(一段含多个桶),同段不同桶操作仍互相阻塞;③ 1.6 后 synchronized 锁升级优化已不慢,直接锁单个桶头,粒度更细、内存更省。

13.3HashMap vs ConcurrentHashMap

特性HashMapConcurrentHashMap
线程安全✗ 不安全✓ 安全
null 键 / 值允许不允许(抛 NPE)
性能单线程最快多线程下远好于 Hashtable
为什么 ConcurrentHashMap 不允许 null多线程下无法区分"key 不存在"和"key 存在但 value 是 null"——get 返回 null 时没法判断是哪种,会产生歧义。单线程 HashMap 可再用 containsKey 确认,但并发环境下这个二次确认不可靠,干脆禁止 null。

13.4其他常用并发容器

  • CopyOnWriteArrayList:写时复制。写时复制新数组改完再替换,读完全不加锁。适合读极多写极少(黑白名单、监听器列表),代价是写开销大、占内存。
  • ConcurrentLinkedQueue:基于 CAS 的无锁并发队列,高性能。
  • BlockingQueue:生产者消费者的利器,满时 put 阻塞、空时 take 阻塞。实现有 ArrayBlockingQueueLinkedBlockingQueueSynchronousQueue(手递手)、PriorityBlockingQueue。线程池的工作队列就是它。
14

JUC 同步工具类

多个线程之间如何配合

java.util.concurrent 提供了几个开箱即用、全部基于 AQS 的协调工具。

14.1CountDownLatch(倒计时门闩)

让一个或多个线程等待其他若干线程完成后再继续。计数器一次性的,减到 0 就开门,不能重置

CountDownLatch.java
CountDownLatch latch = new CountDownLatch(3);   // 等 3 个任务
latch.countDown();   // 每个子任务完成时 计数 -1
latch.await();       // 主线程阻塞直到计数归 0
System.out.println("3 个任务全完成,继续");

典型场景:主线程等所有子任务跑完汇总;或运动员就绪后裁判一声令下同时起跑。

14.2CyclicBarrier(循环栅栏)

让一组线程互相等待,全部到达"屏障点"后再一起继续。与 CountDownLatch 最大区别是可循环复用(通过后自动重置)。

CyclicBarrier.java
CyclicBarrier barrier = new CyclicBarrier(3, () -> System.out.println("三人到齐,出发"));
barrier.await();   // 到这里等其他人,凑够 3 个一起放行

14.3Semaphore(信号量)

控制同时访问某资源的线程数量,本质是"发许可证"。

Semaphore.java
Semaphore semaphore = new Semaphore(3);   // 最多 3 个线程同时进
semaphore.acquire();   // 拿一张许可(没了就阻塞)
try { /* 访问受限资源 */ }
finally { semaphore.release(); }           // 还回许可

典型场景:限流、连接池、停车场限位(3 个车位,来第 4 辆就等)。

14.4三者对比

工具核心语义可否重用典型场景
CountDownLatch一个 / 多个线程等 N 个事件完成主线程等子任务汇总
CyclicBarrierN 个线程互相等待集合分阶段并行计算
Semaphore限制并发访问数量限流、资源池
15

ThreadLocal:线程隔离与内存泄漏陷阱

高频面试点

15.1它解决什么问题

有些变量我们希望每个线程独享一份、互不干扰,比如数据库连接、用户登录态、SimpleDateFormat(它本身线程不安全)。ThreadLocal 就是给每个线程一个独立副本,一个线程改自己的不影响别的。

ThreadLocal.java
ThreadLocal<SimpleDateFormat> sdf =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
String today = sdf.get().format(new Date());   // 每个线程拿到专属实例,互不干扰

15.2实现原理

关键数据不是存在 ThreadLocal 对象里,而是存在每个 Thread 对象里

每个 Thread 内部有一个 ThreadLocalMap 字段。threadLocal.set(value) 实际是以当前 threadLocal 实例为 key、value 为值,存进当前线程的这个 map。get() 也从当前线程的 map 按 key 取。因为操作的永远是"当前线程自己的 map",所以天然隔离。

Thread A ThreadLocalMap threadLocalX → "A 的值" Thread B ThreadLocalMap threadLocalX → "B 的值"
共用同一个 key(threadLocalX),但 map 各自独立,值天然隔离

15.3内存泄漏陷阱

ThreadLocalMap 的 Entry 设计很微妙:key 是对 ThreadLocal 的弱引用,value 是强引用

key 用弱引用,是为了在 ThreadLocal 对象不再被外部使用时能被 GC 回收(key 变 null)。但问题来了:key 被回收变 null 后,value 还被强引用着无法回收。若这是线程池里的长期存活线程,这个"key=null 但 value 还在"的 Entry 就会一直占内存——这就是 ThreadLocal 内存泄漏。

safe.java
try {
    threadLocal.set(something);
    // 使用
} finally {
    threadLocal.remove();   // 关键!尤其在线程池环境下
}
线程池里不 remove 的第二个坑线程被复用,下一个任务可能读到上一个任务残留的脏数据(用户 A 的信息泄漏给用户 B 的请求)。所以 remove() 既防泄漏又防串数据,是必须的纪律。

15.4InheritableThreadLocal

普通 ThreadLocal 在子线程里读不到父线程设的值。InheritableThreadLocal 能让子线程继承父线程的值(创建时拷贝过去)。但在线程池里会失效(线程是复用而非新建的),这种场景需要阿里开源的 TransmittableThreadLocal

16

四种引用类型与 GC

决定对象何时被回收

引用类型决定对象在什么时候被垃圾回收,它和并发缓存设计密切相关(软 / 弱引用做缓存、虚引用做资源清理)。

STRONG
强引用

只要还有强引用就永远不回收(哪怕 OOM)。普通的 Object o = new Object()

"我死也要抱着你"
SOFT
软引用

内存够时不回收,内存不足时才回收。适合内存敏感的缓存(图片缓存)。

"内存紧张才放手"
WEAK
弱引用

只要发生 GC 就回收,不管内存够不够。用于可有可无的缓存、ThreadLocalMap 的 key。

"下次 GC 就再见"
PHANTOM
虚引用

随时可能被回收,get() 永远返回 null。用于跟踪回收时机、管理堆外内存。

"形同虚设,只为收尸"
理解要点软引用很适合做缓存——内存够时缓存一直在,内存吃紧时 JVM 自动清掉腾地方,不会撑爆 OOM。虚引用本身不能访问对象,必须配合引用队列 ReferenceQueue 使用:对象被回收时收到通知做清理(如 NIO 的 DirectByteBuffer 用它释放堆外内存)。
17

IO 模型:BIO / NIO / AIO

一个线程怎么高效处理大量连接

IO 模型属于"广义并发"——它关心一个线程怎么高效处理大量网络连接,是高性能服务器(Netty、Redis、Nginx)的命脉。

17.1先理解两个维度

  • 阻塞 vs 非阻塞:发起 IO 后,线程是傻等,还是立刻返回去干别的、过会儿再问
  • 同步 vs 异步:数据真正读写的活,是你自己干,还是交给操作系统干完通知你
🥡
去餐厅打包——BIO:在窗口前一直站着等,做好直接拿走,期间啥也干不了。NIO:点完单去逛街,每隔几分钟回来问一次"好了没",要主动轮询。AIO:点完单留个电话走人,做好店家打电话送上门,全程不用操心。

17.2NIO 的三大核心组件

NIO 是高并发服务器的主流方案:

组件作用比喻
Channel 通道双向数据传输管道(可读可写)水管
Buffer 缓冲区数据的容器,读写都先经过它水桶
Selector 多路复用器一个线程监控多个 Channel,哪个有数据就处理哪个一个保安看一墙监控屏
精髓在 Selector(多路复用)BIO 是"一个线程盯一个连接",NIO 是"一个线程通过 Selector 同时盯几千个连接",哪个来数据 Selector 就通知线程处理。少量线程就能扛住海量连接,彻底解决 BIO 的线程爆炸问题。

17.3三者对比

特性BIONIOAIO
IO 模型同步阻塞同步非阻塞异步非阻塞
数据导向面向流 Stream面向缓冲区 Buffer面向缓冲区
线程模型一连接一线程一线程管多连接(Selector)操作系统回调
编程复杂度简单较复杂复杂
适用场景连接少且固定连接多且短连接多且长
现状Linux 对 AIO 支持不够成熟,所以实际高性能框架(如 Netty)反而基于 NIO 而非 AIO 构建。
18

死锁:成因、定位与预防

谁也不肯让,永远卡死

18.1什么是死锁

两个(或多个)线程互相持有对方需要的资源,又都在等对方先释放,结果谁也不让。

deadlock.java
// 线程 1:先拿锁 A,再去拿锁 B
synchronized (lockA) { synchronized (lockB) { ... } }
// 线程 2:先拿锁 B,再去拿锁 A —— 顺序相反,危险!
synchronized (lockB) { synchronized (lockA) { ... } }
// 若线程1拿到A、线程2拿到B,然后都等对方手里的锁 → 死锁

18.2死锁的四个必要条件

四个条件同时满足才会死锁,破坏任意一个即可预防:

条件 1
互斥

资源同一时刻只能被一个线程占用。

条件 2
请求并保持

持有一些资源,又在请求新资源时不释放已有的。

条件 3
不可剥夺

已分配的资源不能被强行抢走,只能持有者主动释放。

条件 4
循环等待

存在一个线程等待环路(A 等 B、B 等 C、C 等 A)。

18.3怎么预防

  • 破坏"请求并保持":一次性申请所有需要的锁,要么全拿到要么都不拿。
  • 破坏"不可剥夺":用 tryLock(timeout),拿不到就放弃已有锁、退出重试(ReentrantLock 能做到,synchronized 不行)。
  • 破坏"循环等待"(最常用)给所有锁定义全局顺序,所有线程都按同一顺序加锁。上例只要让线程 2 也先拿 A 再拿 B,环就破了。

18.4怎么定位

线上卡死、CPU 不高但请求无响应,多半是死锁。排查工具:jstack <pid> 打印线程栈会直接标出 Found one Java-level deadlockjconsole / VisualVM 图形化检测;关注处于 BLOCKED 状态、互相等待对方锁的线程。

区分两个近亲活锁——线程没阻塞,但一直重试又一直失败(像两人过道互相让来让去谁也过不去);饥饿——某线程长期抢不到资源(非公平锁可能导致)。死锁是"都不动",活锁是"瞎忙活"。
19

生产者消费者模型

最经典的协作模式

几乎所有消息队列、线程池都是它的变体。

19.1核心思想

生产者产生数据放进缓冲区,消费者从缓冲区取数据处理。中间用一个缓冲区(队列)解耦

解耦
互不依赖

生产者和消费者不直接通信。

削峰填谷
暂存缓冲

生产快时数据暂存队列,消费者慢慢处理,应对流量波动。

平衡速度
缓冲速度差

生产和消费速度不一致时由缓冲区缓冲。

19.2两种实现方式

方式一:wait / notify(传统)

wait-notify.java
synchronized (queue) {
    while (queue.isFull()) {     // 注意必须用 while 不能用 if(防虚假唤醒)
        queue.wait();            // 满了,生产者等待并释放锁
    }
    queue.add(item);
    queue.notifyAll();           // 唤醒在等的消费者
}
为什么用 while 而不是 if线程可能被"虚假唤醒"(spurious wakeup),或被唤醒后条件又被别的线程改变。用 while 在醒来后重新检查条件,确保条件真满足才往下走。wait/notify 必须在 synchronized 块内、成对配合使用。

方式二:BlockingQueue(推荐)——把"满了等待、空了等待"全封装好,代码极简:

BlockingQueue.java
BlockingQueue<Item> queue = new ArrayBlockingQueue<>(10);
queue.put(item);            // 生产者:队列满时自动阻塞
Item item = queue.take();   // 消费者:队列空时自动阻塞

实际开发中几乎都用 BlockingQueue,wait/notify 主要用来理解原理。

20

工程实践与避坑清单

实战中怎么选、怎么不踩坑

20.1CompletableFuture:现代异步编程

Future.get() 是阻塞的,且不能优雅地组合多个异步任务。CompletableFuture(JDK 1.8)支持链式回调、任务编排:

CompletableFuture.java
CompletableFuture
    .supplyAsync(() -> queryUser())               // 异步查用户
    .thenApply(user -> user.getName())            // 拿到结果后转换
    .thenAccept(name -> System.out.println(name)) // 消费结果
    .exceptionally(ex -> { log(ex); return null; }); // 异常处理

CompletableFuture.allOf(taskA, taskB).join();     // 组合:等两个都完成

它能把"查用户→查订单→合并"这类异步流程写得像同步代码一样清晰,是现代 Java 异步编程的首选。

20.2常见并发 Bug 与避坑清单

  • 检查再操作不原子if (map.get(k)==null) map.put(k,v) 两步间会被插入,用 putIfAbsent 等原子方法。
  • volatile 当原子用:volatile 计数器做 ++ 仍不安全,要用 AtomicInteger
  • 锁错对象:锁了会变的对象引用等于没锁,锁对象要用 final 修饰、保证唯一。
  • 忘记 finally 里 unlock:ReentrantLock 临界区抛异常没释放锁,导致死锁。
  • ThreadLocal 不 remove:线程池场景下内存泄漏 + 数据串台。
  • 用 Executors 建线程池:无界队列 / 无限线程导致 OOM,手动 new ThreadPoolExecutor。
  • wait 用 if 判断条件:虚假唤醒导致逻辑错误,改用 while。
  • DCL 不加 volatile:指令重排导致拿到半成品对象。
  • 持有锁时调用外部未知方法:可能引发死锁或长时间持锁,应缩小锁范围。

20.3减少锁竞争的通用思路

  • 缩小锁粒度:只锁真正需要保护的代码,别锁整个方法。
  • 缩短持锁时间:耗时操作(IO、计算)尽量挪到锁外。
  • 读写分离:读多写少用读写锁或 CopyOnWrite。
  • 分段 / 分散:像 ConcurrentHashMap 分桶、LongAdder 分槽,把竞争打散。
  • 用无锁结构:CAS、原子类、不可变对象天然线程安全。
  • 能不共享就不共享:ThreadLocal、栈封闭、局部变量本就线程安全——最好的同步是"不需要同步"
21

高频面试题速答

点开看答案 · 细节回到对应章节
Qsynchronized 和 volatile 的区别?
volatile 只保证可见性和有序性、不保证原子性、不阻塞、开销小,适合状态标志;synchronized 三者都保证、会阻塞、用于临界区。
Qsynchronized 锁升级过程?
无锁 → 偏向锁(单线程,记线程 ID)→ 轻量级锁(CAS + 自旋应对轻度竞争)→ 重量级锁(操作系统互斥量,阻塞挂起)。只升不降。(偏向锁在新版 JDK 已移除。)
QCAS 是什么?有什么问题?
比较并交换,乐观锁的硬件实现:值等于预期才更新。问题:ABA(用版本号 / AtomicStampedReference 解决)、自旋耗 CPU(LongAdder 分散解决)。
QAQS 的原理?
一个 volatile 的 state 表示同步状态 + 一个 FIFO 等待队列。抢不到的线程入队挂起。模板方法模式,子类定义 state 含义和获取 / 释放规则。ReentrantLock、Semaphore、CountDownLatch 都基于它。
QReentrantLock 和 synchronized 怎么选?
默认用 synchronized(简单、自动释放、JVM 优化好);需要可中断、可超时、公平锁、多条件变量时才用 ReentrantLock(记得 finally 里 unlock)。
Q线程池任务处理流程?
核心线程没满→建核心线程;满了→进队列;队列满了→建临时线程到 max;再满→执行拒绝策略。注意是"先塞队列再开临时工"
Q为什么不用 Executors 创建线程池?
无界队列和无上限线程数会在流量突增时 OOM,应手动 new ThreadPoolExecutor 指定有界队列和拒绝策略。
QConcurrentHashMap 1.7 和 1.8 区别?
1.7 分段锁 Segment(默认 16 段);1.8 放弃分段锁,用 CAS + synchronized 锁单个桶头节点,粒度更细、内存更省。
QHashMap 为什么线程不安全?1.7 的死循环?
多线程扩容时 1.7 头插法会形成环形链表导致 get 死循环;1.8 改尾插消除死循环,但仍可能丢数据,多线程必须用 ConcurrentHashMap。
QThreadLocal 内存泄漏原因?
ThreadLocalMap 的 key 是弱引用、value 是强引用。key 被回收后 value 仍被强引用无法释放,线程池长生命周期线程下会泄漏。解决:用完 remove()
Q死锁四条件?怎么破?
互斥、请求并保持、不可剥夺、循环等待。最常用的破解是统一加锁顺序(破坏循环等待)。
Qsleep 和 wait 的区别?
sleep 是 Thread 静态方法、不释放锁、到时自醒;wait 是 Object 方法、释放锁、须在 synchronized 内、靠 notify 唤醒。
QBIO / NIO / AIO 区别?
BIO 同步阻塞、一连接一线程;NIO 同步非阻塞、Selector 一线程管多连接;AIO 异步非阻塞、操作系统回调。Netty 基于 NIO(因 Linux 的 AIO 不成熟)。

全书一图速记

从问题到工具的完整因果链
并发的三大问题 原子性 可见性 有序性 synchronizedLock / 原子类 volatile · finalsynchronized volatilesynchronized 底层支撑:JMM(happens-before)+ CAS CAS → 原子类 → AQS AQS → Lock / 同步工具 高层封装 · 容器与池 高层封装 · 协调工具 线程池 / 并发容器 ConcurrentHashMap CountDownLatch CyclicBarrier · Semaphore

这份手册以"三大并发问题"为主线重新组织:JMM 暴露了问题 → CAS / 锁解决了问题 → AQS 把锁标准化 → 线程池 / 并发容器 / JUC 工具是上层封装。理解了这条链,遇到新的并发组件也能快速归位。

Java Concurrency · 深度理解手册 最后更新 2026 · 05 · 29