并发2️⃣Java线程①Thread API、两阶段终止模式
1、Thread
注:Thread 类实现了 Runnable 接口。
1.1、创建线程(❗)
- 通常以匿名内部类的形式创建线程,且通常以 lambda 表达式简化。
- 建议为每个线程指定线程名。
- 有 3 种创建线程的方式。
1.1.1、Thread
创建 Thread 匿名内部类,实现 run()
方法。
Thread t1 = new Thread("t1") {
@Override
public void run() {
System.out.println("hello");
}
};
1.1.2、Runnable & Thread(❗)
策略模式:任务和线程分开创建
创建 Runnable 匿名内部类,作为 Thread 构造方法参数。
-
任务:创建 Runnable 匿名内部类,实现 run() 方法。
-
线程:创建 Thread 类,参数依次为 Runnable 任务,线程名。
// 任务 Runnable target = new Runnable() { @Override public void run() { System.out.println("hello"); } }; // 线程 Thread t2 = new Thread(target, "t2");
lambda 简化
// 简化
target = () -> System.out.println("hello");
t2 = new Thread(target, "t2");
// 更简化
t2 = new Thread(() -> System.out.println("hello"), "t2");
run() 源码
Thread 始终执行的是自定义的 run() 逻辑。
-
target 非空:创建 Thread 时传入了 Runnable 参数,则执行 Runnable 的方法。
-
target 为空:创建 Thread 时没有传 Runnable 参数(重写
run()
方法),则执行重写后的方法。private Runnable target; @Override public void run() { if (target != null) { target.run(); } }
1.1.3、FutureTask & Thread
策略模式:任务和线程分开创建
-
任务:FutureTask
-
实现了 RunnableFuture 接口(间接实现 Runnable 接口),可作为 Thread 的构造方法参数传入。
-
FutureTask 实例化时需传入 Callable 参数,用于处理有返回结果的情况
-
泛型 FutureTask 指定方法返回值类型。
-
Callable 接口:相比 Runnable 的
run()
,call()
具有返回值。@FunctionalInterface public interface Callable<V> { V call() throws Exception; }
-
-
线程:创建 Thread 类,参数依次为 Runnable 任务,线程名。
示例
Callable<String> callable = new Callable<String>() {
@Override
public String call() throws Exception {
return "hello";
}
};
FutureTask<String> task = new FutureTask<>(callable);
Thread t3 = new Thread(task, "t3");
t3.start();
// 主线程种获取task执行结果
String result = task.get();
1.2、查看进程、线程
Java
jps命令
:查看所有 Java 进程。jstack <PID> 命令
:查看指定 Java 进程的所有线程。jconsole
:查看指定 Java 进程中线程的运行情况(可视化界面)
Windows
- 任务管理器
tasklist命令
:查看所有进程taskkill命令
:杀进程
Linux
ps -fe
:查看所有进程。ps -fT -p <PID>
:查看指定进程的所有线程。top -H -p <PID>
:查看指定进程的所有线程。kill
:杀进程。
1.3、线程运行原理(❗)
涉及 JVM 知识
- 运行时数据区 - 2.2:虚拟机栈、程序计数器
- 字节码技术
1.3.1、虚拟机栈
- 虚拟机栈: 每个线程运行时所需的内存,由多个栈帧(Frame)组成。
- 栈帧:对应每个方法调用时所占内存。
- 存储局部变量表、操作数栈、动态链接、方法出口等信息
- 每个线程只能有一个活动栈帧,对应当前正在执行的方法。
1.3.2、上下文切换
Thread Context Switch
内核在 CPU 上对线程进行切换,即任务切换。
可能的原因
- 回到可运行状态
- 线程的 CPU 时间片用完(回顾 并行,线程轮流执行)
- 有更高优先级的线程需要运行。
- 进入阻塞状态
- 垃圾回收:GC 线程会触发 STW(stop-the-world),暂停所有用户线程。
- 线程自身调用
sleep
、yield
、wait
、join
、park
、synchronized
、lock
等方法。
当 Context Switch 发生时,操作系统需要保存当前线程的状态,并恢复另一个线程的状态。
状态:包括程序计数器、每个栈帧的信息等
对应 Java 的程序计数器:
- 若当前执行的是 JVM 的方法,程序计数器保存当前执行指令的地址。
- 若当前执行的是 native 方法,程序计数器为空。
Context Switch 频繁发生会影响性能。
1.4、守护线程
- 通常,Java 进程会等待所有线程结束后停止。
- 守护线程:所有其它非守护线程结束时,守护进程随即停止(即使还有代码没执行完)
- 举例
- 垃圾回收线程
- Tomcat 中的 Acceptor 和 Poller 线程
2、Thread API 常用方法(❗)
获取属性的方法
getId()
:获取线程 ID(ID 具有唯一性)getName()
:获取线程名setName(String)
:修改线程名getState()
:获取线程状态- Java 规定 6 个线程状态,以枚举类的形式表示。
NEW
、RUNNABLE
、BLOCKED
、WAITING
、TIMED_WAITING
、TERMINATED
isAlive()
:判断线程是否存活(线程执行完任务则死亡)currentThread()
:static,表示当前线程。
2.1、执行:start & run
- start():启动一个新线程(进入就绪状态)。
- run():线程被 CPU 调度后,执行任务。
调用 start() 和 run() 的区别
-
线程 t1:先休眠 2 秒,再打印输出语句。
-
主线程:分别调用 start() 和 run(),方法调用后在主线程打印输出语句。
private static final Logger logger = Logger.getLogger(Join.class); public static void foo() { Thread t1 = new Thread(() -> { try { LogUtils.debug("sleeping..."); Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } LogUtils.debug("run()执行"); }, "t1"); // 调用两个方法之一 // t1.start() // t1.run() LogUtils.debug("主线程方法执行"); }
结果
-
start()
-
启动线程 t1,由线程 t1 执行 run() 方法。
-
run() 方法异步调用(主线程无需等待 run() 执行结束)
-
-
run()
-
没有启动线程 t1,主线程直接调用 run() 方法。
-
run() 方法同步调用(主线程需等待 run() 执行结束)
-
2.2、睡眠:sleep & yield
2.2.1、sleep
使当前线程从
Running
进入Timed Waiting
状态(阻塞),不会让出时间片。
- 线程睡眠结束后,进入就绪状态。
- 睡眠中的线程被中断时,抛出
InterruptedException
异常。
JUC 的 TimeUnit 枚举工具类
可理解为 状态模式:每个枚举相当于一个状态,执行当前状态的不同操作。
-
定义了不同时间单位的枚举(纳秒、微秒、毫秒、秒、分钟、小时、天)
-
定义了基于时间的操作方法:如 timedwait()、timedJoin()、sleep()。
-
建议使用 TimeUnit 工具类,提高可读性。
// 单位毫秒 Thread.sleep(1); Thread.sleep(1000); Thread.sleep(1000 * 60); Thread.sleep(1000 * 60 * 60); // 可读性更高 TimeUnit.MILLISECONDS.sleep(1); TimeUnit.SECONDS.sleep(1); TimeUnit.MINUTES.sleep(1); TimeUnit.HOURS.sleep(1);
sleep() 工具类
若需频繁使用 sleep() 功能,建议封装工具类,提前捕获方法异常。
public class SleepUtils {
public static void sleepSeconds(int n) {
Thread thread = Thread.currentThread();
try {
TimeUnit.SECONDS.sleep(n);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void sleepMillis(int n) {
Thread thread = Thread.currentThread();
try {
TimeUnit.MILLISECONDS.sleep(n);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2.2.2、yield
使当前线程从
Running
进入Runnable
(就绪),让出 CPU 时间片。
- 调用 yield() 的线程会让出 CPU 时间片,但可能再次抢到。
- 可能没有其它线程,yield() 无效。
2.3、优先级:prioritiy
提示(hint)调度器优先调度该线程。
仅仅是一个提示,调度器可以忽略它。
-
多线程下,优先级高的线程,获得 CPU 时间片的几率更高。
-
范围:1 到 10 的整数,默认 5。
-
获取:
getPriority()
-
设置:
setPriority(int)
private int priority; public final static int MIN_PRIORITY = 1; public final static int NORM_PRIORITY = 5; public final static int MAX_PRIORITY = 10; public final int getPriority() { return priority; } public final void setPriority(int newPriority) { ... }
2.4、等待:join
等待线程执行完毕(死亡)
-
join 状态的线程被中断时,抛出
InterruptedException
异常。 -
可读性:TimeUnit 类的
timedJoin(Thread, long)
方法
(注意:此方法的0
表示不等待,与 Thread 的 join() 有所区别) -
重载方法
-
join(long)
:等待线程死亡,指定最大等待毫秒数,0 表示永远等待。 -
join()
:等待,直到线程死亡。public final synchronized void join(long millis) throws InterruptedException { ... } public final void join() throws InterruptedException { join(0); }
-
示例:没有使用 join()
以下代码打印
r = 0
,而不是预期的 20。
-
可通过让主线程 sleep() 的方式,等待 t1 线程执行结束。
-
缺点:不确定 t1 线程的任务执行多久,无法提前确定睡眠时间。
static int r = 0; public static void main(String[] args) { Thread t1 = new Thread(() -> { SleepUtils.sleepSeconds(2); r = 20; }, "t1"); t1.start(); System.out.println("r = " + r); }
示例:使用 join()
以下代码打印
r = 20
主线程等待 t1 线程死亡之后才执行。
static int r = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
SleepUtils.sleepSeconds(2);
r = 20;
}, "t1");
t1.start();
try {
TimeUnit.SECONDS.timedJoin(t1, 0);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("r = " + r);
}
2.5、中断:interrupt
Java 中断机制:设置中断标志以提示线程 t 即将被中断,由 t 自身进行中断。
(而不是直接中断)
2.5.1、中断
interrupt()
设置中断标志,提示线程即将被中断。
- 正常:试图中断运行状态的线程(含
park
),设置中断标志为true
。 - 异常:试图中断非运行状态的线程 t(
sleep
、wait
、join
) 。- 结果:t 抛出
InterruptedException
异常,清除中断标记(true →false
) - 原因:非运行状态的线程不需要被中断。
- 结果:t 抛出
测试:分别调用 t1() 和 t2(),中断线程并查看中断标志。
主线程需休眠一会,否则与线程 t 并发执行,看不到效果。
public static void t1() {
Thread t1 = new Thread(() -> {
while (true) {
}
}, "t1");
t1.start();
SleepUtils.sleepMillis(10);
// 中断
t1.interrupt();
LogUtils.debug(t1.isInterrupted());
}
public static void t2() {
Thread t2 = new Thread(() -> SleepUtils.sleepSeconds(10), "t2");
t2.start();
SleepUtils.sleepMillis(10);
// 中断
t2.interrupt();
LogUtils.debug(t2.isInterrupted());
}
结果
-
运行的 t1:正常,设置中断标志(true)
-
睡眠的 t2:异常,清除中断标志(设为 false)
2.5.2、判断是否中断
判断当前线程是否被中断
isInterrupted()
:不清除中断标志。interrupted()
:静态方法,会清除中断标志。
源码:二者都调用了带参的本地方法,参数为
ClearInterrupted
。
顾名思义,ClearInterrupted
就是清除中断标志。
public boolean isInterrupted() {
return isInterrupted(false);
}
public static boolean interrupted() {
return currentThread().isInterrupted(true);
}
private native boolean isInterrupted(boolean ClearInterrupted);
2.5.3、InterruptedException
- 试图中断非运行状态的线程 t(
sleep
、wait
、join
),会抛出InterruptedException
异常,并清除中断标记。 - 通常,只要声明为抛出
InterruptedException
的方法,都可以被interrupt()
中断。
2.6、过时方法
存在安全性问题,容易破坏同步代码块。
stop()
:停止线程。suspend()
:暂停线程。resume()
:恢复线程。
3、模式:两阶段终止
3.1、Java 中断机制(❗)
Two Phase Termination
不是直接中断某个线程,而是 “优雅” 地中断。
- 设置中断标志,以提示线程 t 即将被中断。
- 线程 t 判断中断标志,得知即将被中断,处理完相关工作后终止(如释放锁)。
实现方式
- 利用
isInterrupted()
volatile
+ 停止标记
3.2、isInterrupted() 实现
案例:模拟后台监控,每 1 秒打印一次输出语句(或是打印当前时间、处理业务等)
3.2.1、流程分析
循环
-
判断中断标志:即
isInterruptrd()
,判断是否被中断。- true:处理后事(如保存结果、释放锁等),结束线程。
- false:执行任务。
-
执行任务:执行结束后睡眠 1 秒,捕获睡眠期间的异常。
- 正常:进入下一轮循环。
- 异常:睡眠线程被中断会清除中断标志,因此需要手动重新设置中断标志,进入下一轮循环。
3.2.2、实现
获取当前线程,进入循环
-
判断中断标志:若被中断则处理后事并结束进程。
-
执行任务:执行结束后睡眠 1 秒,捕获睡眠期间的异常。
-
睡眠期间被中断:中断标志被清除,需要重新设置中断标志。
public class TwoPhaseTermination { private Thread thread; public void turnOn() { thread = new Thread(() -> { Thread current = Thread.currentThread(); while (true) { if (current.isInterrupted()) { // 模拟:处理后事 LogUtils.debug("terminating..."); // 处理完毕后退出 LogUtils.debug("Exit"); break; } // 执行任务 LogUtils.debug("working..."); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { // 设置中断标志 current.interrupt(); e.printStackTrace(); } } }, "t"); thread.start(); } public void turnOff() { thread.interrupt(); } }
3.2.3、测试
① main() 测试
开启线程,5 秒后关闭线程。
-
public static void main(String[] args) { TwoPhaseTermination tpt = new TwoPhaseTermination(); tpt.turnOn(); SleepUtils.sleepSeconds(5); tpt.turnOff(); }
② JUnit 测试(❗)
代码和测试效果,与 main() 测试相同。
-
但是,JUnit 测试多线程存在一个问题:
system.exit()
-
将以上代码中的
sleep(5)
去掉,方法会直接结束,线程 t1 不会按预期运行。
分析:
org.junit.runner.JUnitCore
JUnit 测试的启动者,所有的 JUnit 测试都从 JUnitCore 的主方法开始。
对 JUnitCore 主方法的大致理解:
-
执行了测试代码的逻辑,获得执行结果。
-
判断是否成功执行,执行
System.exit()
-
作用:终止当前虚拟机。
-
参数:0 表示正常终止,非零表示异常终止。
public static void main(String... args) { Result result = (new JUnitCore()).runMain(new RealSystem(), args); System.exit(result.wasSuccessful() ? 0 : 1); }
-
结论
- JUnit 执行完测试代码逻辑后执行
System.exit()
,虚拟机被终止,因此线程 t1 也被强制终止。 - 加入了
sleep(5)
,主线程会休眠 5 秒,休眠结束后才会执行测试方法中接下来的逻辑。 - 建议:不要在 JUnit 中测试多线程。
4、应用:统筹
4.1、说明
案例:泡茶,两个人负责前期工作
要求工作总耗时最短。
-
工作:洗水壶(1min)、茶壶(1min)、茶杯(1min)、拿茶叶(1min)、烧开水(10min)。
-
工序:洗水壶完成后才能烧开水,所有工序完成才能泡茶。
-
思路:使用 2 个线程模拟 2 个人,使用
sleep()
模拟工作耗时。-
线程 t1:洗水壶、烧开水(有序)
-
线程 t2:洗茶壶茶杯、拿茶叶(无序)
-
线程 t1 或 t2 泡茶。
-
注意
- 线程 t1 开始烧水后也可以做 t2 的部分工作,甚至可以一个线程完成,此处仅探讨两个线程的简单情况。
- 本题有多种解法:join、wait/notify、第三者协调。
4.2、join 实现
t2 使用 join()
等待 t1 执行结束
private static void makeTea() {
Thread t1 = new Thread(() -> {
LogUtils.debug("洗水壶");
SleepUtils.sleepSeconds(1);
LogUtils.debug("烧开水");
SleepUtils.sleepSeconds(10);
}, "t1");
Thread t2 = new Thread(() -> {
LogUtils.debug("洗茶壶茶杯、拿茶叶");
SleepUtils.sleepSeconds(3);
try {
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
LogUtils.debug("泡茶");
}, "t2");
t1.start();
t2.start();
}
结果