1.1 多线程概念
- 多线程 :指的是从软件或者硬件上实现多个线程并发执行 的技术。具有多线程能力的计算机,能够在同一时间段内调度多个线程任务,从而提升程序的响应能力和资源利用效率。
在 Java 中,多线程并不是简单地"多写几段代码同时运行",而是由 JVM、操作系统调度器以及 CPU 共同参与完成的一种执行机制。线程是程序执行流的基本单位,多线程程序的核心目标,是让多个执行路径围绕同一个程序目标协同工作。
多线程强调的是多个执行路径 。这些执行路径可能在多核 CPU 上真正同时执行,也可能在单核 CPU 上快速切换执行。因此,理解多线程时必须区分"并发 "和"并行"。
1.2 并发和并行

1.2.1 并发(Concurrent)
- 并发 :在同一时间段内,有多个任务交替获得 CPU 执行权,看起来像是在同时执行。


并发强调的是任务调度能力。即使只有一个 CPU,操作系统也可以通过时间片轮转,让多个线程交替执行。由于 CPU 切换速度很快,用户通常会感觉多个任务在同时运行。
1.2.2 并行(Parallel)
- 并行 :在同一时刻,有多个指令在多个 CPU 或多个 CPU 核心上同时执行。

并行依赖硬件能力。如果一台计算机拥有多个核心,那么多个线程可能真正做到"同时运行"。例如,一个线程负责下载文件,另一个线程负责解析数据,二者在不同 CPU 核心上同时推进。
| 对比项 | 并发 | 并行 |
|---|---|---|
| 核心含义 | 同一时间段内交替执行 | 同一时刻真正同时执行 |
| 硬件要求 | 单核也可以实现 | 通常依赖多核 CPU |
| 关注重点 | 任务调度能力 | 任务同时执行能力 |
| 典型现象 | 多个线程抢占 CPU 时间片 | 多个线程分别运行在不同核心上 |
注意:Java 多线程程序的运行结果往往具有随机性。因为线程是否能获得 CPU 执行权,并不是由代码书写顺序绝对决定的,而是由线程调度器综合决定。
1.3 进程和线程
1.3.1 进程
- 进程 :是正在运行的程序。

进程是系统分配资源和调度的独立单位。一个正在运行的 IDEA、浏览器、微信都可以看作进程。进程之间默认拥有相对独立的内存空间,一个进程不能随意访问另一个进程内部的数据。
进程具有以下特征:
- 独立性:进程是系统进行资源分配和调度的独立单位。
- 动态性:进程是程序的一次执行过程,会动态创建、运行和消亡。
- 并发性 :多个进程可以在操作系统中并发执行。
1.3.2 线程
- 线程 :是进程中的单个顺序控制流,是一条具体的执行路径。

一个进程可以包含一个线程,也可以包含多个线程。若一个进程只有一条执行路径,则称为单线程程序 ;若一个进程有多条执行路径,则称为多线程程序。
| 对比项 | 进程 | 线程 |
|---|---|---|
| 所属关系 | 操作系统中的运行程序 | 进程中的执行路径 |
| 资源拥有 | 拥有独立内存空间 | 共享所属进程资源 |
| 创建开销 | 较大 | 较小 |
| 通信方式 | 进程间通信较复杂 | 同进程线程共享数据更方便 |
总结:进程是资源分配的基本单位,线程是 CPU 调度的基本单位。Java 多线程开发主要讨论的是:如何创建线程、如何控制线程执行、如何保护共享数据以及如何实现线程协作。
1.4 实现多线程方式一:继承 Thread 类
1.4.1 应用场景
当一个类本身就可以被设计为"线程任务对象",并且不需要再继承其他父类时,可以通过继承 Thread 类来实现多线程。这种方式结构直观,适合初学阶段理解线程启动流程。
1.4.2 核心方法
| 方法名 | 说明 |
|---|---|
void run() |
封装线程启动后要执行的任务代码 |
void start() |
启动线程,JVM 会自动调用该线程对象的 run() 方法 |
1.4.3 实现步骤
- 定义一个类继承
Thread类。- 在子类中重写
run()方法。- 创建该线程类的对象。
- 调用
start()方法启动线程。
1.4.4 代码示例

1.4.4.1 MyThread
java
package com.example.a01threadcase1;
/**
* 自定义线程类。
* <p>
* 通过继承 Thread 类,并重写 run 方法,定义线程启动后要执行的任务。
*/
public class MyThread extends Thread {
@Override
public void run() {
// getName() 获取当前线程的名字,方便观察两个线程交替执行的效果。
for (int i = 0; i < 100; i++) {
System.out.println(getName() + ": HelloWorld");
}
}
}
1.4.4.2 ThreadDemo
java
package com.example.a01threadcase1;
/**
* 演示创建并启动多线程的第一种方式:继承 Thread 类。
*/
public class ThreadDemo {
public static void main(String args[]) {
// 1. 创建自定义线程类的对象。
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
// 2. 设置线程名称,便于在控制台区分不同线程的输出。
t1.setName("线程1");
t2.setName("线程2");
// 3. 调用 start 方法启动线程。
// start 会让 JVM 开启新的线程,并自动调用该线程对象的 run 方法。
t1.start();
t2.start();
}
}
运行结果如下:
1.4.5 核心问题辨析
- 为什么要重写
run()方法?
因为run()方法用于封装线程真正要执行的任务代码。 - 为什么启动线程要调用
start(),而不是直接调用run()?
直接调用run()只是普通方法调用,代码仍然运行在当前线程中;调用start()才会真正开启新的线程执行路径。
注意 :同一个
Thread对象只能调用一次start()。线程启动后再次调用start()会抛出IllegalThreadStateException。
1.5 实现多线程方式二:实现 Runnable 接口
1.5.1 应用场景
当一个类已经继承了其他父类,或者希望将"线程对象 "和"线程任务 "分离时,更推荐实现 Runnable 接口。该方式体现了面向接口编程思想,扩展性更好。
1.5.2 构造方法
| 构造方法 | 说明 |
|---|---|
Thread(Runnable target) |
分配一个新的 Thread 对象,并绑定任务对象 |
Thread(Runnable target, String name) |
分配一个新的 Thread 对象,并指定线程名称 |
1.5.3 实现步骤
- 定义一个类实现
Runnable接口。- 重写
run()方法。- 创建
Runnable实现类对象。- 创建
Thread对象,并将任务对象传入构造方法。- 调用
start()方法启动线程。
1.5.4 代码示例

1.5.4.1 MyRunnable
java
package com.example.a01threadcase2;
/*
* 自定义任务类:
* 实现 Runnable 接口,把线程要执行的代码写在 run 方法中。
*/
public class MyRunnable implements Runnable{
@Override
public void run() {
// run 方法中的代码会在线程启动后执行。
for (int i = 0; i < 100; i++) {
// Thread.currentThread() 可以获取当前正在执行这段代码的线程对象。
System.out.println(Thread.currentThread().getName() +"HelloWorld");
}
}
}
1.5.4.2 MyThread
java
package com.example.a01threadcase2;
/*
* 测试类:
* 演示通过 Runnable 创建线程,并让多个线程执行同一个任务对象。
*/
public class MyThread {
public static void main(String[] args) {
// 创建 Runnable 接口的实现类对象,表示线程要执行的任务。
MyRunnable mr = new MyRunnable();
// 创建两个线程对象,并把同一个任务对象传递给线程。
Thread t1 = new Thread(mr);
Thread t2 = new Thread(mr);
// 设置线程名称,方便观察不同线程的执行结果。
t1.setName("线程1");
t2.setName("线程2");
// 启动线程。调用 start 方法后,JVM 会自动调用对应线程的 run 方法。
t2.start();
t1.start();
}
}
运行结果如下:
总结 :Runnable方式将任务与线程对象解耦,多个线程可以共享同一个任务对象。因此,在实际开发中,它比直接继承Thread更常用。
1.6 实现多线程方式三:实现 Callable 接口
1.6.1 应用场景
如果线程任务执行完毕后需要返回结果,就可以使用 Callable 接口。与 Runnable 相比,Callable 的核心优势是:可以通过 call() 方法返回结果,并且可以抛出异常。
1.6.2 核心 API
| 方法名 | 说明 |
|---|---|
V call() |
计算结果,如果无法计算结果,则抛出异常 |
FutureTask(Callable<V> callable) |
创建一个 FutureTask 对象,用于包装 Callable 任务 |
V get() |
等待计算完成,并获取线程执行结果 |
1.6.3 实现步骤
- 定义一个类实现
Callable接口。- 重写
call()方法,并设置返回值。- 创建
Callable实现类对象。- 创建
FutureTask对象包装Callable。- 创建
Thread对象,并将FutureTask传入构造方法。- 启动线程。
- 调用
get()方法获取执行结果。
1.6.4 代码示例

1.6.4.1 MyCallable
java
package com.example.a01threadcase3;
import java.util.concurrent.Callable;
// Callable 表示一个可以在线程中执行,并且能返回结果的任务
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
int sum = 0;
// 计算 1 到 100 的累加和
for (int i = 1; i <= 100; i++) {
sum += i;
}
// call 方法的返回值,会被 FutureTask 保存起来
return sum;
}
}
1.6.4.2 ThreadDemo
java
package com.example.a01threadcase3;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo {
/*
* 多线程的第三种实现方式:
* 特点:可以获取到多线程运行的结果
* 1.创建一个类MyCallable实现callable接口
* 2.重写call(是有返回值的,表示多线程运行的结果)
*
* 3.创建MyCallable的对象(表示多线程要执行的任务)
* 4.创建FutureTask的对象(作用管理多线程运行的结果)
* 5.创建Thread类的对象,并启动(表示线程)
* */
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 3.创建 Callable 任务对象,任务中定义了线程要执行的代码和返回值
MyCallable mc = new MyCallable();
// 4.FutureTask 可以接收 Callable 任务,并在任务执行结束后获取返回结果
FutureTask<Integer> ft = new FutureTask<>(mc);
// 5.Thread 不能直接接收 Callable,所以需要用 FutureTask 包装后再交给 Thread
Thread t1 = new Thread(ft);
// 启动线程,线程会执行 MyCallable 中的 call 方法
t1.start();
// get 方法会等待线程执行完毕,并获取 call 方法的返回值
Integer result = ft.get();
System.out.println(result);
}
}
运行结果如下:
1.6.5 三种实现方式对比
| 实现方式 | 优点 | 缺点 | 是否有返回值 |
|---|---|---|---|
继承 Thread |
编写简单,可直接使用 Thread 方法 |
Java 单继承限制,扩展性较差 | 否 |
实现 Runnable |
扩展性强,任务与线程分离 | 无法直接返回结果 | 否 |
实现 Callable |
可返回结果,可抛出异常 | 需要配合 FutureTask 使用 |
是 |
最佳实践提示 :如果只是执行任务,优先考虑
Runnable;如果需要返回计算结果,使用Callable;如果只是学习线程启动机制,继承Thread最直观。
1.7 设置和获取线程名称 与 线程休眠
1.7.1 应用场景
多线程程序的输出通常是交替出现的。如果不为线程设置名称,控制台只能看到默认的 Thread-0、Thread-1 等名称,排查问题不够直观。因此,在学习和调试阶段,应当主动为线程命名。
1.7.2 常用方法
| 方法名 | 说明 |
|---|---|
void setName(String name) |
将线程名称修改为指定名称 |
String getName() |
返回当前线程名称 |
static Thread currentThread() |
返回当前正在执行的线程对象 |
static void sleep(long time) |
让当前线程休眠指定毫秒数 |
1.7.3 代码示例

1.7.3.1 MyThread
java
package com.example.a02threadcase1;
public class MyThread extends Thread {
// 空参构造方法:不指定线程名称时,会使用 Thread 类提供的默认名称。
public MyThread() {
}
// 带参构造方法:接收线程名称,并交给父类 Thread 保存。
public MyThread(String name) {
/*
super(name) 表示调用父类 Thread 的有参构造方法。
MyThread 继承了 Thread,线程名称这个属性和相关功能本来就定义在 Thread 中。
如果这里只接收 name,却不调用 super(name),父类 Thread 就拿不到这个名称,
后面调用 getName() 时仍然会得到默认名称,例如 Thread-0、Thread-1。
*/
super(name);
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
try {
/*
sleep 方法会让当前线程休眠指定时间。
Thread.sleep(1000) 声明了可能抛出 InterruptedException。
这是一个编译时异常,Java 要求必须处理:要么继续向外抛出,要么用 try-catch 捕获。
run 方法是重写 Thread 的方法,父类中的 run 没有声明抛出 InterruptedException,
所以这里不能直接在 run 方法后面写 throws InterruptedException,只能在方法内部用 try-catch 处理。
*/
Thread.sleep(1000);
} catch (InterruptedException e) {
// 将中断异常包装成运行时异常抛出,避免静默吞掉线程执行中的异常信息。
throw new RuntimeException(e);
}
System.out.println(getName()+":"+i);
}
}
}
1.7.3.2 MyThreadDemo
java
package com.example.a02threadcase1;
public class MyThreadDemo {
/*
** String getName() **
返回当前线程的名称。
** void setName(String name) **
设置线程名称;也可以通过构造方法设置。
线程名称说明:
1. 如果没有手动设置名称,线程会使用默认名称。
2. 默认格式为 Thread-X,其中 X 是从 0 开始的序号。
3. 可以通过 setName 方法或构造方法给线程设置名称。
--------------------------------------------
** static Thread currentThread() **
获取当前正在执行的线程对象。
main 线程说明:
1. JVM 启动后会自动启动多条线程。
2. 其中负责调用 main 方法并执行其中代码的线程叫 main 线程。
3. 以前编写的普通代码,默认都是运行在 main 线程中的。
--------------------------------------------
** static void sleep(long time) **
让当前线程休眠指定时间,参数单位为毫秒。
sleep 方法说明:
1. 哪条线程执行到 sleep 方法,哪条线程就会暂停对应的时间。
2. 参数表示休眠时间,单位是毫秒;1 秒 = 1000 毫秒。
3. 休眠时间结束后,线程会自动醒来并继续执行后续代码。
*/
public static void main(String[] args) {
MyThread my1 = new MyThread();
MyThread my2 = new MyThread();
// void setName(String name):将当前线程的名称修改为指定名称。
my1.setName("高铁");
my2.setName("飞机");
// // Thread(String name):创建线程时直接设置线程名称。
// // 注意: 在继承 Thread 类自定义线程时,
// // 若想通过构造方法为新建线程命名,
// // 必须在子类的构造方法中显式调用父类的有参构造方法(即 super(name))。
// MyThread my1 = new MyThread("高铁");
// MyThread my2 = new MyThread("飞机");
// start():启动线程。
my1.start();
my2.start();
// static Thread currentThread():返回当前正在执行的线程对象。
System.out.println(Thread.currentThread().getName());
}
}
运行结果如下:
注意 :Thread.sleep()让当前线程进入休眠状态,但不会释放已经持有的同步锁。该方法声明了InterruptedException,因此必须进行异常处理。
1.8 线程优先级
在多线程环境中,如果计算机只有一个 CPU,那么在某一个特定时刻 CPU 只能执行一条指令。线程只有抢夺到 CPU 的时间片(即使用权) 才可以执行自己的指令。这就引出了不同的线程调度方式以及程序执行的"随机性"特征。
1.8.1 线程调度模型
主流的操作系统或虚拟机通常采用以下两种调度模型之一:
- 抢占式调度模型 :
系统优先让优先级高的线程 使用 CPU。如果多个线程的优先级相同,那么系统会随机选择一个。在这种模型下,优先级高的线程获取 CPU 时间片的概率相对大一些(但这并不意味着绝对先执行完,仅仅是概率上的优势)。
如下列各图:





- 分时调度模型(非抢占式) :
所有线程轮流获取 CPU 的使用权,系统会平均分配每个线程占用 CPU 的时间片。这种模式主打公平交替,也就是"你一次我一次",并且每个线程每次占用的执行时间也大致相同。
如下列各图:





1.8.2 常用方法
| 方法名 | 说明 |
|---|---|
final int getPriority() |
返回线程优先级 |
final void setPriority(int newPriority) |
修改线程优先级 |
优先级参数说明:
- 范围 :
1 ~ 10(超出该范围会抛出IllegalArgumentException异常) - 默认值 :
5(主线程及新建线程默认优先级均为 5)
1.8.3 代码示例

1.8.3.1 MyRunnable
java
package com.example.a02threadcase2;
public class MyRunnable implements Runnable{
// 重写 run 方法,这里面包含了线程启动后要执行的具体逻辑
@Override
public void run() {
// 循环 100 次
for (int i = 1; i <= 100; i++) {
// Thread.currentThread() 获取当前正在执行这段代码的线程对象
// .getName() 获取该线程的名称(比如我们在 Demo 中设置的 "飞机" 或 "坦克")
System.out.println(Thread.currentThread().getName() + "---" + i);
}
}
}
1.8.3.2 ThreadDemo
java
package com.example.a02threadcase2;
public class ThreadDemo {
public static void main(String[] args) {
// 1. 创建线程要执行的任务对象 (由于 MyRunnable 实现了 Runnable 接口,它可以作为参数传递给 Thread)
MyRunnable mr = new MyRunnable();
// 2. 创建线程对象
// 构造方法的第一个参数是任务对象 mr,第二个参数是给这个线程起的名字
Thread t1 = new Thread(mr, "飞机");
Thread t2 = new Thread(mr, "坦克");
// 3. 设置线程的优先级
// 优先级范围是 1 到 10,默认是 5。
// 优先级越高,抢占到 CPU 执行权的概率就越大(但这只是概率变大,并不保证绝对优先执行完)。
t1.setPriority(1); // 将 t1("飞机") 的优先级设置为最低 1
t2.setPriority(10); // 将 t2("坦克") 的优先级设置为最高 10
// 4. 启动线程
// 注意:必须调用 start() 方法线程才会真正启动,如果直接调用 run() 方法,那就变成普通的方法调用了。
t1.start();
t2.start();
// 5. 获取当前线程的默认优先级
System.out.println("main线程的默认优先级为: "+Thread.currentThread().getPriority());
}
}
运行结果如下:
注意 :线程优先级只会影响调度概率,不保证执行顺序。 不能把业务正确性建立在线程优先级之上。
1.9 守护线程
1.9.1 核心概念
- 守护线程 :用于服务其他线程。当 JVM 中所有非守护线程(如主线程、普通用户线程)都执行结束后,守护线程也会随之陆续结束,即使它的任务代码还没执行完。
| 方法名 | 说明 |
|---|---|
void setDaemon(boolean on) |
将此线程标记为守护线程 |
经典应用场景:
如图所示的聊天软件
- 非守护线程(主业务):聊天窗口(线程 1)。只要你还在聊天,程序就必须一直运行。
- 守护线程(辅助业务):后台文件传输(线程 2)。当聊天窗口(即所有非守护线程)被关闭、程序退出了,此时还在后台传输文件的线程就没有继续运行的必要了,它作为守护线程会被系统随之关闭。
- (常见的守护线程还有:Java 的垃圾回收器、内存监控线程等)
1.9.2 代码示例("女神"与"备胎")
为了更容易理解,我们用一个"女神"与"备胎"的通俗比喻来演示代码。核心思想是:当女神(非守护线程)离开(结束)了,备胎(守护线程)也就没有存在的意义了,会跟着陆续离开。

1.9.2.1 MyThread1(普通线程 / 女神)
java
package com.example.a02threadcase3;
/**
* 普通线程(用户线程)------ 也就是例子中的"女神"线程。
* 它的存活周期只受自己任务代码的控制。
*/
public class MyThread1 extends Thread{
@Override
public void run() {
// 女神线程的任务:只循环 10 次就结束了
for (int i = 1; i <= 10; i++) {
// getName() 会获取当前线程的名字(在这里就是"女神")
System.out.println(getName() + "@" + i);
}
// 当这个循环执行完毕,"女神"线程就正式死亡(结束运行)了
}
}
1.9.2.2 MyThread2(守护线程 / 备胎)
java
package com.example.a02threadcase3;
/**
* 守护线程(Daemon Thread)
* 它的目的是为其他线程提供服务。
* 当系统中所有的非守护线程都结束时,守护线程也就没有存在的意义了,会被 JVM 陆续停止。
*/
public class MyThread2 extends Thread {
@Override
public void run() {
// 备胎线程的任务:目标是循环 100 次
for (int i = 1; i <= 100; i++) {
System.out.println(getName() + "---" + i);
}
// 核心原理解析:
// 虽然这里写了循环 100 次,但因为它是守护线程,
// 一旦非守护线程(如:女神线程和主线程)全部执行完毕,
// 守护线程(备胎)就会随之陆续结束,通常跑不到第 100 次。
}
}
1.9.2.3 ThreadDemo(测试类)
java
package com.example.a02threadcase3;
public class ThreadDemo {
public static void main(String[] args) {
/*
final void setDaemon(boolean on) 设置为守护线程
细节:
当其他的非守护线程执行完毕之后, 守护线程会陆续结束
通俗易懂:
当女神线程结束了, 那么备胎也没有存在的必要了
*/
MyThread1 t1 = new MyThread1();
MyThread2 t2 = new MyThread2();
// 1. 为线程设置名字
t1.setName("女神");
t2.setName("备胎");
// 2. 将 t2 设置为守护线程(备胎线程)
// 意味着:只要女神线程(以及 main 线程)结束了,这个备胎线程就会随之结束。
t2.setDaemon(true);
// 3. 启动线程
t1.start();
t2.start();
}
}
运行结果分析 :
运行代码后会发现,当"女神@10"打印完毕后,"备胎"线程并不会完整执行到 100 次,而是会在打印几句之后(因为 JVM 停止线程需要一个短暂的过程,所以会"陆续"结束)被迫终止。
注意 :必须在调用start()启动线程之 前 调用setDaemon(true)。如果线程已经启动,再试图将其设置为守护线程状态,会抛出IllegalThreadStateException异常。
1.10 礼让线程与插入线程
1.10.1 礼让线程:yield()
yield() 表示当前线程愿意让出 CPU 执行权,但下一次 CPU 是否继续分配给该线程并不确定。因此,它只能改善线程交替执行的概率,不能保证严格顺序。

1.10.1.1 MyThread
java
package com.example.a02threadcase4;
/**
* 演示 yield() 礼让线程
*/
public class MyThread extends Thread {
@Override
public void run() {
// "飞机" 和 "坦克" 线程的任务都是循环打印 100 次
for (int i = 1; i <= 100; i++) {
System.out.println(getName() + "@" + i);
/*
* 核心原理解析:Thread.yield()
* * 如果没有这行代码:
* "飞机" 线程抢到 CPU 后,可能会一口气打印 10 甚至 20 次,然后才轮到 "坦克"。
* 结果就是两者的打印是成大块大块交替的。
* * 加了这行代码后:
* "飞机" 打印完第 1 次,立马执行 yield() 放弃 CPU,变成大家重新抢。
* 这样 "坦克" 抢到的概率就变大了。
* * 【注意】:
* yield() 只是"礼让"或者"客气一下"。它只是让出当前的执行权,
* 下一秒 CPU 把执行权分给谁是不确定的。
* 有可能 "飞机" 刚让出 CPU,下一秒立刻又抢到了。
* 所以 yield() 只能让多个线程的执行显得"尽可能均匀"一点,但绝对保证不了"你一次我一次"的严格交替。
*/
Thread.yield();
}
}
}
1.10.1.2 ThreadDemo
java
package com.example.a02threadcase4;
public class ThreadDemo {
public static void main(String[] args) {
/*
public static void yield() 出让线程 / 礼让线程
*/
// 1. 创建两个线程对象
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
// 2. 为线程设置名字,方便在控制台观察交替现象
t1.setName("飞机");
t2.setName("坦克");
// 3. 启动线程
// 启动后,这两个线程在执行各自的 run() 方法时,
// 每次循环都会遇到 yield(),从而触发"礼让"机制。
t1.start();
t2.start();
}
}
运行结果如下:
注意 :
Thread.yield()仅令当前线程主动出让 CPU 时间片 并退回就绪状态 。由于底层的抢占式调度机制,刚出让 CPU 的线程仍可能立刻再次抢到执行权。因此,yield()只能提高交替执行的概率,不能保证绝对的先后顺序。
1.10.2 插入线程:join()
join() 表示等待指定线程执行完毕。在哪个线程中调用 join(),哪个线程就进入等待状态,直到被插入的线程结束后再继续执行。

1.10.2.1 MyThread.java
java
package com.example.a02threadcase5;
/**
* 自定义线程类
*/
public class MyThread extends Thread {
@Override
public void run() {
// "土豆" 线程的任务是循环打印 8 次
for (int i = 1; i <= 8; i++) {
System.out.println(getName() + "@" + i);
}
}
}
1.10.2.2 ThreadDemo
java
package com.example.a02threadcase5;
public class ThreadDemo {
// 注意:调用 join() 方法可能会抛出 InterruptedException 异常,需要 throws 声明或 try-catch
public static void main(String[] args) throws InterruptedException {
/*
public final void join() 插入线程 / 插队线程
*/
// 1. 创建线程对象并命名
MyThread t = new MyThread();
t.setName("土豆");
// 2. 启动 "土豆" 线程
t.start();
/*
* 3. 核心原理解析:t.join()
* * 什么是插队?
* 表示把 t 这个线程(即"土豆"),插入到【当前线程】之前。
* * 谁是当前线程?
* 这行代码是写在 main 方法里的,所以【当前线程】就是 main 线程。
* * 执行效果:
* 当代码执行到 t.join() 时,main 线程会被完全阻塞(停下来等待)。
* 它必须眼睁睁地看着 "土豆" 线程把这 8 次循环全部执行完毕。
* 只有当 "土豆" 线程彻底死掉(执行结束)后,main 线程才会苏醒,继续往下执行自己的代码。
*/
t.join();
// 4. 下面的代码运行在 main 线程当中
// 由于上面调用了 t.join(),这部分代码绝对不会和 "土豆" 线程交替打印。
// 它必须等到 "土豆@8" 打印完之后,才会开始打印 "main线程0" 到 "main线程9"。
for (int i = 0; i < 10; i++) {
System.out.println("main线程" + i);
}
}
}
运行结果如下:
总结 :yield()是"让一下",结果不确定;join()是"等你执行完",结果确定。二者都与线程调度有关,但语义完全不同。
1.11 线程的生命周期(当前周期缺少等待唤醒机制与锁机制)

注意 :
sleep()睡眠时间结束后,线程不会 立刻执行后续代码。此时它仅解除"阻塞"并重新进入就绪状态 ,必须再次参与抢夺 CPU 执行权。只有成功进入运行状态后,才会继续向下执行。
1.12 线程安全问题
1.12.1 问题引入:卖票案例
多个售票窗口共同出售 100 张票 ,本质上是多个线程共同操作同一份共享数据。如果没有任何同步控制,就可能出现重票 、超卖等数据安全问题。


1.12.2 不加锁的卖票程序

1.12.2.1 MyThread
java
package com.example.a03threadcase1;
public class MyThread extends Thread {
// 【共享数据】:必须加 static,保证三个窗口共享这 100 张票
static int ticket = 0;
@Override
public void run() {
while (true) {
if (ticket < 100) {
/*
* ==============================================================================
* 【核心原理解析:为什么会出现线程安全问题?】
* 这里的 sleep(100) 模拟了网络延迟或系统调度耗时,
* 它的作用是把瞬间完成的操作"慢放",从而 100% 暴露出以下两个严重的并发 Bug:
* * Bug 1:超卖现象(卖出第 101 张票)
* ------------------------------------------------------------------------------
* [前提] 当前 ticket = 99。
* [1] 窗口1 进入 if,判断 99 < 100 成立,准备卖票,但在此处 sleep 睡着了。
* [2] 窗口2 趁机进入,此时 ticket 仍是 99,判断 99 < 100 成立,也睡着了。
* [3] 窗口1 醒来,执行 ticket++,ticket 变为 100,打印卖出【第 100 张】。
* [4] 窗口2 醒来,由于它已经过了 if 判断,直接执行 ticket++,
* 导致 ticket 变为 101,打印卖出【第 101 张】。超卖发生!
* * Bug 2:重票现象(两个窗口卖出同一张票,且某张票凭空消失)
* ------------------------------------------------------------------------------
* [前提] 当前 ticket = 0。
* [1] 窗口1 刚执行完 ticket++(此时 ticket 变 1),还没来得及打印,CPU 被剥夺。
* [2] 窗口2 抢到 CPU,也执行 ticket++(此时 ticket 变 2),并打印:卖出【第 2 张】。
* [3] 窗口1 重新抢回 CPU,继续执行未完成的打印代码。
* 但此时内存中的 ticket 已被窗口2 改为 2,于是窗口1 也打印:卖出【第 2 张】。
* 导致 1 号票未被打印,2 号票被卖了两次!重票发生!
* ==============================================================================
*/
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 核心购票动作:票号自增 -> 打印出票信息
ticket++;
System.out.println(getName() + "正在卖第" + ticket + "张票");
} else {
// 票号达到 100,票已售空,退出死循环
break;
}
}
}
}
1.12.2.2 ThreadDemo
java
package com.example.a03threadcase1;
public class ThreadDemo {
public static void main(String[] args) {
/*
需求:
某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票
*/
// 1. 创建三个独立的线程对象,代表三个售票窗口
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
// 2. 为线程起名字,方便在控制台观察到底是哪个窗口卖出了哪张票
t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");
// 3. 开启线程
// 三个窗口同时开始工作,去执行 MyThread 里面的 run() 方法争抢着卖票
t1.start();
t2.start();
t3.start();
}
}
运行结果如下:
1.12.2.3 重票 与 超卖 现象解析
1.12.2.3.1 重票现象







1.12.2.3.2 超卖现象





1.12.3 安全问题出现的条件
线程安全问题通常需要同时满足三个条件:
- 存在多线程环境。
- 多个线程共享同一份数据。
- 多条语句共同操作这份共享数据。
结论 :卖票案例中的
ticket++并不是一个不可拆分的操作,它至少包含"读取旧值、计算新值、写回新值"等步骤。一旦线程在中途失去 CPU 执行权,就可能破坏数据一致性。
1.13 同步代码块解决线程安全问题

1.13.1 核心概念
Java 提供了 synchronized 关键字,用于给一段操作共享数据的代码加锁。
java
synchronized(锁对象) {
// 多条语句操作共享数据的代码
}

synchronized(锁对象) 相当于给代码块上锁。任意时刻,只允许一个线程持有这把锁并进入同步代码块执行。
1.13.2 正确示范:使用唯一锁对象

1.13.2.1 MyThread
java
package com.example.a03threadcase2;
public class MyThread extends Thread {
// 【共享数据】:必须加 static,保证三个售票窗口共享这 100 张票
static int ticket = 0;
// 【锁对象】:必须加 static!保证所有的线程对象共享同一把"锁"。
// 随便什么对象都可以作为锁(这里用 Object),但极其关键的是:锁必须是唯一的!
// 如果不加 static,每个窗口拿着自己的锁进房间,那就形同虚设了。
static Object obj = new Object();
@Override
public void run() {
while (true) {
/*
* ==============================================================================
* 【核心原理解析:同步代码块 (Synchronized)】
* 语法:synchronized (锁对象) { 操作共享数据的代码 }
* * 【运行机制推演】:
* 1. 当窗口1(线程1)执行到这里时,它会检查 obj 这把锁是否被别人拿走。
* 2. 如果锁还在,窗口1 就会【拿走这把锁】,然后进入花括号里的代码块执行。
* 3. 此时,即使窗口1 在里面执行了 sleep(10) 呼呼大睡,CPU 执行权被抢走,
* 当窗口2(线程2)来到这里时,发现门上的锁没了!
* 4. 窗口2 就会被【阻塞】在 synchronized 门外(进入 BLOCKED 状态),
* 只能眼巴巴地等着,绝对进不去!
* 5. 只有等窗口1 睡醒了,把票卖了(ticket++ 并打印),然后【走出】了这个 synchronized 的大括号,
* 窗口1 才会把锁还回去。
* 6. 这个时候,门外的窗口2 才能抢到这把锁,进去继续卖下一张票。
* * 【结论】:
* synchronized 强制把"判断票数"、"休眠"、"卖票"这三个步骤绑定成了
* 一个不可分割的【原子操作】。从而完美杜绝了超卖和重票。
* ==============================================================================
*/
synchronized (obj) { // 开始同步,获取锁
if (ticket < 100) {
try {
// 此时即使休眠,也不会释放 obj 锁。别的线程只能在外面干等。
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
ticket++;
System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
} else {
// 票卖完了,退出循环
break;
}
} // 同步代码块结束,立刻释放 obj 锁,让其他线程进来抢
}
}
}
1.13.2.2 ThreadDemo
java
package com.example.a03threadcase2;
public class ThreadDemo {
public static void main(String[] args) {
/*
需求:
某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票
*/
// 1. 创建三个独立的线程对象,代表三个售票窗口
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
// 2. 为线程起名字,方便在控制台观察到底是哪个窗口卖出了哪张票
t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");
// 3. 开启线程
// 三个窗口同时开始工作,去执行 MyThread 里面的 run() 方法争抢着卖票
t1.start();
t2.start();
t3.start();
}
}
运行结果如下:
1.13.3 错误示范一:同步代码块包裹范围过大
同步代码块应该只包裹真正操作共享数据的关键代码。如果把整个循环都放入同步代码块,虽然安全,但会导致一个线程长期持有锁,其他线程几乎没有执行机会。

1.13.3.1 MyThread
java
package com.example.a03threadcase3;
public class MyThread extends Thread {
static int ticket = 0; // 0 ~ 99
// 锁对象,一定要是唯一的
static Object obj = new Object();
@Override
public void run() {
/*
* 【同步代码块细节1:不要包裹循环】
* * 【运行机制推演】:
* 1. 假设 "窗口1" 最先抢到了 CPU,它顺利拿到了 obj 这把锁,走进了 synchronized 的大门。
* 2. 紧接着,窗口1 进入了 while(true) 死循环开始卖票。
* 3. 重点来了:在循环内部,哪怕窗口1 执行了 sleep(10) 睡着了,
* 由于它【还没有走到 synchronized 的右大括号 "}"】,它【绝对不会把锁交出来】!
* 4. 此时外面的 "窗口2" 和 "窗口3" 只能眼巴巴地在门外死等。
* 5. 窗口1 就这样在里面循环:醒了卖,卖了睡,不断循环...
* 6. 直到它一口气把 100 张票全部卖光(ticket >= 100),执行 else 里面的 break 跳出循环。
* 7. 出了循环后,窗口1 才终于走到结尾,释放了锁。
* 8. 等门外的 窗口2 和 窗口3 终于拿到锁进去时,发现票已经没了,只能直接下班。
* * 【最终结果】:
* 毫无并发可言,100张票全被 "窗口1" 一个人包圆了,其他线程饿死。
*/
synchronized (obj) {
while (true) {
// 原本同步代码块应该只包住这一部分,现在包在了外面
if (ticket < 100) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
ticket++;
System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
} else {
break;
}
}
} // 必须要等到100张票卖完,跳出循环走到这里,才会释放锁!
}
}
1.13.3.2 ThreadDemo
java
package com.example.a03threadcase3;
public class ThreadDemo {
public static void main(String[] args) {
/*
需求:
某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票
*/
// 1. 创建三个独立的线程对象,代表三个售票窗口
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
// 2. 为线程起名字,方便在控制台观察到底是哪个窗口卖出了哪张票
t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");
// 3. 开启线程
// 三个窗口同时开始工作,去执行 MyThread 里面的 run() 方法争抢着卖票
t1.start();
t2.start();
t3.start();
}
}
运行结果如下:
1.13.4 错误示范二:锁对象不唯一
如果每个线程拿到的锁不是同一个对象,那么同步代码块形同虚设。多个线程依然可以同时进入"看似加锁"的代码区域。

1.13.4.1 MyThread
java
package com.example.a03threadcase4;
public class MyThread extends Thread {
// 表示这个类所有的对象,都共享 ticket 数据
static int ticket = 0; // 0 ~ 99
// 原本正确的唯一锁被注释掉了
// static Object obj = new Object();
@Override
public void run() {
while (true) {
/*
* 【严重错误示范:使用了不唯一的锁对象 this】
* * 【原理解析:为什么 this 在这里没用?】
* 1. 结合测试类的代码,我们是通过 new MyThread() 创建了 t1、t2、t3 三个线程对象。
* 2. 在 Java 中,关键字 `this` 代表的是【当前调用这个方法的对象】。
* 3. 也就是说:
* - 当 t1 执行到这里时,锁对象是 t1 自己。
* - 当 t2 执行到这里时,锁对象是 t2 自己。
* - 当 t3 执行到这里时,锁对象是 t3 自己。
* 4. 【灾难后果】:
* 这就相当于这 100 张票放在一个房间里,但这个房间开了 3 扇门。
* t1 拿着第 1 扇门的钥匙,t2 拿着第 2 扇门的钥匙...
* 大家各开各的门,各进各的房间,谁也拦不住谁!
* 最终结果就是:依然会出现超卖和重票,线程安全问题完全没有解决。
*/
synchronized (this) {
// 同步代码块
if (ticket < 100) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
ticket++;
System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
} else {
break;
}
}
}
}
}
1.13.4.2 ThreadDemo
java
package com.example.a03threadcase4;
public class ThreadDemo {
public static void main(String[] args) {
/*
需求:
某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票
*/
// 1. 创建三个独立的线程对象,代表三个售票窗口
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
// 2. 为线程起名字,方便在控制台观察到底是哪个窗口卖出了哪张票
t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");
// 3. 开启线程
// 三个窗口同时开始工作,去执行 MyThread 里面的 run() 方法争抢着卖票
t1.start();
t2.start();
t3.start();
}
}
运行结果如下:
1.13.5 使用 类名.class 作为唯一锁
当共享数据是静态变量时,可以使用当前类的字节码对象作为锁。由于一个类在 JVM 中只对应一个 Class 对象,因此 类名.class 是天然唯一的。
1.13.5.1 MyThread
java
package com.example.a03threadcase5;
public class MyThread extends Thread {
// 表示这个类所有的对象,都共享 ticket 数据
static int ticket = 0; // 0 ~ 99
@Override
public void run() {
while (true) {
/*
* ==============================================================================
* 【底层原理解析:为什么 MyThread.class 是绝对安全的唯一锁?】
* * 1. 【JVM 类加载机制】:
* 当 Java 程序启动并首次使用到 MyThread 类时,JVM 的类加载器(ClassLoader)
* 会将 MyThread.class 字节码文件加载到内存(方法区/元空间)中。
* * 2. 【Class 对象的单例性】:
* 在加载的同时,JVM 会在堆内存中为这个类自动创建一个与之对应的 `java.lang.Class` 对象。
* 核心重点:无论你在主程序中 `new MyThread()` 创建了多少个线程实例(t1, t2, t3...),
* 这个代表类的 `Class` 对象在整个 JVM 运行周期内,有且仅有一个!
* * 3. 【替代静态对象】:
* 之前我们使用 `static Object obj = new Object();` 来保证锁的唯一性,
* 虽然可行,但在工程实践中容易因为开发者的疏忽(比如漏写 static)而导致锁失效。
* 直接使用 `MyThread.class`,则是利用了 Java 语言本身的底层机制来获取唯一的互斥锁。
* 这也是并发编程中处理类级别(静态级别)共享数据时,最正规的实践范式。
* ==============================================================================
*/
synchronized (MyThread.class) {
// 同步代码块,确保对共享变量 ticket 的"读-改-写"具备原子性
if (ticket < 100) {
try {
// 模拟系统调度延迟或网络 I/O 耗时
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
ticket++;
System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
} else {
break;
}
}
}
}
}
1.13.5.2 ThreadDemo
java
package com.example.a03threadcase5;
public class ThreadDemo {
public static void main(String[] args) {
/*
需求:
某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票
*/
// 1. 创建三个独立的线程对象,代表三个售票窗口
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
// 2. 为线程起名字,方便在控制台观察到底是哪个窗口卖出了哪张票
t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");
// 3. 开启线程
// 三个窗口同时开始工作,去执行 MyThread 里面的 run() 方法争抢着卖票
t1.start();
t2.start();
t3.start();
}
}
运行结果如下:
同步原则 :锁对象必须唯一 ,且同步范围应尽量精确。范围过小无法保证安全,范围过大会降低并发效率。
1.14 同步方法

1.14.1 核心概念
同步方法就是把 synchronized 关键字加到方法声明上。
java
修饰符 synchronized 返回值类型 方法名(参数列表) {
// 方法体
}
- 非静态同步方法的锁对象是
this。
java
修饰符 static synchronized 返回值类型 方法名(方法参数) {
// 方法体
}
- 静态同步方法的锁对象是
当前类.class。
1.14.2 代码示例

1.14.2.1 MyRunnable
java
package com.example.a03threadcase6;
public class MyRunnable implements Runnable {
// 【核心细节 1:为什么这里不需要加 static?】
// 因为在测试类中,我们只 new 了一个 MyRunnable 对象(mr),
// 然后把这同一个 mr 对象作为参数传给了三个 Thread。
// 这意味着三个线程天然共享这一个 mr 对象里的 ticket 成员变量。
int ticket = 0;
@Override
public void run() {
// 1. 循环
while (true) {
// 2. 调用同步方法。
// 如果方法返回 true,说明票卖完了,break 跳出循环结束线程。
// 如果返回 false,说明刚卖了一张票,继续下一次循环。
if (method()) break;
}
}
/*
* ==============================================================================
* 【底层原理解析:同步方法 (Synchronized Method)】
* * 1. 【语法与表现】:
* 直接把 synchronized 关键字加在方法返回值的前面。
* 它的作用和同步代码块一样,保证同一时刻只能有一个线程进入这个方法执行。
* * 2. 【隐式锁对象到底是谁?】:
* - 对于【非静态】的同步方法,它的锁对象是 `this`(即当前调用该方法的对象)。
* - 对于【静态】的同步方法,它的锁对象是 `当前类的字节码文件对象`(即 MyRunnable.class)。
* * 3. 【在这里为什么安全?】:
* 因为我们用的是非静态方法,锁是 `this`。
* 在主程序中,三个线程(t1, t2, t3)都是基于同一个 `mr` 对象启动的。
* 当它们调用 method() 时,这里的 `this` 全都指向堆内存中那个唯一的 `mr` 对象!
* 锁对象唯一,因此线程安全得以保证。
* ==============================================================================
*/
private synchronized boolean method() {
// 3. 判断共享数据是否到了末尾,如果到了末尾
if (ticket == 100) {
return true;
} else {
// 4. 判断共享数据是否到了末尾,如果没有到末尾,执行核心卖票逻辑
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
ticket++;
System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
return false;
}
}
}
1.14.2.2 ThreadDemo
java
package com.example.a03threadcase6;
public class ThreadDemo {
public static void main(String[] args) {
/*
需求:
某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票。
利用同步方法完成。
技巧:
同步代码块抽取成独立的方法。
*/
// 1. 【核心机制】:只创建了一个任务对象 mr
// 这个 mr 对象内部包含了那个唯一的 ticket 变量。
MyRunnable mr = new MyRunnable();
// 2. 创建三个线程对象,把同一个任务对象 mr 传给它们
// 这样 t1, t2, t3 就绑定在了同一个对象上,执行同一套逻辑,竞争同一把 this 锁。
Thread t1 = new Thread(mr);
Thread t2 = new Thread(mr);
Thread t3 = new Thread(mr);
// 3. 设置线程名称
t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");
// 4. 启动线程
t1.start();
t2.start();
t3.start();
}
}
运行结果如下:
注意 :同步方法并不是新的锁机制,本质仍然是synchronized。只是锁对象由语法自动确定,因此必须清楚它背后使用的是this还是类名.class。
1.15 Lock 锁
1.15.1 应用场景
synchronized 的加锁和释放锁是隐式完成的。为了更清晰地表达"在哪里加锁、在哪里释放锁",JDK 5 以后提供了 Lock 接口,常用实现类是 ReentrantLock。
1.15.2 核心 API
| 方法名 | 说明 |
|---|---|
void lock() |
获取锁 |
void unlock() |
释放锁 |
ReentrantLock() |
创建一个ReentrantLock的实例 |
1.15.3 代码示例

1.15.3.1 MyThread
java
package com.example.a04threadcase1;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class MyThread extends Thread {
// 共享的票数数据
static int ticket = 0;
/*
* ==============================================================================
* 【核心机制:显式锁 Lock】
* 1. Lock 是一个接口,通常我们使用它的实现类 ReentrantLock(可重入锁)。
* 2. 【极其重要】:因为我们这里是继承 Thread 类的方式,会 new 出三个不同的线程对象,
* 所以这把锁必须加上 `static`,确保这三个窗口争抢的是【同一把锁】!
* ==============================================================================
*/
static Lock lock = new ReentrantLock();
@Override
public void run() {
// 1. 死循环,不断卖票
while (true) {
// 2. 手动加锁:相当于走到这里把门锁上,其他线程只能在外面等
lock.lock();
/*
* ==============================================================================
* 【黄金法则:为什么要把释放锁放在 finally 里面?】
* try-finally 结构是使用 Lock 锁的【标准范式】。
* * 情景 1:代码正常运行完。走到 finally 释放锁,没问题。
* 情景 2:遇到 break 跳出循环。注意!在执行 break 真正跳出循环之前,
* JVM 会强制先去执行 finally 里面的代码!所以锁依然会被安全释放。
* 情景 3:【最危险的情况】如果在 try 里面代码抛出了异常(比如遇到除数为0等bug),
* 程序会直接中断向下执行,跳到 catch 里。如果不加 finally,
* 这个线程就会带着锁"死掉",导致其他等待的线程永远拿不到锁(死锁)。
* 放在 finally 里,就能保证无论发生什么,锁最终一定会被释放!
* ==============================================================================
*/
try {
// 3. 判断是否卖完
if (ticket == 100) {
break; // 即使执行 break,也会先执行下面的 finally 代码块
} else {
// 4. 模拟卖票耗时
Thread.sleep(10);
ticket++;
System.out.println(getName() + "在卖第" + ticket + "张票");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 无论如何,最终一定要手动释放锁!相当于把门打开,让其他线程进来。
lock.unlock();
}
}
}
}
1.15.3.2 ThreadDemo
java
package com.example.a04threadcase1;
public class ThreadDemo {
public static void main(String[] args) {
/*
需求:
某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票。
用 JDK5 的 Lock 接口实现线程安全。
*/
// 1. 创建三个线程对象
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
// 2. 设置线程名字
t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");
// 3. 启动线程
t1.start();
t2.start();
t3.start();
}
}
运行结果如下:
注意 :使用Lock时,释放锁必须写在finally中。否则一旦同步代码中发生异常,锁可能无法释放,其他线程将永久等待。
1.16 死锁

1.16.1 核心概念
- 死锁:两个或多个线程互相持有对方需要的资源,导致这些线程都处于等待状态,无法继续向下执行。
死锁常见产生条件如下:
- 资源有限:系统中的互斥资源(如唯一的锁对象)数量少于同时争抢的线程数,这是发生死锁的客观前提。
- 存在同步嵌套:一个线程在没有释放已持有锁的情况下,又去尝试获取其他锁(即"锁中套锁"),导致资源被长期霸占不放。
- 获取锁的顺序不一致:例如线程A先拿锁1再拿锁2,而线程B先拿锁2再拿锁1。双方互相握着对方需要的下一把锁,形成解不开的死循环等待。

1.16.2 代码示例

1.16.2.1 MyThread
java
package com.example.a04threadcase2;
public class MyThread extends Thread {
// 创建两个唯一的锁对象 A 和 B
static Object objA = new Object();
static Object objB = new Object();
@Override
public void run() {
// 1. 循环
while (true) {
/*
* ==============================================================================
* 【死锁原理解析:互相等待,谁也不让谁】
* * 【运行推演】:
* 1. 假设 "线程A" 先抢到 CPU,进入 if 判断,拿到了 objA 锁。
* 此时它准备去拿 objB 锁,但在拿之前,CPU 执行权被剥夺了!
* 2. "线程B" 抢到 CPU,进入 else if 判断,它毫无阻碍地拿到了 objB 锁。
* 紧接着,它准备去拿 objA 锁,发现 objA 已经被别人拿走了(在线程A手里),
* 于是 "线程B" 陷入阻塞状态(死等)。
* 3. 此时 CPU 执行权切回给 "线程A"。"线程A" 接着往下执行,准备拿 objB 锁,
* 发现 objB 已经被别人拿走了(在线程B手里),于是 "线程A" 也陷入阻塞。
* * 【最终结局】:
* 线程A 握着 objA 想要 objB;
* 线程B 握着 objB 想要 objA。
* 双方互相僵持,程序永远卡死在这里,这就是死锁。
* ==============================================================================
*/
if ("线程A".equals(getName())) {
synchronized (objA) {
System.out.println("线程A拿到了A锁,准备拿B锁");
// 嵌套锁:在没有释放 A 锁的情况下,尝试去获取 B 锁
synchronized (objB) {
System.out.println("线程A拿到了B锁,顺利执行完一轮");
}
} // 走到这里才会同时释放 A 和 B
} else if ("线程B".equals(getName())) {
if ("线程B".equals(getName())) { // 这里的 if 其实有点多余,因为上面已经 else if 判断过了
synchronized (objB) {
System.out.println("线程B拿到了B锁,准备拿A锁");
// 嵌套锁:在没有释放 B 锁的情况下,尝试去获取 A 锁
synchronized (objA) {
System.out.println("线程B拿到了A锁,顺利执行完一轮");
}
}
}
}
}
}
}
1.16.2.2 ThreadDemo
java
package com.example.a04threadcase2;
public class ThreadDemo {
public static void main(String[] args) {
/*
需求:
演示死锁现象
*/
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
t1.setName("线程A");
t2.setName("线程B");
t1.start();
t2.start();
}
}
运行结果如下:
最佳实践提示 :避免死锁的核心,是保证多个线程获取锁的顺序一致 ,减少同步嵌套 ,并尽量缩短持有锁的时间。
1.17 生产者和消费者模式
1.17.1 模式概述
生产者消费者模式是一个经典的多线程协作模型。它包含两类线程:
- 生产者线程:负责生产数据,并放入共享区域。
- 消费者线程:负责从共享区域取出数据并消费。
为了降低生产者与消费者之间的耦合,通常会引入一个共享数据区域,例如桌子、仓库、队列等。生产者只负责放数据,消费者只负责取数据,二者通过共享区域间接协作。
1.17.1.1 案例描述
核心思想:利用第三方共享资源(如本例中的"桌子")的状态,来控制生产者和消费者线程的交替执行。

1.17.1.1.1 理想情况
厨师(生产者)和吃货(消费者)的执行节奏完美契合:
- 厨师刚做好一碗面放到桌子上,吃货立刻拿走吃掉;
- 桌子一空,厨师马上再做一碗。两者自然交替,无需触发任何等待。


1.17.1.1.2 消费者等待
当消费者(吃货)抢占 CPU 速度过快时:
- 判断与等待 :吃货抢到执行权,发现桌子上没有食物 ,只能调用
wait()强制进入等待状态。 - 生产与唤醒 :厨师随后抢到执行权,制作食物并放到桌子上,接着调用
notify()唤醒正在等待的吃货起来吃面。

1.17.1.1.3 生产者等待
当生产者(厨师)抢占 CPU 速度过快时:
- 判断与等待 :厨师抢到执行权,发现桌子上已经有食物 (吃货还没吃),为了防止食物溢出,厨师调用
wait()进入等待状态。 - 消费与唤醒 :吃货抢到执行权,吃光桌子上的食物后,调用
notify()唤醒正在等待的厨师继续做饭。

1.17.2 等待和唤醒方法
| 方法名 | 说明 |
|---|---|
void wait() |
使当前线程等待,直到其他线程调用该对象的 notify() 或 notifyAll() |
void notify() |
唤醒正在等待对象监视器的单个线程 |
void notifyAll() |
唤醒正在等待对象监视器的所有线程 |
注意 :
wait()、notify()、notifyAll()必须由锁对象调用,并且必须写在同步代码块或同步方法中。否则会抛出IllegalMonitorStateException。
1.18 生产者消费者案例:等待唤醒机制
1.18.1 案例需求
该案例通过厨师 和吃货模拟生产者消费者模型:
- 桌子上没有食物时,厨师生产食物,吃货等待。
- 桌子上有食物时,吃货消费食物,厨师等待。
- 食物总数达到指定次数后,程序结束。
1.18.2 代码示例

1.18.2.1 Desk
java
package com.example.a05threadcase1;
public class Desk {
/*
* 作用:控制生产者(厨师)和消费者(吃货)的执行
*/
// 共享变量 1:桌子的状态(是否有面条)
// 0: 没有面条(需要厨师做,吃货等待)
// 1: 有面条(需要吃货吃,厨师等待)
public static int foodFlag = 0;
// 共享变量 2:总共要吃/做的碗数
// 控制整个程序的结束条件
public static int count = 10;
// 共享变量 3:唯一的锁对象
// 厨师和吃货必须争夺同一把锁,保证桌子状态的修改是绝对安全的
public static Object lock = new Object();
}
1.18.2.2 Cook
java
package com.example.a05threadcase1;
public class Cook extends Thread {
@Override
public void run() {
/*
* 1. 循环
* 2. 同步代码块(获取桌子的锁)
* 3. 判断共享数据是否到了末尾(到了末尾,结束循环)
* 4. 判断共享数据是否到了末尾(没有到末尾,执行核心逻辑)
*/
while (true) {
// 2. 厨师去抢桌子的锁
synchronized (Desk.lock) {
// 3. 检查总数,如果 10 碗都吃完了,厨师下班
if (Desk.count == 0) {
break;
} else {
// 4. 核心业务逻辑
// 先判断桌子上是否有食物(foodFlag == 1 表示有)
if (Desk.foodFlag == 1) {
// 【等待机制】:如果桌子上已经有面条了,厨师就不做了,进入等待状态
try {
// 调用 wait() 会立刻释放手里的 Desk.lock 锁!
// 并在原地睡着,直到被别人(吃货)唤醒。
Desk.lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
// 如果桌子上没有面条(foodFlag == 0),厨师就开始做
System.out.println("厨师做了一碗面条");
// 面条做好了,修改桌子的状态为"有面条"
Desk.foodFlag = 1;
// 【唤醒机制】:面条做好了,赶紧喊醒正在等待的吃货起来吃!
// notifyAll() 会唤醒绑定在这个锁上的所有等待线程
Desk.lock.notifyAll();
}
}
}
}
}
}
1.18.2.3 Foodie
java
package com.example.a05threadcase1;
public class Foodie extends Thread {
@Override
public void run() {
while (true) {
// 2. 吃货去抢桌子的锁
synchronized (Desk.lock) {
// 3. 检查总数,如果 10 碗都吃完了,吃货吃饱溜了
if (Desk.count == 0) {
break;
} else {
// 4. 核心业务逻辑
// 先判断桌子上是否有面条(foodFlag == 0 表示没有)
if (Desk.foodFlag == 0) {
// 【等待机制】:如果没有面条,吃货只能流着口水等待
try {
// 让当前线程(吃货)跟锁进行绑定并进入等待状态,同时释放锁!
Desk.lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
// 如果有面条,吃货开吃!
// 把要吃的总数减 1
Desk.count--;
System.out.println("吃货在吃面条,还能再吃" + Desk.count + "碗!!!");
// 吃完之后,立马唤醒正在等待的厨师继续做
Desk.lock.notifyAll();
// 修改桌子的状态,表示桌子现在空了(没面条了)
Desk.foodFlag = 0;
}
}
}
}
}
}
1.18.2.4 ThreadDemo
java
package com.example.a05threadcase1;
public class ThreadDemo {
public static void main(String[] args) {
/*
* 需求:完成生产者和消费者(等待唤醒机制)的代码
* 实现线程轮流交替执行的效果
*/
// 1. 创建线程对象
Cook c = new Cook();
Foodie f = new Foodie();
// 2. 给线程设置名字
c.setName("厨师");
f.setName("吃货");
// 3. 开启线程
// 两个线程启动后,会疯狂争抢 Desk.lock 这把锁。
// 但由于 wait() 和 notifyAll() 的配合,它们会完美地实现:做一碗,吃一碗,交替进行。
c.start();
f.start();
}
}
运行结果如下:
1.18.3 核心逻辑解析
-
锁对象统一 :
厨师和吃货必须围绕同一个
Desk.lock加锁,否则等待唤醒机制无法正确协作。 -
状态标记控制执行权 :
foodFlag表示桌子上是否有食物。生产者和消费者根据该标记决定执行、等待或唤醒对方。 -
等待会释放锁 :
调用
wait()后,当前线程会进入等待状态,并释放手中的锁,让其他线程有机会进入同步代码块。
总结 :等待唤醒机制解决的不是"数据安全"一个问题,而是"线程之间如何有序协作"的问题。
1.19 阻塞队列
1.19.1 核心概念
阻塞队列是 Java 并发包中用于线程协作的重要工具。它可以在队列满时阻塞生产者,在队列空时阻塞消费者,从而自动完成等待和唤醒。

常见阻塞队列如下:
| 阻塞队列 | 底层结构 | 特点 |
|---|---|---|
ArrayBlockingQueue |
数组 | 有界队列 |
LinkedBlockingQueue |
链表 | 通常视为无界队列,但最大容量仍受 int 最大值限制 |
核心方法如下:
| 方法名 | 说明 |
|---|---|
put(E e) |
将元素放入队列;如果队列已满,则阻塞等待 |
take() |
取出队列头部元素;如果队列为空,则阻塞等待 |
1.19.2 阻塞队列实现生产者消费者


1.19.2.1 Cook
java
package com.example.a05threadcase2;
import java.util.concurrent.ArrayBlockingQueue;
public class Cook extends Thread {
// 声明一个阻塞队列的成员变量
ArrayBlockingQueue<String> queue;
// 通过有参构造,接收主线程传过来的那个唯一的 queue
public Cook(ArrayBlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
//此处为了突出阻塞队列特性,采用了死循环,实际开发中需加入退出条件
while (true) {
// 不断的把面条放到阻塞队列当中
try {
/*
* ==============================================================================
* 【核心原理解析:put() 方法的阻塞机制】
* 1. 厨师调用 queue.put("面条") 往队列里放食物。
* 2. 如果此时队列没满(比如容量是1,当前是空的),面条顺利放入。
* 3. 【重点】:如果此时队列已经满了(里面已经有1碗面条了),
* put() 方法会在底层自动把当前线程(厨师)给【阻塞】住!
* 厨师只能在原地干等,直到吃货把面条拿走,队列有了空位,厨师才会被自动唤醒继续放。
* ==============================================================================
*
* 避坑指南:为什么控制台会连续打印两次?】
* 原因:put(放面条) 和 println(打印) 是两步独立操作。
* 现象:厨师刚放完面条,还没来得及打印,CPU 就切给吃货了。吃货光速吃完。
* 厨师下次醒来补发一句打印,加上他新做的一碗的打印,就出现了连续输出。
* 结论:只要用了阻塞队列,底层数据就是绝对安全的。别为了控制台好看去加 synchronized 大锁,那会拖垮性能!
*/
queue.put("面条");
System.out.println("厨师放了一碗面条");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
1.19.2.2 Foodie
java
package com.example.a05threadcase2;
import java.util.concurrent.ArrayBlockingQueue;
public class Foodie extends Thread {
// 声明一个阻塞队列的成员变量
ArrayBlockingQueue<String> queue;
// 通过有参构造,接收主线程传过来的那个唯一的 queue
public Foodie(ArrayBlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
// 不断从阻塞队列中获取面条
try {
/*
* ==============================================================================
* 【核心原理解析:take() 方法的阻塞机制】
* 1. 吃货调用 queue.take() 从队列里拿食物。
* 2. 如果此时队列里有面条,顺利拿走并返回。
* 3. 【重点】:如果此时队列是空的(厨师还没做),
* take() 方法会在底层自动把当前线程(吃货)给【阻塞】住!
* 吃货只能流着口水死等,直到厨师做好了面条放进队列,吃货才会被自动唤醒拿走面条。
* ==============================================================================
* 【避坑指南:交错打印是正常的】
* 同理,take(拿面条) 和 println(打印) 也可能被中途打断。
* 不要被控制台的错乱输出骗了,ArrayBlockingQueue 底层已经处理好了一切,
* 保证了同一时刻桌子上最多只有 1 碗面。
*/
String food = queue.take();
System.out.println(food);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
1.19.2.3 ThreadDemo
java
package com.example.a05threadcase2;
import java.util.concurrent.ArrayBlockingQueue;
public class ThreadDemo {
public static void main(String[] args) {
/*
* 需求:利用阻塞队列完成生产者和消费者(等待唤醒机制)的代码
* 细节:
* 生产者和消费者必须使用同一个阻塞队列
*/
// 1. 创建阻塞队列的对象
// 参数 capacity: 1 表示这个队列的容量只有 1。
// 也就是说,桌子上最多只能放 1 碗面条。
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(1);
// 2. 创建线程的对象,并把阻塞队列通过构造方法传递过去
// 这样 Cook 和 Foodie 内部使用的就是刚才创建的同一个 queue
Cook c = new Cook(queue);
Foodie f = new Foodie(queue);
// 3. 开启线程
c.start();
f.start();
}
}
运行结果如下:
最佳实践提示:在实际开发中,如果只是实现典型的生产者消费者模型,优先考虑使用阻塞队列。它把底层加锁、等待、唤醒逻辑封装起来,代码更简洁,也更不容易出错。
1.20 线程的状态与生命周期(完整版)
Java 线程在整个生命周期中会经历多种状态的复杂流转。我们可以从执行逻辑流转 与 Java 原生状态枚举两个维度来深入理解。
1.20.1 线程执行逻辑流转图
根据底层执行机制,线程的流转核心在于执行资格 与执行权的获取:

1.20.2 Java Thread.State 枚举状态
在 Java 源码层面(Thread.State),并没有独立区分"就绪 "和"运行",而是统一归纳为 6 种标准状态:
| 状态枚举 | 说明 | 触发条件 |
|---|---|---|
NEW |
新建状态 | 刚创建线程对象,尚未启动 |
RUNNABLE |
就绪状态 | 调用了 start 方法(包含了逻辑上的抢 CPU 和正在运行阶段) |
BLOCKED |
阻塞状态 | 处于无法获得锁对象的状态 |
WAITING |
等待状态 | 调用了无参的 wait 方法,无限期等待唤醒 |
TIMED_WAITING |
计时等待状态 | 调用了带有时间参数的方法(如 sleep 方法),到达时间后自动唤醒 |
TERMINATED |
结束状态 | 全部代码运行完毕,线程终止 |
1.21 小结
| 知识点 | 核心结论 |
|---|---|
| 线程创建 | 可通过继承 Thread、实现 Runnable、实现 Callable 三种方式创建线程 |
| 线程启动 | 必须调用 start() 才是真正开启新线程,直接调用 run() 只是普通方法调用 |
| 线程调度 | Java 线程调度具有随机性,优先级只影响概率,不保证顺序 |
| 线程安全 | 多线程共享数据并进行多语句操作时,必须考虑同步控制 |
| 同步代码块 | 锁对象必须唯一,同步范围应尽量精确 |
| 同步方法 | 非静态同步方法锁是 this,静态同步方法锁是 类名.class |
| Lock 锁 | 显式加锁和解锁,释放锁应写在 finally 中 |
| 死锁 | 多线程嵌套获取多把锁且顺序不一致时容易发生 |
| 生产者消费者 | 通过共享区域、等待唤醒或阻塞队列实现线程协作 |


















