Java并发编程笔记:从原理到实战
Java并发编程笔记:从原理到实战
1. 引言:为什么需要并发编程
1.1 背景与目的
现代服务器普遍采用多核架构,单线程程序只能利用一个核心,吞吐量低、响应慢。并发编程的核心目的就是提升资源利用率和程序性能,让多个任务同时推进,充分利用CPU、内存和I/O资源。同时,Java作为主流后端语言,其并发模型(线程、锁、内存模型)是写出高性能、高可靠服务的基石。
1.2 整体知识架构概览
学习Java并发,建议沿着这条主线深入:
线程基础 → 线程状态与API → 共享模型(临界区/synchronized) → 锁优化(偏向/轻量/重量)
→ JMM(可见/有序/原子) → Lock工具(ReentrantLock/AQS) → 线程池与实战
核心问答
Q:什么是并发?为什么Java开发者必须掌握并发编程?
A:并发指多个任务在同一时段内交替执行(微观串行,宏观并行)。Java开发者必须掌握,因为:
- 几乎所有后端服务都需处理多用户请求,并发能力决定系统吞吐量。
- 线程安全、锁、内存可见性等问题处理不当会引发生产事故(数据错乱、死锁、CPU飙升等)。
- 掌握并发是进阶高级工程师的必备能力。
2. 基本概念与线程实现
2.1 进程与线程:操作系统视角
- 进程:操作系统分配资源的基本单位,一个软件启动就对应一个进程,拥有独立的堆、方法区等内存空间。
- 线程:CPU调度的最小单位,是一批顺序指令的集合。一个进程包含若干线程,它们共享进程的堆和方法区,每个线程拥有独立的虚拟机栈和程序计数器。
2.2 Java创建线程的三种方式
- 继承Thread类
class MyThread extends Thread { public void run() { /* ... */ } } new MyThread().start(); - 实现Runnable接口(更推荐,分离任务与线程)
new Thread(() -> { /* task */ }).start(); - FutureTask + Callable(可获取返回值、抛出异常)
FutureTask<Integer> futureTask = new FutureTask<>(() -> { // 计算 return 42; }); new Thread(futureTask).start(); Integer result = futureTask.get(); // 阻塞获取
核心问答
Q:Runnable与Callable的本质区别?FutureTask如何获取返回值?
A:Runnable的run()无返回值且不抛受检异常;
Callable的call()有返回值并可抛异常。
FutureTask实现了RunnableFuture(即Runnable与Future),
内部维护一个Callable引用和结果变量;
当线程执行run()时会调用call()并将结果保存在outcome字段,之后通过get()返回(若未完成则阻塞等待)。
3. JVM常用诊断命令
3.1 jps:查看Java进程
jps -l 列出所有Java进程PID及主类全名。常用于获取目标进程ID。
3.2 jstack:线程快照与死锁检测
jstack <pid> 打印所有线程状态、堆栈,末尾会自动检测死锁。线上排查死锁、线程卡顿第一工具。
3.3 jstat:堆内存、GC、类加载监控详解
jstat -gcutil <pid> 1000 每秒输出各代使用占比、GC次数和耗时,对分析GC频率、内存泄漏极有价值。jstat -class <pid> 查看加载/卸载类数量。
3.4 jconsole:可视化管理工具
图形界面连接JMX,可查看堆内存曲线、线程图、死锁检测,适合开发阶段观察趋势。
核心问答
Q:如何用jstack定位死锁?jstat中各个指标代表什么?
A:jstack <pid>输出末尾会显示Found one Java-level deadlock并列出相互等待的线程和锁。jstat -gcutil各列:S0/S1(Survivor区使用率)、E(Eden)、O(老年代)、M(元空间)、YGC/YGCT(Young GC次数/总耗时)、FGC/FGCT(Full GC次数/总耗时)。
4. 虚拟机栈与线程执行模型
4.1 程序计数器(PC寄存器)
线程私有,记录当前线程正在执行的字节码指令地址。执行native方法时值为空。它是线程切换后能恢复到正确执行位置的关键。
4.2 栈帧结构
每次方法调用会创建一个栈帧,栈帧中包含:
- 局部变量表:存储方法参数和局部变量,包括基本类型和对象引用(指向堆)。线程安全的基础,因为每个线程有自己的栈帧。
- 操作数栈:字节码指令执行时的临时数据区(如
iadd指令从操作数栈弹出两个int相加再压回)。 - 动态链接:指向运行时常量池中该方法的引用。
- 返回地址:方法执行完毕后返回到调用者的指令位置。
- 锁记录:优化篇会详述,用于轻量级锁。
4.3 方法调用示例
int add(int a, int b) { return a + b; }
字节码大致为:iload_1 (压a入操作数栈) → iload_2 (压b) → iadd (弹两数相加再压入) → ireturn。整个过程在栈帧内完成。
核心问答
Q:为什么局部变量表是线程安全的?栈帧在方法调用时如何创建和销毁?
A:局部变量表随栈帧创建,每个线程私有,不存在共享,因此无需同步就是安全的。方法调用时JVM创建一个新栈帧并入栈,方法正常返回或异常退出时栈帧出栈,其所占内存自动释放(局部变量也随之销毁)。
5. 线程上下文切换
5.1 触发切换的场景
- CPU时间片用完,系统调度其他线程。
- 发生GC(STW阶段所有线程需暂停)。
- 有更高优先级的线程就绪(抢占式调度)。
- 当前线程调用
wait()/join()/LockSupport.park()等阻塞方法。
5.2 切换代价
切换时操作系统需保存当前线程的寄存器、程序计数器、栈指针等,再加载下一线程的上下文,涉及内核态开销。频繁切换会严重拖慢吞吐量。
核心问答
Q:频繁上下文切换为什么影响性能?如何减少?
A:上下文切换本身消耗CPU,且会清空CPU缓存、刷新TLB,导致后续代码执行变慢。减少手段:合理的线程数(CPU密集型N+1,I/O密集型可稍多)、避免不必要的锁竞争、使用无锁算法、调整JVM参数减少GC停顿。
6. 线程基本API与状态转换
6.1 sleep / yield / join
- sleep:线程进入
TIMED_WAITING,不会释放锁,可用TimeUnit.SECONDS.sleep(1)增强可读性;结束后进入就绪态。可被中断。 - yield:让出CPU,进入就绪态,可能立即又被调度,不推荐用于业务控制。
- join:等待目标线程执行完毕或超时,底层用
wait()实现。
6.2 interrupt中断机制
在线程内部调用interrupt()将设置中断标志。若线程正在sleep/wait/join,会立即抛出InterruptedException并清除中断标志。
线上事故:某个线程在while循环中处理任务,当被中断时抛出异常,但异常处理中未再次设置中断或调用Thread.interrupted()清除标志,导致后续循环条件检查不到中断,线程无法优雅退出,永远运行部分代码。
正确做法:
try {
while (!Thread.currentThread().isInterrupted()) {
// do work
}
} catch (InterruptedException e) {
// 重新设置中断标志或退出
Thread.currentThread().interrupt();
}
6.3 线程状态:操作系统5状态 vs Java 6状态
- 操作系统:新建、就绪、运行、阻塞、终止。
- Java(Thread.State):NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。RUNNABLE对应操作系统就绪+运行,BLOCKED是JVM层面的等待监视器锁。
6.4 状态切换全景图
RUNNABLE → WAITING:wait()、join()、LockSupport.park()RUNNABLE → TIMED_WAITING:sleep(n)、wait(n)、join(n)、parkNanos(n)RUNNABLE → BLOCKED:竞争synchronized锁失败- 各等待状态被唤醒后回到
RUNNABLE
核心问答
Q:interrupt()后线程会立刻停止吗?sleep与yield状态转移有何不同?
A:不会,只是设置中断标志,仅当线程检查该标志或处于阻塞操作时抛出异常。sleep进入TIMED_WAITING,yield建议调度器让出CPU但状态仍为RUNNABLE,可能立刻又继续运行。
7. 线程通信与临界区
7.1 共享内存通信模型与竞态条件
Java多线程通过共享堆内存通信。竞态条件:多线程对共享变量读写时,由于上下文切换、CPU缓存等导致实际执行顺序不确定,结果不可预测。
7.2 临界区与synchronized
临界区:访问共享资源的代码段。synchronized通过互斥保证临界区代码的原子性(同一时刻仅一个线程执行),其锁对象关联Monitor,进入临界区获取Monitor,退出释放。
核心问答
Q:多线程下i++为什么不安全?synchronized到底锁的是什么?
A:i++在字节码层面是iload → iinc → istore三条指令,线程可能在这之间切换导致丢失更新。synchronized锁的是对象实例(或Class对象),每个对象关联一个Monitor,线程必须获得Monitor所有权才能进入同步块。
8. 线程安全深入分析
8.1 安全判定
- 类变量:若只有读操作则安全,有写操作需同步。
- 局部变量:基本类型天然安全,对象引用若未逃逸出方法(未发布给其他线程)也安全。但多个安全方法组合调用可能不安全,需注意整体原子性。
- 不可变类(如String、Integer)天然线程安全。
8.2 Monitor与对象头
对象头中的Mark Word存储GC年龄、锁状态等信息,与Monitor相关联。JVM通过对象头的Mark Word实现synchronized的锁机制。
8.3 锁升级全链路(重点)
JDK 6起对synchronized做了大量优化,引入了锁膨胀过程:
- 无锁 → 偏向锁:第一次有线程获取锁时,Mark Word记录该线程ID(偏向),后续同一线程重入仅检查ID,无需CAS。
- 偏向锁 → 轻量级锁:当另一个线程尝试竞争偏向锁时,偏向撤销,升级为轻量级锁(CAS操作Mark Word指向栈中Lock Record)。
- 轻量级锁 → 重量级锁:竞争加剧,轻量级锁CAS自旋超过一定次数(默认10次,自适应)仍不成功,则膨胀为重量级锁,线程进入Monitor的EntryList阻塞等待。
- 批量重偏向与撤销:当某个类的偏向撤销次数达到阈值20(默认)时,JVM认为偏向锁不再合适,会进行批量重偏向;当撤销次数达到40时,彻底禁用该类偏向锁。
8.4 wait / notify 标准范式
synchronized (lock) {
while (条件不满足) { // 必须用while,防止虚假唤醒
lock.wait();
}
// 条件满足,执行逻辑
lock.notifyAll(); // 尽量用notifyAll
}
线上问题:用if判断条件,线程唤醒后未重新检查条件就执行,导致数据出错。
8.5 Benchmark性能基准测试
应基于JMH(Java Microbenchmark Harness)在真实多核环境下测试,注意JIT预热、锁竞争程度等因素。轻量级锁在低竞争下性能优秀,重量级锁稳定但开销大。
核心问答
Q:锁升级的过程是怎样的?为什么wait要放在while里?
A:偏向锁(重入无CAS)→轻量级锁(CAS自旋)→重量级锁(阻塞)。wait放在while是为了防止虚假唤醒或条件被其他线程抢先改变,唤醒后必须重新检查条件。
9. 同步模式之保护性暂停
9.1 模式定义
一个线程等待另一个线程的特定结果,即Guarded Suspension模式。核心结构:等待方持有一个条件变量,当结果未就绪时阻塞;产生方设置结果并唤醒等待方。
9.2 用wait/notifyAll实现简易Join与FutureTask
public class GuardedObject<T> {
private T response;
public synchronized T get() {
while (response == null) {
try { wait(); } catch (InterruptedException e) {}
}
return response;
}
public synchronized void complete(T response) {
this.response = response;
notifyAll();
}
}
FutureTask内部本质上也是类似机制。
9.3 生产者-消费者(建议阻塞队列)
使用BlockingQueue实现,生产者放入数据,消费者取出,利用其内部锁和条件队列优雅实现线程协作,避免自行实现复杂同步。
核心问答
Q:保护性暂停和生产者-消费者有什么设计上的区别?
A:保护性暂停是一对一的直接匹配,生产者等待消费者消费当前结果后才继续;生产者-消费者通常多对多,通过队列解耦,生产和消费速率相对独立。
10. LockSupport工具类
10.1 park / unpark 原理
每个线程内部有一个许可(permit,类似二元信号量),park()消耗许可,若许可存在则不阻塞;若许可不存在则阻塞。unpark()发放一个许可(至多一个)。与synchronized的wait不同,不需要持有锁。
10.2 与wait/notify对比
unpark可在park之前调用,线程随后调用park不会阻塞。- 不需要配合
synchronized,使用更灵活。 - 但许可证只能有一个,不累计。
核心问答
Q:为什么unpark可以在park之前调用?这比wait/notify方便在哪?
A:因为许可机制是状态化的,unpark提前“存”下许可,之后park直接消耗通过。这避免了wait/notify必须“等待方先阻塞、通知方后通知”的顺序限制,实现更简洁的线程同步。
11. 多把锁与活跃性问题
11.1 细粒度锁与死锁
使用多把不相干的锁可提升并发度,但若获取顺序不当易导致死锁(线程持有A等B,另一线程持有B等A)。
诊断:jstack直接检测死锁,jconsole线程页也有检测按钮。
11.2 活锁
多个线程相互谦让资源或改变对方结束条件,导致均无法推进。如多线程加减同一个计数器,触发互斥使计数器来回摆动。
解决方案:引入随机退避时间打破对称性。
11.3 饥饿
使用顺序锁(固定获取锁的顺序)可避免死锁,但若某些线程请求锁的频率远低于其他线程,可能总获取不到执行机会(饥饿)。可通过公平锁缓解。
核心问答
Q:死锁的四个必要条件是什么?顺序锁为何能避免死锁却可能引发饥饿?
A:互斥、持有并等待、不可剥夺、循环等待。顺序锁打破了循环等待条件,但若一个线程频繁获取锁,另一个低频率线程可能永远等待,产生饥饿。公平锁按请求顺序调度可解决。
12. ReentrantLock高级锁机制
12.1 特性一览
- 可重入:同一线程可多次获取同一把锁。
- 可中断:
lockInterruptibly()支持响应中断。 - 可超时:
tryLock(time, unit)避免永久阻塞。 - 公平/非公平:构造函数参数控制,公平锁按等待队列分配,解决饥饿但吞吐量略低。
- 多条件变量:一个锁可创建多个
Condition。
12.2 Condition与 await/signal
Lock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
可精确控制等待/通知,优于synchronized的单一等待集合。
12.3 ReadWriteLock读写锁
ReentrantReadWriteLock,读读共享,读写互斥,适合读多写少场景。内部同样基于AQS,写锁可降级为读锁(获取写锁后再获取读锁,释放写锁)。
12.4 实践:控制交替输出
要求:三个线程分别打印A、B、C,循环10次。用ReentrantLock条件变量实现:
public class PrintABC {
private int state = 0;
private Lock lock = new ReentrantLock();
private Condition condA = lock.newCondition();
private Condition condB = lock.newCondition();
private Condition condC = lock.newCondition();
public void printA() {
lock.lock();
try {
for (int i = 0; i < 10; i++) {
while (state % 3 != 0) condA.await();
System.out.print("A");
state++;
condB.signal();
}
} finally { lock.unlock(); }
}
// B、C线程类似...
}
等价实现还有synchronized+wait/notify、LockSupport的park/unpark版本。
核心问答
Q:synchronized与ReentrantLock如何选择?tryLock带超时如何避免死锁?
A:简单互斥首选synchronized,需要可中断、超时、公平锁或多条件变量时用ReentrantLock。使用tryLock可尝试获取多个锁,若成功则执行,失败则释放已获锁并重试,打破“持有并等待”从而避免死锁。
13. Java内存模型(JMM)
13.1 三大特性
- 原子性:一个或多个操作整体不被中断。
synchronized和Lock可保证。 - 可见性:一个线程对共享变量的修改对其他线程立即可见。
volatile、synchronized、final均可保证。 - 有序性:禁止指令重排序。
volatile通过内存屏障禁止特定重排序,synchronized块内可重排但对外呈现原子性。
13.2 volatile关键字
修饰的变量会直接刷新到主存,并通知其他线程缓存行失效,保证可见性。同时禁止对该变量读写前后的指令重排序(内存屏障)。
陷阱:volatile不保证复合操作的原子性(如count++),只适用于一个线程写、多个线程读的场景。
13.3 指令重排序与happens-before
CPU和编译器可能在不改变单线程执行结果的前提下重排指令,但多线程下可能暴露问题。happens-before规则定义操作间的内存可见性顺序(如锁释放happens-before后续锁获取,volatile写happens-before后续volatile读等)。
核心问答
Q:volatile适合什么场景?为什么不能替代synchronized?
A:适合状态标志位、双重检查锁定(DCL单例)、volatile bean模式等。不能替代,因为它不保证代码块的原子性,不能保护多个步骤组成的临界区。
14. 线程池核心原理
14.1 参数与工作流程
ThreadPoolExecutor构造参数:核心线程数、最大线程数、空闲存活时间、工作队列、线程工厂、拒绝策略。提交任务时的流程:
- 当前线程数 < 核心线程数,直接新建线程执行任务。
- 否则尝试将任务加入工作队列。
- 若队列已满,且线程数 < 最大线程数,新建线程执行。
- 否则执行拒绝策略。
关键问题:当任务队列未满,但当前线程数小于最大线程数时,会不会创建新线程?
答:不会。只有队列满了才会创建超过核心线程数的线程。因为设计原则是优先用核心线程+队列处理任务,只有压力大到队列塞满时才额外创建临时线程,任务高峰过后临时线程会被回收。
14.2 常见拒绝策略
- AbortPolicy(抛异常)
- CallerRunsPolicy(由调用线程执行)
- DiscardPolicy(静默丢弃)
- DiscardOldestPolicy(丢弃队列最老任务)
14.3 自定义线程池监控
可继承ThreadPoolExecutor,重写beforeExecute、afterExecute、terminated来记录任务耗时、异常、队列大小等。
核心问答
Q:线程池在什么情况下会创建超过核心线程数的线程?队列未满时提交任务的行为是怎样的?
A:只有核心线程满且队列满时才会创建新线程,直至最大线程数。队列未满时,任务一律入队,不创建新线程(无论当前线程数是否小于最大线程数),这体现了“核心线程+队列缓冲”优先的设计。
15. 总结与学习路线
15.1 整体流程串联
本文从进程线程基础→线程API与状态→共享模型与锁优化→JMM→高级并发工具→线程池,层层递进。掌握这些就能应对大多数并发编程场景。
15.2 线上事故复盘精要
- 中断标识不清除:导致线程永不退出。
- wait误用if:虚假唤醒造成逻辑错误。
- 锁顺序不当:死锁。
- 不可变对象未正确发布:可见性问题。
- 线程池队列无界:OOM。
15.3 进一步学习建议
- 深入AQS(AbstractQueuedSynchronizer)——几乎所有并发工具的基础。
- 并发集合:ConcurrentHashMap、CopyOnWriteArrayList等。
- ForkJoinPool、CompletableFuture异步编程。
- JVM对锁的底层实现源码分析。
并发编程是实践性极强的领域,代码+工具+监控缺一不可。愿这份笔记能帮你构建扎实的并发知识体系。
更多推荐

所有评论(0)