Java 面向对象 · 交互课程

多线程机制

多线程机制

目标:理解线程与进程的区别、Java 多线程机制;掌握线程六状态生命周期(NEW/RUNNABLE/BLOCKED/WAITING/TIMED_WAITING/TERMINATED);会用 extends Thread / implements Runnable 两种方式创建线程;理解 synchronized 同步与 join 联合,能解释竞态条件与死锁的四个必要条件。

一、为什么需要多线程

二、线程的状态与生命周期

JVM 中每个线程对象有六个状态(java.lang.Thread.State):

状态含义
NEWnew Thread(...) 之后、start() 之前
RUNNABLEstart() 之后:正在执行,或就绪等待调度
BLOCKED等监视器锁(想进被锁住的 synchronized)
WAITINGwait()/join() 无限期挂起
TIMED_WAITINGsleep(ms)/wait(ms)/join(ms) 限时挂起
TERMINATEDrun() 结束,终态不可恢复

两个高频误区:

  1. start() 不是直接执行 run()——它让线程进入 RUNNABLE,真正何时跑由调度器决定;
  2. BLOCKED 只等锁——sleep/wait 是「等待」不是「阻塞」,两者在状态机里是不同的状态。

交互实验:线程生命周期(ThreadLifecycleMachine)

状态机 · 六状态

点下面的「事件」按钮,看线程在 NEW / RUNNABLE / BLOCKED / WAITING / TIMED_WAITING / TERMINATED 之间如何迁移。 RUNNABLE 是枢纽:抢锁、等待、限时等待都从它出发,唤醒后回到它;TERMINATED 是终态。

start()抢锁 ⇄ 拿到锁wait() ⇄ 唤醒sleep(ms) ⇄ 超时/中断run() 结束新建 NEW可运行 RUNNABLE阻塞 BLOCKED等待 WAITING限时等待 TIMED_WAITING终止 TERMINATED

当前:新建 NEW —— new Thread(...) 已创建对象,尚未 start()——线程还没有被 JVM 真正启动

状态迁移日志

  1. 线程对象已创建(new Thread(...)),尚未 start()

常用线程方法速查

方法签名作用
startt.start()启动线程并进入 RUNNABLE;一个线程只能 start() 一次,重复调用抛 IllegalThreadStateException
sleepThread.sleep(ms)当前线程挂起 ms 毫秒(不释放已持有的锁);声明抛出 InterruptedException,必须处理
yieldThread.yield()放弃当前时间片、回到就绪队列——只是「建议」调度器,不保证立即让出
joint.join() / t.join(ms)调用方线程挂起,直到目标线程 t 结束(或最多 ms 毫秒)——实现「联合」
setPriorityt.setPriority(1~10)设置优先级,默认 5:只影响调度概率,不保证高优先级一定先执行

两种创建方式:extends Thread vs implements Runnable

方面extends Threadimplements 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 Threadimplements Runnable
定义方式继承 Thread,重写 run()实现 Runnable,实现 run()
创建线程对象new MyThread()——对象本身就是线程需 new Thread(runnable) 包装
继承槽位占用 Java 单继承槽不占继承槽,类仍可继承其他父类
命名/管理直接 this.setName()构造器传名:new Thread(r, "售票点1")
推荐场景简单独立任务首选——避开单继承限制,一个任务对象可被多个线程共享

四、常用线程方法

Thread customer = new Thread(a);   // 顾客
Thread cakeMaker = new Thread(a);  // 蛋糕师
a.setJoinThread(cakeMaker);        // 顾客任务里:cakeMaker.join()
customer.start();                  // 顾客先等蛋糕师结束,再继续买蛋糕

五、线程同步 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 监视器(SynchronizedMonitor)

线程 A、B 各执行两次 x = x + 1(期望结果 4)。 选「有锁」看 monitor 如何排队;选「无锁」看读-改-写交错怎样丢掉一次更新。

  1. 线程 AA:enter monitor(拿到锁)
  2. 线程 AA:读 x=0,写 x=1
  3. 线程 AA:读 x=1,写 x=2
  4. 线程 AA:exit monitor(释放锁)
  5. 线程 BB:enter monitor(拿到锁)
  6. 线程 BB:读 x=2,写 x=3
  7. 线程 BB:读 x=3,写 x=4
  8. 线程 BB:exit monitor(释放锁)

共享变量 x

0

有锁:B 想进临界区时 A 还没 exit,monitor 让 B 排队——读-改-写不可被打断。

六、生产者–消费者:synchronized 协作模拟

上一节的找零套路,推广到最经典的并发模型——生产者–消费者(Producer-Consumer):生产者线程把「产品」放入一块共享缓冲,消费者线程从中取出。缓冲满 → 生产者必须 wait();缓冲空 → 消费者必须 wait();一方改变缓冲后调用 notifyAll() 唤醒对方。三个要点:

  1. wait()/notifyAll() 只能在 synchronized 上下文中调用——先拿到监视器锁,才谈得上等与唤醒;
  2. wait() 会释放锁并无限期挂起(WAITING 状态);被唤醒后须重新拿锁、用 while 复查条件(用 if 会在多生产者场景下超产);
  3. 「条件不满足就 wait,条件可能满足就 notify」——谁在等、谁拿着锁,下面逐步看。

交互实验:生产者–消费者同步模拟(ProducerConsumerSim)

synchronized · wait/notifyAll

生产者把产品放入共享缓冲,消费者从中取出:缓冲满 → 生产者 wait(),缓冲空 → 消费者 wait();一方改变缓冲后调用 notifyAll() 唤醒对方。选一个场景,用「下一步」逐步走查同步协议——注意谁在等、谁拿着锁。

第 1 / 28 步 · 生产者只要可行动就先行动:缓冲放满 → 生产者 wait(),消费者取走产品并 notifyAll() 唤醒它
生产者 Producer🔒 持有监视器锁
RUNNABLE · 持锁执行中

已生产:0/4

共享缓冲(容量 2)

空
空

满 → 生产者 wait() · 空 → 消费者 wait()

消费者 Consumer
就绪(等调度)

已消费:0/4

同步协议走查日志

  1. 生产者拿监视器锁进入 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):优先级越高越容易被挑中,但只是概率更高,不保证一定先执行。下面的模拟用「最高优先级优先、同级轮转」的确定策略把这一效果夸张化,三种对照:

交互实验:线程优先级调度模拟(SchedulerDemo)

setPriority · 就绪队列

多个线程都就绪(RUNNABLE)时,CPU 一次只能跑一个——调度器每步从就绪队列里挑一个执行 1 个时间片。 本模拟按「优先级最高者优先、同级轮转」的确定策略夸张化展示效果:选一个场景,用「下一步」看谁先跑、谁在等。

第 1 / 12 步 · 三个线程同为 P5:公平轮转,每个都恰好跑完 4 个时间片,无人饿死
T1 PrintTaskP5
RUNNABLE · 正在执行本时间片

已执行:1 / 4 个时间片

T2 AudioMixTaskP5
READY · 等待调度

已执行:0 / 4 个时间片

T3 BackgroundScanP5
READY · 等待调度

已执行:0 / 4 个时间片

CPU(一次只能执行一个线程)

T1 PrintTask

就绪队列(接下来的调度顺序)

▶ T2 AudioMixTask · P5T3 BackgroundScan · P5T1 PrintTask · P5

调度走查日志

  1. 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。

八、总结:死锁与同步要点

思考题

  1. start() 之后线程一定立刻执行 run() 吗?RUNNABLE 包含哪两种情形?
  2. 三个售票线程共用一个 tickets 变量,不加 synchronized 会出现什么现象?加在方法上和加在代码块上,锁的范围有何不同?
  3. 网络版 ERASE「一个客户端一个线程」的设计,如果客户端数量很大(如上万),这种设计会遇到什么问题?(提示:线程创建与调度的开销)

学习小测验

学习小测验

Q1

Java 中创建新线程的方式不包括( )

Q2

启动一个线程的方法是( )

Q3

线程调用 start() 之后进入( )状态

Q4

同步多线程共享数据时使用的关键字是( )

Q5

死锁产生的四个必要条件包括互斥、占有并等待、__、循环等待

Q6

线程同步的目的是防止__条件引发的数据不一致

Q7

主线程对线程 t 调用 t.join() 的作用是( )

Q8

线程生命周期中不可恢复的终态是( )

Q9

生产者–消费者模型中,缓冲已满时生产者线程的正确做法是( )

Q10

线程被 notifyAll() 唤醒后、继续执行之前必须( )

Q11

Java 中线程优先级的取值范围是( )

Q12

多个就绪线程中,把某线程 t 的优先级设为 10 意味着( )