多线程机制
多线程机制
目标:理解线程与进程的区别、Java 多线程机制;掌握线程六状态生命周期(NEW/RUNNABLE/BLOCKED/WAITING/TIMED_WAITING/TERMINATED);会用
extends Thread/implements Runnable两种方式创建线程;理解synchronized同步与join联合,能解释竞态条件与死锁的四个必要条件。
一、为什么需要多线程
- 单线程瓶颈:Java 程序默认只有一个主线程,依次执行语句。若主线程里做一件耗时的事(读文件、网络请求、大计算),在它结束前整个程序都「卡住」——GUI 无法响应鼠标键盘,服务器无法接待下一个客户;
- 并发需求:把耗时任务交给独立线程,让「界面响应」与「后台计算」同时进行——这就是多线程要解决的问题;
- 进程 vs 线程:进程是操作系统分配资源的单位,线程是CPU 调度的单位。同一进程内的多个线程共享内存(堆上的对象、静态字段)——共享带来便利,也带来风险:竞态条件(后文第五节);
- 真实场景(网络版 ERASE):服务端每来一个客户端连接就
new一个ServThreadPerClient线程专门处理它的收发——「一个客户端一个线程」;客户端的界面绘制与网络接收也分属不同线程并发执行。
二、线程的状态与生命周期
JVM 中每个线程对象有六个状态(java.lang.Thread.State):
| 状态 | 含义 |
|---|---|
NEW | new Thread(...) 之后、start() 之前 |
RUNNABLE | start() 之后:正在执行,或就绪等待调度 |
BLOCKED | 等监视器锁(想进被锁住的 synchronized) |
WAITING | wait()/join() 无限期挂起 |
TIMED_WAITING | sleep(ms)/wait(ms)/join(ms) 限时挂起 |
TERMINATED | run() 结束,终态不可恢复 |
两个高频误区:
start()不是直接执行run()——它让线程进入 RUNNABLE,真正何时跑由调度器决定;- BLOCKED 只等锁——
sleep/wait是「等待」不是「阻塞」,两者在状态机里是不同的状态。
交互实验:线程生命周期(ThreadLifecycleMachine)
状态机 · 六状态点下面的「事件」按钮,看线程在 NEW / RUNNABLE / BLOCKED / WAITING / TIMED_WAITING / TERMINATED 之间如何迁移。 RUNNABLE 是枢纽:抢锁、等待、限时等待都从它出发,唤醒后回到它;TERMINATED 是终态。
当前:新建 NEW —— new Thread(...) 已创建对象,尚未 start()——线程还没有被 JVM 真正启动
状态迁移日志
- 线程对象已创建(new Thread(...)),尚未 start()
常用线程方法速查
| 方法 | 签名 | 作用 |
|---|---|---|
| start | t.start() | 启动线程并进入 RUNNABLE;一个线程只能 start() 一次,重复调用抛 IllegalThreadStateException |
| sleep | Thread.sleep(ms) | 当前线程挂起 ms 毫秒(不释放已持有的锁);声明抛出 InterruptedException,必须处理 |
| yield | Thread.yield() | 放弃当前时间片、回到就绪队列——只是「建议」调度器,不保证立即让出 |
| join | t.join() / t.join(ms) | 调用方线程挂起,直到目标线程 t 结束(或最多 ms 毫秒)——实现「联合」 |
| setPriority | t.setPriority(1~10) | 设置优先级,默认 5:只影响调度概率,不保证高优先级一定先执行 |
两种创建方式:extends Thread vs implements Runnable
| 方面 | extends Thread | implements Runnable |
|---|---|---|
| 定义方式 | 继承 Thread 类,重写 run() | 实现 Runnable 接口,实现 run() |
| 创建线程对象 | new MyThread()——对象本身就是线程 | 需 new Thread(runnable) 包装成线程 |
| 继承槽位 | 占用 Java 单继承槽(MyThread 不能再继承别的类) | 不占继承槽,类仍可继承其他父类 |
| 命名/管理 | 可直接 this.setName() | 通过 Thread 构造器传名:new Thread(r, "售票点1") |
| 推荐场景 | 简单、独立的线程任务 | 首选——避开单继承限制,一个任务对象可被多个线程共享(如三个售票点共用一个 TicketSaler) |
网络版 ERASE(课件 25/Erase-JAVA-NET):服务端每来一个客户端连接,就 new 出一个 ServThreadPerClient 线程专门处理它的收发——「一个客户端一个线程」是典型多线程设计;客户端的界面绘制与网络接收也分属不同线程并发执行。多端同时读写共享的对局信息,用 synchronized 保护,避免竞态。
三、实现多线程的两种方法
方式一:继承 Thread 类——重写 run(),对象本身就是线程:
public class MyThread extends Thread {
public void run() { /* 线程要执行的任务 */ }
}
// 启动:new MyThread().start();
方式二:实现 Runnable 接口——任务与线程分离,用 Thread 包装(课件 26/TicketSaler.java:三个售票点共用同一个任务对象):
class TicketSaler implements Runnable {
public void run() { /* 卖票任务 */ }
}
// 启动:Thread t = new Thread(new TicketSaler()); t.setName("张三"); t.start();
| 方面 | extends Thread | implements Runnable |
|---|---|---|
| 定义方式 | 继承 Thread,重写 run() | 实现 Runnable,实现 run() |
| 创建线程对象 | new MyThread()——对象本身就是线程 | 需 new Thread(runnable) 包装 |
| 继承槽位 | 占用 Java 单继承槽 | 不占继承槽,类仍可继承其他父类 |
| 命名/管理 | 直接 this.setName() | 构造器传名:new Thread(r, "售票点1") |
| 推荐场景 | 简单独立任务 | 首选——避开单继承限制,一个任务对象可被多个线程共享 |
四、常用线程方法
start():启动线程进入 RUNNABLE;一个线程只能 start 一次,重复调用抛IllegalThreadStateException;sleep(ms):当前线程挂起 ms 毫秒(不释放已持有的锁);声明抛出InterruptedException,必须 try/catch;yield():放弃当前时间片、回到就绪队列——只是「建议」,不保证立即让出;join():联合——调用方线程挂起直到目标线程结束(课件26/DemoThreadJoin.java:顾客线程join蛋糕师线程,蛋糕做完了顾客才继续):
Thread customer = new Thread(a); // 顾客
Thread cakeMaker = new Thread(a); // 蛋糕师
a.setJoinThread(cakeMaker); // 顾客任务里:cakeMaker.join()
customer.start(); // 顾客先等蛋糕师结束,再继续买蛋糕
setPriority(1~10):优先级默认 5,只影响调度概率,不保证高优先级一定先执行。
五、线程同步 synchronized
竞态条件:多个线程交替读写同一份共享数据,最终结果取决于谁先谁后——错误就藏在「读-改-写」被拆开的缝隙里。典型反例是三个售票线程共用 tickets = 500:不加同步会卖出负数票、超卖。
修复:用 synchronized 保证同一时刻只有一个线程执行同步代码(课件 26/Thread/TicketsSaling_syn.java):
public synchronized void sale() { // 同步方法:拿到 this 的监视器锁才进得来
if (tickets > 0) {
Thread.sleep(1000); // 模拟卖票耗时,放大并发窗口
jta.append(Thread.currentThread().getName() + "售票" + tickets-- + "\n");
}
}
- 同步方法:
synchronized加在方法上,锁是this(实例方法)或类对象(静态方法); - 同步代码块:
synchronized (obj) { ... },锁是指定的 obj——锁范围尽量小,只包住共享数据的读-改-写; - wait/notify 家族:
Object的wait()/notify()/notifyAll()只能在同步上下文中调用(先拿锁才能等)。课件26/TicketSaler.java的找零问题:李四给 20 元要换三张 5 元,若张三还没收够三张 5 元,李四线程wait()挂起;张三每收一张 5 元就notifyAll()唤醒大家重新检查——**「等条件不满足就 wait,条件可能满足就 notify」**是同步协作的基本套路。
交互实验:synchronized 监视器(SynchronizedMonitor)
线程 A、B 各执行两次 x = x + 1(期望结果 4)。 选「有锁」看 monitor 如何排队;选「无锁」看读-改-写交错怎样丢掉一次更新。
- 线程 AA:enter monitor(拿到锁)
- 线程 AA:读 x=0,写 x=1
- 线程 AA:读 x=1,写 x=2
- 线程 AA:exit monitor(释放锁)
- 线程 BB:enter monitor(拿到锁)
- 线程 BB:读 x=2,写 x=3
- 线程 BB:读 x=3,写 x=4
- 线程 BB:exit monitor(释放锁)
共享变量 x
有锁:B 想进临界区时 A 还没 exit,monitor 让 B 排队——读-改-写不可被打断。
六、生产者–消费者:synchronized 协作模拟
上一节的找零套路,推广到最经典的并发模型——生产者–消费者(Producer-Consumer):生产者线程把「产品」放入一块共享缓冲,消费者线程从中取出。缓冲满 → 生产者必须 wait();缓冲空 → 消费者必须 wait();一方改变缓冲后调用 notifyAll() 唤醒对方。三个要点:
wait()/notifyAll()只能在 synchronized 上下文中调用——先拿到监视器锁,才谈得上等与唤醒;wait()会释放锁并无限期挂起(WAITING 状态);被唤醒后须重新拿锁、用while复查条件(用if会在多生产者场景下超产);- 「条件不满足就 wait,条件可能满足就 notify」——谁在等、谁拿着锁,下面逐步看。
交互实验:生产者–消费者同步模拟(ProducerConsumerSim)
synchronized · wait/notifyAll生产者把产品放入共享缓冲,消费者从中取出:缓冲满 → 生产者 wait(),缓冲空 → 消费者 wait();一方改变缓冲后调用 notifyAll() 唤醒对方。选一个场景,用「下一步」逐步走查同步协议——注意谁在等、谁拿着锁。
已生产:0/4
共享缓冲(容量 2)
满 → 生产者 wait() · 空 → 消费者 wait()
已消费:0/4
同步协议走查日志
- 生产者拿监视器锁进入 synchronized produce() 方法:先拿到 this 的监视器锁(其他线程须排队等锁)
经典写法:synchronized + wait/notifyAll
public synchronized void produce(int item) {
while (size == buf.length) // 缓冲满 → 条件不满足就 wait
try { wait(); } catch (InterruptedException e) {}
buf[(head + size) % buf.length] = item;
size++;
notifyAll(); // 条件可能已满足 → 唤醒等待者
}public synchronized int consume() {
while (size == 0) // 缓冲空 → 条件不满足就 wait
try { wait(); } catch (InterruptedException e) {}
int v = buf[head]; // 取出队首产品
head = (head + 1) % buf.length;
size--;
notifyAll(); // 条件可能已满足 → 唤醒等待者
return v;
}为什么用 while 而不是 if?
| 方面 | while(正确) | if(有漏洞) |
|---|---|---|
| 写法 | while (条件不满足) wait(); | if (条件不满足) wait(); |
| 被唤醒后 | 重新检查条件:仍不满足就继续 wait() | 不再检查,直接往下执行——可能破坏共享数据 |
| 典型事故 | 无事故 | 多个生产者被 notifyAll() 同时唤醒:A 先生产放满缓冲,B 用 if 不复查就再放 → 超产(越界/丢数据) |
| 结论 | ✅ 唯一正确写法 | ❌ 竞态漏洞:唤醒≠条件一定满足,必须用 while 复查 |
同步协作要点
- 「条件不满足就 wait(),条件可能满足就 notifyAll()」是多线程协作的基本套路(TicketSaler 找零问题同理);
- wait()/notifyAll() 只能在 synchronized 上下文中调用——先拿到监视器锁,才谈得上等与唤醒;
- wait() 会释放监视器锁并无限期挂起(WAITING 状态);被 notifyAll() 唤醒后须重新拿锁、用 while 复查条件;
- 缓冲容量决定谁等待:满 → 生产者 wait(),空 → 消费者 wait();notifyAll() 唤醒所有等待者,而非只唤醒一个。
七、优先级与调度:谁先跑?
多个线程都就绪(RUNNABLE)之后,CPU 一次只能执行一个——调度器每一步从就绪队列里挑一个来跑。Java 给每个线程设了 1~10 的优先级(默认 5):优先级越高越容易被挑中,但只是概率更高,不保证一定先执行。下面的模拟用「最高优先级优先、同级轮转」的确定策略把这一效果夸张化,三种对照:
- 同优先级:公平轮转,没有线程被饿死;
- 优先级不同:高优先级线程先跑完,低优先级依次收尾——顺序被改变;
- 长时运行的高优先级任务:一直占着 CPU → 低优先级线程拿不到时间片,即饿死(真实系统用「老化」机制——随等待时间逐渐提升线程优先级——来防止)。
交互实验:线程优先级调度模拟(SchedulerDemo)
setPriority · 就绪队列多个线程都就绪(RUNNABLE)时,CPU 一次只能跑一个——调度器每步从就绪队列里挑一个执行 1 个时间片。 本模拟按「优先级最高者优先、同级轮转」的确定策略夸张化展示效果:选一个场景,用「下一步」看谁先跑、谁在等。
已执行:1 / 4 个时间片
已执行:0 / 4 个时间片
已执行:0 / 4 个时间片
CPU(一次只能执行一个线程)
就绪队列(接下来的调度顺序)
调度走查日志
- 1T1 PrintTask 执行 1 个时间片与其他就绪线程同优先级(P5)→ 已用时间片最少者先执行(轮转):轮到 T1 PrintTask
Java 线程优先级速查
| 优先级范围 | 1(MIN_PRIORITY)~ 10(MAX_PRIORITY) |
| 默认优先级 | 5(NORM_PRIORITY);子线程继承创建者线程的优先级 |
| 作用 | 只影响调度概率——JVM/操作系统不保证高优先级一定先执行 |
| 设置方式 | t.setPriority(1~10) 或 new Thread(runnable, name, prio) |
经典写法:setPriority
Thread t1 = new Thread(task, "PrintTask"); // 默认优先级 5
Thread t2 = new Thread(task, "AudioMixTask");
t2.setPriority(Thread.MAX_PRIORITY); // 请求优先被调度(优先级 10)
t1.start();
t2.start(); // 两者都就绪时,t2 获得 CPU 的概率更高优先级调度要点
- 调度器每一步从就绪队列挑线程:优先级越高越容易被挑中——本模拟用「最高优先级优先」把这一效果夸张化、可视化;
- 优先级相同时调度是公平的(轮转):没有哪个线程会被系统性地饿死;
- 长时运行的高优先级任务会一直占着 CPU,低优先级线程可能永远得不到时间片:这就是饿死;真实系统用「老化」(随等待时间逐渐提升线程优先级)来防止;
- 优先级只影响概率、不保证顺序:即使 MAX_PRIORITY 也可能被抢占——不要依赖优先级保证正确性,共享数据仍然要靠 synchronized。
八、总结:死锁与同步要点
- 死锁四必要条件(缺一即无死锁):互斥(资源一次只给一个线程)、占有并等待(拿着 A 等 B)、不可抢占(锁只能自己释放)、循环等待(甲等乙、乙等甲);
- 预防思路:按固定顺序申请多把锁(破坏循环等待);用
tryLock类机制超时放弃(破坏占有并等待); - 同步使用要点:锁范围最小化;同步块内不要调用可能阻塞的业务方法(如 sleep、IO),否则排队线程越积越多。
思考题
start()之后线程一定立刻执行run()吗?RUNNABLE 包含哪两种情形?- 三个售票线程共用一个
tickets变量,不加synchronized会出现什么现象?加在方法上和加在代码块上,锁的范围有何不同? - 网络版 ERASE「一个客户端一个线程」的设计,如果客户端数量很大(如上万),这种设计会遇到什么问题?(提示:线程创建与调度的开销)
学习小测验
学习小测验
Java 中创建新线程的方式不包括( )
启动一个线程的方法是( )
线程调用 start() 之后进入( )状态
同步多线程共享数据时使用的关键字是( )
死锁产生的四个必要条件包括互斥、占有并等待、__、循环等待
线程同步的目的是防止__条件引发的数据不一致
主线程对线程 t 调用 t.join() 的作用是( )
线程生命周期中不可恢复的终态是( )
生产者–消费者模型中,缓冲已满时生产者线程的正确做法是( )
线程被 notifyAll() 唤醒后、继续执行之前必须( )
Java 中线程优先级的取值范围是( )
多个就绪线程中,把某线程 t 的优先级设为 10 意味着( )