零基础学习书 · Java 21
从零开始学 Java 高并发
用实验理解原理,用场景准备面试
18 章讲解 · 5 个完整实验 · 26 道面试问答
下载 EPUB · 下载配套源码 · Apache 开源书籍
从目录选择章节,或搜索全书。阅读位置与字号保存在当前浏览器。
写给第一次学习并发的你
你也许见过这样的招聘要求:“熟悉多线程、JUC、线程池,理解高并发。”这些词像一堵墙。本书的目标是把墙拆成台阶:先知道程序为什么会出错,再学会选择工具,最后练习解释自己的选择。
并发知识不是背出几个类名,而是回答三个问题:哪些状态被共享?哪些操作必须作为一个整体完成?任务和资源怎样受到限制? 学到后面,仍然请反复问这三个问题。
本书面向能读懂变量、方法、循环和类的初学者。如果你完全没有写过 Java,先完成第 1 章,再开始实验。你不需要先学完 JVM 源码,也不需要一上来理解汇编。
正文分成四段:第 1—4 章打基础,第 5—10 章学习工具,第 11—14 章连接真实系统,第 15—18 章集中实验、面试和复习。第一次读不必记忆全部 API;先运行实验,看到现象,再回来看解释。
本书使用 JDK 21 作为代码基线。Java 8 常见的线程、锁、原子类、线程池仍是核心;虚拟线程示例需要 Java 21。涉及 Java 24 的变化会明确标出。JDK 的实现细节可能变化,优先记住规范与 API 保证,不把某个版本的内部代码当成永恒规则。
资料以 Java 语言规范、Oracle API 文档和 OpenJDK JEP 为主要依据,参考资料集中在书末,正文用“资料 R1”等编号指向来源。这是学习与面试准备书,不宣称覆盖所有公司的试题,也不提供未经来源支持的“出现频率排名”。
怎样使用这本书
每次学习控制在 40—60 分钟:读一小节,运行一个例子,用自己的话解释一次。练习中的错误输出不一定每次出现;并发调度没有义务按你期待的顺序发生。“运行一百次没问题”不等于证明线程安全。
面试回答建议用四步:先说结论,再讲机制,然后给例子,最后说明适用条件。比如回答 volatile:它能为读写提供可见性与相关的顺序保证,但不能把自增变成原子操作;共享计数可以用原子类,多个字段共同维护约束时通常要用锁。
第 1 章 先搭好能运行的学习环境
1.1 你需要的最少 Java 知识
变量保存值,方法组织动作,类描述数据和行为。对象是类创建出来的实例。new Counter()
会创建对象;两个变量可以指向同一个对象,因此两个线程也可能操作同一个对象。
int n = 0 表示整数变量。n++
看起来是一句,含义却包含读取旧值、计算新值和写回。static
成员属于类,在同一个类加载器所定义的这个类的范围内共享;它并不会自动获得线程安全。
异常表示操作失败。try 放正常流程,catch
处理异常,finally 执行清理。学习锁时,你会经常看到在
finally 中释放锁,因为中途抛异常也不能把锁永远占住。
接口描述可以做什么;Runnable 表示一个没有返回值、且
run 方法不声明受检异常的任务。Lambda
() -> System.out.println("你好")
可以看作一段稍后执行的行为。先能读懂,不必马上研究语言设计。
1.2 安装与验证
安装一个可信发行版的 JDK 21,例如 Eclipse Temurin 21。确保安装的是
JDK,它同时包含运行器 java 和编译器
javac。在终端执行:
java -version
javac -version
两者都应显示 21 或兼容的更高版本。若只安装了运行环境、命令不存在,或两者主版本不同,先修正 PATH 和 JAVA_HOME。不要在尚未确认版本时调试虚拟线程代码。
新建 Hello.java,注意文件名与公开类名完全一致:
public class Hello {
public static void main(String[] args) throws Exception {
Thread worker = new Thread(() ->
System.out.println("工作线程:" + Thread.currentThread().getName()));
worker.start();
worker.join();
System.out.println("主线程:工作完成");
}
}
运行:
javac -encoding UTF-8 Hello.java
java Hello
start() 请求启动新线程;join()
等待它结束。如果直接调用
worker.run(),只是当前线程执行一个普通方法。主线程通过
join()
确认子线程已经结束,再继续打印;这比“随便睡一秒,估计它结束了”可靠得多。
自测: 删除 join()
后,最后一句是否仍一定最后打印?答案是否定的。线程启动先后不能保证所有动作的完成先后。
第 2 章 并发、高并发与线程到底是什么
2.1 餐厅里的三件事
一个厨师交替处理几张订单,是并发:多个任务的执行过程在时间上交错。多个厨师同时炒菜,是并行:某个时刻真的在同时执行。很多客人一起下单,是高并发需求:系统需要在重叠的时间段内处理很多请求。
三者有关,但不同。单核 CPU 可以通过切换实现并发;多核让并行成为可能。一个服务可以用异步 I/O 处理很多连接,而不为每个连接安排一个长期占用的操作系统线程。线程数不是高并发能力的成绩单。
进程通常是资源隔离的基本单位,线程是进程内的执行路径。Java 线程有各自的方法调用栈,但可以访问同一堆里的对象。共享对象带来协作,也带来冲突。
2.2 不共享,往往最简单
假设每个请求都创建自己的局部
ArrayList,不把它交给其他线程,那么通常不需要为这份列表加锁。局部变量如果保存了共享对象的引用,该对象仍可能被多个线程访问;“局部变量都线程安全”是过度概括。
线程安全的常见路线依次是:避免共享;共享不可变对象;使用已经封装好并发控制的类;确有必要再自行用锁维护约束。不可变对象要求构造完成后其可观察状态不再变化,也要避免把内部可变对象直接暴露出去。只有
final List 并不能阻止列表内容被修改。
2.3 三个指标,别混在一起
吞吐量是每秒完成多少请求,常用 QPS 表示。延迟是单个请求耗时,P99 表示约 99% 的观测请求耗时不超过该数值。并发量是在系统中同时尚未完成的任务数。
在稳定系统、同一统计边界、长期平均的条件下,Little 定律给出:平均系统内请求数 ≈ 到达率 × 平均停留时间。假设每秒处理 200 个请求、平均每个停留 0.1 秒,平均约有 20 个请求处于处理中。这个估算不是容量保证,也不能把 P99 直接代入当作平均值。
本章面试练习: 为什么增加线程可能更慢?因为 CPU 核数、锁竞争、上下文切换、内存和下游容量都有成本;线程增加不能创造数据库连接和 CPU 算力。
第 3 章 线程的生命周期与合作退出
3.1 六种状态
Java 的 Thread.State
有六种:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。资料
R2 对并发包作整体介绍,具体线程状态以 Thread.State API
为准。
| 状态 | 初学者可用的理解 | 常见触发 |
|---|---|---|
| NEW | 创建了,尚未启动 | new Thread(...) |
| RUNNABLE | 可运行,包含正在执行或等待系统调度 | 已启动且未进入其他 Java 状态 |
| BLOCKED | 等待进入某个内置监视器 | 竞争 synchronized |
| WAITING | 无指定超时地等待 | wait()、join()、park() |
| TIMED_WAITING | 有时间限制地等待 | sleep、带超时的等待 |
| TERMINATED | 执行结束 | 正常返回或未捕获异常 |
ReentrantLock 竞争中停放的线程常显示 WAITING,而不是
BLOCKED。RUNNABLE 也不能直接解读成“正在吃满 CPU”;具体 I/O
和运行时实现会影响观测。状态快照只是线索。
3.2 sleep、wait、join 的区别
Thread.sleep
让当前线程暂时停止执行,不会释放已持有的监视器锁。obj.wait()
必须在拥有 obj
监视器时调用,会释放这个对象的监视器并等待通知、超时或中断;它不会释放你持有的其他锁。返回前还要重新取得该监视器。
join() 用来等待目标线程结束。不要靠 sleep()
建立先后关系;时间过去了,不代表其他线程完成了,也不提供你需要的通用共享状态同步协议。
3.3 中断是一种请求
interrupt()
通常表示“请尽快停止或改变当前工作”,不是强行杀死线程。任务需要检查中断标记,或者调用会响应中断的阻塞方法。
while (!Thread.currentThread().isInterrupted()) {
// 做一小段可以结束的工作
}
sleep、wait、join 等方法在抛出
InterruptedException
时会清除中断状态。如果当前层不能处理,通常选择向上抛出;若接口不允许抛出,恢复标记并结束当前任务:
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
Thread.interrupted()
查询并清除当前线程的中断标记;isInterrupted()
查询目标线程的标记而不清除。不要把捕获异常后继续无限循环当作“处理了中断”。
练习: 编写一个每隔 100
毫秒执行一次工作的线程,主线程中断它,再
join()。成功标准是工作线程及时退出、进程自然结束;不要求它恰好打印固定次数。
第 4 章 线程安全的三个基本问题
4.1 原子性:整体不能被拆开观察
两人同时更新库存。库存是 1,甲读到 1,乙也读到 1;甲写 0,乙也写 0,结果两个订单都以为自己买到了最后一件商品。
count++ 也有同样问题。即使读写一个 int
本身是原子的,读—改—写这个复合操作仍然不是原子的。原子性必须针对“哪个操作或业务步骤”来讨论。
4.2 可见性:别人能否可靠地看到更新
一个线程更新停止标志,另一个线程持续读取。如果没有正确同步,程序不能假定读者必然及时观察到更新。问题不只是“CPU 缓存没刷新”这一句口诀;编译器与处理器优化也受内存模型约束。应使用规范规定的同步机制建立保证。
4.3 有序性与 happens-before
Java 内存模型 JMM 描述多线程读写可见结果的规则,不是 JVM 内存区域图。happens-before 是规范中的顺序关系:若 A happens-before B,则 A 的相关效果对 B 可见,并受其规定的顺序约束;这不要求所有机器指令按源码原样串行执行。
最常用的规则如下,详见资料 R1 的 17.4.5:
- 同一线程中,前面的动作 happens-before 后面的动作。
- 对同一监视器的解锁 happens-before 后续对它的加锁。
- 对同一 volatile 字段的写 happens-before 后续对它的读。
- 调用线程的
start()happens-before 被启动线程中的动作。 - 一个线程中的所有动作 happens-before 其他线程成功从对它的
join()返回。 - 关系可以传递:A 到 B、B 到 C,则 A 到 C。
可以把这些规则想成可靠的交接手续。你不仅需要把文件写完,还需要用正确方式把“文件已经写完”这件事交给接收者。
4.4 volatile 的能力边界
private volatile boolean stopping;
这个字段适合表达独立的状态标记。写入 true
的线程与之后读到相应状态的线程之间可以建立所需的可见性关系。但
volatile int count; count++;
仍可能丢失更新,因为它没有排除两个线程交错执行整个自增。
volatile 也可以用于发布:先准备数据,再写 volatile 标记,读者观察标记后再读数据。协议必须完整;发布后继续无同步地修改同一份数据,仍可能产生竞争。
面试回答示范: “volatile 解决单个字段读写的可见性和相关顺序问题,不能为复合业务操作提供互斥。独立停止标志可以用它;账户余额与流水一起变更,需要锁或事务等更完整的协调。”
第 5 章 synchronized:先学会保护一条业务规则
5.1 锁保护的是约束
临界区是需要按约定互斥执行的代码。锁对象好比卫生间钥匙:只有持有同一把钥匙的人才彼此排队。两个不同对象上的锁,不能保护同一份共享数据。
final class Stock {
private int remaining = 1;
public synchronized boolean buy() {
if (remaining <= 0) return false;
remaining--;
return true;
}
}
同一个 Stock 对象的 buy()
在同一个实例监视器上互斥。检查库存和扣减库存在一起执行,业务约束才成立。如果把检查和扣减分别放到两个同步方法里,再由调用者分两次调用,中间仍可插入其他线程。
实例同步方法锁住 this;静态同步方法锁住对应的
Class
对象;同步代码块锁住括号中的对象。静态方法和实例方法不会因为都写了
synchronized 就自动互斥。
5.2 为什么它还能提供可见性
锁不只是让人排队。对同一监视器的解锁与后续加锁之间有 happens-before 关系,因此遵循同一锁协议的访问者能获得可见性保证。若写入时加锁、读取时完全绕开协议,不能笼统声称“已经用了锁,所以一定安全”。
synchronized 可重入:同一线程可以再次进入它已经持有的监视器。正常退出同步块或异常退出时都会释放相应监视器。可重入不代表能避免多锁死锁。
5.3 合理的锁范围
锁太小可能没覆盖完整约束;锁太大可能把网络请求、磁盘等待也串行化。一个实用原则是:在锁内读取或修改真正共享的状态,在锁外完成能独立进行的耗时工作,同时检查这样拆分是否改变业务语义。
不要把字符串常量、装箱整数等可能被意外复用的对象作为私有锁。更清楚的写法是
private final Object lock = new Object();,并统一使用它。
版本提醒: 面试常出现“偏向锁→轻量级锁→重量级锁”。这是特定 HotSpot 历史实现的学习线索,不是 Java 规范承诺的固定流程。偏向锁已在较新 JDK 中退出常规使用;先讲互斥、可重入、可见性,再在明确版本的前提下谈实现。
第 6 章 显式锁、条件队列与死锁
6.1 什么时候用 ReentrantLock
当你需要可中断地获取锁、尝试获取锁、限时等待锁,或多个条件队列时,显式锁更方便。普通
lock() 的获取过程不按 lockInterruptibly()
那样响应中断。
lock.lock();
try {
// 访问由这把锁保护的数据
} finally {
lock.unlock();
}
使用带超时的 tryLock 时,只有获取成功才执行
unlock。公平锁倾向按等待顺序授予锁,但会有吞吐成本,也不保证操作系统调度公平;无参
tryLock()
还允许插队。不要把公平理解成严格的全局执行顺序。
6.2 等待条件必须用 while
生产者在队列满时等待,消费者在队列空时等待。条件队列提供“暂时让出锁,等状态可能变化后再试”的能力。
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
return queue.remove();
} finally {
lock.unlock();
}
await() 会释放关联锁,返回之前重新取得锁。用 while
的原因有两个:允许虚假唤醒;被唤醒到重新获得锁之间,条件也可能被别人改变。signal()
只是让等待者有机会继续竞争,不是立即把锁转交过去。修改状态和发信号应遵守同一把锁的协议。资料
R4 提供了完整的条件队列示例。
初学阶段,生产者消费者优先使用
BlockingQueue,先理解它解决了什么,再考虑自己写条件队列。
6.3 死锁与饥饿、活锁
甲持有 A 等 B,乙持有 B 等 A,就是典型死锁。常见必要条件为互斥、持有并等待、不可强制剥夺、循环等待。工程中最容易实施的预防方式是统一加锁顺序,例如总是先锁较小账户编号,再锁较大编号。
超时 tryLock
能帮助退出等待,但失败后必须回滚已取得资源,避免忙于重试且没有进展。两个线程不停互相让步可能形成活锁;某个线程长期拿不到资源是饥饿。
面试场景:“转账锁住两个账户怎么办?”先说明所有路径按稳定顺序加锁,不能只修一个方法。若排序键不唯一,还要处理相等情况;使用
identityHashCode 排序时也不能忽略哈希冲突。
第 7 章 CAS、原子类与高竞争计数
7.1 CAS 是带条件的更新
CAS 可以理解为:如果当前位置仍是我预期的旧值,就把它换成新值;否则失败。它允许竞争者检测“我读取以后有没有发生冲突”,常通过重试完成更新。
AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
这次自增是原子操作。实现细节不应简单概括成“原子类完全无锁、一定更快”;具体操作、实现和竞争程度都重要。大量线程不断失败并重试,会消耗 CPU。
7.2 ABA 为什么麻烦
你看到值是 A,别人把它改成 B 又改回 A,之后你比较发现仍是
A,无法识别中间发生过变化。某些基于节点身份的算法会因此出错。AtomicStampedReference
用引用加版本戳帮助识别变化,但需要调用者正确维护版本,不能神奇地修复全部生命周期问题。
更新函数可能因竞争被重复计算。传给 updateAndGet 等 API
的函数应没有需要“恰好一次”的外部副作用;不要在里面发送扣款请求。
7.3 AtomicLong 与 LongAdder
AtomicLong
适合需要单个计数值的原子更新、比较与决策。LongAdder
在竞争激烈的统计计数场景把更新分散到多个位置,读取时汇总,常有更好的吞吐,但占用更多空间。
LongAdder.sum()
不是对并发更新的原子快照。用它统计访问次数通常合适;用它执行“若总数小于
100
就扣减名额”的严格业务条件通常不合适。正确性首先决定选型,性能其次。
自测: 两个 AtomicInteger
分别保存余额和总资产,能保证它们一起变动吗?不能。每个变量自己的原子性不自动组成跨变量事务。
第 8 章 并发容器与任务协作工具
8.1 ConcurrentHashMap 不等于业务自动原子
普通 HashMap
不支持无外部同步的并发修改。ConcurrentHashMap
提供并发访问能力,不允许 null 键和值,并提供
putIfAbsent、compute、merge
等复合操作。
ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/orders", key -> new LongAdder()).increment();
上面适合累加统计。相反,if (!map.containsKey(k)) map.put(k, v);
是两个操作,期间可以插入其他线程。映射函数应短小,不要塞入慢网络调用,也不要随意递归更新同一个映射。
容器的并发迭代通常不会给你一个冻结的全局快照。多个 key 之间的业务不变量仍需要另行协调。Java 8+ 常见实现也不应再被描述为“固定使用 Segment 分段锁”;那是旧实现知识。
8.2 三种常见容器怎么选
CopyOnWriteArrayList
修改时复制底层数组,适合读多写少、列表较小、可以接受迭代快照的监听器配置。写入多或数据量大时,复制与内存成本很高。
ArrayBlockingQueue
是有界阻塞队列,适合明确限制积压任务。put
在满时等待,take 在空时等待,offer
可以立即或限时失败。LinkedBlockingQueue
如果不指定容量,上限非常大,应用层面往往等同没有实用限制;积压可能先耗尽内存。
SynchronousQueue
不存储元素,每次交接都需要匹配生产者与消费者。它的含义不是“容量小所以只能一个工作线程”。
8.3 CountDownLatch、CyclicBarrier、Semaphore
CountDownLatch 是一次性的完成门闩。初始值为 N,每完成一项
countDown(),等待者在计数归零后继续。任务应在
finally 中报告完成,否则异常可能让等待者永远等不到。
CyclicBarrier
是可重复使用的集合点:一组参与者都到达后一起继续,适合分阶段计算。参与者中断或超时可能使屏障破损,需要处理
BrokenBarrierException,不能假设其他人继续照常等待。
Semaphore 是许可证计数器。例如只允许 20 个请求同时调用慢接口。拿到许可后才进入;在 finally 里释放。未成功获取就释放会凭空增加许可,这是很隐蔽的错误。它限制同时进行的数量,不等同每秒请求数限制器。
选型练习: 等待三份报表全部下载用门闩或 Future 聚合;限制数据库外的昂贵资源并发用 Semaphore;每轮计算后全部参与者同步再下一轮用屏障。
第 9 章 线程池:面试重点也是生产事故高发区
9.1 为什么复用线程
平台线程的创建、销毁和栈空间都有成本。线程池复用线程、集中管理任务、控制并发。任务是要做的事,线程是执行任务的工人。线程池的价值不仅是“快”,还在于对资源设置边界。
execute(Runnable)
提交没有直接结果句柄的任务;submit 返回 Future。用
submit 后,异常通常被记录在 Future 里,调用
get() 才能通过 ExecutionException
观察;如果从不检查结果,任务失败可能被忽略。
9.2 ThreadPoolExecutor 的七个参数
| 参数 | 含义 | 决策关注点 |
|---|---|---|
| corePoolSize | 核心线程数量阈值 | 正常工作并发 |
| maximumPoolSize | 最大线程数 | 允许的峰值资源占用 |
| keepAliveTime | 超过核心数量的空闲线程存活时间 | 回收速度 |
| unit | 时间单位 | 避免单位错误 |
| workQueue | 等待队列 | 积压上限与排队延迟 |
| threadFactory | 线程工厂 | 命名与异常观测 |
| handler | 拒绝处理器 | 饱和时如何失败或退让 |
允许核心线程超时是另外可配置的行为,不要把 keepAliveTime 说成永远只影响非核心线程。
9.3 提交顺序必须真正理解
在正常运行、线程工厂能创建线程的通常场景中,执行器大体按以下顺序处理任务:当前工作线程数少于 core 时先尝试新建工作线程;否则尝试入队;队列装不下时,再尝试扩展到 maximum;仍不能接纳则拒绝。执行器状态变化存在并发复查,资料 R3 是完整规则的依据。
不是先开满 maximum 再入队。 假设 core=2、max=4、队列容量=3,前两个任务一直阻塞。再提交三个任务通常进入队列;接下来的两个任务促使线程增加到 4;再来一个才会拒绝。这个推演假定前面任务没有完成,真实系统的执行时机会改变观测。
如果使用几乎无界的队列,任务通常一直入队,maximum 可能根本不起扩容作用。队列越长并不代表越稳,它会让请求在排队中耗尽超时预算。
9.4 拒绝策略是业务决策
AbortPolicy 抛出异常,便于明确失败;CallerRunsPolicy 在调用者线程执行,可能形成反馈压力,也可能阻塞 HTTP 请求线程或事件循环;DiscardPolicy 静默丢弃;DiscardOldestPolicy 丢弃队列头部并重试,可能破坏业务语义。
尤其注意:某些静默丢弃策略与 submit 组合时,返回的
Future 可能一直不完成。CallerRunsPolicy
在执行器关闭时也不会替你执行被拒绝任务。不能把它们当成“永不丢任务”的承诺。
对于不能丢的订单,进程内线程池本身不提供持久化保证,应根据业务使用可靠存储、消息队列、重试与幂等机制。
9.5 怎样估算线程数
CPU 密集任务可以从接近可用 CPU 核数的量级起步,再测量。阻塞 I/O 任务常用“核数 ×(1 + 等待时间 / 计算时间)”估算平台线程数量,但这是粗略模型,不能绕开下游连接限制、内存、容器 CPU 配额和延迟目标。
把不同依赖的慢任务放进隔离的执行器,可以减少互相拖累。提交给同一个小线程池的父任务如果同步等待该池中的子任务,可能所有工作线程都在等待,子任务永远得不到执行。
9.6 正确关闭
shutdown()
不再接收新任务,已经接收的任务继续执行。awaitTermination
限时等待结束。shutdownNow()
尝试中断正在执行的任务,并返回尚未开始的排队任务;它不保证任务一定停下,也不替你补偿外部副作用。
退出流程需要时间预算与告警。如果忽略中断或无限阻塞,进程可能迟迟无法退出。实验代码见第 15 章。
第 10 章 Future 与 CompletableFuture:组织异步结果
10.1 异步不是自动更快
异步表示当前调用不必同步等待全部工作完成。两个独立远程调用可以重叠等待,从而缩短总耗时;有依赖关系的步骤仍需要按顺序组合。把原来 100 毫秒的 CPU 计算放进 Future,并不会凭空变成 1 毫秒。
Future 的 get()
会等待结果,也可以指定超时。它可能抛中断、执行失败、超时或取消相关异常。超时表示等待者不再等,不等于底层操作已经停止;必须设计底层网络超时、取消响应和资源清理。
10.2 组合操作读法
thenApply 把值映射成另一个值;thenCompose
把返回的另一个阶段接进链条,避免层层嵌套;thenCombine
合并两个独立阶段;allOf
等待所有阶段完成,但不会自动替你收集各自返回值。
CompletableFuture<String> greeting =
CompletableFuture.supplyAsync(() -> "小林", executor)
.thenApply(name -> "你好," + name);
没有 Async 后缀的回调可能由完成前一阶段的线程执行,也可能由其他完成相关操作的线程执行,不要依赖固定线程。带 Async 后缀但没有显式执行器的操作通常使用默认异步执行设施,常见是公共 ForkJoinPool;阻塞外部调用最好显式提供适当执行器并控制容量。详见资料 R5。
10.3 失败处理与超时
exceptionally 将异常恢复为值;handle
同时处理成功与失败并产生新结果;whenComplete
主要用于观察完成情况,它自己抛异常也可能影响返回阶段。
orTimeout 让 CompletableFuture
在超时后异常完成,不保证停止底层请求。cancel(true) 在
CompletableFuture
上也不能按普通线程中断去理解;该实现不使用这个参数控制中断计算。
组合三个服务时,应给整个请求设置截止时间,再给各子操作分配剩余时间。每一层各等 3 秒,串起来可能让总耗时远超 3 秒。
练习: 商品价格与库存并行查询,用
thenCombine
组合结果;给其中一个查询制造异常,先观察异常传播,再决定业务上能否回退为“库存暂不可用”。不要用虚假的
0 库存掩盖系统故障。
第 11 章 ThreadLocal、不可变性与安全发布
11.1 ThreadLocal 是按线程存放数据
ThreadLocal 常用于请求上下文、跟踪 ID 等。它不是给共享变量自动加锁,而是让不同线程通常取得各自绑定的值。如果你把同一个可变对象放进多个线程的 ThreadLocal,它仍是共享对象。
平台线程池会复用线程。上一个请求放入的用户 ID 如果没有清理,下一个请求可能读到错误身份;这既是内存管理问题,也是数据正确性问题。
try {
context.set(requestId);
// 执行这次请求
} finally {
context.remove();
}
常见实现中键为弱引用,而值由线程内映射持有强引用。键被回收并不保证值立即消失。不要把“弱引用”误解成“无需清理”。异步任务切换线程时,也不会自动继承普通 ThreadLocal 上下文;需要明确传递与清理策略。
11.2 final 为什么不等于不可变
final
引用不能重新指向另一个对象,但对象内容仍可改变。真正的不可变对象通常需要私有
final 字段、构造期间不让 this
逃逸、对可变输入做防御性复制、不提供修改方法,也不把内部可变对象直接返回给外界。
正确构造的 final 字段有特殊初始化安全保证,但这不代表可以忽视整个对象图后续修改与发布协议。共享对象的常见安全发布方式包括静态初始化、通过 volatile 引用发布、在同一锁协议下交接、通过并发容器或任务提交等 API 建立的内存一致性关系。
11.3 双重检查单例为什么要 volatile
双重检查锁定在外层无锁读取实例引用,内层加锁检查并初始化。如果采用这种模式,实例字段应是 volatile,以满足正确发布所需的内存模型保证。不要只背“防止 new 被重排”却说不清读者为什么需要看见初始化结果。
初学者常可优先选择静态内部类持有者或枚举单例,减少手写并发协议的负担。单例对象中的可变业务状态仍然需要保护;“单例创建安全”与“单例所有方法安全”是两件事。
第 12 章 虚拟线程:更轻的等待,不是无限容量
12.1 它改变了哪一层
Java 21 正式提供虚拟线程,见资料 R6。它由 JDK 调度到承载它的平台线程上,许多阻塞操作可以挂起虚拟线程并释放承载线程,让“一个任务一个线程”的代码风格用于大量等待型任务。
虚拟线程不让 CPU 算法算得更快;它主要降低大量线程等待时的资源成本。仍要限制数据库连接、外部 API 调用、文件句柄和在途请求。百万个等待者也可能持有百万份请求对象。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Integer> result = executor.submit(() -> 42);
System.out.println(result.get());
}
这是每任务创建虚拟线程的执行器。不要为了“复用更便宜的线程”再建一个固定大小的虚拟线程池;如果要限制某个资源的并发,直接用资源池或 Semaphore 表达限制。
12.2 JDK 21 与 24 的重要差异
在 JDK 21,虚拟线程在某些场景下会固定在承载线程上,例如在 synchronized 相关区域中执行阻塞操作。这可能降低扩展性。应观测真实阻塞与固定情况,而不是机械地把所有 synchronized 都改掉。
JDK 24 的 JEP 491 改进了同步相关的固定问题,使虚拟线程能够在 synchronized 相关阻塞情况下更灵活地卸载。原生代码等其他情形仍需关注。面试说“虚拟线程一进入 synchronized 就必然造成同样的性能问题”,必须说明版本和实际操作;也不能反过来说“从此完全没有固定问题”。资料 R7 给出了设计范围。
大量虚拟线程上使用 ThreadLocal 还会带来每线程数据的内存成本。迁移时首先检查资源边界、超时、取消、上下文传递和观测工具,再讨论把多少平台线程替换掉。
回答示范: “对于大量独立阻塞 I/O,我会评估虚拟线程,简化异步组合代码;对于 CPU 密集任务,它不会增加核数。上线仍需要限流、连接池和压测,并核对 JDK 版本对固定的影响。”
第 13 章 从多线程走向系统高并发
13.1 JVM 内的锁管不到其他进程
单个 Java 进程里的 synchronized 可以保护本进程对象,不能阻止另一台服务器同时修改数据库。处理库存必须把最终约束放在所有写入者都遵守的地方,例如数据库条件更新。
UPDATE stock
SET remaining = remaining - 1
WHERE sku_id = ? AND remaining > 0;
在合适事务与数据模型下,可以根据受影响行数判断是否成功。订单创建、库存扣减、支付与消息发布涉及更大的事务边界,不能只靠这一句 SQL 就宣称全链路一致。
数据库还可以用版本号进行乐观并发控制;版本比较失败要按业务重试或返回冲突。不要不加上限地立即重试,把竞争进一步放大。
13.2 限流、背压、熔断与降级
限流控制进入系统的速率或数量,常见方法包括令牌桶和滑动窗口。背压让上游感受到下游处理不过来,例如有界队列满后拒绝、阻塞或降速。熔断在持续故障时暂时停止部分调用,给系统恢复机会。降级减少非关键工作,例如暂时不展示个性化推荐。
这几种机制目的不同。Semaphore 限并发不直接保证每秒只来 100 次;令牌桶控制速率也不自动限制每个请求持有资源的时间。
缓存可以降低读取压力,却要面对缓存穿透、击穿与雪崩。穿透是不存在的数据反复打到存储,可考虑空值短缓存或布隆过滤器;击穿是热点失效引发大量回源,可考虑单飞合并或受控刷新;雪崩是大量缓存或缓存服务同时失效,可考虑过期打散、故障隔离和回源限流。每一种缓解手段都要说明一致性和可用性代价。
13.3 幂等与重试
网络超时只说明调用方没拿到结果,不代表服务端没执行。如果用户点击支付后超时,再试一次可能造成重复操作。因此需要幂等键、唯一约束、状态机或去重记录,让重复请求不会重复产生业务效果。
重试应有次数上限、退避和抖动,并只对适合重试的失败使用。请求已经超过截止时间时继续排队重试,可能只是在制造无用工作。分布式锁也不是免思考的总开关:租约过期后旧持有者可能继续执行,必要时要由受保护资源校验 fencing token 等防陈旧写入机制。
13.4 小案例:一个查询订单接口
先测出每次请求的 CPU 时间、两次外部调用耗时与数据库连接占用。独立外部调用可以并行,但为各自设置并发上限和超时。数据库连接池有限时,让上游无限扩线程只会把等待搬到连接池。
入口限制在途请求;执行器使用有界队列;超时后停止不再有价值的后续步骤;关键写入具备幂等;监控错误率、P99、队列长度和下游等待。如果吞吐提升却把 P99 从 300 毫秒拉到 5 秒,这次调整可能不满足用户体验目标。
系统面试回答顺序: 先确认规模与一致性要求,再找瓶颈,再提出边界和故障处理,最后说明怎样压测与验证。不要一开口就堆 Redis、Kafka 和分布式锁。
第 14 章 排障:从现象回到证据
14.1 CPU 高与请求慢不是同一问题
CPU 高可能来自有效计算、忙循环、CAS 重试、序列化、GC 或其他原因。请求慢而 CPU 不高,常见原因是锁等待、数据库、网络、排队或连接池等待。先确认影响时间、版本、流量与最近变更,再采集证据。
一个实用排查流程是:定位受影响进程与时间段,结合 CPU 与内存指标,采集多份线程栈,检查执行器队列及下游耗时,再用 JFR 等工具补充事件证据。单份栈只是一张照片,多次出现在同一位置才更有判断价值。
14.2 基本工具
在具备合适权限且目标是对应 Java 进程时,可以使用:
jcmd -l
jcmd <pid> Thread.print -l
jcmd <pid> JFR.start name=study settings=profile duration=60s filename=study.jfr
把 <pid>
替换为进程号,不能原样复制。命令支持、权限和具体选项以目标 JDK
为准,资料 R8 是 JDK 21 工具说明。JFR 文件可用 JDK Mission Control
等工具分析。虚拟线程的观测方式与传统平台线程栈不同,可查询目标 JDK
的线程转储命令与 JFR 事件,不要假设旧工具展示了全部虚拟线程。
生产环境采集会有一定成本,应按现场容量、权限和数据管理要求执行。初学者先在自己的实验进程上练习。
14.3 读栈时问什么
很多线程在等同一监视器:查谁持有它,锁内是否进行了慢 I/O。很多任务在等连接:查连接池是否用满、是否泄漏、数据库是否变慢。线程池全忙且队列上涨:查任务耗时与到达率,扩容前核对下游能否承受。
发现死锁报告后,要找出资源获取顺序并修复协议。简单重启可能暂时恢复服务,却没有消除再次死锁的原因。记录事件时区与精确时间,关联应用日志、数据库与监控。
14.4 怎样做有意义的压测
先预热,再以多个负载台阶观察吞吐、P50/P95/P99、错误率、CPU、GC、队列长度和连接等待。测试数据与下游环境应接近目标场景,避免把缓存命中率变化误判为线程参数优化。
微基准用 JMH 等工具处理预热、JIT 与测量问题;不要用一次
currentTimeMillis()
差值就宣布某把锁比另一把快。并发正确性可以借助压力测试与 jcstress
等工具寻找反例,但没有发现反例不等于形式证明。对本书例子,我们用明确的不变量与同步关系解释正确性。
第 15 章 五个完整实验:运行、观察、解释
这些例子是完整的独立 Java
源文件,和电子书一同提供。将代码保存为对应文件名后,执行
javac -encoding UTF-8 文件名.java,再执行
java 类名。先预测输出,再实际运行。
实验一:丢失更新与原子计数
保存为 CounterLab.java:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
public class CounterLab {
private static int unsafe;
private static final AtomicInteger safe = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
int threads = 4, loops = 100_000;
CountDownLatch start = new CountDownLatch(1);
Thread[] workers = new Thread[threads];
for (int i = 0; i < threads; i++) {
workers[i] = new Thread(() -> {
try {
start.await();
for (int j = 0; j < loops; j++) {
unsafe++;
safe.incrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
workers[i].start();
}
start.countDown();
for (Thread worker : workers) worker.join();
System.out.println("期望=" + threads * loops);
System.out.println("普通=" + unsafe + ",原子=" + safe.get());
if (safe.get() != threads * loops) throw new AssertionError();
}
}
没有外部中断、任务正常执行时,原子结果必须是
400000。普通结果可能更小,也可能碰巧正确,不应断言它一定错。join()
确保主线程等所有更新完成再观察结果;它不能回头把已经丢失的更新补回来。
改造题: 用同一个锁保护普通自增,结果应正确。再只给打印加锁,计数仍然不因此安全。解释它们的差异。
实验二:库存不能卖成负数
保存为 StockLab.java:
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class StockLab {
static final class Stock {
private int remaining = 100;
synchronized boolean buy() {
if (remaining == 0) return false;
remaining--;
return true;
}
synchronized int remaining() { return remaining; }
}
public static void main(String[] args) throws Exception {
Stock stock = new Stock();
AtomicInteger sold = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(8);
Future<?>[] tasks = new Future<?>[1000];
try {
for (int i = 0; i < tasks.length; i++) {
tasks[i] = pool.submit(() -> {
if (stock.buy()) sold.incrementAndGet();
});
}
for (Future<?> task : tasks) task.get();
} finally {
pool.shutdown();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) pool.shutdownNow();
}
System.out.println("售出=" + sold.get() + ",剩余=" + stock.remaining());
if (sold.get() != 100 || stock.remaining() != 0) throw new AssertionError();
}
}
这个例子只提交固定 1000
个很短任务,用于演示互斥;生产中不应不加评估地照搬
newFixedThreadPool
的近乎无界队列。关键不是“用了线程池”,而是所有购买者操作同一个
Stock,且检查和扣减处在同一临界区。
实验三:稳定复现线程池拒绝
保存为 PoolLab.java:
import java.util.concurrent.*;
public class PoolLab {
public static void main(String[] args) throws Exception {
CountDownLatch release = new CountDownLatch(1);
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 4, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
Runnable task = () -> {
try { release.await(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
int accepted = 0, rejected = 0;
try {
for (int i = 0; i < 7; i++) {
try { pool.execute(task); accepted++; }
catch (RejectedExecutionException e) { rejected++; }
}
System.out.println("接收=" + accepted + ",拒绝=" + rejected);
System.out.println("线程=" + pool.getPoolSize()
+ ",排队=" + pool.getQueue().size());
if (accepted != 6 || rejected != 1) throw new AssertionError();
} finally {
release.countDown();
pool.shutdown();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
pool.shutdownNow();
throw new AssertionError("线程池未及时结束");
}
}
}
}
门闩阻止任务提前结束,因此在通常可正常创建线程的环境下,四个线程被占用、两个任务排队,第七个被拒绝。这里用同步工具固定关键条件,而不是依靠
sleep(1000) 赌调度时机。
改造题: 把队列容量改成 10,仍提交七个任务。为什么没有扩到四个线程?回到第 9 章逐步推演。
实验四:有界队列生产者消费者
保存为 QueueLab.java:
import java.util.concurrent.*;
public class QueueLab {
public static void main(String[] args) throws Exception {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(2);
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Future<Integer> consumer = pool.submit(() -> {
int sum = 0;
while (true) {
int value = queue.take();
if (value == -1) return sum;
sum += value;
}
});
Future<?> producer = pool.submit(() -> {
try {
for (int i = 1; i <= 5; i++) queue.put(i);
queue.put(-1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
});
producer.get(5, TimeUnit.SECONDS);
int sum = consumer.get(5, TimeUnit.SECONDS);
System.out.println("合计=" + sum);
if (sum != 15) throw new AssertionError();
} finally {
pool.shutdownNow();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) throw new AssertionError();
}
}
}
-1
是本实验约定的结束标记,数据范围保证不包含它。多消费者时,通常每个消费者都需要结束信号,或采用其他明确的关闭协议。生产者异常退出时也要唤醒或取消消费者;本例通过
Future 超时与 finally 中断避免永久等待。
实验五:虚拟线程与资源上限
保存为 VirtualLab.java,需要 Java 21:
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class VirtualLab {
public static void main(String[] args) throws Exception {
Semaphore permits = new Semaphore(3);
AtomicInteger active = new AtomicInteger();
AtomicInteger peak = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < 20; i++) {
futures.add(executor.submit(() -> {
boolean acquired = false;
try {
permits.acquire();
acquired = true;
int now = active.incrementAndGet();
try {
peak.accumulateAndGet(now, Math::max);
Thread.sleep(20); // 模拟等待外部服务
} finally { active.decrementAndGet(); }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
if (acquired) permits.release();
}
}));
}
for (Future<?> future : futures) future.get();
}
System.out.println("最大同时调用=" + peak.get());
if (peak.get() > 3 || active.get() != 0) throw new AssertionError();
}
}
20 个任务都可以拥有自己的虚拟线程,但昂贵资源区最多同时进入 3 个。观测峰值通常为 3,严格正确性要求是不得超过 3,而不是必须恰好观察到 3。这里 sleep 用来模拟等待,不承担正确性同步职责。
第 16 章 高频主题面试问答与追问
以下是常见知识主题整理,不是特定公司的真实题库或频次统计。先遮住答案说 30 秒,再读解析。
16.1 基础与内存模型
1.并发与并行有什么区别? 并发是多个任务在时间上交错推进,并行是同时执行。单核也能并发,多核才能提供更多 CPU 并行能力。追问:I/O 并发为什么不要求每个请求独占 CPU?因为任务等待外部资源时 CPU 可以处理别的任务。
2.为什么 count++ 不安全? 它是读改写复合操作,两个线程可以读取同一旧值,覆盖对方的增量。追问 volatile 能否修复?不能,应使用锁或原子操作。
3.JMM 与 JVM 内存结构的区别? JMM 规定多线程访问共享数据的允许行为和顺序关系;堆、栈、方法区等是运行时数据区域概念。追问 happens-before:用解锁与后续加锁、start、join 举例。
4.volatile 能做什么? 提供字段读写的可见性与相关顺序保证,适合独立状态标记和正确设计的发布协议,不提供通用互斥。追问多个 volatile 字段能否形成事务?不能。
5.final 引用一定线程安全吗? 不一定,它只是不能改指向;被引用对象可能仍可变。追问如何构建不可变对象:构造完成不逃逸、防御性复制、封闭修改入口。
6.start 与 run 的差别? start 启动新线程执行 run;直接 run 是普通调用。一个 Thread 不能重复 start,任务要重复执行应创建新线程或提交到执行器。
16.2 锁与协作
7.synchronized 锁住什么? 实例方法锁 this,静态方法锁 Class 对象,同步块锁指定对象。互斥要基于同一个监视器。追问两个实例同步方法:不同实例之间通常不互斥。
8.synchronized 与 ReentrantLock 怎么选? 默认先用简单的同步结构;需要限时、可中断获取、多条件队列时考虑显式锁。两者都可以提供互斥和可见性,不应只背“一定谁更快”。
9.wait 与 sleep 的区别? wait 属于对象监视器等待协议,需要拥有该监视器并释放它;sleep 暂停当前线程,不释放已持有的锁。两者都可响应中断。追问为何 wait 用 while:虚假唤醒与条件再次变化。
10.怎样停止线程? 使用协作式中断或正确可见的停止状态,并为阻塞操作设置可取消和超时机制。追问 shutdownNow 能否强杀?不能保证,它主要尝试中断。
11.什么是死锁?如何避免? 多个参与者循环持有并等待资源,彼此无法推进;统一锁顺序、减少嵌套、超时获取与失败回滚是常见手段。追问怎样证明:采集线程栈并识别等待与持有关系。
12.公平锁一定更好吗? 不一定。公平可能改善等待分布,却降低吞吐;也不能保证系统调度公平。追问 tryLock:无参 tryLock 不遵守同样的公平排队策略。
16.3 原子类、容器和线程池
13.CAS 有什么问题? 竞争失败重试可能耗 CPU,存在 ABA 等算法风险,单地址更新也不能直接维护多字段业务约束。追问怎么办:版本戳、合适的数据结构或锁,具体取决于语义。
14.LongAdder 为什么常用于统计? 分散更新缓解热点竞争,汇总读取;并发 sum 不是原子快照,所以不适合严格库存决策。
15.ConcurrentHashMap 为什么仍可能用错? 单个操作的并发安全不等于多个操作组合原子;先查再写仍有竞争。用 compute、merge 等表达合适的复合动作,并让函数短小。
16.线程池什么时候扩到 maximum? 达到 core 后通常先入队,队列不能接纳时再扩展到 max。追问无界队列:可能导致 max 失去实际扩容作用,且积压耗尽内存。
17.execute 与 submit 的异常如何观察? submit 把失败保存在 Future,get 时观察;execute 中未捕获异常会走工作线程的未捕获异常路径等处理机制。应有统一的任务失败观测,不要依赖控制台偶然打印。
18.拒绝策略怎么选? 根据是否允许丢失、是否可阻塞调用者、能否持久化和重试来选择。默认明确失败通常比无声丢弃更易诊断,关键业务仍需要端到端可靠性设计。
19.线程池设多大? 先区分 CPU 与等待型工作,检查 CPU 配额、下游连接、队列和内存,用模型给初始值,再以吞吐与尾延迟压测。没有普适的“CPU × 2 最优”。
20.CountDownLatch 和 CyclicBarrier 的区别? 前者一次性倒计数,可由另一组线程等待;后者让固定参与者分轮集合,可复用。追问异常:屏障可能破损,门闩若漏 countDown 则可能永久等待。
16.4 现代并发与系统设计
21.CompletableFuture 超时会停止底层任务吗? 不保证。它可以让结果阶段超时完成,底层 HTTP、数据库或计算需要自己的取消与超时机制。
22.ThreadLocal 为什么泄漏或串数据? 长寿命线程复用,未清理的值可能长期保留或被下一请求读到。try/finally remove;跨线程上下文需要显式设计。
23.虚拟线程适合什么? 大量独立、阻塞等待为主的任务;不增加 CPU 计算能力。追问 synchronized 固定:说明 JDK 21 与 JDK 24 JEP 491 的差异。
24.高并发下库存怎么不超卖? 把约束放到数据库原子条件更新、事务等所有写入者共同遵守的机制里;单 JVM 的锁不能协调多实例。再处理重复下单、重试、幂等与失败补偿。
25.接口突然变慢怎么办? 对齐故障时间、流量与变更,观察 CPU、GC、执行器队列、连接池和下游延迟,多次线程栈与 JFR 验证猜测。不要尚未定位就盲目扩大线程池。
26.如果不知道底层实现怎么回答? 先说明自己知道的规范保证与使用边界;明确实现版本待确认。能画出正确交互和失败路径,通常比背错源码细节更有价值。
第 17 章 场景题与代码审查训练
场景一:查完缓存再写入,仍然重复加载
代码是
if (cache.get(k) == null) cache.put(k, load(k));。两个线程可以同时看到缺失,然后都加载。先确认重复加载是否可接受;若代价大,可设计按
key 合并在途请求,或谨慎使用
computeIfAbsent。慢加载放进映射函数会影响相关更新,要考虑超时、异常、失败移除与热点隔离。
评估答案时问:是否只防止了一个进程内的重复?多实例同时回源怎么办?缓存失效时能否保护数据库?不要把一个 API 调用描述成完整的分布式缓存方案。
场景二:线程池增加到 500 后数据库更慢
推理链条:更多任务同时到达连接池;连接数不够就等待;连接数增加又可能压垮数据库;等待拉长导致在途请求增多,内存和超时进一步恶化。
改进从限制入口与在途量、设置队列上限、限制数据库并发、优化慢查询开始,再按测量调整。成功标准包括错误率和尾延迟,而不只是工作线程变多。
场景三:两个接口查询可以并行吗
若价格与库存彼此独立,可以并行,完成后合并;若第二步需要第一步的鉴权或事务结果,就不能假装独立。要说明总截止时间、其中一个失败怎么办、取消是否真正到达下游,以及执行器饱和时的行为。
场景四:读多写少一定用读写锁吗
不一定。临界区非常短时,读写锁管理开销可能抵消收益;写者等待和业务一致性也影响选择。不可变快照、CopyOnWrite 或普通互斥锁都可能更合适。StampedLock 的乐观读需要校验,而且不可重入,初学者不要把它当成万能升级版。
场景五:找出这段代码的问题
if (permits.tryAcquire()) {
callRemote();
}
permits.release();
如果没拿到许可仍释放,会增加许可;如果 callRemote 抛异常,又可能来不及释放。正确结构是获取成功后,try 执行,finally 释放;失败分支明确返回限流或安排受控等待。
再看这一段:
Future<?> f = pool.submit(() -> { throw new RuntimeException("失败"); });
System.out.println("提交成功");
提交成功不等于任务成功。应观察 Future 结果或有统一的异常收集机制。若业务只关心提交,应有可观测、可追踪的后续完成状态。
一次 15 分钟模拟面试
前 3 分钟解释一个 count++ 竞态;接着 4 分钟画线程池入队扩容过程;再用 4 分钟讨论库存跨实例正确性;最后 4 分钟解释排查慢接口的证据链。
每项按 0—2 分自评:0 分只有术语;1 分结论基本正确但无法解释例子;2 分能说明正确性边界与失败处理。得分低的章节回去做实验,不用先背更多术语。
第 18 章 四周学习计划与速查表
第一周:能解释为什么错
第 1 天安装 JDK,运行 Hello;第 2 天读并发、并行与线程状态;第 3 天做中断退出练习;第 4 天运行 CounterLab;第 5 天读 happens-before;第 6 天运行 StockLab 并解释完整临界区;第 7 天复述基础问答,不看答案。
验收:你能画出两线程丢失更新的交错,知道 volatile 不能修复自增,能用 join 等待完成。
第二周:能选对工具
第 8—9 天学习锁与条件队列;第 10 天学习 CAS、AtomicInteger、LongAdder;第 11 天比较三种并发容器;第 12 天练门闩与许可证;第 13 天运行 QueueLab;第 14 天做场景代码审查。
验收:你能说出“容器安全不等于业务原子”,知道为什么等待条件用 while,能够在异常路径释放许可和锁。
第三周:能控制任务与资源
第 15—16 天学习线程池并运行 PoolLab;第 17 天学习异常观测与关闭;第 18 天学习 CompletableFuture 组合;第 19 天复习 ThreadLocal 清理;第 20 天运行 VirtualLab;第 21 天比较 JDK 21 与 24 的虚拟线程差异。
验收:你能按提交顺序推演线程数与队列,能区分等待超时和任务终止,知道虚拟线程仍需资源边界。
第四周:能把知识放进系统
第 22—23 天读库存、幂等、限流;第 24 天对自己的 Java 进程采集线程栈;第 25 天录制一次 JFR;第 26 天完成五个场景题;第 27 天做模拟面试;第 28 天把容易混淆的知识写成一页自己的笔记。
每天记录三句话:“今天看到什么现象”“为什么发生”“我如何验证”。如果进度慢,延长周期即可;先理解基本模型比按期背完更重要。
工具选型速查
| 需求 | 首先考虑 | 要记住的限制 |
|---|---|---|
| 独立停止标记 | volatile / 中断协议 | 不保护多步业务 |
| 多字段保持一致 | 同一锁保护完整约束 | 所有访问者遵守协议 |
| 单变量精确原子更新 | AtomicInteger/Long | 外部副作用别放重试函数 |
| 高竞争统计累加 | LongAdder | sum 不是并发原子快照 |
| 并发映射 | ConcurrentHashMap | 多 key 事务另行设计 |
| 小型读多写少列表 | CopyOnWriteArrayList | 写入复制成本 |
| 有界生产消费 | BlockingQueue | 设计关闭与失败路径 |
| 等一组任务结束 | Future / CountDownLatch | 异常与超时仍需处理 |
| 控制同时访问资源数 | Semaphore | 非每秒速率控制 |
| 管理平台线程任务 | 有边界的执行器 | 队列与拒绝都要设计 |
| 大量阻塞等待任务 | 虚拟线程 | CPU、下游、内存仍有限 |
| 多实例库存约束 | 数据库并发控制等 | JVM 锁不足以跨实例 |
面试前最后检查
你是否能解释一个失败交错,而不是只说“不安全”?是否说明锁住哪个对象?是否处理异常、中断和清理?是否知道队列满了会怎样?是否明确 JDK 版本?是否给出验证指标?
能把这些问题讲清楚,就已经具备扎实的初级并发思维。之后再逐步学习 AQS、ForkJoin 工作窃取、JMH、jcstress、JFR 与数据库事务隔离,不必一次全部背下来。
附录 A 容易误记的说法
| 常见说法 | 更准确的表述 |
|---|---|
| volatile 让变量完全线程安全 | 要看操作,复合更新仍需协调 |
| sleep 一会就同步好了 | 不能用睡眠建立完成或可见性协议 |
| synchronized 只负责互斥 | 同一监视器的解锁与加锁还提供可见性关系 |
| 线程越多越快 | 资源、争用与排队决定收益 |
| 线程池先开到 max 再排队 | 通常先到 core,再排队,再到 max |
| submit 的异常会自动提醒我 | 需要观察 Future 或统一监控 |
| shutdownNow 保证任务停止 | 它只是尝试中断,任务需要合作 |
| 并发容器能解决所有业务竞争 | 复合业务约束仍要单独表达 |
| LongAdder 可以直接当库存 | 并发汇总不是原子决策快照 |
| 超时了就一定没执行 | 可能执行成功但结果丢失,要设计幂等 |
| ThreadLocal 自动跟着请求走 | 绑定的是线程,线程会复用和切换 |
| 虚拟线程没有任何上限 | 仍受对象、下游、内存与调度资源约束 |
附录 B 术语小词典
- 临界区:按同一协议互斥访问共享状态的代码区域。
- 竞态条件:结果依赖未经控制的执行交错或时机。
- 数据竞争:访问同一变量、至少一个为写、且未被 happens-before 正确排序的冲突访问。
- 安全发布:使其他线程以符合内存模型的方式取得正确初始化对象。
- 可重入:同一线程可再次获取已持有的相同锁。
- 原子操作:对所讨论的并发观察而言不可被拆成中间状态的操作。
- 背压:下游容量不足时,让上游减速或拒绝更多工作。
- 尾延迟:分布末端的高百分位延迟,例如 P99。
- 幂等:重复同一逻辑请求不会产生重复业务效果的性质。
- 线程饥饿:某些任务长期无法得到执行或资源的情况。
- 承载线程:执行虚拟线程的平台线程。
- 截止时间:操作必须完成的绝对时间边界,可用于分配剩余超时预算。
附录 C 进阶面试第一站:AQS 与读写锁
AQS 先理解框架职责
AQS 是 AbstractQueuedSynchronizer 的缩写。它是构建锁和同步器的框架,不是一把具体的业务锁。可以先把它理解为“共享一个状态值,并为暂时不能成功的线程提供排队和等待机制”。ReentrantLock、Semaphore、CountDownLatch 都可以借助它实现各自的语义,但对状态值的解释不同。
ReentrantLock 的独占同步状态可以表示同一线程的重入次数;Semaphore 的状态可以表示许可数量;CountDownLatch 的状态可以表示未完成计数。不要背成“AQS 的 state 永远表示重入次数”。是否独占、是否共享、怎样尝试获取,取决于同步器的实现。
简化流程是:先尝试获取;失败则进入等待协议;资源释放后,符合条件的等待者获得再次尝试的机会。实际框架要处理取消、中断、超时、唤醒与队列竞争,比这个示意复杂得多。被唤醒不等于已经拥有资源。
面试可先这样回答:“AQS 把同步状态管理和等待队列等公共机制抽象出来,具体同步器实现获取、释放语义。它支持独占与共享模式。理解它有助于解释不同工具为什么能用相近框架实现不同规则。”不要把队列节点字段和某一版源码行号当成概念本身。
park/unpark 与等待条件
LockSupport 的 park 可以让线程停放,unpark 为线程提供继续执行的许可。许可最多保留一份,不会像计数器一样无限累加,因此先 unpark 后 park 在正确协议下也有意义。park 可能因中断或其他原因返回,使用者仍需循环检查实际条件。
这解释了显式锁为何不需要把所有等待都表现成 Java 的 BLOCKED 状态。park 是底层等待手段,业务正确性仍来自状态、原子操作、内存可见性与完整等待协议的组合。
读写锁适合哪种问题
ReentrantReadWriteLock 允许多个读者并行,写者独占。前提是所有相关访问者遵循同一个读写锁协议,且读操作确实不修改共享状态。读锁不是“低等级的写锁”,也不能因为持有读锁就放心修改容器。
同一线程可以先持有写锁,再取得读锁,最后释放写锁,完成降级,以便在保持某些保护的情况下进入只读阶段。持有读锁后直接等待升级为写锁通常会造成无法推进,应重新设计流程;释放读锁再取得写锁时,必须重新验证条件,因为中间状态可能改变。
StampedLock 提供写锁、读锁与乐观读。乐观读先取 stamp,复制相关字段,再 validate;验证失败必须退回正确的加锁读取路径。它不可重入,使用复杂度更高。不要在未经验证的乐观读取结果上直接执行外部副作用。
进阶自测: 为什么缓存读多写少也不一定适合读写锁?因为读临界区时长、锁管理成本、写频率与等待分布会共同影响性能。先证明语义正确,再测量收益。
参考:AQS API、LockSupport API、ReentrantReadWriteLock API、StampedLock API。
附录 D 参考资料与检索说明
检索与核对日期:2026 年 10 月 20 日。中文讲解与例子为原创组织,下面链接用于核对规范与 API,不是对外部教程的大段复制。阅读英文文档时先看类说明,再看方法的异常、线程安全与内存一致性条款。
R1.Java Language Specification 21,第 17 章 Threads and
Locks
https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html
对应第 4、5、11 章。重点:17.4.5 happens-before、17.5 final
字段语义,以及 wait 和通知。
R2.java.util.concurrent 包总览,Java 21
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/package-summary.html
对应第 3、8、9、10 章。重点:执行器、同步工具、并发集合和 Memory
Consistency Properties。
R3.ThreadPoolExecutor API,Java 21
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html
对应第 9、15 章。重点:Core and maximum pool sizes、Queuing、Rejected
tasks。
R4.Condition API,Java 21
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/locks/Condition.html
对应第 6 章。重点:await
释放与重新获取锁,虚假唤醒与有界缓冲区示例。
R5.CompletableFuture API,Java 21
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/CompletableFuture.html
对应第 10 章。重点:回调执行策略、异常完成、cancel、orTimeout。
R6.OpenJDK JEP 444:Virtual Threads
https://openjdk.org/jeps/444
对应第 12、15 章。Java 21
正式特性;重点:吞吐、不要池化虚拟线程、固定与观测。
R7.OpenJDK JEP 491:Synchronize Virtual Threads without
Pinning
https://openjdk.org/jeps/491
对应第 12 章。Java 24 特性;重点:监视器相关固定问题的改进与范围。
R8.jcmd 工具文档,Java 21
https://docs.oracle.com/en/java/javase/21/docs/specs/man/jcmd.html
对应第 14 章。重点:线程转储、JFR 命令及目标进程限制。
R9.java.util.concurrent.atomic 包总览,Java
21
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/atomic/package-summary.html
对应第 7 章。重点:原子更新、内存效果及计数器的选型。
进一步查阅(方法级别的细节以 API 文档为准):
- Thread API:生命周期、中断、join。
- ReentrantLock API:公平性与 tryLock。
- ConcurrentHashMap API:复合操作、弱一致迭代。
- LongAdder API:sum 语义。
- ThreadLocal API:绑定与 remove。
- OpenJDK JMH 与 jcstress:后续性能与并发正确性实验。
R10.JavaGuide:Java 并发常见面试题总结(上)
https://javaguide.cn/java/concurrent/java-concurrent-questions-01.html
用来核对入门面试主题范围:线程与进程、生命周期、上下文切换、wait/sleep、线程安全与死锁。具体语义以官方规范为准。本书没有采用其中对所有
Java 线程统一描述为操作系统直接调度的概括,虚拟线程需另行区分。
建议检索词:Java concurrency interview volatile synchronized thread pool、Java 高并发 线程池 拒绝策略 面试、JMM happens-before。搜索结果用于发现主题,结论优先回到上面的规范与官方
API 核对。