20260810-笔记.md 93 KB

20260810 课堂笔记 — 线程操作与线程安全

  • 日期:2026-08-10
  • 项目:c260810
  • 包路径:course / exericse / homework0808
  • 作者:WanJL

目录

  1. Thread.sleep() —— 线程休眠
  2. 线程调度方式:分时调度模型 vs 抢占式调度模型
  3. 多线程执行的随机性:CPU 时间片机制
  4. 线程优先级:getPriority() / setPriority()
  5. Demo01 综合演示:sleep + 优先级 + Lambda 双线程并发
  6. 守护线程:setDaemon() 与 JVM 退出机制(Demo02)
  7. 线程生命周期:6 种状态(Demo03)
  8. 线程操作方法:join() / interrupt() / yield() / wait-notify(ThreadMethodDemo)
  9. 线程安全问题:数据竞争与三大特性(Demo04)
  10. synchronized 线程同步:三种写法与锁机制(SynchronizedDemo)
  11. 随堂练习:三种创建线程方式回顾 + 线程常用方法(Exercise01)
  12. 课后作业回顾:Callable + FutureTask 带返回值求和(Homework_03)
  13. 知识点全景总结
  14. 随堂练习要点
  15. 拓展阅读

1. Thread.sleep() —— 线程休眠

概念 —— 让当前线程暂停执行

Thread.sleep(long millis) 是操作线程的静态方法,作用是线程休眠:让当前线程休眠 millis 毫秒,休眠期间线程让出 CPU,时间到后恢复执行。

// 来源:course/Demo01.java(注释部分)
/*
    操作线程的方法:

    public static void sleep(long millis) 线程休眠 ,让当前线程休眠 millis 毫秒
*/

要点:

  • 静态方法:Thread.sleep(毫秒) 直接通过线程类名调用,作用是让当前正在执行的线程休眠
  • 单位是毫秒:Thread.sleep(100) 表示休眠 100 毫秒
  • 必须处理中断异常:sleep() 会抛受检异常 InterruptedException,调用处必须 try-catch 或 throws(Demo01 中用的是 try-catch + 抛 RuntimeException)

sleep 的经典使用场景

// 来源:course/Demo01.java(main 方法片段)
Runnable run = () -> {
    for (int i = 0; i < 100; i++) {
        try {
            //休眠100毫秒
            Thread.sleep(100);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(Thread.currentThread().getName() + "----->" + i);
    }
};

要点:

  • sleep 放在循环里,让线程每隔 100 毫秒打印一次——放慢执行速度,方便观察多线程并发交替的效果
  • 每次 sleep 都会主动让出 CPU 时间片,为其他线程创造执行机会,所以两条线程的输出会交替出现
  • 中断异常用 try-catch 捕获后抛 RuntimeException(运行时异常),保证 Lambda 表达式无受检异常签名约束

2. 线程调度方式:分时调度模型 vs 抢占式调度模型

概念 —— 操作系统如何决定"下一个执行哪个线程"

线程调度就是给线程分配 CPU 使用权的规则。Java 中共有两种调度模型:

// 来源:course/Demo01.java(注释部分)
/*
    线程调度方式:
        分时调度模型:所有的线程轮流获得CPU的使用权,平均分配每个线程占用CPU的时间片
        抢占式调度模型:优先让优先级高的线程使用CPU,如果优先级相同,则会随机选择一个,
        优先级高的线程获取的CPU时间片就更多一些。

        Java使用的就是抢占式调度模型。
*/

两种调度模型对比表

调度模型 分配规则 优先级作用 典型系统
分时调度模型 所有线程轮流获得 CPU 使用权,平均分配时间片 基本不区分优先级 早期分时操作系统
抢占式调度模型 优先让优先级高的线程使用 CPU;优先级相同则随机选择一个 优先级高的线程获取的 CPU 时间片更多 Java(默认采用)

要点:

  • 分时调度讲究"公平":大家轮流来、时间片均分,每个线程占用 CPU 的时间片平均分配
  • 抢占式调度讲究"择优":优先级高的线程优先获得 CPU,且拿到的时间片更多;如果两个线程优先级相同,就随机选择一个执行
  • Java 使用抢占式调度模型——所以设置线程优先级(第 4 节)会影响线程获得 CPU 的机会

3. 多线程执行的随机性:CPU 时间片机制

概念 —— 为什么多线程程序的输出顺序不确定

// 来源:course/Demo01.java(注释部分)
/*
   所谓的随机性,其实就是假如计算机只有一个CPU,那么CPU在某一个时刻只能执行一个指令。
   线程只有得到了CPU时间片(使用权),才可以执行指令。所以多线程程序的执行是有随机性的,
   谁抢到CPU时间片不确定。
*/

随机性产生的两个前提

① 单 CPU 时:某一时刻 CPU 只能执行一个指令
② 线程只有抢到 CPU 时间片(使用权)才能执行指令
→ 多个线程"谁先抢到时间片"不确定 → 执行顺序具有随机性

要点:

  • 单 CPU 同一时刻只能执行一条指令——这是并发切换的硬件前提
  • 线程必须拿到 CPU 时间片(使用权)才能执行指令——拿不到就排队等待
  • 谁抢到时间片不确定 → 多线程程序的执行顺序具有随机性,每次运行结果可能不同
  • 这就是为什么"线程1 / 线程2 哪个先打印、打印多少"无法预判,只能观察到输出交错混排

4. 线程优先级:getPriority() / setPriority()

概念 —— 通过优先级影响抢占调度的机会

在抢占式调度模型下,优先级高的线程获取 CPU 时间片更多。Java 提供两个方法查看和修改线程优先级:

// 来源:course/Demo01.java(注释部分)
/*
    设置线程的优先级:
    public final int getPriority()  返回此线程的优先级
    public final void setPriority(int newPriority)  修改此现场的优先级,线程默认优先级是5,范围是1~10
*/

优先级规则速查

项目 说明
int getPriority() 返回此线程的优先级
void setPriority(int newPriority) 修改此线程的优先级
默认优先级 5
取值范围 1 ~ 10(数值越大优先级越高)

要点:

  • getPriority():实例方法,返回当前线程对象的优先级数值
  • setPriority(int):实例方法,修改线程的优先级,参数范围 1~10
  • 默认优先级是 5:new 出来的线程默认优先级为 5(如 Thread.NORM_PRIORITY)
  • 优先级与调度模型联动:优先级影响的是抢占式调度下获得 CPU 的机率与时间片多少,而不是"优先级高的线程一定先执行完"
  • 超范围设置会抛 IllegalArgumentException(1~10 之外的取值非法)

5. Demo01 综合演示:sleep + 优先级 + Lambda 双线程并发

演示目标

把今天三个知识点串起来:用 Lambda 创建同一个 Runnable 任务,创建两个线程 t1 / t2,打印默认优先级,再用 setPriority(8) 修改 t1 的优先级后再次打印,最后 start() 启动两个线程观察并发执行:

// 来源:course/Demo01.java(main 方法完整逻辑)
public static void main(String[] args) {
    // Lambda 创建 Runnable 任务:休眠100毫秒后打印当前线程名和循环值
    Runnable run = () -> {
        for (int i = 0; i < 100; i++) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(Thread.currentThread().getName() + "----->" + i);
        }
    };

    Thread t1 = new Thread(run);
    Thread t2 = new Thread(run);

    // 打印默认优先级(两个线程都是5)
    System.out.println(t1.getName() + "线程优先级:" + t1.getPriority());
    System.out.println(t2.getName() + "线程优先级:" + t2.getPriority());

    // 修改 t1 的优先级为8,再次打印对比
    t1.setPriority(8);
    System.out.println(t1.getName() + "线程优先级:" + t1.getPriority());
    System.out.println(t2.getName() + "线程优先级:" + t2.getPriority());

    t1.start();
    t2.start();
}

运行流程拆解

1、Runnable run = () -> {...}       用 Lambda 创建任务(回顾方式二:Runnable)
2、Thread t1 = new Thread(run)      两个线程共用同一个任务对象
3、t1.getPriority() / t2.getPriority()   初始优先级都是 5(默认值)
4、t1.setPriority(8)                把 t1 优先级改成 8,t2 仍为 5
5、t1.start() / t2.start()          启动两个线程,run() 交替并发执行

要点:

  • 同一个 Runnable 两个线程:t1 / t2 共用 run 这个 Lambda 任务——再次体会"任务与线程解耦"(Runnable 是任务,Thread 是线程承载者)
  • 默认优先级验证:初始 getPriority() 都返回 5,印证"线程默认优先级是 5"
  • setPriority 生效验证:t1.setPriority(8) 后,t1 优先级变 8、t2 仍是 5——优先级是每个线程独立的属性
  • sleep + 并发观察:两个线程各自休眠 100 毫秒后打印,输出会交错出现;t1 优先级更高,理论上抢占 CPU 的机会更多(但不会严格保证执行顺序)
  • 线程名:未指定名字时默认是 Thread-0 / Thread-1,用 Thread.currentThread().getName() 获取

6. 守护线程:setDaemon() 与 JVM 退出机制(Demo02)

概念 —— 什么是守护线程

守护线程是随着其他非守护线程的结束而结束的线程。它与普通线程(用户线程)的区别,关键在于 JVM 退出的条件:

// 来源:course/Demo02.java(注释部分)
/*
    守护线程 是随着其他非守护线程的结束而结束
    public final void setDaemon(boolean on) 将当前线程标记为守护线程,当运行的线程都是守护线程的时候,JVM虚拟机会退出

    JVM只有当所有的普通线程(用户线程)都结束的时候,才会退出。
    守护线程并不会阻碍JVM的退出。
    普通线程就是我们正常创建的线程,main主线程也是用户线程。只要有任何一个线程存活,JVM进程就不会退出。
    守护线程是属于服务型线程,专门为用户线程提供后台支持,当进程中只剩下守护线程的时候,JVM会直接结束,
    守护线程会被强制终止,甚至都来不及执行finally块的代码。
*/

核心方法 —— setDaemon()

// 来源:course/Demo02.java(注释部分)
public final void setDaemon(boolean on)   // 将当前线程标记为守护线程
public final boolean isDaemon()           // 判断当前线程是否是守护线程

用户线程 vs 守护线程对照表

对比维度 用户线程(普通线程) 守护线程
是否阻碍 JVM 退出 阻碍——只要有任何一个用户线程存活,JVM 就不会退出 不阻碍——当运行的线程都是守护线程时,JVM 退出
角色定位 正常创建的线程(main 主线程也是用户线程) 服务型线程,专门为用户线程提供后台支持
被强制终止 正常执行完才结束 当只剩守护线程时 JVM 直接结束,守护线程被强制终止(连 finally 都来不及执行)
经典场景 业务逻辑(求和、打印、IO) GC 垃圾回收线程、心跳检测、后台日志、监控统计

要点:

  • JVM 退出条件:只有当所有的用户线程(普通线程)都结束时 JVM 才会退出;只要还有一个用户线程存活,JVM 就不会退出——这正是很多服务程序"卡着不退"的原因
  • 守护线程不阻碍退出:守护线程是服务型线程,专门为用户线程提供后台支持;当只剩下守护线程时,JVM 会直接结束,守护线程被强制终止
  • 经典场景 GC 线程:垃圾回收线程(GC Thread)就是最典型的守护线程——所有用户线程都执行完,GC 也就没有存在的意义,JVM 直接退出
  • 其他场景:心跳检测、后台日志、监控统计等"后台支撑型"任务都适合用守护线程

守护线程的四大特点

// 来源:course/Demo02.java(注释部分)
/*
    守护线程的特点:
        设置时机:守护线程必须要在执行start()方法前设置,否则会抛出异常:java.lang.IllegalThreadStateException
        继承性:守护线程创建的子线程也默认是守护线程
        finally不保证:JVM退出的时候,守护线程会被强杀,finally不一定执行,不能用于资源清理
        使用场景:心跳检测、后台日志、监控统计等等
        判断是否是守护线程:public final boolean isDaemon()
*/
# 特点 说明
1 设置时机 setDaemon(true) 必须要在 start() 之前调用,否则抛 IllegalThreadStateException
2 继承性 守护线程创建的子线程也默认是守护线程(继承守护属性)
3 finally 不保证 JVM 退出时守护线程被强杀,finally 不一定执行——不能用于资源清理
4 判断方法 isDaemon() 判断当前线程是否是守护线程

要点:

  • 设置必须在 start() 之前:setDaemon(true) 调用时机错误会抛 IllegalThreadStateException
  • 守护属性会继承:守护线程创建的子线程默认也是守护线程
  • 强杀时 finally 不可靠:守护线程在 JVM 退出时被强杀,finally 块不保证执行——所以不要用守护线程做资源清理(如关闭文件、释放连接)
  • 适合后台支撑型任务:心跳检测、后台日志、监控统计等"可有可无、随主线程而生灭"的任务

main 演示 —— 普通线程 + 守护线程对比

// 来源:course/Demo02.java(main 方法,本次更新修正设置时机)
public static void main(String[] args) {
    MyThread01 mt1 = new MyThread01();
    MyThread01 mt2 = new MyThread01();
    mt1.setName("线程1");
    mt2.setName("守护线程");
    // 设置线程为守护线程
    // 普通线程执行完后,守护线程也就没有继续执行下去的必要了
    mt2.setDaemon(true);   // 必须在 start() 之前设置,否则抛 IllegalThreadStateException

    mt1.start();
    mt2.start();
}

演示用线程类 —— MyThread01(本次已抽取为独立文件)

// 来源:course/MyThread01.java(本次更新从 Demo02 内部类抽取为独立类)
public class MyThread01 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            try {
                Thread.sleep(1000);   // 每隔 1 秒打印一次,共 100 次(约 100 秒)
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(this.getName() + "---->" + i);
        }
    }
}

思考题 —— main 结束后 JVM 会退出吗

// 来源:course/Demo02.java(注释部分)
/*
   Q: JVM退出的条件是"所有的用户线程全部结束",如果main结束了,但是还有非守护线程的子线程在跑,JVM会退出吗?
   A: 不会!只要还有一个用户线程存活,JVM就不会退出。也正是因为这个原因,所以会有很多服务程序,卡着不退出的情况。
*/

要点:

  • main 结束 ≠ JVM 退出:只要还有一个用户线程存活,JVM 就不会退出——子线程会把 JVM"拖住"
  • 服务程序卡住的原因:很多后台服务程序"关不掉、卡着不退",往往就是还有用户线程在跑(如未停止的监听线程、任务线程)
  • 设置时机修正(本次更新):代码更新前 setDaemon(true) 写在 start() 之后,实际运行会抛 IllegalThreadStateException;本次已把 setDaemon(true) 移到 start() 之前,保证守护线程设置合法生效——正对应"设置时机"特点
  • 正确演示思路:让"线程1"是普通用户线程(持续打印),让"守护线程"在用户线程结束后被 JVM 强制终止——直观体会"守护线程随用户线程结束而结束"

7. 线程生命周期:6 种状态(Demo03)

概念 —— 线程从生到死的阶段

线程生命周期就是线程从生到死的过程。直观上可分为 新建、就绪、运行、死亡 四个阶段,但运行过程中可能因为方法调用进入新的阶段——阻塞。实际上 Java 中线程的生命周期状态有 6 种,基于 Thread.State 这个枚举类:

// 来源:course/Demo03.java(注释部分)
/*
    线程生命周期:也就是线程从生到死的过程,它可以分为这样几个阶段:
        新建、就绪、运行、死亡。
    但是在运行过程中,可能会因为其他方法调用,让线程出现一个新的阶段:阻塞

    实际上Java中线程的生命周期状态,有6种:
        新建--NEW
        ...
        终止--TERMINATED
    基于Thread.state 这个枚举类
*/

线程的 6 种状态速查表

状态 枚举名 触发条件 说明
新建 NEW new Thread() 已创建 Thread 对象,还没调用 start(),尚未与操作系统底层线程关联
可运行(就绪) RUNNABLE start() 已调用 start(),可能正在运行,也可能在等待 CPU 时间片;已与底层线程关联,完全由操作系统调度
阻塞 BLOCKED 竞争同步锁失败 等待获取监视器锁进入同步块,抢锁失败会被动阻塞,不占用 CPU 时间
等待 WAITING wait() / join() / LockSupport.park() 无限期等待另一个线程的通知,不占用 CPU 时间
计时等待 TIMED_WAITING sleep(ms) / wait(timeout) / join(timeout) 有明确等待时限,超时自动恢复,不占用 CPU 时间
终止 TERMINATED run() 方法结束 run() 执行完毕或抛出未捕获异常而结束,线程终止后与底层线程取消关联
// 来源:course/Demo03.java(注释部分)
/*
    新建--NEW
        |- 已经创建Thread对象,但是还没有调用start()方法,这个时候还没有和操作系统底层线程进行关联。
        |- new Thread();
    可运行(就绪)--RUNNABLE
        |- 已经调用了start()方法,可能正在运行,也可能在等待CPU时间片,已经开始和底层操作系统线程关联了。完全有操作系统进行调度。
        |- start();
    阻塞--BLOCKED
        |- 等待获取监视器锁进入同步块,抢锁失败会被动阻塞,不占用CPU时间。
        |- 竞争同步锁失败
    等待--WAITING
        |- 无限期的等待另一个线程的通知,不占用CPU时间
        |- wait()
        |- join();
        |- LockSupport.park()
    计时等待--TIMED_WAITING
        |- 有明确等待时限,超时自动回复,不占用CPU时间。
        |- sleep(ms)
        |- wait(timeout)
        |- join(timeout)
    终止--TERMINATED
        |- run()方法执行完毕,或抛出未捕获异常而结束,线程终止后会和底层线程取消关联。
        |- run()方法结束
*/

要点:

  • NEW:对象建好了但没 start(),此时还没和操作系统底层线程关联(纯粹是 Java 层的 Thread 对象)
  • RUNNABLE:start() 之后开始和底层线程关联,完全由操作系统调度——它既包括"正在运行",也包括"排队等 CPU 时间片"(Java 把运行和就绪合并成一个 RUNNABLE)
  • BLOCKED:synchronized 抢监视器锁失败被被动阻塞(后续线程同步课程会用到),不占用 CPU
  • WAITING vs TIMED_WAITING:都是"等",区别是 WAITING 无限期等(wait()/join()/LockSupport.park()),TIMED_WAITING 限时等(sleep(ms)/wait(timeout)/join(timeout)),超时自动恢复
  • TERMINATED:run() 执行完毕或抛出未捕获异常而结束,终止后与底层线程取消关联
  • 第 1 节复习:Thread.sleep(ms) 让线程进入的就是 TIMED_WAITING(计时等待) 状态——超时自动恢复

状态转换规律 —— RUNNABLE 是总枢纽,TERMINATED 是终点站

// 来源:course/Demo03.java(注释部分)
/*
    RUNNABLE(可运行状态)是总枢纽,大多数状态都要回到RUNNABLE状态
    抢锁失败-->BLOCKED,拿到锁-->RUNNABLE
    主动休息-->TIMED_WAITING
    无线等待-->WAITING
    所有的阻塞状态都有办法回到RUNNABLE状态,只有TERMINATED状态是终点站。
*/

状态转换图

                        ┌───────────────┐
     new Thread()       │               │ run() 结束
        │               ▼               ▼
   ┌─────────┐   start()   ┌─────────┐   TERMINATED(终点站)
   │  NEW    │ ──────────► │ RUNNABLE│
   └─────────┘             └────┬────┘
        ▲                       │
        │              ┌────────┼────────┐
        │              ▼        ▼        ▼
        │         ┌────────┐ ┌────────┐ ┌────────┐
        │         │BLOCKED │ │WAITING │ │TIMED_  │
        │         │抢锁失败 │ │wait()  │ │WAITING │
        │         │        │ │join()  │ │sleep() │
        │         └────────┘ └────────┘ └────────┘
        │              ▲        ▲        ▲
        │              └────────┼────────┘
        │                   回到RUNNABLE
        │        (阻塞状态都有办法回到RUNNABLE,只有TERMINATED是终点)

要点:

  • RUNNABLE 是总枢纽:大多数状态都要回到 RUNNABLE——抢锁失败→BLOCKED,拿到锁→RUNNABLE;主动休息→TIMED_WAITING;无限等待→WAITING
  • 所有阻塞状态都能回到 RUNNABLE:BLOCKED / WAITING / TIMED_WAITING 都有办法恢复执行
  • TERMINATED 是终点站:线程一旦终止就回不去了——只有 TERMINATED 状态不可逆

操作系统层面 vs Java 层面的状态划分

// 来源:course/Demo03.java(注释部分)
/*
    操作系统层面一般把线程划分为5种状态:
        新建、就绪、运行、阻塞、终结
*/
层面 状态数量 状态清单
操作系统层面 5 种 新建、就绪、运行、阻塞、终结
Java(Thread.State)层面 6 种 NEW、RUNNABLE(就绪+运行合并)、BLOCKED、WAITING、TIMED_WAITING、TERMINATED

要点:

  • 区别在于"运行/就绪":操作系统把"就绪"和"运行"分开(2 个状态);Java 把二者合并成 RUNNABLE(1 个状态)——因为 Java 无法精确区分"正在跑"还是"排队等 CPU"
  • Java 多出来的 WAITING / TIMED_WAITING:这两个是 Java API 层面的"等待"细分,操作系统层统一归入"阻塞"

8. 线程操作方法:join() / interrupt() / yield() / wait-notify(ThreadMethodDemo)

概念 —— join():让当前线程等待目标线程结束

// 来源:course/Demo03.java(注释部分,本次更新新增 yield / isAlive / wait / notify)
/*
    线程操作方法:
    public final void join()    让当前线程等待调用join()方法的那个线程执行完毕后再执行。
        比如: t.join(); 表示当前线程等待t结束。
    public final void join(long millis) 最多等待指定毫秒,超过了就不等了
    public void interrupt() 终端目标线程,只是设置一个中断标志,不会杀死线程,并且当目标线程处在join/wait/sleep的时候会抛异常,并且清除中断标志。
    public static void yield()  让当前线程主动让出CPU时间片,重新参与竞争,只是建议,调度器不一定采纳。
    public final boolean isAlive() 判断线程是否存活,已经执行了start()但是未结束
    public static native Thread currentThread() 获取当前正在运行的线程对象

    Object类的关于线程操作的方法:
    让线程等待
    public final void wait()
    public final void wait(long timeoutMillis)
    public final void wait(long timeoutMillis, int nanos)
    唤醒线程
    public final native void notify()   唤醒线程
    public final native void notifyAll()    唤醒所有线程
*/

线程操作方法速查表(本次更新扩展)

方法 归属 作用
final void join() Thread 当前线程等待调用 join() 的那个线程执行完毕后再执行(如 t.join() 表示当前线程等待 t 结束)
final void join(long millis) Thread 最多等待指定毫秒,超过就不等了
void interrupt() Thread 中断目标线程:只是设置一个中断标志,不会杀死线程;目标线程处在 join/wait/sleep 时会抛异常并清除中断标志
static void yield() Thread 让当前线程主动让出 CPU 时间片,重新参与竞争——只是建议,调度器不一定采纳
final boolean isAlive() Thread 判断线程是否存活:已经执行了 start() 但未结束
static native Thread currentThread() Thread 获取当前正在运行的线程对象
final void wait() Object 让线程等待:无限期等待(配合 notify 使用)
final void wait(long timeoutMillis) Object 让线程限时等待指定毫秒
final void wait(long timeoutMillis, int nanos) Object 让线程限时等待(毫秒 + 纳秒精度)
final native void notify() Object 唤醒一个正在等待的线程
final native void notifyAll() Object 唤醒所有正在等待的线程

要点:

  • 方法归属分两类:join / interrupt / yield / isAlive / currentThread 是 Thread 类的线程操作方法;wait / notify / notifyAll 是 Object 类的线程操作方法(注意 wait() 不是 Thread 的方法)
  • yield 是"建议":yield() 主动让出 CPU 时间片只是建议,调度器不一定采纳(与抢占式调度模型呼应)
  • isAlive 判活:执行了 start() 但未结束的线程算"存活";TERMINATED 后返回 false
  • wait/notify 依赖锁:wait() / notify() 是线程通信/同步的基础(等待与唤醒配对),后续线程同步课程会深入

Demo03 main 演示 —— main 线程等待 mt2 线程结束

// 来源:course/Demo03.java(main 方法)
public static void main(String[] args) throws InterruptedException {
    MyThread01 mt1 = new MyThread01();
    MyThread01 mt2 = new MyThread01();
    MyThread01 mt3 = new MyThread01();
    MyThread01 mt4 = new MyThread01();
    mt1.setName("线程1");
    mt2.setName("线程2");
    mt3.setName("线程3");
    mt4.setName("线程4");

    mt1.start();
    mt2.start();
    mt3.start();
    mt4.start();

    for (int i = 0; i < 1000; i++) {
        System.out.println(Thread.currentThread().getName() + "---->" + i);
    }
    //我们是在哪调用的join?是在main线程,也就是说
    //当前的线程main线程,要等待mt2线程结束
    mt2.join();
}

要点:

  • 4 个子线程 + main 并发:mt1~mt4 各打印 0~99,main 打印 0~999——五条执行路径并发
  • join 的调用方是 main:mt2.join() 写在 main 方法里,当前线程 main 等待 mt2 线程结束后再继续
  • join 与 sleep 的关系:调用 join() 的线程(main)进入 WAITING(等待) 状态;join(timeout) 则进入 TIMED_WAITING(对应第 7 节状态表)
  • main 抛异常:throws InterruptedException(join 是受检方法)

ThreadMethodDemo —— sleep / join / interrupt / yield 综合演示

ThreadMethodDemo 把 sleep、join、interrupt、yield 四种线程操作方法做了综合演示,其中 sleep 与 join 部分被注释保留,活动代码演示 interrupt() 中断机制 与 yield() 让出 CPU:

// 来源:course/ThreadMethodDemo.java(注释部分,sleep / join 演示)
//1、sleep()让当前线程休眠指定毫秒--TIME_WAITING 计时等待状态
System.out.println(Thread.currentThread().getName() + "开始睡2秒.....");
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + "睡醒了");

//2、join()让当前线程等待指定线程结束后再继续
Thread worker = new Thread(() -> {
    for (int i = 0; i < 5; i++) {
        System.out.println(Thread.currentThread().getName() + "工作中--->" + i);
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();   // 如果触发异常,就中断当前线程
        }
    }
}, "worker-1");
worker.start();
worker.join();   // main线程会等待worker执行完成后继续执行

Thread t = new Thread(() -> { ... sleep(5000) ... }, "worker-2");
t.start();
t.join(1000);    // main线程会等待1秒,不等5秒

interrupt() 中断机制 —— 中断标志 + 主动检查

// 来源:course/ThreadMethodDemo.java(活动代码,interrupt 演示)
//3、interrupt() 中断操作,设置中断标志
Thread t1 = new Thread(() -> {
    while (true) {
        //主动检查是否中断--检查中断标志
        if (Thread.currentThread().isInterrupted()) {
            System.out.println("检查到中断标志...正常退出");
            break;
        }
        System.out.println("工作.......");

        try {
            //如果线程正在被 sleep/wait的时候被中断会抛异常并清除标志
            Thread.sleep(100);
        } catch (InterruptedException e) {
            System.out.println("sleep中被中断....");
            //重新设置中断标志,方便上层能感知
            Thread.currentThread().interrupt();
            break;
        }
    }
}, "worker-3");

t1.start();
Thread.sleep(300);  //让t1先跑一会儿
t1.interrupt();     //向t1发送中断请求

interrupt 中断机制核心要点

环节 说明
interrupt() 只是设标志 t1.interrupt() 不会杀死线程,只是设置一个中断标志
主动检查 isInterrupted() 线程内部用 Thread.currentThread().isInterrupted() 主动检查中断标志,发现被中断则正常退出
sleep/wait/join 中被中断 线程正处在 sleep / wait / join 时被 interrupt(),会抛 InterruptedException 并清除中断标志
catch 后重设标志 catch 块里调用 Thread.currentThread().interrupt() 重新设置中断标志,方便上层代码感知到中断

要点:

  • interrupt() 是"温柔"的中断:只是设置标志位,不会像"杀线程"那样强制终止——线程需要主动检查标志或在阻塞方法中收到异常才会响应
  • 两种响应方式:①循环里用 isInterrupted() 主动检查标志(有标志就 break 退出);②线程处于 sleep/wait/join 阻塞时被 interrupt(),会抛 InterruptedException 中断当前阻塞
  • 清除标志的细节:InterruptedException 抛出时中断标志被清除——所以 catch 里通常要再调 interrupt() 重设标志,让上层 isInterrupted() 能感知到中断状态
  • 演示流程:t1 启动后无限循环打印 → main sleep(300) 让 t1 先跑 → t1.interrupt() 发送中断请求 → t1 下次循环 isInterrupted() 检测到标志 → 正常退出
  • join(timeout) 场景:t.join(1000) 表示 main 最多等 1 秒,而 worker-2 要睡 5 秒——超时后 main 不等了,继续执行

interrupt 机制的补充说明(本次更新新增)

ThreadMethodDemo 末尾补充了 interrupt 机制的完整流程总结,并新增了 yield() 的演示代码:

// 来源:course/ThreadMethodDemo.java(末尾注释,本次更新新增)
/*
    interrupt() 并不会杀死线程,而是设置一个中断标志,请求线程自行响应。
    如果线程处在sleep()/wait()/join()等阻塞状态,收到中断标志后,会抛出异常,并清除中断标志
    捕获异常后,应该重新调用interrupt()恢复中断标志,否则外层代码会感知不到中断
    线程自用interrupt()/Thread.interrupt()检查标志后,再决定是否退出。
*/

要点:

  • interrupt() 不杀线程:只是设置中断标志,请求线程自行响应——是否退出由线程自己决定
  • 阻塞状态响应:线程处在 sleep() / wait() / join() 等阻塞状态时收到中断标志,会抛异常并清除中断标志
  • catch 后恢复标志:捕获异常后应重新调用 interrupt() 恢复中断标志,否则外层代码感知不到中断
  • 自检后决定退出:线程用 isInterrupted() / Thread.interrupted() 检查标志后,再决定是否退出

yield() —— 主动让出 CPU 时间片(本次更新新增)

// 来源:course/ThreadMethodDemo.java(活动代码,yield 演示)
//4、yield() 主动让出CPU时间片
Thread t2 = new Thread(() -> {
    for (int i = 0; i < 50; i++) {
        Thread.yield();   // 主动让出CPU时间片,重新参与竞争
        System.out.println(Thread.currentThread().getName() + "第" + i + "轮");
    }
}, "yield线程");

t2.start();

要点:

  • yield() 是静态方法:Thread.yield() 让当前线程主动让出 CPU 时间片,重新参与竞争
  • 只是建议:yield() 只是建议调度器让出时间片,调度器不一定采纳——并发场景下别的线程可能立刻又抢到 CPU
  • yield 与 sleep 的区别:sleep(ms) 是限时休眠(进入 TIMED_WAITING,时间到自动醒);yield() 是让出一次(回到 RUNNABLE,马上可能又被调度)——都不占用 CPU
  • 演示逻辑:yield 线程每轮先 Thread.yield() 再打印"第 i 轮",50 轮循环——让出时间片后与其他线程(如 main)交替执行

Object 类的线程操作方法 —— wait() / notify() / notifyAll()(本次更新新增)

Demo03 新增了 Object 类关于线程操作的方法——这些方法属于线程通信 / 同步的基础(等待与唤醒配对):

// 来源:course/Demo03.java(注释部分,本次更新新增)
/*
    Object类的关于线程操作的方法:
    让线程等待
    public final void wait()
    public final void wait(long timeoutMillis)
    public final void wait(long timeoutMillis, int nanos)
    唤醒线程
    public final native void notify()   唤醒线程
    public final native void notifyAll()    唤醒所有线程
*/

wait / notify 方法速查表

方法 作用
final void wait() 让线程等待:无限期等待另一个线程的 notify / notifyAll
final void wait(long timeoutMillis) 让线程限时等待指定毫秒,超时自动恢复
final void wait(long timeoutMillis, int nanos) 让线程限时等待(毫秒 + 纳秒精度)
final native void notify() 唤醒一个正在等待的线程
final native void notifyAll() 唤醒所有正在等待的线程

要点:

  • 归属 Object 而非 Thread:wait() / notify() / notifyAll() 是 Object 类的方法——因为"等待/唤醒"针对的是对象监视器(monitor),任何对象都可以作为锁对象调用
  • 与线程状态呼应:调用 wait() 的线程进入 WAITING(对应第 7 节状态表);wait(timeout) 进入 TIMED_WAITING
  • 等待/唤醒配对:wait() 让线程等待,notify() / notifyAll() 唤醒等待的线程——这是生产者-消费者等线程通信模式的基础
  • 依赖锁:wait() / notify() 必须在持有对象锁的同步块(synchronized)中调用,否则抛 IllegalMonitorStateException(后续线程同步课程深入)

9. 线程安全问题:数据竞争与三大特性(Demo04)

概念 —— 为什么多线程会出问题(数据竞争)

进程和进程之间的数据和内存空间是不共享的、相互独立的;而一个进程中的多个线程之间的数据和内存是共享的。正因为线程间共享数据,多线程带来的最大隐患就是数据竞争:

// 来源:course/Demo04.java(注释部分)
/*
    我们知道进程和进程之间的数据和内存空间是不共享的,是相互独立的。
    一个进程中的多个线程之间的数据和内存是共享的。
    那么多线程所带来的最大的隐患就是:数据竞争。
    当多个线程同时读写同一个共享变量的时候,程序的执行结果将会变得不可预测。有时正确,有时错误。而且错误无法复现。
*/

要点:

  • 进程间不共享:进程与进程的数据、内存空间相互独立
  • 线程间共享:一个进程内的多个线程共享数据和内存
  • 数据竞争:多个线程同时读写同一个共享变量时,执行结果不可预测——有时正确、有时错误,且错误无法复现(这正是多线程 bug 最难排查的原因)

亲手做一个 Bug —— 卖票问题(SellTicket)

理解线程安全的第一步,就是亲手制作一个 bug:模拟 3 个售票窗口(3 个线程)同时卖 100 张票。

// 来源:course/SellTicket.java
public class SellTicket implements Runnable {
    private Integer tickets = 100;   // 一共100张票

    @Override
    public void run() {
        while (true) {
            if (tickets > 0) {       // 多个线程可能同时通过这个判断
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println(Thread.currentThread().getName() + "正在出售第" + tickets + "张票");
                tickets--;           // 减一张票
            }
        }
    }
}
// 来源:course/Demo04.java(main 方法)
public static void main(String[] args) {
    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();
}

卖票问题运行后的两个经典 Bug

// 来源:course/Demo04.java(注释部分)
/*
    运行后看效果:
        1、相同的票出现了多次
        2、出现了负数的票
     原因:线程执行的随机性(抢占式调度)导致的,可能在卖票的过程中丢失了CPU的执行权,导致出现问题
     相同的票出现了多次:
        多个线程同时执行到if (tickets>0)判断通过,这个时候tickets还是同一个值,所以都是打印出 窗口3正在出售第10张票
     出现了负数的票:
        tickets已经减到1的时候,多个线程同时通过if判断if (tickets>0),然后一个线程执行tickets--变成0,另一个线程继续执行tickets--变成-1
     程执行的随机性(抢占式调度)导致某个线程在"判断-输出-减票数"这三步的执行过程中,可能丢失CPU执行权,
     由另一个线程趁机插入到对共享数据的操作,破坏了数据的完整性。
*/
Bug 现象 原因
相同的票多次出现 多个线程都打印"窗口3正在出售第10张票" 多个线程同时通过 if (tickets>0) 判断,此时 tickets 还是同一个值
负数票 tickets 减到 1 时出现 -1 tickets 为 1 时多个线程同时通过判断,一个线程 tickets-- 变 0,另一个再 tickets-- 变 -1

要点:

  • 根因是随机性:抢占式调度下,某个线程在"判断 → 输出 → 减票数"三步的执行过程中可能丢失 CPU 执行权,另一个线程趁机插入对共享数据(tickets)的操作,破坏了数据的完整性
  • "判断-操作"不是原子:if (tickets>0) 和 tickets-- 之间不是一气呵成的,线程随时可能被切换走

计数器问题 —— count++ 不是原子操作(Counter)

// 来源:course/Counter.java
public class Counter {
    private Integer count = 0;

    public void increment() {
        count++;    // 看似是1行,但底层是3步
        //1、拿到当前count的值
        //2、count+1
        //3、自增后的值赋值给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());
    }
}

count++ 的非原子性 —— 读-改-写三步

// 来源:course/Demo04.java(注释部分)
/*
    计数器问题:
        理论上应该是1000,但是实际上少于1000。
        为什么结果不对?
        因为count++ 并不是原子操作(原子操作就是无论是多少步,都是一次性全部成功,要么全部失败回滚)
        count++,表面上是一行代码,但在JVM中被拆成了3步:
            1、从内存读取count到寄存器(读)
            2、在寄存器中加1(改)
            3、把结果写会到内存(写)
        当多个线程同时执行 读-改-写 三步 的时候,就可能发生 读到了旧值,用旧值进行计算,覆盖别人写入的结果。
        导致丢失最新的结果
*/

要点:

  • count++ 不是原子操作:原子操作指"无论多少步,一次性全部成功或全部失败回滚"
  • count++ 被拆成 3 步:①读(内存 → 寄存器)②改(寄存器 +1)③写(结果写回内存)
  • 丢失最新结果:多个线程同时执行"读-改-写"三步时,可能读到旧值、用旧值计算、覆盖别人写入的结果——导致 count 理论 1000、实际小于 1000

多线程安全问题引发的 3 个原因(缺一不可)

// 来源:course/Demo04.java(注释部分)
/*
    多线程安全问题引发原因(缺一不可):
        1、多线程环境,至少两条路径在并发执行。
        2、存在共享数据,多个线程访问同一个变量/对象。
        3、有多条语句操作共享数据,并且这些语句之间存在 读-改-写 的符合操作,比如count++,比如ticket--等
*/
# 原因 说明
1 多线程环境 至少两条执行路径在并发执行
2 存在共享数据 多个线程访问同一个变量 / 对象
3 读-改-写复合操作 有多条语句操作共享数据,且语句间存在读-改-写复合操作(如 count++、tickets--)

解决多线程安全问题的基本思想

// 来源:course/Demo04.java(注释部分)
/*
    解决多线程安全性问题的基本方案思想:
        让程序不再具备产生安全问题的环境:
            1、把多条操作共享数据的语句锁起来。
            2、让任意时刻只有一个线程能执行这段代码,其他线程只能排队等待。
*/

要点:

  • 思路是"消除产生安全问题的环境":不满足 3 个原因之一就不会出问题
  • 方案一:把多条操作共享数据的语句锁起来(synchronized / Lock)
  • 方案二:任意时刻只有一个线程能执行这段代码,其他线程排队等待

线程安全三大特性(核心,必须背)

// 来源:course/Demo04.java(注释部分)
/*
    线程安全的三大特性,必须背下来 多线程的核心

        原子性:操作不可分割,要么全部执行,要么全部不执行。防止 读-改-写 被并发打断。
            |-解决方案:synchronized、Lock(锁)、Atomic类(原子类)
        可见性:一个线程的修改对另一个线程立即可见。防止线程把变量换成在CPU缓存中,其他线程看不到修改。
            |-解决方案:volatile、synchronized、Lock(锁)
        有序性:代码按照书写顺序执行,不会被指令重排。防止编译器和CPU在执行的可能会对指令进行重排。
            |-解决方案:volatile、synchronized
    原可序(原子性、可见性、有序性),但凡是线程安全方案,最终都是围绕这三个特性的。
*/
特性 含义 解决的问题 解决方案
原子性 操作不可分割,要么全部执行、要么全部不执行 防止"读-改-写"被并发打断 synchronized、Lock(锁)、Atomic 类(原子类)
可见性 一个线程的修改对另一个线程立即可见 防止线程把变量缓存到 CPU 缓存中,其他线程看不到修改 volatile、synchronized、Lock(锁)
有序性 代码按书写顺序执行,不会被指令重排 防止编译器和 CPU 对指令重排 volatile、synchronized

要点:

  • 三大特性口诀:原子性、可见性、有序性(记法"原可序")——但凡线程安全方案,最终都是围绕这三个特性的
  • 原子性:防"读-改-写"被打断 → 锁 / 原子类
  • 可见性:防 CPU 缓存导致的修改不可见 → volatile / 锁
  • 有序性:防指令重排 → volatile / synchronized
  • 卖票问题和计数器问题正是原子性被破坏的典型案例(读-改-写三步被打断)

10. synchronized 线程同步:三种写法与锁机制(SynchronizedDemo)

概念 —— synchronized 是保证原子性最简单可靠的手段

// 来源:course/Demo04.java(注释部分,本次更新新增)
/*
    保证原子性:
        最简单的最可靠的同步手段保证原子性:synchronized(线程同步)
        synchronized(线程同步)是Java内置的关键字,也是使用最广泛的同步手段。
        每个Java对象在底层都对应一把监视器锁(Monitor Lock),当线程进入到synchronized修饰的代码块之前,必
        须要先获取这个锁。如果这个锁已经被其他线程拿到了,那么当前现在就会进入到BLOCKED(阻塞)状态排队等待。
        持有锁的线程会执行完代码然后自动释放锁,在等待队列中的线程会重新开始进行新一轮的竞争。
*/
项目 说明
synchronized 定位 Java 内置关键字,保证原子性最简单最可靠的同步手段,使用最广泛
监视器锁(Monitor Lock) 每个 Java 对象底层都对应一把监视器锁
获取锁 线程进入 synchronized 代码块之前必须先获取锁
锁被占用 当前线程进入 BLOCKED(阻塞) 状态排队等待
释放锁 持有锁的线程执行完代码自动释放锁,等待队列中的线程重新竞争

要点:

  • synchronized 是关键字:Java 内置的同步手段,不是方法,使用最广泛
  • 每对象一把监视器锁:Monitor Lock 是 synchronized 底层的锁机制
  • BLOCKED 状态呼应:拿不到锁的线程进入 BLOCKED(对应第 7 节线程状态的"阻塞——等待获取监视器锁进入同步块")

synchronized 的优点与弊端

// 来源:course/Demo04.java(注释部分)
/*
    synchronized优点:
        解决了多线程的数据安全问题,保证了同一时刻只有一个线程能执行被保护的代码。
    synchronized弊端:
        当线程很多的时候,每个线程都有去竞争同一把锁,比较耗资源,无形当中就会降低程序的运行效率。
        所以锁的粒度应该尽量小,只锁真正操作共享数据的代码,而不是整个方法。

    synchronized修饰方法,该方法会变为同步方法。
    synchronized修饰代码块,会变成同步代码块。
*/
维度 说明
优点 解决多线程数据安全问题,保证同一时刻只有一个线程能执行被保护的代码
弊端 线程多时都去竞争同一把锁比较耗资源,降低程序运行效率
优化方向 锁的粒度尽量小——只锁真正操作共享数据的代码,而不是整个方法

synchronized 的三种写法(SynchronizedDemo)

// 来源:course/SynchronizedDemo.java
public class SynchronizedDemo {
    private Integer count = 0;

    //写法1---同步实例方法,锁的是当前的对象 this
    public synchronized void increment() {
        count++;
    }

    //写法2---同步静态方法(锁的是当前类的Class对象)
    private static int staticCount = 0;
    public static synchronized void staticIncrement() {
        staticCount++;
    }

    //写法3---同步代码块(锁的是指定的对象,最灵活)
    private final Object lock = new Object();
    public void increment03() {
        synchronized (lock) {    //只锁这一个代码块,粒度更细,性能更好
            count++;
        }
    }
}

三种写法对照表

写法 示例 锁的是什么 特点
同步实例方法 public synchronized void increment() 当前对象 this 锁住同一个对象的方法调用
同步静态方法 public static synchronized void staticIncrement() 当前类 Class 对象 锁住所有该类的静态调用,全局唯一
同步代码块 synchronized(lock) { ... } 指定的任意对象 obj 最灵活,只锁需要保护的部分,粒度最细、性能更好

synchronized 锁的到底是什么(Demo04 注释详解)

// 来源:course/Demo04.java(注释部分,本次更新新增)
/*
    synchronized锁的是什么?

        public synchronized void method01(){
            //假如300行代码,只有3行是修改共享数据的。那么先锁的是整个方法的300代码,粒度就太大了。
        }
    synchronized修饰的方法也就是同步方法。它锁的其实是当前的对象(this),锁住同一个对象的方法调用

        public static synchronized void method01(){
            //假如300行代码,只有3行是修改共享数据的。那么先锁的是整个方法的300代码,粒度就太大了。
        }
    synchronized修饰的静态方法也就是同步静态方法,它锁的其实是当前对象所属的类的Class对象。锁住的所有这个类的静态调用,全局唯一。

        Object obj=new Object();
        synchronized(obj){
            //这个synchronized代码块只锁 那几行修改共享数据的代码,这样粒度就小很多。
        }
    synchronized修饰的代码块,锁的是指定的任意对象obj,更加灵活,只锁需要保护的部分。
*/
// 来源:course/Demo04.java(新增方法示例)
public synchronized void method01() {
    //假如300行代码,只有3行是修改共享数据的。那么先锁的是整个方法的300代码,粒度就太大了。
}

public void method02() {
    Object obj = new Object();
    synchronized (obj) {
        //这个synchronized代码块只锁 那几行修改共享数据的代码,这样粒度就小很多。
    }
}

要点:

  • 同步实例方法锁 this:锁的是当前对象 this,锁住同一个对象的方法调用
  • 同步静态方法锁 Class 对象:锁的是当前类所属的 Class 对象,全局唯一——锁住所有该类的静态调用
  • 同步代码块锁指定对象:锁的是指定的任意对象 obj,更灵活,只锁需要保护的部分
  • 锁粒度意识:一个方法 300 行只有 3 行改共享数据——同步方法会锁整个 300 行(粒度太大);同步代码块只锁那 3 行(粒度小、性能好)

模拟业务场景 —— 转账(检查-修改必须整体锁住)

// 来源:course/SynchronizedDemo.java
public class SynchronizedDemo {
    //模拟业务场景--转账(多个变量要保持一致)
    private int balance = 100;

    public void transfer(int amount) {
        //必须要把 检查-修改 这个过程锁住(同步)
        synchronized (this) {
            if (balance >= amount) {
                balance -= amount;
                System.out.println("转账" + amount + "成功,余额:" + balance);
            } else {
                System.out.println("余额不足");
            }
        }
    }
}

要点:

  • 转账场景:if (balance >= amount)(检查)+ balance -= amount(修改)必须整体锁住——否则多个线程同时"检查"通过后,余额可能被透支
  • synchronized(this):用当前对象作为锁,保护 balance 的"检查-修改"过程
  • 这正对应第 9 节"解决基本思想":把操作共享数据的语句锁起来、同一时刻只有一个线程执行

main 演示 —— 加锁后 count 正确性验证

// 来源:course/SynchronizedDemo.java(main 方法)
public static void main(String[] args) throws InterruptedException {
    SynchronizedDemo sd = new SynchronizedDemo();

    //1000个线程同时调用同步方法   调用的是同步方法,必然是1000
    Thread[] threads = new Thread[1000];
    for (int i = 0; i < 1000; i++) {
        threads[i] = new Thread(sd::increment03);
        threads[i].start();
    }

    for (Thread t : threads) {
        t.join();
    }
    System.out.println("加锁后最终 count=" + sd.count);
}

要点:

  • 与 Counter 对比:第 9 节 Counter 用 1000 线程对无锁的 count++ 并发,count 往往小于 1000;这里 1000 线程调用加了 synchronized 的 increment03(),count 必然是 1000
  • join 等待全部线程:t.join() 等所有线程执行完再打印结果
  • 方法引用:sd::increment03 是方法引用(回顾 08-04 函数式编程),等价于 () -> sd.increment03()
  • 同一对象加锁才有效:所有线程用的是同一个 sd 对象的 lock——若每个线程 new 一个 SynchronizedDemo,锁对象不同就失效了

11. 随堂练习:三种创建线程方式回顾 + 线程常用方法(Exercise01)

练习要求 —— 把多线程三种实现方式与常用方法串起来

Exercise01 是随堂练习的骨架代码,题目要求用三种方式创建并使用线程,并调用线程常用方法:

// 来源:exericse/Exercise01.java(注释部分)
public static void main(String[] args) {
    //1、使用继承Thread方式创建线程并使用线程
    //2、使用runnable方式创建并使用线程
    //3、使用callable方式创建并使用线程

    /*
    调用一些方法
        获取当前线程名称的方法
        设置当前线程名称的方法
        获取线程返回结果的方法
    */
}

三种创建线程方式速查表(回顾 20260808)

方式 核心动作 返回结果
方式一:继承 Thread 自定义类 extends Thread + 重写 run() + start() 启动 run() 无返回值
方式二:实现 Runnable 实现类 implements Runnable + 重写 run() + new Thread(runnable, "名") run() 无返回值
方式三:实现 Callable 实现类 implements Callable<V> + 重写 call() + new FutureTask<>(callable) + new Thread(futureTask) call() 有返回值

练习要求调用的线程方法

方法 归属 作用
获取当前线程名称 Thread.currentThread().getName() 静态方法拿当前线程对象,再取名字
设置当前线程名称 thread.setName(String) 动态修改线程名
获取线程返回结果 futureTask.get() 拿到 Callable 线程 call() 的返回值

要点:

  • 这是把 20260808 学过的三种多线程实现方式做一次综合练习:继承 Thread、实现 Runnable、实现 Callable 各写一个线程任务
  • 三个"常用方法"对应三种能力:取线程名(getName)、改线程名(setName)、取返回结果(get)——分别覆盖了方式二/三中"不继承 Thread 时如何定位线程"以及"Callable 如何拿返回值"的要点
  • 与 Demo01 呼应:Demo01 已经用了 Thread.currentThread().getName()(取线程名)和 setPriority()(改优先级),练习还要补上 setName() 与 get()

随堂练习完成版 —— CooperationTest(join + yield + interrupt 综合)

CooperationTest(exericse 包)是本日随堂练习的完成版,把第 8 节的线程操作方法 join / yield / interrupt 串成一个完整演示——worker-1 用 yield() 让出 CPU 并配合 join() 等待,worker-2 用 interrupt() 中断长 sleep 任务:

// 来源:exericse/CooperationTest.java
public static void main(String[] args) throws InterruptedException {
    // ========== 1、join + yield 演示 ==========
    Thread worker = new Thread(() -> {
        for (int i = 0; i < 5; i++) {
            Thread.yield();   // 主动让出 CPU 时间片
            System.out.println(Thread.currentThread().getName() + "工作中--->" + i);
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();   // 重设中断标志
                break;
            }
        }
    }, "worker-1");

    worker.start();
    worker.join();   // 主线程等待 worker-1 结束
    System.out.println(worker.getName() + "已结束,主线程继续...");

    // ========== 2、interrupt 中断演示 ==========
    Thread sleeper = new Thread(() -> {
        try {
            Thread.sleep(5000);   // 长任务:睡 5 秒
            System.out.println("任务完成");   // 被中断就不会执行到这
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();   // 重设中断标志
            System.out.println("sleep 中被中断,isInterrupted()=" + Thread.interrupted());
        }
    }, "worker-2");

    sleeper.start();
    Thread.sleep(500);       // 让 sleeper 先进入 sleep 状态
    sleeper.interrupt();     // 向 worker-2 发送中断请求
    sleeper.join();          // 主线程等 worker-2 处理完中断
    System.out.println("主线程结束...");
}

要点:

  • join 等待:worker.join() 让主线程等 worker-1 跑完 5 轮再继续;sleeper.join() 等 worker-2 处理完中断
  • yield 让出:worker-1 每轮先 Thread.yield() 主动让出 CPU,与其他线程交替执行
  • interrupt 中断:worker-2 正在 sleep(5000) 长任务,主线程 sleep(500) 后 interrupt(),worker-2 在 sleep 中被中断抛 InterruptedException,catch 里重设标志后打印中断信息
  • 细节注意:代码中打印用的是 Thread.interrupted()(静态方法,检查并清除中断标志)——与 isInterrupted()(只检查不清除)对比理解

12. 课后作业回顾:Callable + FutureTask 带返回值求和(Homework_03)

作业背景

homework0808/Homework_03.java(本次由 homework_03 重命名、类名规范化为 Homework_03,逻辑不变)是 8 月 8 日课后作业的第三题:用方式三(实现 Callable 接口 + FutureTask)创建 3 个线程,分别求 1~100 / 1~1000 / 1~10000 的和,并通过 get() 获取返回值。与本日的"线程操作方法、三种实现方式综合练习"呼应。

① 实现 Callable 接口:泛型指定返回类型

// 来源:homework0808/Homework_03.java
static class SumCallable implements Callable<Integer> {
    private int n;   // 求 1~n 的和

    public SumCallable(int n) {
        this.n = n;
    }

    @Override
    public Integer call() throws Exception {
        int sum = 0;
        for (int i = 1; i <= n; i++) {
            sum += i;
        }
        System.out.println(Thread.currentThread().getName() + "求和结果:" + sum);
        return sum;   // 返回计算结果(get() 会拿到这个值)
    }
}

要点:

  • Callable<Integer> 泛型 = call() 的返回值类型(求 int 和所以用 Integer 包装类)
  • call() 是带返回值的线程任务方法,且声明 throws Exception(对比 Runnable 的 run() 无返回值)
  • 任务携带参数:SumCallable(int n) 通过构造方法把求和上限 n 传入任务对象——任务对象可以"携带数据"
  • Thread.currentThread().getName():方式三不继承 Thread,用静态方法获取当前线程名

② FutureTask 包装 + 3 个线程启动 + get() 获取返回值

// 来源:homework0808/Homework_03.java(main 方法核心逻辑)
public static void main(String[] args) throws Exception {
    // 1、创建 3 个 SumCallable 对象(求 1~100 / 1~1000 / 1~10000 的和)
    SumCallable sc1 = new SumCallable(100);
    SumCallable sc2 = new SumCallable(1000);
    SumCallable sc3 = new SumCallable(10000);

    // 2、用 FutureTask<Integer> 包装每个 Callable 对象
    FutureTask<Integer> ft1 = new FutureTask<>(sc1);
    FutureTask<Integer> ft2 = new FutureTask<>(sc2);
    FutureTask<Integer> ft3 = new FutureTask<>(sc3);

    // 3、new Thread(futureTask, "线程名") 创建 3 个线程并 start()
    Thread t1 = new Thread(ft1, "线程1");
    Thread t2 = new Thread(ft2, "线程2");
    Thread t3 = new Thread(ft3, "线程3");
    t1.start();
    t2.start();
    t3.start();

    // 4、调用 ft.get() 获取每个线程的返回值,打印并在 main 中汇总总结果
    System.out.println(ft1.get());
    System.out.println(ft2.get());
    System.out.println(ft3.get());

    // 5、(思考)对比三种实现方式:run() 无返回值 vs call() 有返回值,各自适合什么场景
}

完整调用链

SumCallable(100) → FutureTask<Integer>(sc1) → new Thread(ft1, "线程1") → t1.start()
                                                                    ↓ 异步执行 call()
ft1.get()  ←————— 等待线程1计算完成,返回 call() 的返回值(5050)

要点:

  • FutureTask 是"中间人":Callable 不能直接交给 Thread,用 FutureTask<Integer> 包装后传给 Thread 构造方法(Thread 构造只接收 Runnable / FutureTask)
  • 每个线程独立求和:sc1/sc2/sc3 三个任务对象,分别算 1~100、1~1000、1~10000 的和,各自包装成 ft1/ft2/ft3
  • 指定线程名:new Thread(ft1, "线程1") 第二个参数指定线程名,call() 里 Thread.currentThread().getName() 就能拿到它
  • get() 阻塞等待 + 拿返回值:ft1.get() 会等待线程1计算完成,返回其 call() 的返回值(5050),打印结果
  • main 抛异常:throws Exception 统一处理 call() / get() 抛出的受检异常
  • 三种方式对比(作业思考题):run() 无返回值,适合只执行任务、不需要结果;call() 有返回值,适合"计算完要把结果拿回来用"的场景(如求和、查询、累加)

13. 知识点全景总结

本课知识点围绕"线程操作方法 + 守护线程 + 线程生命周期 + 线程安全问题 + synchronized 线程同步 + 多线程实现方式综合应用"展开,一张表看清"知识 → 代码位置":

知识点 具体体现 源码位置
Thread.sleep(毫秒) 静态方法,让当前线程休眠指定毫秒数;抛 InterruptedException 需处理 course/Demo01
sleep 的应用 循环中每 100 毫秒打印一次,放慢执行、让出时间片,便于观察并发交替 course/Demo01 main
线程调度方式 分时调度(轮流 + 平均分配时间片)vs 抢占式调度(优先高优先级、同优先级随机选);Java 使用抢占式调度 course/Demo01 注释
多线程执行的随机性 单 CPU 同一时刻只能执行一条指令;线程抢到时间片才执行;谁抢到不确定 → 输出顺序随机 course/Demo01 注释
getPriority() 返回此线程的优先级(默认 5) course/Demo01
setPriority(int) 修改线程优先级,范围 1~10 course/Demo01
优先级与调度联动 抢占式调度下优先级高的线程获取 CPU 时间片更多 course/Demo01 注释
Lambda 创建 Runnable Runnable run = () -> {...} 作为任务对象,可同时给多个 Thread 使用(回顾方式二) course/Demo01 main
双线程并发演示 同一个 Runnable 创建 t1 / t2 两个线程,start() 后 run() 交替并发执行 course/Demo01 main
线程默认名 未指定名字时默认 Thread-0 / Thread-1 course/Demo01 main
守护线程概念 守护线程是随其他非守护线程结束而结束的服务型线程;用户线程全部结束 JVM 才退出 course/Demo02 注释
setDaemon(boolean) 将当前线程标记为守护线程;当运行的线程都是守护线程时 JVM 退出 course/Demo02 注释
isDaemon() 判断当前线程是否是守护线程 course/Demo02 注释
JVM 退出机制 只要有任何一个用户线程存活,JVM 就不退出(main 结束 ≠ JVM 退出)——服务程序"卡着不退"的原因 course/Demo02 注释
守护线程经典场景 GC 垃圾回收线程最典型;心跳检测、后台日志、监控统计等后台支撑任务 course/Demo02 注释
守护线程四大特点 ①setDaemon 必须在 start() 前(否则 IllegalThreadStateException)②守护线程创建的子线程默认也是守护线程 ③JVM 强杀时 finally 不一定执行、不能用于资源清理 ④isDaemon() 判断 course/Demo02 注释
守护线程演示 MyThread01(sleep 1000ms 循环打印)创建"线程1"普通线程 + "守护线程";setDaemon(true) 在 start() 之前设置(本次更新修正,保证合法生效) course/Demo02 main
MyThread01/02 抽取独立文件 原 Demo02 内部静态类抽取为独立外部类 MyThread01 / MyThread02(代码重构,逻辑不变) course/MyThread01、MyThread02
线程生命周期概念 线程从生到死的过程:新建、就绪、运行、死亡,运行中可能进入阻塞 course/Demo03 注释
线程 6 种状态(Thread.State 枚举) NEW(new 未 start)/ RUNNABLE(start 后运行或等时间片)/ BLOCKED(抢监视器锁失败)/ WAITING(wait/join/park 无限期等)/ TIMED_WAITING(sleep/wait(timeout)/join(timeout) 限时等)/ TERMINATED(run 结束) course/Demo03 注释
状态转换规律 RUNNABLE 是总枢纽(阻塞状态都有办法回到 RUNNABLE);TERMINATED 是终点站(不可逆) course/Demo03 注释
操作系统 5 状态 vs Java 6 状态 OS 分新建/就绪/运行/阻塞/终结 5 种;Java 把就绪+运行合并为 RUNNABLE,且细分 WAITING / TIMED_WAITING course/Demo03 注释
join() / join(long) t.join() 当前线程等待 t 执行完毕;join(millis) 最多等待指定毫秒(超过不等)——调用方进入 WAITING / TIMED_WAITING course/Demo03、ThreadMethodDemo
interrupt() 中断机制 只是设置中断标志不杀线程;isInterrupted() 主动检查;sleep/wait/join 中被中断抛 InterruptedException 并清除标志,catch 后通常重设标志 course/ThreadMethodDemo
interrupt 机制补充说明 interrupt 请求线程自行响应;阻塞状态收到中断标志抛异常并清除标志;catch 后重新 interrupt() 恢复标志;线程自检标志后再决定退出 course/ThreadMethodDemo 末尾注释
yield() 主动让出 CPU 静态方法,让当前线程主动让出 CPU 时间片重新竞争;只是建议调度器不一定采纳;与 sleep 区别(yield 让一次回到 RUNNABLE,sleep 限时进 TIMED_WAITING) course/Demo03 注释、ThreadMethodDemo
isAlive() 判断存活 判断线程是否存活:已执行 start() 但未结束;TERMINATED 后返回 false course/Demo03 注释
Object 类线程方法 wait/notify wait() / wait(timeout) / wait(timeout,nanos) 让线程等待(进 WAITING / TIMED_WAITING);notify() 唤醒一个 / notifyAll() 唤醒所有——线程通信基础,依赖对象锁 course/Demo03 注释
Demo03 main 演示 4 个 MyThread01 线程 + main 并发打印,最后 mt2.join() 让 main 等待线程2结束 course/Demo03 main
ThreadMethodDemo 综合演示 sleep(TIMED_WAITING)/ join(main 等 worker)/ join(1000)(最多等 1 秒)/ interrupt(t1 循环检查标志退出)/ yield(yield 线程每轮先让出 CPU 再打印) course/ThreadMethodDemo
数据竞争概念 进程间数据不共享、线程间共享;多线程同时读写同一共享变量 → 结果不可预测、错误无法复现 course/Demo04 注释
卖票问题(SellTicket) 3 窗口线程卖 100 张票;相同票多次出现(多线程同时通过 if 判断)+ 负数票(tickets-- 连续执行) course/SellTicket、Demo04 main
计数器问题(Counter) count++ 不是原子操作,JVM 拆成"读-改-写"3 步;多线程并发时读到旧值覆盖新值,理论 1000 实际小于 1000 course/Counter
多线程安全问题 3 原因 ①多线程环境 ②存在共享数据 ③多条语句操作共享数据且有读-改-写复合操作(缺一不可) course/Demo04 注释
解决基本思想 把操作共享数据的语句锁起来;任意时刻只有一个线程执行,其他线程排队等待 course/Demo04 注释
线程安全三大特性 原子性(防读-改-写打断→synchronized/Lock/Atomic)、可见性(防 CPU 缓存→volatile/锁)、有序性(防指令重排→volatile/synchronized);"原可序"是线程安全核心 course/Demo04 注释
synchronized 与监视器锁 Java 内置关键字,保证原子性最简单可靠手段;每个对象底层对应一把监视器锁(Monitor Lock),进 synchronized 前必须拿锁,拿不到进 BLOCKED 排队,持有线程执行完自动释放 course/Demo04 注释
synchronized 优点 / 弊端 优点:同一时刻只有一个线程执行被保护代码;弊端:线程多时竞争同一把锁耗资源、降效率 → 锁粒度尽量小,只锁共享数据代码 course/Demo04 注释
同步实例方法(写法1) public synchronized void increment() 锁的是当前对象 this,锁住同一对象的方法调用 course/SynchronizedDemo
同步静态方法(写法2) public static synchronized void staticIncrement() 锁的是类的 Class 对象,全局唯一 course/SynchronizedDemo
同步代码块(写法3) synchronized(lock) { ... } 锁指定的任意对象,最灵活、粒度最细、性能更好 course/SynchronizedDemo
转账业务场景 synchronized(this) 把"检查-修改"(balance>=amount 判断 + balance-=amount)整体锁住,防止并发透支 course/SynchronizedDemo
加锁后 count 验证 1000 线程调用加锁的 increment03(),count 必然是 1000(对比 Counter 无锁时小于 1000);方法引用 sd::increment03 course/SynchronizedDemo main
CooperationTest 练习完成版 join(主线程等 worker-1)/ yield(每轮让出 CPU)/ interrupt(中断 sleep 长任务,catch 重设标志);打印用 Thread.interrupted()(检查并清除标志) exericse/CooperationTest
随堂练习:三种创建线程方式 继承 Thread / 实现 Runnable / 实现 Callable 各写一个线程任务 exericse/Exercise01
线程常用方法 获取当前线程名 getName / 设置线程名 setName / 获取返回结果 get exericse/Exercise01
实现 Callable 接口 implements Callable<Integer>,泛型指定返回类型;重写 call() 带返回值 homework0808/Homework_03
FutureTask 包装 Callable new FutureTask<>(callable) 包装任务,传给 new Thread(futureTask, "线程名") homework0808/Homework_03
get() 获取线程返回值 ft.get() 阻塞等待该线程 call() 计算完成,返回结果 homework0808/Homework_03
多线程独立求和 三个 SumCallable 分别求 1~100 / 1~1000 / 1~10000 的和,各自线程并行计算 homework0808/Homework_03
三种实现方式适用场景 run() 无返回值(只执行任务);call() 有返回值(需拿回计算结果) homework0808/Homework_03 注释
作业类名规范化 homework_03 重命名为 Homework_03(类名遵循大驼峰规范),逻辑不变 homework0808/Homework_03

14. 随堂练习要点

  • 加锁后 count 验证实验:运行 SynchronizedDemo,观察加锁后 1000 线程 count 必然是 1000;与第 9 节 Counter(无锁 count 往往小于 1000)对比,直观体会 synchronized 解决原子性问题的效果
  • 三种写法改造实验:把 SynchronizedDemo 的 increment03(同步代码块)改成同步实例方法 / 同步静态方法,分别运行验证结果;对比三种写法在"锁的对象"上的差异(this / Class 对象 / 指定对象)
  • 锁粒度实验:给 transfer() 中的"检查-修改"去掉 synchronized 再运行多次,观察余额是否出现透支(负数);加上 synchronized(this) 后验证正确——体会"检查-修改必须整体锁住"
  • 卖票问题加锁修复实验:给 SellTicket.run() 中操作 tickets 的代码块加 synchronized(this),再次运行观察相同票和负数票是否消失(回顾第 9 节的预演,本次正式验证)
  • BLOCKED 状态观察:在线程 run() 中进入 synchronized 代码块前后打印 getState(),让多个线程竞争同一把锁,观察抢锁失败线程是否处于 BLOCKED 状态
  • 不同锁对象失效实验:把 SynchronizedDemo main 中 sd::increment03 改成每次 new SynchronizedDemo() 再调用 increment03,观察 count 是否不再等于 1000——体会"锁对象必须一致才有效"
  • 卖票问题复现实验:运行 Demo04(3 个售票窗口线程),多运行几次,观察相同的票多次出现和负数票两个 bug——体会数据竞争(每次结果都可能不同、错误无法复现)
  • 计数器问题复现实验:运行 Counter(1000 个线程同时 count++),多次运行观察最终 count 往往小于 1000——体会 count++ 的"读-改-写"非原子性
  • 缩小并发窗口实验:把 SellTicket 的 Thread.sleep(10) 去掉或加大到 100,观察卖票 bug 出现的频率变化——sleep 越大(并发窗口越大),bug 越容易复现
  • 线程安全解决预演:试着给 SellTicket.run() 中操作 tickets 的代码块加 synchronized(this) { ... },再次运行观察相同票和负数票是否消失——提前体验"锁"的作用(后续课程正式学习)
  • 三个原因分析练习:对照"多线程安全问题 3 原因",分析卖票问题和计数器问题分别满足哪 3 个条件(缺一不可),加深理解
  • 三大特性识记:背下"原子性、可见性、有序性(原可序)"及其对应解决方案(原子性→synchronized/Lock/Atomic;可见性→volatile/锁;有序性→volatile/synchronized)
  • CooperationTest 练习:补全 exericse/CooperationTest 中的 TODO,运行验证 join / yield / interrupt 三种方法的协作效果;把打印处 Thread.interrupted() 换成 isInterrupted(),对比两种方法在"检查后是否清除标志"上的差异
  • yield 让出 CPU 实验:运行 ThreadMethodDemo,观察"yield线程"打印"第 i 轮"与 main 等其他线程输出的交错情况;把 Thread.yield() 注释掉再运行对比,体会 yield 主动让出 CPU 时间片的效果
  • yield vs sleep 对比:把 Thread.yield() 换成 Thread.sleep(1) 运行,对比两者对输出交错密度的影响——yield 只是让出一次(可能马上又被调度),sleep 是限时休眠(至少等 1 毫秒)
  • isAlive 实验:在 main 中启动子线程后立刻打印 t.isAlive()(true),等子线程 run() 结束后再打印(false);对比 NEW(未 start)与 TERMINATED 状态的 isAlive 返回值
  • wait/notify 入门实验:写一个简单的生产者-消费者雏形——一个线程对对象调用 wait() 等待,另一个线程 sleep 后对同一对象调用 notify() 唤醒,观察等待线程是否被唤醒继续执行(需在 synchronized 同步块中调用,提前体会线程同步)
  • wait(timeout) 限时实验:线程调用 wait(2000) 后不调用 notify,观察等待线程是否在 2 秒后自动恢复执行——对应第 7 节状态表中 wait(timeout) 进入 TIMED_WAITING
  • 线程生命周期状态打印实验:在 MyThread01 的 run() 不同位置打印 Thread.currentThread().getState(),观察线程在 main 中 new Thread() 后(NEW)、start() 后(RUNNABLE)、sleep() 期间(TIMED_WAITING)、run() 结束后(TERMINATED)的状态变化
  • join 等待实验:运行 Demo03,把 mt2.join() 注释掉再运行一次,对比两次输出中 main 与线程2 打印顺序的差异——有 join 时 main 会等待线程2 结束,无 join 时 main 与线程2 并发交错
  • join(timeout) 实验:把 ThreadMethodDemo 中 t.join(1000) 改成 t.join()(无限等),观察 main 是否一直等 worker-2 睡满 5 秒;再改回 join(1000),体会"最多等待指定毫秒,超过就不等"
  • interrupt 主动检查实验:运行 ThreadMethodDemo,把 t1.interrupt() 注释掉,观察线程 t1 是否一直循环打印停不下来;恢复 interrupt 后再观察 t1 检测到中断标志正常退出
  • interrupt 阻塞中断实验:在线程 run() 中先 Thread.sleep(10000),main 里 sleep(500) 后 t1.interrupt()——观察 sleep 中抛 InterruptedException、catch 里重设标志后 isInterrupted() 恢复为 true 的过程
  • sleep 与 TIMED_WAITING:在 run() 中 Thread.sleep(100) 前后分别打印 Thread.currentThread().getState(),验证 sleep 期间线程处于 TIMED_WAITING(计时等待)状态
  • 守护线程设置时机实验:运行 Demo02(代码已把 setDaemon(true) 放在 start() 之前,设置合法生效),观察"线程1"(用户线程)持续打印,而"守护线程"在用户线程结束后被 JVM 强制终止;再把 setDaemon(true) 故意移回 mt2.start() 之后运行,观察抛 IllegalThreadStateException,体会"设置时机"特点
  • JVM 退出条件实验:只启动一个普通子线程(不设守护)跑几秒就退出 main,观察JVM 是否立即退出——只要用户线程还在跑,JVM 就"卡着不退";再把该线程设为守护线程,观察 main 结束后 JVM 直接退出
  • isDaemon 判断实验:在线程 run() 中打印 Thread.currentThread().isDaemon(),对比普通线程与守护线程的输出差异(true / false)
  • 守护属性继承实验:在守护线程的 run() 中再 new 一个子线程并启动,用 isDaemon() 打印它的守护属性,验证"守护线程创建的子线程默认也是守护线程"
  • finally 不保证实验:在守护线程 run() 中写 try-finally(finally 里打印"清理资源"),让 JVM 在它执行完前退出,观察 finally 是否被执行——体会"守护线程被强杀时 finally 不一定执行,不能用于资源清理"
  • sleep 实验:把 Thread.sleep(100) 的毫秒数改成 1000 / 10,运行 Demo01,观察两条线程输出的交错密度变化——休眠越长切换越明显;再分别注释 sleep 前后的 start() 对比
  • 优先级实验:把 t1 优先级改成 10、t2 保持 5(或改成 1),运行多次,用 Thread.currentThread().getName() 统计两个线程输出的条数,观察高优先级线程获得 CPU 机会是否更多
  • getPriority 验证:在 new Thread(run) 前后分别打印 getPriority(),验证新线程默认优先级都是 5;再试试 setPriority(0) / setPriority(11),观察抛 IllegalArgumentException
  • 随机性实验:反复运行 Demo01 多次,观察每次控制台输出顺序是否一致——体会"谁抢到 CPU 时间片不确定"
  • run 与 start 对比:把 t1.start() 改成 t1.run(),观察是否还有两个线程交替——直接调 run() 不会创建新线程
  • 三种方式综合练习:仿照 Exercise01 的要求,分别用继承 Thread、实现 Runnable、实现 Callable 各写一个"打印 0~99 并求和"的线程任务,对比三种代码结构
  • 常用方法练习:在 Runnable / Callable 实现类中,用 Thread.currentThread().getName() 打印线程名;用 new Thread(task, "指定名") 或 setName("新名") 设置线程名,验证 getName 的结果
  • 课后作业运行:运行 homework0808/Homework_03,观察控制台依次打印三个线程各自的求和结果,再通过 ft1.get() / ft2.get() / ft3.get() 拿到返回值,验证三个线程是否并发计算(结果正确、输出顺序不定)
  • get() 汇总思考:作业要求"在 main 中汇总总结果"——把 ft1.get() + ft2.get() + ft3.get() 相加,打印三种规模求和的总和,体会"主线程等待子线程结果并汇总"
  • 三种方式适用场景对比:思考题——run() 无返回值适合"烧水、打印、发消息"这类只执行不看结果的场景;call() 有返回值适合"求和、查询数据库、计算成绩"这类需要拿结果继续处理的场景
  • 知识点串联:把 20260808 的"三种实现方式 + 线程调度"与今天的"sleep / 优先级"串成一条线——创建线程(3 种方式)→ 控制线程(sleep / 优先级)→ 获取结果(get)

15. 拓展阅读

  • JDK 文档:java.lang.Thread(线程类,sleep() / getPriority() / setPriority() / getName() / setName() / currentThread() / setDaemon() / isDaemon() / join() / interrupt() / isInterrupted() / getState() / yield() / isAlive() 等核心方法)、java.lang.Object(wait() / notify() / notifyAll() 线程通信方法)、java.lang.Thread.State(线程 6 种状态枚举)
  • 线程安全入门:多线程的数据竞争是并发编程的根源性问题——卖票问题(相同票 / 负数票)和计数器问题(count 小于理论值)是理解线程安全最经典的入门案例,都源于读-改-写复合操作被并发打断
  • 原子操作:count++ 不是原子操作(读-改-写 3 步);Java 提供 AtomicInteger / AtomicLong 等原子类,其内部方法(如 incrementAndGet())是原子的,无需加锁即可保证线程安全
  • volatile 关键字:volatile 修饰的变量不缓存到 CPU 寄存器 / 缓存,保证可见性(一个线程的修改对其他线程立即可见);但它不能保证原子性(count++ 用 volatile 依然不安全),需配合锁或原子类
  • synchronized 预读:卖票问题的标准解法是 synchronized 同步块 / 同步方法——同一时刻只允许一个线程进入临界区,其余线程排队等待(对应线程状态中的 BLOCKED 状态,抢锁失败被动阻塞)
  • synchronized 的锁对象选择:锁必须选所有线程共享的对象才有效——同步实例方法锁 this、同步静态方法锁 Class 对象、同步代码块锁指定对象;如果各线程 new 各自对象去加锁(锁对象不一致),synchronized 就失效了
  • synchronized 的可重入性:synchronized 是可重入锁——同一线程可以重复获取自己已经持有的锁(如同步方法内再调用另一个同步方法),不会自己把自己锁死;每个监视器锁都关联一个持有计数
  • synchronized 与 wait/notify:wait() / notify() 必须持有 synchronized 锁时调用(回顾第 8 节 Object 类方法)——它们本质是"释放锁并等待" / "唤醒等待并重新竞争锁",是 synchronized 锁之上实现的线程通信
  • 指令重排:编译器和 CPU 为了优化性能可能重排指令执行顺序(有序性问题);volatile 通过内存屏障禁止重排,synchronized 通过锁保证临界区内代码的有序执行
  • Lock 与 synchronized 对比:JDK5 引入的 Lock 接口(如 ReentrantLock)功能更强——可尝试 tryLock()(尝试获取不阻塞)、lockInterruptibly()(可中断获取锁)、公平锁;但使用更复杂(需手动 unlock),synchronized 是内置关键字更简单
  • debug 多线程 bug 的困难:数据竞争类 bug 错误无法复现(每次运行结果可能不同),排查时常用思路是缩小并发窗口(加大 sleep 放大 bug)、线程转储(thread dump)、添加日志打印中间状态
  • 生产环境线程安全:真实项目中很少手写 synchronized 管理大量线程,而是用线程池(ExecutorService) + 并发容器(ConcurrentHashMap / CopyOnWriteArrayList) + 原子类等 JDK 提供的线程安全组件
  • 守护线程与 JVM 生命周期:JVM 在最后一个非守护线程结束时才退出(守护线程不阻止退出);main 方法本身运行在用户线程上,但 main 返回不代表 JVM 退出——只要还有用户线程存活,JVM 就继续运行(这正是很多服务程序"关不掉"的原因)
  • setDaemon 的时机限制:setDaemon(true) 必须在 start() 之前调用,否则抛 IllegalThreadStateException;线程启动后守护属性不可再修改
  • 守护线程的典型应用:GC 垃圾回收线程是最经典的守护线程;此外心跳检测、后台日志、监控统计、自动保存等"随主程序生灭"的后台任务都适合用守护线程,避免程序退出时被"后台线程卡住"
  • 守护线程 vs 用户线程选型:需要程序结束前必须完成的任务(如写文件、释放连接)用用户线程;"可有可无、主程序结束就应终止"的任务用守护线程——因为守护线程被强杀时 finally 不保证执行,不能依赖它做资源清理
  • Thread 优先级常量:Thread 类内置三个优先级常量——MIN_PRIORITY = 1、NORM_PRIORITY = 5(默认)、MAX_PRIORITY = 10;setPriority() 参数超出 1~10 范围会抛 IllegalArgumentException
  • sleep 与 yield 的区别:Thread.sleep(ms) 让线程休眠指定毫秒(可被中断,抛 InterruptedException);Thread.yield() 只让出一次 CPU(回到就绪状态,马上又可能被调度)——都是主动让出 CPU 的手段
  • wait 与 sleep 的区别(再深化):wait() 是 Object 方法,必须持有对象锁调用,调用后释放锁并进入 WAITING,需 notify() / notifyAll() 唤醒;sleep() 是 Thread 静态方法,不释放锁,时间到自动醒——两者是"线程通信"与"线程暂停"的分水岭
  • notify vs notifyAll:notify() 只唤醒一个等待该对象的线程(具体哪个由 JVM 决定);notifyAll() 唤醒所有等待线程——多个线程等待同一条件时优先用 notifyAll,避免"通知丢失"导致部分线程永久等待
  • wait/notify 的应用:经典的生产者-消费者模式——生产者生产数据后 notify(),消费者 wait() 等待数据;缓冲区满时生产者等待、缓冲区空时消费者等待,实现线程间的协调与解耦
  • yield 的适用场景:yield() 适合"高优先级线程主动谦让、避免饿死低优先级线程"等场景;但它是建议性的,生产环境一般不依赖它做精确控制(调度器可能忽略)
  • isAlive 的典型用途:isAlive() 常用于轮询判断子线程是否执行完(while (t.isAlive()) { ... });但更优雅的做法是用 join() 阻塞等待或 FutureTask.get() 获取结果(避免空转消耗 CPU)
  • sleep 与 wait 的区别:sleep() 是 Thread 的静态方法,不释放锁(monitor);wait() 是 Object 的实例方法,必须持有锁时调用且会释放锁(后续线程同步课程会深入)
  • 线程状态(Thread.State 枚举):Java 定义 6 种状态——NEW / RUNNABLE(就绪+运行合并)/ BLOCKED(抢锁失败)/ WAITING(无限等)/ TIMED_WAITING(限时等)/ TERMINATED(终点);sleep() 期间线程处于 TIMED_WAITING(计时等待) 状态;join() 让调用线程进入 WAITING,join(timeout) 进入 TIMED_WAITING
  • getState() 方法:Thread.getState() 返回线程当前状态对应的 Thread.State 枚举值,配合演示可以观察线程从 NEW → RUNNABLE → TIMED_WAITING → TERMINATED 的完整生命周期
  • join 的应用场景:join() 常用于"主线程等待子线程计算结果/执行完成后,再继续处理"的场景;join(timeout) 适合"最多等多久"的限时协作(如限时等待子任务完成)
  • interrupt 的正确用法:interrupt() 是 Java 协作式中断的标准手段——线程内部主动检查中断标志(isInterrupted() / Thread.interrupted())或在阻塞方法中收到 InterruptedException 来响应;InterruptedException 抛出时会清除中断标志,catch 后重设标志是常见最佳实践
  • Thread.interrupted() vs isInterrupted():静态方法 Thread.interrupted() 检查中断标志并清除;实例方法 isInterrupted() 只检查不清除——两者都常用于循环任务的中断响应
  • FutureTask.get() 与 join 的关联:第 8 节作业中 ft.get() 获取 Callable 结果,本质也是阻塞等待线程任务完成——与 join() 有相似的"等待"语义(get() 底层也是等待任务结束)
  • 抢占式调度的局限:设置优先级只是"提高获得 CPU 的概率",不能保证执行顺序,也不适合做严格的任务先后控制——生产环境更常用线程池(ExecutorService) + 任务队列来精细控制
  • FutureTask 与 Future 接口:FutureTask 实现了 Future 接口——get() 获取结果、isDone() 判断完成、cancel() 取消任务;get() 是阻塞方法,会一直等待该线程任务执行完才返回
  • Callable 的现代写法:生产环境中更常用 ExecutorService.submit(Callable) 直接返回 Future,比手动 new Thread + FutureTask 更规范(线程复用、统一管理)
  • Lambda 回顾:Runnable 是函数式接口(只有一个抽象方法 run()),所以可以用 Lambda 表达式创建任务对象(回顾 08-04 函数式编程);Demo01 中 Runnable run = () -> {...} 就是典型写法
  • 线程安全预告:多线程并发访问共享数据时会引发数据竞争(如两个线程同时给同一个变量自增);后续将学习 synchronized 同步、Lock 锁、volatile 等机制解决线程安全问题
  • 任务与线程解耦:Runnable / Callable 描述"任务"(做什么),Thread / FutureTask 承载"线程"(怎么跑)——同一个任务对象可以交给多个线程执行,这种设计更符合面向接口编程思想