c260811course / homework0810volatile 是 Java 的关键字,用来解决线程安全的可见性问题和有序性问题,但不能保证原子性。它正是上节课(20260810)线程安全三大特性中"可见性、有序性"两个问题的解决方案之一。
// 来源:course/Demo01.java(类注释)
/*
volatile是用来解决可见性和有序性问题的,不能保证原子性。
可见性问题:一个线程对共享数据的修改,对其他线程不可见,导致其他线程无法做出相应的操作和响应。
volatile是可以解决程序永不退出的问题。
不加volatile可能导致程序永不退出。
可见性问题:程序永不退出:
代码看VolatileDemo.java
*/
| 解决的能力 | 说明 |
|---|---|
| 解决可见性 | 一个线程对共享数据的修改,对其他线程立即可见,其他线程能做出相应操作和响应 |
| 解决有序性 | 禁止编译器和 CPU 对 volatile 读写及其前后指令进行重排序 |
| 不解决原子性 | volatile count++ 依旧会丢失更新,做不到让"读-改-写"3 步变成 1 步 |
要点:
- volatile 是三大特性中可见性、有序性的解决方案(回顾上节课):可见性 → volatile / synchronized / Lock;有序性 → volatile / synchronized
- 原子性只能靠锁或原子类:synchronized、Lock、Atomic 类——volatile 无法保证 count++ 的原子性
- volatile 的定位:比 synchronized 更"轻量"——只解决可见性与有序性,不涉及锁机制
可见性问题的根源在 JVM 内存模型:每个线程都有自己的独立工作内存(其实就是 CPU 缓存),线程读写变量时优先从工作内存操作,而不是每次直接读写主内存的真实值。
// 来源:course/Demo01.java(类注释)
/*
在JVM内存模型中,每个线程都有自己的独立的工作内存(其实就是CPU缓存),线程读取变量的时候会优先
从工作内存读,而不是再次读取真实的变量值。写变量的时候也是会先写入到工作内存,再刷新到主内存。
而且这个刷新也不是立即发生的,它是可能会有延迟的。
所以就可能出现:main线程把running属性改为了false并写入到了主内存。
但是worker线程一直读的都是自己的CPU缓存(工作内存)的旧值(true),导致while(running)永不退出,这就是可见性问题。
*/
┌──────────────────────────────────────────────────────────┐
│ CPU 核心 1 —— worker 线程 │
│ L1/L2 缓存 running = true(旧值缓存) │
├──────────────────────────────────────────────────────────┤
│ CPU 核心 2 —— main 线程 │
│ L1/L2 缓存 running = false │
├──────────────────────────────────────────────────────────┤
│ 主内存 running = false │
└──────────────────────────────────────────────────────────┘
worker 的缓存 -.需要及时刷新.-> 主内存
main 的缓存 -.写操作会立即刷回.-> 主内存
加 volatile 后:写 volatile 变量立即刷主存,
读 volatile 变量强制从主存读 = 禁止使用线程本地缓存
要点:
- 工作内存 = CPU 缓存:每个线程有独立的 L1/L2 缓存,读写变量优先走工作内存,不会每次都访问主内存
- 刷新有延迟:线程写变量先写工作内存再刷新主内存,刷新不是立即发生、可能有延迟
- 可见性问题的产生:main 线程把
running改为 false 并写入主内存,但 worker 线程一直读自己 CPU 缓存里的旧值 true →while(running)永不退出- volatile 的作用:写 volatile 变量立即刷主存,读 volatile 变量强制从主存读——禁止使用线程本地缓存,从而解决可见性
不加 volatile 时,主线程修改 running 为 false,但 worker 线程一直读旧值 true,导致程序永不退出;加了 volatile 后,worker 线程能立刻看到修改,正常退出。
// 来源:course/VolatileDemo.java
public class VolatileDemo {
//如果不加volatile,其他线程可能永远都看不到修改
private volatile boolean running=true;
/**
* 线程工作的方法
*/
public void work(){
System.out.println("work 线程开始......");
while (running){
//空循环,什么都不做....
//System.out.println("work....");
}
System.out.println("线程退出......");
}
/**
* 停止线程工作的方法
*/
public void stop(){
running=false;
}
public static void main(String[] args) throws InterruptedException {
VolatileDemo vd=new VolatileDemo();
Thread t=new Thread(vd::work,"worker");
/*
相当于:
new Thread(()->{
vd.work();
},"worker")
相当于:
new Thread(new Runnable(){
@Override
public void run(){
vd.work();
}
},"worker")
*/
t.start();
Thread.sleep(1000);
//主线程停止vd对象的工作
vd.stop();
}
}
1、VolatileDemo vd = new VolatileDemo() 创建对象,running 初始为 true
2、Thread t = new Thread(vd::work,"worker") 创建 worker 线程,任务方法是 vd.work()
3、t.start() 启动 worker 线程 → 打印"开始...",进入 while(running) 空循环
4、main 线程 Thread.sleep(1000) 让 worker 先跑起来(此时 running 还是 true)
5、vd.stop() 主线程把 running 改为 false
├─ 不加 volatile:worker 读的是自己 CPU 缓存里的旧值 true → while 永不退出("线程退出..."打不出来)
└─ 加了 volatile:worker 强制从主存读 false → while 退出,打印"线程退出......"
要点:
- volatile 解决"程序永不退出":这正是可见性问题的经典现场——worker 线程读不到主线程的修改
- while(running) 空循环:循环体为空(sleep 都没有),worker 线程疯狂读
running——读的始终是工作内存里的旧值vd::work方法引用:等价于() -> vd.work(),也等价于匿名 Runnable(回顾 08-04 方法引用 + 08-08 Runnable 方式)- 演示要点:
Thread.sleep(1000)让 worker 先跑起来,再stop()修改——对比加/不加 volatile 时是否打印"线程退出......"
// 来源:course/Demo01.java(类注释)
/*
volatile语义:
1、可见性:写volatile修饰的变量,会立刻刷新到主内存,读volatile变量会强制从主内存读,禁止使用CPU缓存。
2、禁止重排序(有序性):一般情况下编译器和CPU会对代码指令进行重排序以方便运行,但是如果使用了volatile,编译器和CPU就不会把volatile的读写操作以及它前后的指令进行指令重排。
3、不保证原子性:volatile count++,它依旧会丢失更新,做不到让3步变为1步。必须要synchronized/lock/原子类
*/
| # | volatile 语义 | 说明 |
|---|---|---|
| 1 | 可见性 | 写 volatile 变量立刻刷新到主内存;读 volatile 变量强制从主内存读,禁止使用 CPU 缓存 |
| 2 | 禁止重排序(有序性) | 编译器和 CPU 不会把 volatile 的读写操作以及它前后的指令进行指令重排 |
| 3 | 不保证原子性 | volatile count++ 依旧会丢失更新,做不到让 3 步变 1 步——必须用 synchronized / Lock / 原子类 |
要点:
- 语义 1 可见性:解决第 2、3 节"程序永不退出"的问题——写立即刷主存、读强制从主存读
- 语义 2 有序性:默认情况下编译器和 CPU 会重排指令以方便运行;用了 volatile 后,volatile 读写及其前后指令不被重排,代码按书写顺序执行
- 语义 3 不保证原子性:volatile 不能替代 synchronized / 原子类——
count++的"读-改-写"3 步依旧可能被打断(第 6 节 Counter 验证)
volatile 正是三大特性中"可见性、有序性"的解决方案之一,这节把上节课(20260810)的三大特性与解决方案做一个整体回顾对齐:
| 特性 | 含义 | 解决的问题 | 解决方案 |
|---|---|---|---|
| 原子性 | 操作不可分割,要么全部执行、要么全部不执行 | 防止"读-改-写"被并发打断 | synchronized、Lock(锁)、Atomic 类 |
| 可见性 | 一个线程的修改对另一个线程立即可见 | 防止线程把变量缓存到 CPU 缓存中,其他线程看不到修改 | volatile、synchronized、Lock(锁) |
| 有序性 | 代码按书写顺序执行,不被指令重排 | 防止编译器和 CPU 对指令重排 | volatile、synchronized |
要点:
- volatile 的两把刷子:只解决可见性和有序性,是这两个特性的解决方案之一
- 原子性不归 volatile 管:要保证原子性必须用 synchronized / Lock / 原子类
- volatile vs synchronized:volatile 轻量、不涉及锁、只解决可见性/有序性;synchronized 重量、加锁、同时保证三大特性(原子性 + 可见性 + 有序性)
延续上节课的计数器问题(1000 线程并发 count++,理论 1000 实际小于 1000)——这次给 count 加了 volatile 修饰,验证:volatile 依然不能保证原子性,count 还是小于 1000;只有加 synchronized 的 increment() 才能保证原子性,count 必然是 1000。
// 来源:course/Counter.java
public class Counter {
//使用volatile修饰也不能保证原子性
private volatile Integer count=0;
//只要加了synchronized,就能够保证原子性
public synchronized void increment(){
count++; //看似是1行,但底层是3步
//1、拿到当前count的值
//2、count+1
//3、自增后的值赋值给count
}
public Integer getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
Counter counter=new Counter();
int threadCount=1000;
Thread[] threads=new Thread[threadCount];
for (int i = 0; i < threadCount; i++) {
threads[i]=new Thread(()->{
counter.increment(); //执行相加
});
threads[i].start();
}
//等待所有线程结束
for (Thread t:threads){
t.join();
}
//理论上应该是1000,但是实际上往往小于1000
System.out.println("最终 count="+counter.getCount());
}
}
| 关注点 | 说明 |
|---|---|
| volatile 修饰 count | private volatile Integer count=0 —— volatile 能保证 count 的可见性(各线程读到最新值) |
| 但原子性仍然缺失 | count++ 底层是"读-改-写"3 步:①拿到当前 count 值 ②count+1 ③赋值给 count——volatile 无法把这 3 步变成 1 步 |
| synchronized increment() | 同步实例方法(锁 this),同一时刻只有一个线程能执行 count++ → 保证原子性 |
| 1000 线程 + join 等待 | 1000 个线程并发执行 increment(),join() 等所有线程结束后打印结果 |
要点:
- volatile 修了可见性,没修原子性:即使 count 被 volatile 修饰(所有线程能读到最新值),但 count++ 的"读-改-写"3 步依然可能被打断——多个线程读旧值 → 计算 → 覆盖,结果依旧小于 1000
- synchronized 才是原子性的保证:
public synchronized void increment()是同步实例方法(锁 this),保证同一时刻只有一个线程执行 count++ → count 必然是 1000- join() 等待所有线程:
for (Thread t : threads) t.join()让 main 等 1000 个线程全部结束后再打印(回顾 08-10 join)- 对比上节课 Counter:上节课 Counter 的 count 是普通
Integer,这节课给 count 加了 volatile——结果照样小于 1000,直观证明"volatile 不能保证原子性"- 三方案对比:普通变量(丢更新)< volatile(丢更新,但解决可见性)< synchronized(原子性保证,count=1000)
除了 synchronized,Java 从 JDK 1.5 开始提供 java.util.concurrent.locks.Lock 接口以及它的实现类 ReentrantLock(可重入锁)。相比于 synchronized,Lock 提供了更精细的锁控制,但使用上有一条规定动作必须严格遵守:
// 来源:course/Demo02.java(类注释)
/*
从JDK1.5开始,Java就提供了java.util.concurrent.locks.Lock接口以及它的实现类ReentrantLock(可重入锁)。
相比于synchronized,Lock提供了更精细的锁控制,比如可以进行超时等待(拿不到锁了不会再傻等),可以响应中断(等等锁的过程中可以被interrupt()进行中断)
可以指定公平性(可以按照申请顺序获取锁),而且还支持多个条件变量(可以精准的唤醒指定的线程)
但是使用Lock必须要记住一条铁律:
synchronized是自动释放锁的(在方法结束或出现异常的时候)。
但是Lock必须要手动释放锁,而且unlock() 释放锁的方法必须要放到finally块中,否则一旦代码抛异常或提前return,锁就永远不会被释放了,其他线程也会永远阻塞(死锁)
*/
// 来源:course/Demo02.java(类注释,本次更新新增完整七维对比)
/*
语法简洁度:
synchronized--更简单,只需要关键字就可以了
ReentrantLock--比较复杂,需要try-finally块
锁释放:
synchronized--自动释放(方法结束、出现异常都会自动释放锁)
ReentrantLock--必须要手动释放unlock()
是否可中断:
synchronized--不可中断不支持中断
ReentrantLock--支持中断,lockInterruptibly()可以设置可中断锁
是否可以超时等待:
synchronized--不支持超时等待
ReentrantLock--支持超时等待,tryLock(timeout)
是否支持锁读:
synchronized--不支持
ReentrantLock--是属于读写分离,单独上锁
底层实现:
synchronized-- 监视器锁(Monitor Lock)
ReentrantLock-- AQS (AbstractQueuedSynchronizer)抽象队列同步器
JDK21版本更新:
synchronized-- 进行了大幅度的优化
ReentrantLock-- 性能相近
*/
| 对比维度 | synchronized | Lock(ReentrantLock) |
|---|---|---|
| 语法简洁度 | 更简单,只需要关键字即可 | 比较复杂,需要 try-finally 块 |
| 锁释放 | 自动释放(方法结束、出现异常都会自动释放锁) | 必须手动释放 unlock() |
| 是否可中断 | 不可中断,不支持中断 | 支持中断,lockInterruptibly() 可设置可中断锁 |
| 是否可以超时等待 | 不支持超时等待 | 支持超时等待,tryLock(timeout) |
| 是否支持锁读 | 不支持 | 支持(读写分离),单独上锁 |
| 底层实现 | 监视器锁(Monitor Lock) | AQS(AbstractQueuedSynchronizer)抽象队列同步器 |
| JDK21 版本更新 | 进行了大幅度的优化 | 性能相近 |
| 公平性(概念部分) | 非公平 | 可指定公平锁——按申请顺序获取锁 |
| 条件变量(概念部分) | 单条件 | 支持多个条件变量——可精准唤醒指定线程 |
要点:
- Lock 的优势:①超时等待(拿不到锁不会再傻等)②响应中断(等待锁的过程中可以被 interrupt() 中断)③指定公平性(按申请顺序获取锁)④多个条件变量(精准唤醒指定线程)⑤支持锁读(读写分离)——单独上锁
- 使用 Lock 的铁律:synchronized 是自动释放锁的;Lock 必须手动释放锁,且
unlock()必须放到 finally 块中——否则代码抛异常或提前 return 时锁永远不会释放,其他线程将永远阻塞(死锁)- 底层实现差异:synchronized 基于监视器锁(Monitor Lock);ReentrantLock 基于 AQS(AbstractQueuedSynchronizer)抽象队列同步器——这是两者最本质的机制区别
- JDK21 新变化:JDK21 中 synchronized 进行了大幅度优化,与 ReentrantLock 性能相近——简单场景用 synchronized 更合适
- Lock 的定位:更精细的锁控制,适合复杂并发场景;synchronized 简单可靠,适合一般场景
// 来源:course/Demo02.java(类注释,本次更新补充 lockInterruptibly)
/*
public ReentrantLock() 创建一个可重入锁对象,默认是非公平锁,释放后所有线程竞争锁资源。
public ReentrantLock(boolean fair) 创建一个可重入锁对象,如果fair是true,则创建公平锁,按照申请顺序获取锁,性能略低
public void lock() 加锁
public void unlock() 释放锁
public boolean tryLock() 尝试获取锁,获取不到就算了
public boolean tryLock(long timeout, TimeUnit unit) 尝试获取锁 并设置等待时间和时间单位
public void lockInterruptibly() 设置可中断的锁
*/
| 方法 | 作用 |
|---|---|
new ReentrantLock() |
创建可重入锁对象,默认非公平锁(释放后所有线程竞争锁资源) |
new ReentrantLock(true) |
创建公平锁,按申请顺序获取锁(性能略低) |
void lock() |
加锁 |
void unlock() |
释放锁(必须放 finally) |
boolean tryLock() |
尝试获取锁,获取不到就算了(立即返回 false) |
boolean tryLock(long timeout, TimeUnit unit) |
尝试获取锁,并设置等待时间和时间单位(限时等待) |
void lockInterruptibly() |
设置可中断的锁——获取锁时等待过程中可被 interrupt() 中断(LockDemo 使用) |
// 来源:course/LockDemo.java(基本用法)
public class LockDemo {
private Integer count=0;
//创建锁对象---默认是非公平锁
ReentrantLock lock=new ReentrantLock();
//new ReentrantLock(true); 公平锁,按照申请顺序获取锁,性能略低
//---------基本用法:lock()+unlock() ,前提是必须要finally
public void increment(){
//加锁-- lock()
lock.lock();
try{
count++;
}finally {// 释放锁,必须要释放,否则其他线程永远拿不到锁
lock.unlock();
}
}
要点:
- 标准模板:
lock.lock()加锁 →try { 共享数据操作 } finally { lock.unlock() }——unlock 必须放 finally,否则异常时锁不释放- 非公平锁默认:
new ReentrantLock()默认非公平锁(释放后所有线程竞争);new ReentrantLock(true)创建公平锁(性能略低)
// 来源:course/LockDemo.java(tryLock 用法)
//--------进阶用法:tryLock()尝试获取锁,如果实在拿不到就算了,继续干别的。避免死锁的手段。
public boolean tryIncrement(){
if (lock.tryLock()){ //立刻尝试获取锁,获取到了就返回true,否则返回false
try {
count++;
return true;
}finally {
lock.unlock();
}
}
return false; //没抢到锁就返回false
}
要点:
- tryLock() 立刻尝试:获取到锁返回 true,获取不到立即返回 false(不等待)
- 拿不到就算了:没抢到锁就
return false,线程继续干别的——这是避免死锁的手段(不会一直阻塞等锁)
// 来源:course/LockDemo.java(tryLock(timeout) 用法)
//--------进阶用法:tryLock(timeout)限时等待
public boolean timedIncrement(){
try {
if (lock.tryLock(1, TimeUnit.SECONDS)){ //最多等待1秒
try {
count++;
return true;
}finally {
lock.unlock(); //释放锁
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return false;
}
要点:
- tryLock(timeout) 限时等待:
lock.tryLock(1, TimeUnit.SECONDS)表示最多等待 1 秒,超时还没拿到锁就返回 false——对应 Demo02 注释"拿不到锁了不会再傻等"- 中断异常处理:tryLock(timeout) 会抛
InterruptedException,需 try-catch(这里抛 RuntimeException 简化)
// 来源:course/LockDemo.java(lockInterruptibly 用法)
//--------进阶用法:可中断的获取锁(和sleep中断机制配合)
public void interruptLock(){
try {
lock.lockInterruptibly(); //等待所的过程中可以被 Interrupt()打断
try {
count++;
}finally {
lock.unlock();
}
} catch (InterruptedException e) {
System.out.println("等待锁的过程中被中断");
Thread.currentThread().interrupt();
}
}
要点:
- lockInterruptibly() 可中断:在等待锁的过程中可以被 interrupt() 打断(对应 Demo02 注释"可以响应中断")——与 08-10 的 interrupt 中断机制配合
- catch 重设标志:被中断时打印提示并
Thread.currentThread().interrupt()重设中断标志(回顾 08-10 中断机制最佳实践)- 与 tryLock 区别:tryLock 是"拿不到就算了";lockInterruptibly 是"继续等,但可以被中断"——都是避免死锁/响应中断的手段
// 来源:course/LockDemo.java(main 方法)
public static void main(String[] args) throws InterruptedException {
LockDemo ld=new LockDemo();
Thread[] threads=new Thread[10000];
for (int i = 0; i < threads.length; i++) {
threads[i]= new Thread(ld::interruptLock);
threads[i].start();
}
//等待所有线程结束
for (Thread t:threads){
t.join();
}
//理论上应该是1000,但是实际上往往小于1000
System.out.println("最终 count="+ld.getCount());
}
要点:
- 10000 线程 + Lock 加锁:10000 个线程并发执行
ld::interruptLock(方法引用,等价于() -> ld.interruptLock()),用 lockInterruptibly + finally unlock 保护 count++——保证原子性,count 必然是 10000- join 等待全部线程:
t.join()等 10000 个线程全部结束后打印(回顾 08-10 join)- 注释注意:注释写着"理论上应该是1000"是沿用了上节课 Counter 的注释,实际 threadCount 是 10000——重点体会"加了 Lock 后 count 正确"的效果
- 方法引用:
ld::interruptLock是方法引用(回顾 08-04),等价于 Lambda() -> ld.interruptLock()
线程死锁指的是由两个或多个线程相互持有对方所需要的资源,导致这些线程处在等待状态,无法继续执行。
// 来源:course/Demo03.java(类注释)
/*
线程死锁指的是由两个或多个线程相互持有对方所需要的资源,导致这些线程处在等待状态,无法继续执行。
什么情况会出现死锁?
资源有限
同步嵌套
*/
| 死锁出现场景 | 说明 |
|---|---|
| 资源有限 | 多个线程竞争有限的资源(锁/连接等),资源不够分 |
| 同步嵌套 | 一个同步块里嵌套了另一个同步块(持有锁 A 再等锁 B),多个线程嵌套方向相反就形成死锁 |
要点:
- 死锁的本质:多个线程互相持有对方需要的资源,大家都在等对方释放,谁也无法继续
- 两个典型场景:①资源有限(竞争同一批稀缺资源)②同步嵌套(锁中套锁、嵌套方向相反)
// 来源:course/Demo03.java(类注释)
/*
出现死锁的条件:
1、互斥 资源同一时刻只能被同一个线程占用, 锁A/锁B只能被一个线程持有
2、持有且等待 线程持有一个资源,同时等待另一个资源 线程1持有锁A,还在等锁B
3、不可剥夺 已经持有的资源不能被强行夺走,只能自己释放,锁必须有持有者主动释放
4、循环等待 多个线程形成了 你等我,我等你的环, 线程1等锁B,线程2等锁A
*/
| # | 必要条件 | 说明 |
|---|---|---|
| 1 | 互斥 | 资源同一时刻只能被同一个线程占用(锁 A / 锁 B 只能被一个线程持有) |
| 2 | 持有且等待 | 线程持有一个资源,同时等待另一个资源(线程 1 持有锁 A,还在等锁 B) |
| 3 | 不可剥夺 | 已持有的资源不能被强行夺走,只能自己释放(锁必须有持有者主动释放) |
| 4 | 循环等待 | 多个线程形成"你等我、我等你"的环(线程 1 等锁 B,线程 2 等锁 A) |
要点:
- 4 个条件缺一不可:互斥 + 持有且等待 + 不可剥夺 + 循环等待——只要破坏其中一个,死锁就能避免
- 循环等待是最后的环:前 3 个是资源使用的基本规则,第 4 个"循环等待"是多个线程互相等待形成闭环,直接导致死锁
- 破坏思路:要避免死锁,就从打破这 4 个条件入手——比如打破"循环等待"(统一锁顺序)、打破"持有且等待"(tryLock 不无限等)
// 来源:course/Demo03.java(死锁演示 main 方法,注释保留)
public static void main(String[] args) {
//线程1 先拿lockA,再拿lockB
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("线程1 拿到 lockA");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lockB) { //等待lockB(被线程2持有)
System.out.println("线程1 拿到LockB");
}
}
}, "线程1");
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("线程2 拿到 lockB");
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lockA) { //等待lockA(被线程1持有)
System.out.println("线程2 拿到LockA");
}
}
}, "线程2");
t1.start();
t2.start();
}
线程1:拿 lockA ✅ → sleep(100) → 等 lockB(被线程2持有)❌ 永远等不到
线程2:拿 lockB ✅ → sleep(10000) → 等 lockA(被线程1持有)❌ 永远等不到
→ 线程1 持有 lockA 等 lockB,线程2 持有 lockB 等 lockA → 循环等待 → 死锁
要点:
- 加锁顺序相反:线程 1 按"lockA → lockB"顺序,线程 2 按"lockB → lockA"顺序——两者顺序相反是死锁的直接原因
- sleep 放大死锁:两个线程 sleep 后仍持有第一把锁,让"等第二把锁"的机会更充分,便于复现死锁
- 满足 4 个条件:互斥(两把锁各被一个线程持有)+ 持有且等待(各持一把等另一把)+ 不可剥夺(锁只能主动释放)+ 循环等待(线程 1 等 lockB、线程 2 等 lockA)——4 条件全满足 → 死锁
- 验证死锁:运行后程序卡住不结束,只打印"线程1 拿到 lockA"和"线程2 拿到 lockB",再也打印不出"拿到 LockB / 拿到 LockA"
// 来源:course/Demo03.java(固定锁顺序演示 main 方法,注释保留)
public static void main(String[] args) {
Runnable task = () -> {
synchronized (lockA) { //所有线程统一先拿 lockA
System.out.println(Thread.currentThread().getName() + " 拿到 lockA");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lockB) { //再拿 lockB——顺序统一
System.out.println(Thread.currentThread().getName() + " 拿到 LockB");
}
}
};
Thread t1 = new Thread(task, "线程1");
Thread t2 = new Thread(task, "线程2");
t1.start();
t2.start();
}
要点:
- 核心思想:所有线程都按照相同的顺序进行加锁(统一 lockA → lockB),就不会出现"线程 1 等 lockB、线程 2 等 lockA"的循环等待
- 打破"循环等待"条件:固定锁顺序破坏了死锁第 4 个必要条件(循环等待)——大家排队按同一顺序拿锁,永远不会形成等待环
- 写法区别:死锁演示是两个线程各写各的(顺序相反);这里两个线程共用同一个 task(顺序统一)——对比很直观
// 来源:course/Demo03.java(tryLock 超时演示 main 方法核心逻辑,注释保留)
private static final ReentrantLock rLockA = new ReentrantLock();
private static final ReentrantLock rLockB = new ReentrantLock();
public static void main(String[] args) {
//线程1 先拿lockA,再拿lockB
Thread t1 = new Thread(() -> {
try {
if (rLockA.tryLock(1, TimeUnit.SECONDS)) { //尝试获取lockA,等1秒
try {
System.out.println("线程1 拿到 lockA");
Thread.sleep(100);
if (rLockB.tryLock(1, TimeUnit.SECONDS)) { //等待lockB,等1秒超时放弃
try {
System.out.println("线程1 拿到LockB");
} finally {
rLockB.unlock(); //释放锁
}
} else {
System.out.println("线程1 拿不到锁"); //超时放弃,不无限等待
}
} finally {
rLockA.unlock(); //释放锁
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "线程1");
// 线程2 逻辑与线程1对称(先拿rLockB,再拿rLockA),也全部用 tryLock(1, SECONDS)
...
t1.start();
t2.start();
}
线程1:tryLock(rLockA) ✅ → sleep(100) → tryLock(rLockB, 1秒)
线程2:tryLock(rLockB) ✅ → sleep(100) → tryLock(rLockA, 1秒)
→ 线程1 等 rLockB 最多等 1 秒:拿不到就打印"拿不到锁",并释放 rLockA → 不死锁
→ 线程2 同理:拿不到就放弃,释放 rLockB → 不死锁
要点:
- 核心思想:
tryLock(timeout)超时获取,拿不到就放弃,而不是无限等待——对应第 7 节 Lock 进阶用法- 打破"持有且等待"条件:tryLock 超时后释放自己已持有的锁(finally 里 unlock)并继续执行别的,破坏了死锁第 2 个必要条件(持有且等待)
- 与死锁演示的对比:死锁演示用
synchronized(不可超时,无限等待);这里改用ReentrantLock.tryLock(1, TimeUnit.SECONDS)——就算等不到也最多等 1 秒就放弃,程序不会卡死- finally 释放锁:每把锁拿到后都在 finally 里 unlock——即使拿不到第二把锁,也要先释放第一把(铁律,回顾第 7 节)
// 来源:course/Demo03.java(类注释)
/*
3、使用更加高级的并发工具,避免手动加多把锁,比如使用一些同步锁的API 比如同步Map集合-->ConcurrentHashMap
*/
| 避免方法 | 打破的条件 | 说明 |
|---|---|---|
| 固定锁顺序 | 打破"循环等待" | 所有线程按相同顺序加锁,不会形成等待环 |
| tryLock() 超时获取 | 打破"持有且等待" | 拿不到就放弃并释放已持有的锁,不无限等待 |
| 高级并发工具 | 从源头避免手动加多把锁 | 用 JDK 提供的线程安全容器(如 ConcurrentHashMap 同步 Map 集合),避免手动加多把锁 |
要点:
- 三种避免方法对应打破不同条件:固定锁顺序 → 打破循环等待;tryLock 超时 → 打破持有且等待;高级工具 → 从源头不手动加多把锁
- 高级工具示例:
ConcurrentHashMap(同步 Map 集合)——JDK 提供的线程安全并发容器,内部已经处理好线程安全,不需要自己手动加多把锁- 生产实践:真实项目里少手动管理多把锁,优先用线程池 + 并发容器(ConcurrentHashMap / CopyOnWriteArrayList)+ 原子类等 JDK 组件
线程池(Thread Pool)也是一种容器——存储线程的容器(就像水池是存储水的容器)。每次都 new Thread().start() 创建线程、运行后销毁,创建线程的过程成本很高(涉及和底层操作系统的交互)。当程序需要创建大量生存期非常短的线程时,频繁的创建和销毁会耗费大量资源。
// 来源:course/Demo04.java(类注释)
/*
说到池,第一想到是水池,水池是个容器,存储水的。
线程池也是一种容器,存储线程的。
每次都使用start() 创建线程,然后运行然后销毁线程。创建线程的过程的成本是比较高的,涉及到和底层操作系统的交互。
当程序中需要创建大量的生存期非常短的线程,频繁的线程创建和销毁就会耗费大量的资源。
所以就有了线程池
new Tread().star() 会带来三个问题:
1、创建/销毁线程的开销很大:每个线程都要分配1MB的栈内存资源,还有和底层操作系统进行交互,频繁的创建和销毁会拖垮性能。
2、线程的数量不受控:高并发的时候可能会瞬间创建成千上万个线程,耗尽内存和CPU,导致系统崩溃。
3、资源利用率低:可能一个任务只需要执行几毫秒,但是创建/销毁需要几秒线程的开销比业务本身还要大。
*/
| # | 问题 | 说明 |
|---|---|---|
| 1 | 创建/销毁开销很大 | 每个线程都要分配 1MB 栈内存资源,还要和底层操作系统交互——频繁创建和销毁会拖垮性能 |
| 2 | 线程数量不受控 | 高并发时可能瞬间创建成千上万个线程,耗尽内存和 CPU,导致系统崩溃 |
| 3 | 资源利用率低 | 一个任务可能只需执行几毫秒,但创建/销毁线程需要几秒——线程的开销比业务本身还大 |
要点:
- 线程创建成本高:涉及底层操作系统交互 + 每线程 1MB 栈内存——
new Thread().start()不是"免费"的- 三个问题的本质:开销大(创建销毁贵)、不受控(数量失控)、利用率低(创建时间 >> 执行时间)
- 线程池正是为解决这三个问题而生:提前创建线程并常驻,复用而不重复创建
// 来源:course/Demo04.java(类注释)
/*
线程池(Thread Pool)的解决方案:提前创建一批线程并且常驻。任务来了直接复用空闲的线程,任务过多的时候排队等待。
排队也排不下再进行动态扩容。达到了上限后执行拒绝。
【线程复用+任务排队+动态扩容+上限保护】
*/
| 机制 | 说明 |
|---|---|
| 线程复用 | 提前创建一批线程并且常驻,任务来了直接复用空闲线程 |
| 任务排队 | 任务过多时排队等待 |
| 动态扩容 | 排队也排不下时再进行动态扩容(创建临时线程) |
| 上限保护 | 达到上限后执行拒绝策略 |
要点:
- 一句话记忆:【线程复用 + 任务排队 + 动态扩容 + 上限保护】——这是线程池的四大机制
- 核心思想:不重复创建销毁线程,而是"线程常驻 + 任务分配"——任务来了复用空闲线程,忙不过来排队,排不下扩容,达到上限拒绝
// 来源:course/Demo04.java(类注释)
/*
类比-饭店餐馆:
核心线程数:饭店里面摆好的几张餐桌,比如5个,不管忙不忙都在。
最大线程数:饭店额外存了几张临时餐桌,比如3个,当5个满的时候用临时的。
工作队列:饭店门口摆了5个小凳,当8个桌子都满了的时候,顾客就坐在小凳上排队等待。
空闲存活时间:超过某个时间点3个临时餐桌没人用就收回来。
拒绝策略:如果5+3+5都满了,服务员就拒绝。
*/
| 线程池参数 | 饭店类比 | 说明 |
|---|---|---|
| 核心线程数 | 摆好的几张固定餐桌(5 个) | 不管忙不忙都在 |
| 最大线程数 | 额外存的临时餐桌(3 个) | 核心桌满时用临时的 |
| 工作队列 | 门口排队的小凳(5 个) | 所有桌子都满时顾客排队等待 |
| 空闲存活时间 | 临时餐桌没人用就收回来 | 超过时间点临时资源被回收 |
| 拒绝策略 | 5+3+5 都满了服务员拒绝 | 全部满了就拒绝新顾客(任务) |
要点:
- 类比记忆五要素:核心线程 = 固定餐桌、最大线程 = 临时餐桌、工作队列 = 门口排队凳、空闲存活时间 = 临时桌收回、拒绝策略 = 服务员拒绝
- 线程池参数和饭店运营一一对应——把抽象参数变成生活场景就很好记
// 来源:course/Demo04.java(类注释)
/*
corePoolSize 核心线程数 即使是空闲也要保留
maximumPoolSize 最大线程数 必须要大于核心线程数
keepAliveTime 非核心线程空闲存活时间 默认只对非核心线程有效
unit 时间单位 存活时间的单位
workQueue 工作队列 排队的地方
threadFactory 线程工厂 创建线程的工厂
handler 拒绝策略 如果排不下了怎么办
*/
| 参数 | 说明 |
|---|---|
| corePoolSize | 核心线程数——即使空闲也保留 |
| maximumPoolSize | 最大线程数——必须大于核心线程数 |
| keepAliveTime | 非核心线程空闲存活时间——默认只对非核心线程有效 |
| unit | 时间单位——存活时间的单位(如 TimeUnit.SECONDS) |
| workQueue | 工作队列——排队的地方 |
| threadFactory | 线程工厂——创建线程的工厂(可自定义线程名、是否守护线程) |
| handler | 拒绝策略——如果排不下了怎么办 |
要点:
- 7 个参数逐个记:核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略
- maximumPoolSize > corePoolSize:最大线程数必须大于核心线程数(= 核心 + 临时)
- keepAliveTime 只对非核心线程有效:空闲时间超过 keepAliveTime,非核心(临时)线程被回收,核心线程保留
// 来源:course/Demo04.java(类注释)
/*
线程池的执行流程:
核心线程-->工作队列-->非核心线程-->拒绝策略
线程不够了,不会第一时间创建新的线程,而是先把任务放在工作队列,只有当工作队列满了,并且线程数还没到最大线程数的时候,才会创建新的线程。
*/
任务来了
│
▼
① 核心线程 → 有空闲核心线程就执行,没有则下一步
│
▼
② 工作队列 → 放入队列排队等待(核心线程全忙时)
│
▼
③ 非核心线程 → 队列满了且线程数 < 最大线程数,才创建临时线程
│
▼
④ 拒绝策略 → 队列满 + 线程数已达最大值,执行拒绝
要点:
- 流程一句话:核心线程 → 工作队列 → 非核心线程 → 拒绝策略
- 关键认知:线程不够了不会第一时间创建新线程,而是先把任务放工作队列;只有工作队列满了并且线程数还没到最大线程数时,才创建新线程
- 这与直觉相反:先排队、后扩容——扩容(创建临时线程)是"最后的手段"之一
// 来源:course/Demo04.java(main 方法)
public static void main(String[] args) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
3, //corePoolSize-核心线程数 创建线程池的时候就先创建3个核心的线程,不管有没有任务使用,都留着。
5, //maximumPoolSize-最大线程数 核心线程数 + 临时线程数
60, //keepAliveTime-非核心线程空闲存活时间
TimeUnit.SECONDS, //unit-时间单位
new ArrayBlockingQueue<>(10), //workQueue-工作队列 容量为10
Executors.defaultThreadFactory(), //threadFactory-线程工厂,可以自定义线程名、是否为守护线程
new ThreadPoolExecutor.AbortPolicy() //handler-拒绝策略--AbortPolicy默认策略抛异常
);
}
要点:
- 7 参数齐全示例:3 核心 / 5 最大 / 60 秒存活 / SECONDS 单位 / ArrayBlockingQueue(10) 队列 / defaultThreadFactory 工厂 / AbortPolicy 拒绝策略
- ArrayBlockingQueue(10):基于数组的有界阻塞队列,容量 10——排队的"凳子"最多 10 个
- AbortPolicy(默认拒绝策略):队列满 + 线程满时抛异常拒绝新任务——拒绝策略之一
- threadFactory:可用
Executors.defaultThreadFactory()默认工厂;也可自定义(设置线程名、守护属性)
// 来源:course/ThreadPoolDemo.java(main 方法)
public static void main(String[] args) {
ThreadPoolExecutor pool =
new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
for (int i = 1; i <= 8; i++) {
int taskId = i;
try {
pool.execute(() -> {
String name = Thread.currentThread().getName();
System.out.println(name + "执行任务" + taskId);
try {
Thread.sleep(2000); //模拟任务耗时,占住线程
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
} catch (RejectedExecutionException e) {
System.out.println("任务" + taskId + "被拒绝" + e.getMessage());
}
}
//观察线程池的状态:活动线程数、队列的大小
System.out.println("活动线程数:" + pool.getActiveCount());
System.out.println("队列中任务数:" + pool.getQueue().size());
pool.shutdown(); //关闭线程池
}
配置:corePoolSize=2、maximumPoolSize=4、队列容量=2,连续提交 8 个任务
任务1、2 → 核心线程(2 个)立即执行 [核心线程 2 个满]
任务3、4 → 进入工作队列排队 [队列 2 个满]
任务5、6 → 队列满了 → 创建临时线程(非核心) [线程数 4 = 最大值]
任务7、8 → 线程满 + 队列满 → AbortPolicy 拒绝 → 抛 RejectedExecutionException
观察输出:活动线程数=4、队列中任务数=2
要点:
- 8 任务的走向:2 个核心执行 + 2 个排队 + 2 个临时线程执行 + 2 个被拒绝——完整演示了"核心线程 → 工作队列 → 非核心线程 → 拒绝策略"四阶段流程
- getActiveCount():查看当前活动线程数(这里 2 核心 + 2 临时 = 4)
- getQueue().size():查看队列中排队任务数(这里 2 个)
- RejectedExecutionException:被拒绝的任务抛这个异常,catch 后打印"任务 X 被拒绝"
- shutdown():关闭线程池——不再接受新任务,已提交任务继续执行完
- execute() 与 start() 的区别:
pool.execute(runnable)把任务交给线程池(复用线程),而不是new Thread().start()(创建新线程)——这正是线程池的核心价值
homework0810/Demo_03.java 是 8 月 10 日课后作业的第三题:用 synchronized 修复卖票问题(回顾上节课 SellTicket 的数据竞争 bug)。本题给出代码框架 + TODO 提示,要求学生自己动手:先不加 synchronized 复现 bug,再加 synchronized 修复。
// 来源:homework0810/Demo_03.java
static class SellTicket implements Runnable {
private int tickets = 100; // 一共 100 张票
// 写法1:同步方法(锁 this)—— TODO 2 完成后给方法加 synchronized
public void sell() {
// TODO 1: if (tickets > 0) { sleep(10); 打印 "xxx正在出售第 tickets 张票"; tickets--; }
if (tickets > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName()+"正在出售第 "+tickets+" 张票");
tickets--;
}
}
@Override
public void run() {
while (tickets > 0) {
synchronized (this){
sell(); // TODO 2: 先不加 synchronized 运行复现 Bug,再加 synchronized 修复
}
}
}
}
// 来源:homework0810/Demo_03.java(main 方法)
public static void main(String[] args) throws InterruptedException {
// ========== ② 创建 3 个售票窗口线程(共用同一个 SellTicket 对象) ==========
// TODO 3: new Thread(new SellTicket(), "窗口1") / "窗口2" / "窗口3",start() 启动
SellTicket st=new SellTicket();
//创建三个线程对象,模拟3个售票窗口
Thread t1=new Thread(st,"窗口1");
Thread t2=new Thread(st,"窗口2");
Thread t3=new Thread(st,"窗口3");
t1.start();
t2.start();
t3.start();
// TODO 4: 用线程数组 + join() 等待所有线程卖票结束
// TODO 5: 运行多次观察:无 synchronized 时出现相同票 / 负数票;加 synchronized 后消失
}
| TODO | 要求 | 对应知识点 |
|---|---|---|
| TODO 1 | 完成卖票逻辑:if (tickets>0) { sleep(10); 打印; tickets--; } |
卖票问题数据竞争(相同票 / 负数票) |
| TODO 2 | 先不加 synchronized 运行复现 Bug,再加 synchronized 修复 | synchronized 线程同步(三种写法) |
| TODO 3 | 用同一个 st 创建 3 个窗口线程并 start() |
多线程共享同一对象 → 共享数据 tickets |
| TODO 4 | 用线程数组 + join() 等待所有线程卖票结束 |
join() 等待线程结束(08-10) |
| TODO 5 | 多次运行观察:无 synchronized 相同票 / 负数票,加后消失 | 数据竞争现象验证 + synchronized 修复 |
要点:
- 同一个 SellTicket 对象:三个线程
new Thread(st, "窗口N")共用同一个 st → 共享 tickets 数据 → 产生数据竞争(回顾 08-10 数据竞争三原因:多线程 + 共享数据 + 读-改-写复合操作)- 加锁位置:
synchronized(this)锁住sell()的"判断-打印-减票"整体过程——同一时刻只有一个窗口能执行卖票- 三种写法可替换:作业框架用同步代码块
synchronized(this)(写法 3);也可以给sell()加synchronized修饰(写法 1:同步实例方法锁 this),两种都有效- join() 完善:TODO 4 要求补上"线程数组 + join()"——让 main 等 3 个窗口卖完票再结束(回顾 08-10 join 等待线程结束)
- 验证思路:先不加 synchronized 多次运行看 bug(相同票 / 负数票),加 synchronized 后再运行 bug 消失——用实践验证上节课"锁"的作用
本课知识点围绕"volatile 解决可见性 / 有序性 + JVM 内存模型 + 线程安全三大特性解决方案 + Lock 锁(ReentrantLock)+ 死锁(产生条件与避免方法)+ 线程池(ThreadPoolExecutor)+ 课后作业(synchronized 修复卖票问题)"展开,一张表看清"知识 → 代码位置":
| 知识点 | 具体体现 | 源码位置 |
|---|---|---|
| volatile 概述 | 解决可见性和有序性问题,不能保证原子性;volatile 可解决"程序永不退出" | course/Demo01 注释 |
| 可见性问题定义 | 一个线程对共享数据的修改,对其他线程不可见,其他线程无法做出相应响应 | course/Demo01 注释 |
| JVM 内存模型 | 每个线程有独立工作内存(CPU 缓存);读变量优先从工作内存读、写变量先写工作内存再刷主内存,刷新有延迟 | course/Demo01 注释 |
| 可见性问题原理 | main 把 running 改 false 写主存,worker 一直读 CPU 缓存旧值 true → while(running) 永不退出 | course/Demo01 注释 |
| volatile 语义 1:可见性 | 写 volatile 变量立即刷主存,读 volatile 变量强制从主存读,禁止使用 CPU 缓存 | course/Demo01 注释 |
| volatile 语义 2:禁止重排序 | 编译器和 CPU 不会把 volatile 读写及其前后指令进行指令重排(有序性) | course/Demo01 注释 |
| volatile 语义 3:不保证原子性 | volatile count++ 依旧丢失更新,做不到 3 步变 1 步;必须 synchronized/Lock/原子类 | course/Demo01 注释 |
| 可见性演示:程序永不退出 | running 加 volatile;worker 线程 while(running) 空循环,main 主线程 stop() 后能正常退出 | course/VolatileDemo |
| 方法引用创建线程 | new Thread(vd::work,"worker") 等价于 Lambda () -> vd.work()、等价于匿名 Runnable |
course/VolatileDemo main |
| 三大特性解决方案回顾 | 原子性→synchronized/Lock/Atomic;可见性→volatile/synchronized/Lock;有序性→volatile/synchronized | course/Demo01 注释 |
| volatile vs synchronized | volatile 轻量、只解决可见性/有序性;synchronized 加锁、同时保证三大特性 | course/Demo01 注释 |
| Counter 升级:volatile 不保证原子性 | private volatile Integer count=0——count++ 的"读-改-写"3 步依旧被打断,count 还是小于 1000 |
course/Counter |
| Counter 升级:synchronized 保证原子性 | public synchronized void increment() 同步实例方法(锁 this),同一时刻只一个线程执行 count++ → count 必然是 1000 |
course/Counter |
| 1000 线程 + join 等待 | 线程数组 1000 个线程并发 increment(),for (Thread t : threads) t.join() 等全部结束后打印 |
course/Counter main |
| 课后作业:卖票类 SellTicket | implements Runnable,tickets=100;sell() 完成"判断-打印-减票";run() 中 synchronized(this) 锁住卖票过程 |
homework0810/Demo_03 |
| 课后作业:3 个窗口线程 | 同一个 st 对象创建"窗口1/2/3"三个线程并 start()——共享 tickets 产生数据竞争 |
homework0810/Demo_03 main |
| 课后作业:synchronized 修复 | 先不加 synchronized 复现相同票/负数票,再加 synchronized(this) 修复(三种写法均可) | homework0810/Demo_03 TODO |
| 课后作业:join 等待 | TODO 4 用线程数组 + join() 等所有窗口卖票结束 | homework0810/Demo_03 TODO |
| Lock 接口与 ReentrantLock | JDK1.5 提供 java.util.concurrent.locks.Lock 接口及实现类 ReentrantLock(可重入锁);比 synchronized 更精细 |
course/Demo02 注释 |
| Lock 相比 synchronized 的优势 | 超时等待(拿不到锁不傻等)、响应中断(等待中可被 interrupt() 中断)、指定公平性(按申请顺序)、支持多条件变量(精准唤醒) | course/Demo02 注释 |
| Lock 手动释放锁铁律 | synchronized 自动释放锁;Lock 必须手动 unlock() 且必须放 finally,否则抛异常/return 时锁不释放 → 其他线程永久阻塞(死锁) |
course/Demo02 注释 |
| ReentrantLock 构造方法 | new ReentrantLock() 默认非公平锁(释放后所有线程竞争);new ReentrantLock(true) 公平锁(按申请顺序、性能略低) |
course/Demo02 注释、LockDemo |
| Lock 核心方法 | lock() 加锁 / unlock() 释放锁 / tryLock() 尝试获取(获取不到就算了)/ tryLock(timeout, unit) 限时等待 / lockInterruptibly() 设置可中断的锁 |
course/Demo02 注释 |
| synchronized vs Lock 七维对比 | 语法简洁度(关键字 vs try-finally)、锁释放(自动 vs 手动 unlock)、可中断(不支持 vs lockInterruptibly)、超时等待(不支持 vs tryLock(timeout))、锁读(不支持 vs 读写分离单独上锁)、底层实现(Monitor Lock vs AQS 抽象队列同步器)、JDK21(synchronized 大幅优化、性能相近) | course/Demo02 注释 |
| LockDemo 基本用法 | lock.lock() → try { count++ } finally { lock.unlock() } 标准模板,保证原子性 |
course/LockDemo |
| LockDemo tryLock() | lock.tryLock() 立刻尝试,拿到返回 true 并操作,没拿到返回 false 继续干别的——避免死锁的手段 |
course/LockDemo |
| LockDemo tryLock(timeout) | lock.tryLock(1, TimeUnit.SECONDS) 最多等 1 秒,超时返回 false;抛 InterruptedException 需 try-catch |
course/LockDemo |
| LockDemo lockInterruptibly() | 等待锁的过程中可被 interrupt() 打断;catch 中打印提示并 Thread.currentThread().interrupt() 重设中断标志 |
course/LockDemo |
| LockDemo main 演示 | 10000 线程并发 ld::interruptLock(方法引用),join 等待后打印 count——Lock 保证原子性,count 正确 |
course/LockDemo main |
| 死锁概念 | 两个或多个线程相互持有对方所需资源,导致线程处在等待状态无法继续执行;出现在资源有限 / 同步嵌套场景 | course/Demo03 注释 |
| 死锁 4 个必要条件 | ①互斥(资源同一时刻只被一个线程占用)②持有且等待(持有一个资源同时等另一个)③不可剥夺(已持有的资源不能被强夺)④循环等待(你等我、我等你的环)——缺一不可 | course/Demo03 注释 |
| 死锁演示 | 线程1 拿 lockA 等 lockB、线程2 拿 lockB 等 lockA(synchronized 嵌套顺序相反)→ 4 条件满足 → 死锁卡死 | course/Demo03 main(注释) |
| 避免死锁 1:固定锁顺序 | 所有线程按相同顺序加锁(统一 lockA → lockB),打破"循环等待"条件 | course/Demo03 main(注释) |
| 避免死锁 2:tryLock 超时 | tryLock(1, TimeUnit.SECONDS) 拿不到就放弃并 finally 释放已持锁,打破"持有且等待"条件 |
course/Demo03 main(注释) |
| 避免死锁 3:高级并发工具 | 用 ConcurrentHashMap 同步 Map 集合等 JDK 并发容器,避免手动加多把锁 |
course/Demo03 注释 |
| 线程池概念 | 线程池是存储线程的容器(水池类比);new Thread().start() 创建线程成本高(涉及操作系统交互) | course/Demo04 注释 |
| new Thread().start() 三个问题 | ①创建/销毁开销大(每线程 1MB 栈内存 + 操作系统交互)②线程数量不受控(高并发耗尽内存 CPU 崩溃)③资源利用率低(任务几毫秒、创建销毁几秒) | course/Demo04 注释 |
| 线程池解决方案 | 【线程复用 + 任务排队 + 动态扩容 + 上限保护】——提前创建线程常驻,任务复用空闲线程,排不下动态扩容,达到上限拒绝 | course/Demo04 注释 |
| 饭店餐馆类比 | 核心线程数=固定餐桌、最大线程数=临时餐桌、工作队列=门口排队凳、空闲存活时间=临时桌收回、拒绝策略=服务员拒绝 | course/Demo04 注释 |
| ThreadPoolExecutor 7 参数 | corePoolSize(核心线程数)/ maximumPoolSize(最大线程数,必须 > 核心)/ keepAliveTime(非核心空闲存活时间)/ unit(时间单位)/ workQueue(工作队列)/ threadFactory(线程工厂)/ handler(拒绝策略) | course/Demo04 注释 |
| 线程池执行流程 | 核心线程 → 工作队列 → 非核心线程 → 拒绝策略;线程不够不先创建线程,先排队,队列满且未达最大才扩容 | course/Demo04 注释 |
| Demo04 创建线程池 | new ThreadPoolExecutor(3, 5, 60, SECONDS, ArrayBlockingQueue(10), defaultThreadFactory, AbortPolicy)——7 参数齐全 | course/Demo04 main |
| ThreadPoolDemo 演示 | 8 任务 2 核心 4 最大 队列 2:2 执行 + 2 排队 + 2 临时线程 + 2 被拒绝;getActiveCount / getQueue().size / shutdown | course/ThreadPoolDemo main |
VolatileDemo 中 running 的 volatile 去掉,运行观察 worker 线程是否永远打印不出"线程退出......";加上 volatile 后再运行对比——直观体会可见性问题course/Counter(count 加了 volatile、increment 加了 synchronized),多次运行确认 count 必然是 1000——对比上节课"无锁 count 小于 1000",体会 synchronized 解决原子性的效果homework0810/Demo_03 的 TODO——①完成 sell() 卖票逻辑 ②先不加 synchronized 运行复现相同票/负数票 ③加 synchronized(this) 修复 ④用线程数组 + join() 等待卖票结束 ⑤多次运行验证 bug 消失synchronized(this) { sell(); }(写法 3 同步代码块)改成 sell() 方法加 synchronized(写法 1 同步实例方法锁 this),运行验证同样有效——回顾 synchronized 三种写法course/LockDemo,10000 线程并发调用 interruptLock(),join 等待后观察 count 是否正确(必然 10000)——体会 Lock 锁保证原子性的效果ld::interruptLock 分别换成 ld::increment(lock+unlock 基本用法)、ld::tryIncrement(tryLock 尝试获取)、ld::timedIncrement(tryLock 限时等待),运行对比四种用法的效果tryIncrement() 打印返回值,观察当多个线程竞争锁时,拿不到锁的线程返回 false、继续执行而不阻塞——体会 tryLock 是"避免死锁的手段"interruptLock()(若锁被占用则等待),main 里对该线程 interrupt(),观察 catch 中打印"等待锁的过程中被中断"并重设中断标志——体会 Lock 响应中断的能力(对比 synchronized 不支持)lock.lock() 后不用 try-finally,而是直接 count++; lock.unlock();,当 count++ 抛异常时会发生什么——锁永不释放,其他线程永久阻塞(死锁)new ReentrantLock()(非公平)和 new ReentrantLock(true)(公平)创建锁,观察竞争线程获取锁的顺序差异——公平锁按申请顺序、性能略低Demo03 的死锁演示 main 恢复运行,观察只打印"线程1 拿到 lockA"和"线程2 拿到 lockB"后程序卡死——验证死锁(4 个必要条件同时满足)jstack <进程号> 命令查看线程转储,找到 Found one Java-level deadlock 信息——生产环境排查死锁的标准手段tryLock(1, TimeUnit.SECONDS)(Demo03 第二种修复版),运行观察拿不到锁的线程打印"拿不到锁"后继续执行,不再死锁——体会打破"持有且等待"条件ConcurrentHashMap(同步 Map 集合)等 JDK 并发容器——为什么用它们可以避免手动加多把锁导致的死锁(容器内部已处理好线程安全)course/ThreadPoolDemo,观察 8 个任务的走向——2 个核心执行 + 2 个排队 + 2 个临时线程 + 2 个被拒绝(抛 RejectedExecutionException),以及"活动线程数:4 / 队列中任务数:2"的输出pool.execute(runnable)(复用线程池线程)与 new Thread(runnable).start()(每次创建新线程)——用 getName() 观察线程池中线程是否重复使用(多个任务共用 2 个核心线程名)pool.shutdown() 注释掉再运行,观察程序是否一直不退出(线程池的非守护线程还活着)——体会 shutdown 关闭线程池的作用CallerRunsPolicy(调用者运行)或 DiscardPolicy(丢弃),提交超出容量的任务观察不同拒绝行为java.lang.Thread(线程类核心方法)、java.lang.Object(wait/notify)、java.lang.Thread.State(线程 6 种状态枚举)volatile count++ 中,虽然 count 读写走主内存,但 count++ 的"读-改-写"3 步依旧可能被并发打断(读旧值→计算→覆盖),所以 volatile 不能替代 synchronized / 原子类解决原子性问题count++、tickets-- 这类读-改-写复合操作,必须用 synchronized / Lock / 原子类(AtomicInteger 等)synchronized 和 Lock 也保证可见性(加锁解锁会同步工作内存与主内存);用 Thread.join()、FutureTask.get() 等"等待线程结束"的手段也能建立 happens-before 关系,间接保证可见性AtomicInteger / AtomicLong 等原子类内部用 CAS(比较并交换) 实现原子操作——incrementAndGet() 是原子的,无需加锁即可保证线程安全,性能优于 synchronizedhomework0810/Demo_03 把 08-10 的卖票问题(数据竞争)+ synchronized(三种写法)+ join(等待线程结束) 串成一道综合作业——先复现 bug、再加锁修复、最后 join 等待,完整实践"发现问题 → 分析原因 → 锁解决"的线程安全排错流程java.util.concurrent.locks.Lock 接口与 ReentrantLock(可重入锁)是 JDK1.5 提供的显式锁机制;相比 synchronized 提供超时等待、响应中断、公平锁、多条件变量等精细控制tryLock() 获取不到就放弃(不阻塞)②tryLock(timeout) 限时等待(超时放弃)③lockInterruptibly() 可中断(等待中被 interrupt 打断)——三者都比 lock()(无限期等待)更安全new ReentrantLock(true) 创建公平锁Condition 对象可实现多条件变量精准唤醒(condition.await() / condition.signal()),是对 Object 类 wait/notify 的升级(synchronized 只有单一条件队列)——后续生产者-消费者高级模式会用到tryLock() / tryLock(timeout) 正是避免死锁的重要手段——拿不到锁就放弃(或超时放弃),并释放已持有的锁,从而打破"持有且等待",这是 ReentrantLock 比 synchronized 的明显优势之一(synchronized 无法超时)jstack 命令(JDK 自带)——它会在线程转储中输出 Found one Java-level deadlock,并列出死锁的线程与各自持有的锁/等待的锁,是定位死锁最直接的工具ConcurrentHashMap(JDK5 引入的线程安全 Map)内部通过分段锁 / CAS + synchronized 细粒度锁实现线程安全,外部调用者不需要手动加多把锁,从源头避免了"同步嵌套导致的死锁"——这正是 Demo03 提到的"使用高级并发工具避免死锁"Semaphore(信号量,限制资源访问数)、CountDownLatch(倒计时器,等待多线程完成)、CyclicBarrier(循环屏障) 等 AQS 体系并发工具,减少手动锁管理Executors 工厂类(newFixedThreadPool / newCachedThreadPool / newSingleThreadExecutor / newScheduledThreadPool)和底层实现 ThreadPoolExecutor——生产环境更推荐直接 new ThreadPoolExecutor(参数可控、避免 OOM)corePoolSize(核心线程常驻)、maximumPoolSize(最大线程 = 核心 + 临时)、keepAliveTime + unit(临时线程空闲回收时间)、workQueue(有界/无界队列,如 ArrayBlockingQueue / LinkedBlockingQueue / SynchronousQueue)、threadFactory(自定义线程名、守护属性)、handler(拒绝策略)AbortPolicy(默认,抛 RejectedExecutionException)/ CallerRunsPolicy(调用者线程自己执行任务)/ DiscardPolicy(直接丢弃,静默)/ DiscardOldestPolicy(丢弃队列最旧任务再尝试提交)pool.execute(Runnable) 提交无返回值的任务;pool.submit(Callable/Runnable) 提交带返回值的任务并返回 Future(回顾 08-08 Callable + FutureTask——submit 返回的 Future 就是线程池版的 FutureTask)shutdown()(不再接受新任务,已提交任务执行完)与 shutdownNow()(立即中断所有任务);关闭后可用 awaitTermination(timeout, unit) 等待线程池完全终止——不关闭线程池程序可能一直不退出(池中非守护线程活着)corePoolSize = CPU 核数 + 1;IO 密集型任务建议 corePoolSize = CPU 核数 × 2(或更大)——因为 IO 任务大多在等待,可容纳更多线程;队列大小根据任务峰值设计,避免 OOM