Java 多线程
与并发编程
所有的关键字、锁、容器,本质上都在解决同样的三个问题——原子性、可见性、有序性。抓住这条主线,零散的工具就会连成一张网。
原子性
一组操作要么全做完不被打断,要么不做。count++ 偷偷拆成三步,就坏在这里。
可见性
一个线程改了变量,别的线程能立刻看见。CPU 缓存让"看不见"成为常态。
有序性
代码的执行顺序符合逻辑顺序。编译器和 CPU 的指令重排会打乱它。
为什么需要并发
CPU 也一样。程序运行时经常要等磁盘、等网络、等数据库返回,这些"等待"里 CPU 是空闲的。并发的本质,就是让 CPU 在等待 I/O 的间隙去干别的活,把昂贵的计算资源榨干。
并发主要解决两类问题:
- 提升吞吐量(throughput):等 I/O 时切去处理别的任务,单位时间做更多事——对 Web 服务器、数据库这类 I/O 密集型场景至关重要。
- 缩短响应时间(latency):把大任务拆成多块丢给多个核心同时算(并行排序、MapReduce),让用户更快拿到结果。
进程、线程与 CPU 调度
2.1进程 vs 线程
| 维度 | 进程 Process | 线程 Thread |
|---|---|---|
| 定义 | 操作系统资源分配的基本单位 | CPU 调度执行的基本单位 |
| 内存 | 拥有独立的内存地址空间 | 共享所属进程的内存(堆、方法区) |
| 独占资源 | 整套地址空间、文件句柄等 | 只独占程序计数器、虚拟机栈、本地方法栈 |
| 通信 | 较重(管道、信号、共享内存、Socket) | 轻(直接读写共享变量即可) |
| 崩溃影响 | 一个崩溃通常不影响其他进程 | 一个崩溃可能拖垮整个进程 |
2.2上下文切换:并发的隐藏成本
CPU 同一时刻其实只能跑一个线程。所谓"同时运行多个线程",是 CPU 给每个线程分配一小段时间片(通常几十毫秒),到点就保存当前线程状态(寄存器、程序计数器)、加载下一个线程状态。这个"保存—加载"过程叫上下文切换(Context Switch)。
切换本身不产生任何业务价值,纯属开销。所以并不是线程越多越好——开到几千个,CPU 可能大部分时间都耗在切换上。这也是后面线程池要严格控制线程数量的根本原因。
2.3并发 vs 并行
并发
宏观上"同时"处理多任务,微观上 CPU 快速轮换交替执行。单核也能并发,强调"结构上能处理多任务"。
并行
微观上真有多个任务在多个核心上同一时刻执行。必须多核才能并行,强调"物理上真同时跑"。
一个并发程序在单核上就是交替执行,放到多核上就可能变成并行执行。
Java 内存模型 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,若没有正确的同步机制,修改可能只停在 A 的工作内存里,B 永远看不到。volatile、synchronized、final 的作用,本质上就是在告诉 JMM:"这里需要强制同步,别让我读到过期数据"。
3.3happens-before:JMM 的"承诺"
JMM 太底层,直接面对它太痛苦。于是它对外提供了一套更易理解的规则——happens-before(先行发生)。它的含义不是"时间上先发生",而是:如果操作 A happens-before 操作 B,那么 A 的结果对 B 一定可见,且在 B 看来 A 排在前面。
几条最重要的规则:
- 程序顺序规则:同一线程内,写在前面的操作 happens-before 后面的操作。
- 锁规则:对一个锁的解锁 happens-before 后续对它的加锁。
- volatile 规则:对 volatile 变量的写 happens-before 后续对它的读。
- 传递性:A→B、B→C,则 A→C。
- 线程启动规则:
Thread.start()happens-before 该线程内的任何操作。 - 线程终止规则:线程内所有操作 happens-before 其他线程检测到它已终止(
join()返回)。
核心框架:并发的三大问题
遇到任何并发 bug,先问自己:是哪个问题坏了?
4.1原子性 Atomicity
定义:一个或多个操作,要么全部执行完且中间不被打断,要么就不执行。最经典的反例是 count++——看起来一行,底层却是三步:
read
读取 count 的值到工作内存
add
把值加 1
write
把结果写回主内存
synchronized、Lock、原子类(AtomicInteger 等)。4.2可见性 Visibility
定义:一个线程修改了共享变量,其他线程能立刻看到。根源就是第 3 章讲的 CPU 缓存。一个典型的"死循环 bug":
boolean running = true; // 注意:没有 volatile
// 线程 A
while (running) { /* 干活 */ }
// 线程 B
running = false; // 想让 A 停下来线程 B 把 running 改成了 false,但 A 可能一直在自己缓存里读到 true,永远停不下来。
volatile(最轻量)、synchronized、final。4.3有序性 Ordering
定义:程序执行顺序要符合代码逻辑顺序。但编译器和 CPU 为优化性能,会在不影响单线程结果的前提下对指令重排序。单线程没问题,多线程下重排可能出大事。最著名的例子是双重检查锁(DCL)单例:
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 三个都能解决,但更重。这是后面所有选型的出发点。线程的生命周期与基本操作
5.1Java 线程的六种状态
很多资料把状态画成"新建→就绪→运行→阻塞",那是操作系统视角。Java 的 Thread.State 枚举只有 6 种状态,并没有单独的"运行中"(RUNNABLE 同时涵盖了"就绪"和"运行中")。
| 状态 | 含义 | 怎么进入 |
|---|---|---|
| NEW | 新建,还没 start | new Thread() 之后 |
| RUNNABLE | 可运行(含就绪和运行中) | 调用 start() 后 |
| BLOCKED | 阻塞,等待获取 synchronized 锁 | 进不去 synchronized 块 |
| WAITING | 无限期等待,需被显式唤醒 | wait()、join()、park() |
| TIMED_WAITING | 限期等待,超时自动返回 | sleep(n)、wait(n)、join(n) |
| TERMINATED | 终止,run 执行完 | 正常结束或抛异常退出 |
wait() 之类的方法,必须有其他线程显式 notify 才能醒。5.2几个容易混淆的方法
| 方法 | 释放锁吗 | 所属类 | 说明 |
|---|---|---|---|
sleep(n) | 不释放 | Thread(静态) | 抱着锁睡觉,时间到自己醒 |
wait() | 释放 | Object | 必须在 synchronized 里调用,等别人 notify |
yield() | 不释放 | Thread(静态) | 提示"我可以让一让",但不保证真让 |
join() | 释放调用者持有的该对象锁 | Thread | 等另一线程跑完再继续 |
wait() 为什么必须配 synchronized?调用前必须先持有该对象的锁,
wait() 会释放锁并挂起,被唤醒后重新竞争锁;不在同步块里调用会直接抛 IllegalMonitorStateException。创建线程的几种方式
Java 创建线程本质只有一种——new Thread() 再 start()。区别只在于给线程"喂"什么任务。
6.1继承 Thread 类
public class MyThread extends Thread {
@Override public void run() {
System.out.println("线程跑起来了:" + Thread.currentThread().getName());
}
}
new MyThread().start();6.2实现 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<Integer> task = () -> { Thread.sleep(1000); return 42; };
FutureTask<Integer> future = new FutureTask<>(task);
new Thread(future).start();
Integer result = future.get(); // 阻塞等待结果,拿到 426.4三者对比
| 方式 | 有返回值 | 能抛检查异常 | 受单继承限制 | 推荐度 |
|---|---|---|---|---|
| 继承 Thread | 否 | 否 | 是 | ★ |
| 实现 Runnable | 否 | 否 | 否 | ★★★ |
| 实现 Callable | 是 | 是 | 否 | ★★★ 需返回值时 |
new Thread,而是用线程池统一管理(见第 12 章)。手动创建不可控、不可复用,高并发下容易耗尽系统资源。synchronized:从用法到锁升级
7.1三种用法,锁的是不同的东西
| 用法 | 锁的对象 | 范围 |
|---|---|---|
| 修饰实例方法 | 当前实例 this | 同一对象的所有同步实例方法互斥 |
| 修饰静态方法 | 当前类的 Class 对象 | 整个类级别互斥(所有实例共享) |
| 修饰代码块 | 括号里指定的对象 | 锁粒度最细,最灵活 |
7.2底层原理:对象头与 Monitor
每个 Java 对象在内存里都有对象头(Object Header),其中一块叫 Mark Word,存着锁状态信息(锁标志位、指向锁记录的指针等)。
- 同步代码块编译后生成
monitorenter和monitorexit两条字节码,表示"获取 / 释放监视器锁"。 - 同步方法则在方法访问标志上加
ACC_SYNCHRONIZED,JVM 进入时自动加锁、退出时自动解锁(含异常退出)。
监视器(Monitor)可理解成每个对象自带的一把"门锁",同一时刻只能一个线程拿锁进临界区,其他线程在门口排队(进入 BLOCKED)。
7.3锁升级:synchronized 为什么不再"重"
早期 synchronized 每次加锁都要向操作系统申请互斥量(重量级锁),涉及用户态到内核态切换,非常慢。JDK 1.6 引入锁升级(锁膨胀)机制:锁随竞争激烈程度从轻到重逐级膨胀,且只能升级不能降级。
无锁
没有任何竞争
偏向锁
一个线程反复获取,只在 Mark Word 记线程 ID,连 CAS 都省
轻量级锁
轻度竞争,CAS 改对象头 + 自旋空转重试
重量级锁
激烈竞争,线程挂起进阻塞队列,交给操作系统
7.4自旋锁与适应性自旋
膨胀到重量级锁前的"自旋",是用 while 空转代替线程挂起。挂起 / 唤醒要切换内核态,开销大;若锁很快释放,自旋几圈拿到反而更划算。JDK 还做了适应性自旋——根据上次自旋是否成功,动态调整这次的自旋次数,越来越聪明。
volatile:轻量级的可见性保证
volatile 比 synchronized 轻得多,但能力也小得多。它只做两件事:保证可见性 + 禁止指令重排序。
8.1保证可见性
被 volatile 修饰的变量,写操作立即刷回主内存,读操作强制从主内存读(不走工作内存旧副本)。这就解决了第 4.2 节的 running 死循环 bug:
volatile boolean running = true; // 加上 volatile
while (running) { /* ... */ } // B 改了之后 A 能立刻看到,正常退出底层通过 CPU 的缓存一致性协议(如 MESI)和内存屏障实现:写 volatile 后插一道屏障,强制刷缓存并让其他核心的缓存失效。
8.2禁止指令重排序
这正是 DCL 单例必须给 instance 加 volatile 的原因——防止"对象还没初始化完,引用就先被赋值"的半成品问题:
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 一句话总结
| 对比项 | volatile | synchronized |
|---|---|---|
| 原子性 | ✗ 不保证 | ✓ 保证 |
| 可见性 | ✓ | ✓ |
| 有序性 | ✓ | ✓ |
| 阻塞 | 不阻塞线程 | 会阻塞 |
| 适用 | 状态标志、一写多读 | 复合操作、临界区 |
| 开销 | 很小 | 相对大(虽已优化) |
CAS 与原子类:无锁编程的基石
加锁(悲观锁)的思路是"先把门锁上,谁也别想进"。但锁有开销,竞争不激烈时显得浪费。CAS 提供了另一条路:不锁门,改之前先看看东西有没有被人动过。
9.1CAS 是什么
CAS = Compare And Swap(比较并交换),一条 CPU 原子指令,接收三个值:
当前值
内存里变量当前的值
预期旧值
我预期它应该是的旧值
新值
我想把它改成的新值
逻辑:如果 V == A(没人动过),就把 V 改成 B;否则什么都不做,返回失败。整个"比较 + 交换"由硬件保证原子,中间不会被打断。失败了通常配合循环不断重试,这叫自旋 CAS:
do {
int oldValue = value; // 读旧值
int newValue = oldValue + 1; // 算新值
} while (!compareAndSwap(oldValue, newValue)); // 失败就重来9.2CAS 的两个经典问题
解药:用
AtomicStampedReference 加版本号,CAS 时连版本号一起比,A→B→A 后版本号变了就能识别。解药:限制自旋次数后退化为阻塞,或用分段思想分散竞争(见 LongAdder)。
9.3原子类家族
| 类别 | 代表类 | 用途 |
|---|---|---|
| 基本类型 | AtomicInteger、AtomicLong、AtomicBoolean | 原子的数值 / 布尔操作 |
| 数组 | AtomicIntegerArray 等 | 原子地更新数组某元素 |
| 引用 | AtomicReference、AtomicStampedReference(带版本) | 原子地更新对象引用 |
| 字段更新器 | 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。@Contended 注解做缓存行填充,让每个 Cell 独占一行。AQS 与 Lock 体系
CAS 是无锁的"原子单点操作",而要构建真正的锁、信号量、闭锁这些复杂同步工具,就需要一个统一的底层框架——AQS。JUC 里绝大多数同步组件都基于它搭出来。
10.1AQS 是什么
AQS = AbstractQueuedSynchronizer(抽象队列同步器),把"管理锁 / 同步状态"抽象成两个核心部分:
- 一个 volatile int 的
state:表示同步状态,含义由子类定义——ReentrantLock 里 0 没人持锁 / 1 被持有 / N 重入 N 次;Semaphore 里 state 是剩余许可数;CountDownLatch 里 state 是还需 countDown 几次。 - 一个 FIFO 等待队列(CLH 变体):抢不到锁的线程被包装成 Node 排进双向链表挂起,轮到自己时被唤醒去抢。
10.2AQS 的两种工作模式
| 模式 | 含义 | 代表 |
|---|---|---|
| 独占 Exclusive | 同一时刻只有一个线程能拿到同步状态 | ReentrantLock、写锁 |
| 共享 Shared | 同一时刻允许多个线程拿到同步状态 | Semaphore、CountDownLatch、读锁 |
AQS 用模板方法模式:把"怎么排队、怎么挂起唤醒"写死,把"怎么判定能不能拿到锁"(tryAcquire / tryRelease)留给子类。构建新同步器只需重写几个方法、定义好 state 含义。
10.3ReentrantLock:可重入的显式锁
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 临界区
} finally {
lock.unlock(); // 必须在 finally 里手动释放!
}它比 synchronized 多出的能力:可中断(lockInterruptibly())、可超时(tryLock(timeout),能避免死锁)、公平 / 非公平可选、一个锁可创建多个 Condition 实现精细的分组等待 / 唤醒。
unlock() 且放在 finally 里,否则临界区一旦抛异常,锁永远不释放,造成死锁。synchronized 由 JVM 自动释放,更省心。10.4ReentrantLock vs synchronized
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 关键字 | JDK 类库(基于 AQS) |
| 锁释放 | 自动 | 手动(finally unlock) |
| 可中断 | 否 | 是 |
| 超时获取 | 否 | 是 |
| 公平锁 | 仅非公平 | 可选公平 / 非公平 |
| 条件变量 | 只有一个等待队列 | 可有多个 Condition |
synchronized 就用它(简单、自动释放、JVM 持续优化);只有需要"可中断、可超时、公平锁、多条件"这些高级特性时才上 ReentrantLock。10.5读写锁与 StampedLock
- ReentrantReadWriteLock:把锁拆成读锁和写锁。读读共享、读写互斥、写写互斥,适合读多写少(如缓存),多个读线程能同时进。
- StampedLock(JDK 1.8):读写锁升级版,增加乐观读模式——读时先不加锁,读完再校验期间有没有被写过,没写过就直接用。彻底避免读线程阻塞写线程,性能更高,但不可重入、用法更复杂。
锁的各种"形容词"
并发里关于锁的术语特别多,很多其实是从不同角度给同一把锁贴的标签。理清角度,名词就不再混乱。
11.1乐观锁 vs 悲观锁(看待冲突的态度)
悲观锁
假设"每次访问都会冲突",先加锁再操作。代表:synchronized、Lock。适合写多、冲突频繁。
乐观锁
假设"大概率不冲突",不加锁,更新时才检查有没有被改过。代表:CAS、数据库版本号。适合读多写少。
11.2公平锁 vs 非公平锁(排队规则)
- 公平锁:严格按申请顺序排队,先到先得,不会饿死。但维护队列有开销、吞吐量较低。
- 非公平锁:新来的线程可"插队"直接抢,抢不到再排队。吞吐量更高,但可能有线程长期抢不到(饿死)。
synchronized 是非公平的;ReentrantLock 默认非公平,可选公平。
11.3可重入锁 vs 不可重入锁(能否重复获取)
可重入指:一个线程已持有某把锁,再次请求同一把锁时能直接拿到,不会把自己锁死。
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.1为什么不能手动 new Thread
- 创建 / 销毁开销大:每个线程都要分配栈内存、向操作系统申请资源。
- 数量失控:来一个请求 new 一个线程,高并发下瞬间几万个,内存爆掉 + 上下文切换拖垮 CPU。
- 难以管理:无法统一监控、限流、复用。
线程池的核心思想是复用:预先创建一批线程,任务来了丢给空闲线程跑,跑完不销毁、回池等下一个任务。
12.2七大核心参数
new ThreadPoolExecutor(
corePoolSize, // 1. 核心线程数:常驻员工,即使空闲也不裁
maximumPoolSize, // 2. 最大线程数:核心 + 临时工的上限
keepAliveTime, // 3. 空闲存活时间:临时工闲多久就裁掉
unit, // 4. 时间单位
workQueue, // 5. 任务阻塞队列:忙不过来时的"待办清单"
threadFactory, // 6. 线程工厂:怎么创建线程(可自定义线程名)
handler // 7. 拒绝策略:实在扛不住了怎么办
);12.3任务来了,线程池的处理流程
LinkedBlockingQueue)时,maximumPoolSize 永远不生效——队列永远塞得下。12.4四种拒绝策略
AbortPolicy
直接抛 RejectedExecutionException 异常
CallerRunsPolicy
让提交任务的线程自己执行,变相降速、给池子喘息
DiscardPolicy
默默丢弃新任务,不报错(任务悄无声息没了)
DiscardOldestPolicy
丢弃队列里最老的任务,腾位置给新任务
CallerRunsPolicy 很有用——它形成一种背压,提交太快时让提交方自己干活,自然把速度压下来,且不丢任务。12.5为什么阿里规约禁用 Executors
| 方法 | 特点 | 隐患 |
|---|---|---|
newFixedThreadPool | 固定线程数 | 无界队列,任务堆积可能 OOM |
newSingleThreadExecutor | 单线程,保证顺序 | 同样无界队列,OOM 风险 |
newCachedThreadPool | 线程数可无限增长 | 上限 Integer.MAX_VALUE,可能海量线程 OOM |
newScheduledThreadPool | 支持定时 / 周期 | 同样有队列隐患 |
Executors 创建线程池,必须手动 new ThreadPoolExecutor——无界队列和无上限线程数会在流量突增时把内存撑爆。手动创建能强制你设定有界队列和合理拒绝策略。12.6怎么设置线程池大小
核数 + 1
大量计算、少 I/O。线程太多只会增加无谓的上下文切换。
核数 × (1 + 等待/计算)
大量等待网络 / 磁盘。线程大部分时间在等 I/O,可多开几个填满 CPU 空闲。
以上为经验法则,实际需结合压测确定。
并发容器: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.7 | JDK 1.8 |
|---|---|---|
| 结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 插入链表 | 头插法 | 尾插法 |
| 扩容并发问题 | 多线程下可能形成环形链表,导致死循环 | 改用尾插法,消除环形链表 |
get() 不存在的 key 会陷入死循环,CPU 飙到 100%。但注意:1.8 解决的只是死循环,HashMap 多线程下依然不安全(仍可能丢数据、读脏值),多线程必须用 ConcurrentHashMap。13.2ConcurrentHashMap 的演进
分段锁 Segment
整张表分成默认 16 个 Segment,每个一把独立锁。操作哪段只锁哪段,理论支持 16 线程同时写不同段。
CAS + synchronized
放弃分段锁,锁粒度细化到每个桶:CAS 处理空桶首次插入(无锁),synchronized 锁桶头节点处理冲突链表 / 树。
为什么 1.8 放弃分段锁?① Segment 数组占额外内存且每个是较重的 ReentrantLock;② 锁粒度仍太粗(一段含多个桶),同段不同桶操作仍互相阻塞;③ 1.6 后 synchronized 锁升级优化已不慢,直接锁单个桶头,粒度更细、内存更省。
13.3HashMap vs ConcurrentHashMap
| 特性 | HashMap | ConcurrentHashMap |
|---|---|---|
| 线程安全 | ✗ 不安全 | ✓ 安全 |
| null 键 / 值 | 允许 | 不允许(抛 NPE) |
| 性能 | 单线程最快 | 多线程下远好于 Hashtable |
get 返回 null 时没法判断是哪种,会产生歧义。单线程 HashMap 可再用 containsKey 确认,但并发环境下这个二次确认不可靠,干脆禁止 null。13.4其他常用并发容器
- CopyOnWriteArrayList:写时复制。写时复制新数组改完再替换,读完全不加锁。适合读极多写极少(黑白名单、监听器列表),代价是写开销大、占内存。
- ConcurrentLinkedQueue:基于 CAS 的无锁并发队列,高性能。
- BlockingQueue:生产者消费者的利器,满时 put 阻塞、空时 take 阻塞。实现有
ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue(手递手)、PriorityBlockingQueue。线程池的工作队列就是它。
JUC 同步工具类
java.util.concurrent 提供了几个开箱即用、全部基于 AQS 的协调工具。
14.1CountDownLatch(倒计时门闩)
让一个或多个线程等待其他若干线程完成后再继续。计数器一次性的,减到 0 就开门,不能重置。
CountDownLatch latch = new CountDownLatch(3); // 等 3 个任务
latch.countDown(); // 每个子任务完成时 计数 -1
latch.await(); // 主线程阻塞直到计数归 0
System.out.println("3 个任务全完成,继续");典型场景:主线程等所有子任务跑完汇总;或运动员就绪后裁判一声令下同时起跑。
14.2CyclicBarrier(循环栅栏)
让一组线程互相等待,全部到达"屏障点"后再一起继续。与 CountDownLatch 最大区别是可循环复用(通过后自动重置)。
CyclicBarrier barrier = new CyclicBarrier(3, () -> System.out.println("三人到齐,出发"));
barrier.await(); // 到这里等其他人,凑够 3 个一起放行14.3Semaphore(信号量)
控制同时访问某资源的线程数量,本质是"发许可证"。
Semaphore semaphore = new Semaphore(3); // 最多 3 个线程同时进
semaphore.acquire(); // 拿一张许可(没了就阻塞)
try { /* 访问受限资源 */ }
finally { semaphore.release(); } // 还回许可典型场景:限流、连接池、停车场限位(3 个车位,来第 4 辆就等)。
14.4三者对比
| 工具 | 核心语义 | 可否重用 | 典型场景 |
|---|---|---|---|
| CountDownLatch | 一个 / 多个线程等 N 个事件完成 | 否 | 主线程等子任务汇总 |
| CyclicBarrier | N 个线程互相等待集合 | 是 | 分阶段并行计算 |
| Semaphore | 限制并发访问数量 | 是 | 限流、资源池 |
ThreadLocal:线程隔离与内存泄漏陷阱
15.1它解决什么问题
有些变量我们希望每个线程独享一份、互不干扰,比如数据库连接、用户登录态、SimpleDateFormat(它本身线程不安全)。ThreadLocal 就是给每个线程一个独立副本,一个线程改自己的不影响别的。
ThreadLocal<SimpleDateFormat> sdf =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
String today = sdf.get().format(new Date()); // 每个线程拿到专属实例,互不干扰15.2实现原理
每个 Thread 内部有一个 ThreadLocalMap 字段。threadLocal.set(value) 实际是以当前 threadLocal 实例为 key、value 为值,存进当前线程的这个 map。get() 也从当前线程的 map 按 key 取。因为操作的永远是"当前线程自己的 map",所以天然隔离。
15.3内存泄漏陷阱
ThreadLocalMap 的 Entry 设计很微妙:key 是对 ThreadLocal 的弱引用,value 是强引用。
key 用弱引用,是为了在 ThreadLocal 对象不再被外部使用时能被 GC 回收(key 变 null)。但问题来了:key 被回收变 null 后,value 还被强引用着无法回收。若这是线程池里的长期存活线程,这个"key=null 但 value 还在"的 Entry 就会一直占内存——这就是 ThreadLocal 内存泄漏。
try {
threadLocal.set(something);
// 使用
} finally {
threadLocal.remove(); // 关键!尤其在线程池环境下
}remove() 既防泄漏又防串数据,是必须的纪律。15.4InheritableThreadLocal
普通 ThreadLocal 在子线程里读不到父线程设的值。InheritableThreadLocal 能让子线程继承父线程的值(创建时拷贝过去)。但在线程池里会失效(线程是复用而非新建的),这种场景需要阿里开源的 TransmittableThreadLocal。
四种引用类型与 GC
引用类型决定对象在什么时候被垃圾回收,它和并发缓存设计密切相关(软 / 弱引用做缓存、虚引用做资源清理)。
强引用
只要还有强引用就永远不回收(哪怕 OOM)。普通的 Object o = new Object()。
软引用
内存够时不回收,内存不足时才回收。适合内存敏感的缓存(图片缓存)。
弱引用
只要发生 GC 就回收,不管内存够不够。用于可有可无的缓存、ThreadLocalMap 的 key。
虚引用
随时可能被回收,get() 永远返回 null。用于跟踪回收时机、管理堆外内存。
IO 模型:BIO / NIO / AIO
IO 模型属于"广义并发"——它关心一个线程怎么高效处理大量网络连接,是高性能服务器(Netty、Redis、Nginx)的命脉。
17.1先理解两个维度
- 阻塞 vs 非阻塞:发起 IO 后,线程是傻等,还是立刻返回去干别的、过会儿再问。
- 同步 vs 异步:数据真正读写的活,是你自己干,还是交给操作系统干完通知你。
17.2NIO 的三大核心组件
NIO 是高并发服务器的主流方案:
| 组件 | 作用 | 比喻 |
|---|---|---|
| Channel 通道 | 双向数据传输管道(可读可写) | 水管 |
| Buffer 缓冲区 | 数据的容器,读写都先经过它 | 水桶 |
| Selector 多路复用器 | 一个线程监控多个 Channel,哪个有数据就处理哪个 | 一个保安看一墙监控屏 |
17.3三者对比
| 特性 | BIO | NIO | AIO |
|---|---|---|---|
| IO 模型 | 同步阻塞 | 同步非阻塞 | 异步非阻塞 |
| 数据导向 | 面向流 Stream | 面向缓冲区 Buffer | 面向缓冲区 |
| 线程模型 | 一连接一线程 | 一线程管多连接(Selector) | 操作系统回调 |
| 编程复杂度 | 简单 | 较复杂 | 复杂 |
| 适用场景 | 连接少且固定 | 连接多且短 | 连接多且长 |
死锁:成因、定位与预防
18.1什么是死锁
两个(或多个)线程互相持有对方需要的资源,又都在等对方先释放,结果谁也不让。
// 线程 1:先拿锁 A,再去拿锁 B
synchronized (lockA) { synchronized (lockB) { ... } }
// 线程 2:先拿锁 B,再去拿锁 A —— 顺序相反,危险!
synchronized (lockB) { synchronized (lockA) { ... } }
// 若线程1拿到A、线程2拿到B,然后都等对方手里的锁 → 死锁18.2死锁的四个必要条件
四个条件同时满足才会死锁,破坏任意一个即可预防:
互斥
资源同一时刻只能被一个线程占用。
请求并保持
持有一些资源,又在请求新资源时不释放已有的。
不可剥夺
已分配的资源不能被强行抢走,只能持有者主动释放。
循环等待
存在一个线程等待环路(A 等 B、B 等 C、C 等 A)。
18.3怎么预防
- 破坏"请求并保持":一次性申请所有需要的锁,要么全拿到要么都不拿。
- 破坏"不可剥夺":用
tryLock(timeout),拿不到就放弃已有锁、退出重试(ReentrantLock 能做到,synchronized 不行)。 - 破坏"循环等待"(最常用):给所有锁定义全局顺序,所有线程都按同一顺序加锁。上例只要让线程 2 也先拿 A 再拿 B,环就破了。
18.4怎么定位
线上卡死、CPU 不高但请求无响应,多半是死锁。排查工具:jstack <pid> 打印线程栈会直接标出 Found one Java-level deadlock;jconsole / VisualVM 图形化检测;关注处于 BLOCKED 状态、互相等待对方锁的线程。
生产者消费者模型
几乎所有消息队列、线程池都是它的变体。
19.1核心思想
生产者产生数据放进缓冲区,消费者从缓冲区取数据处理。中间用一个缓冲区(队列)解耦:
互不依赖
生产者和消费者不直接通信。
暂存缓冲
生产快时数据暂存队列,消费者慢慢处理,应对流量波动。
缓冲速度差
生产和消费速度不一致时由缓冲区缓冲。
19.2两种实现方式
方式一:wait / notify(传统)
synchronized (queue) {
while (queue.isFull()) { // 注意必须用 while 不能用 if(防虚假唤醒)
queue.wait(); // 满了,生产者等待并释放锁
}
queue.add(item);
queue.notifyAll(); // 唤醒在等的消费者
}wait/notify 必须在 synchronized 块内、成对配合使用。方式二:BlockingQueue(推荐)——把"满了等待、空了等待"全封装好,代码极简:
BlockingQueue<Item> queue = new ArrayBlockingQueue<>(10);
queue.put(item); // 生产者:队列满时自动阻塞
Item item = queue.take(); // 消费者:队列空时自动阻塞实际开发中几乎都用 BlockingQueue,wait/notify 主要用来理解原理。
工程实践与避坑清单
20.1CompletableFuture:现代异步编程
Future.get() 是阻塞的,且不能优雅地组合多个异步任务。CompletableFuture(JDK 1.8)支持链式回调、任务编排:
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、栈封闭、局部变量本就线程安全——最好的同步是"不需要同步"。
高频面试题速答
Qsynchronized 和 volatile 的区别?▾
Qsynchronized 锁升级过程?▾
QCAS 是什么?有什么问题?▾
QAQS 的原理?▾
state 表示同步状态 + 一个 FIFO 等待队列。抢不到的线程入队挂起。模板方法模式,子类定义 state 含义和获取 / 释放规则。ReentrantLock、Semaphore、CountDownLatch 都基于它。QReentrantLock 和 synchronized 怎么选?▾
Q线程池任务处理流程?▾
Q为什么不用 Executors 创建线程池?▾
QConcurrentHashMap 1.7 和 1.8 区别?▾
QHashMap 为什么线程不安全?1.7 的死循环?▾
QThreadLocal 内存泄漏原因?▾
remove()。Q死锁四条件?怎么破?▾
Qsleep 和 wait 的区别?▾
QBIO / NIO / AIO 区别?▾
全书一图速记
这份手册以"三大并发问题"为主线重新组织:JMM 暴露了问题 → CAS / 锁解决了问题 → AQS 把锁标准化 → 线程池 / 并发容器 / JUC 工具是上层封装。理解了这条链,遇到新的并发组件也能快速归位。