🎞 故事:那是一个周三晚上的面试,从六点下班后就早早回到了出租屋,焦急的等待大厂面试官视频来电。
面试官:看你简历上写着熟悉多线程,我也不废了,先来写道编程题。
从后台拉出一道编程题:"使用三个线程,按顺序循环打印ABC......"
我: 这题熟悉,不过这题还是上一次面试的时候背过(三年前),顿时心里没了底气,但是还是强装镇定,打开编辑器,开始敲下了键盘⌨️......
在故事开始之前,做一些说明 :
📄 阅读须知:
- 本文适合面试找工作的同学,以及对多线程感兴趣的同学。
- 本文以面试题为契机,从原理上理解这个题目,而不是简简单单给出一个参考答案。
- 提供 8 种方法的实现,内容有点长,但力求理解到位。
📋 其他说明
- Mac 笔记本 (cpu物理核数为6;逻辑核数 12)
- VisualVM 2.1.10 (用于观测代码运行的性能,主要是线程运行情况和 cpu 使用率)
- 本文代码使用的代码地址:gitee.com/uzongn/thre...
于是故事就正式拉开帷幕🎥
一、十分钟逼出来的垃圾代码
✏️ 前2分钟脑子里面都是懵的, 越是懵,越是紧张! 无奈,写下一段 "垃圾" 代码。
代码逻辑:
- 定义公共变量 var,初始值为 1
- 线程 A 判断 var = 1,则打印,打印完成后修改成 var = 2; 这个时候 B 线程就能打印,打印完成后修改 var = 3; 然后线程 C 打印,完成后,再修改 var = 1
- 通过修改 var 的值,从而实现 ABC 的循环
代码如下:
😂 这是当时的代码,原封不动的保留了下来!
Java
public class ThreadPrint {
public static int var = 1;
public static void main(String[] args) {
// 线程 A 的逻辑
new Thread(new Runnable() {
public void run() {
while (true) {
// 判断打印条件
if (var == 1) {
System.out.println("A");
// 修改 var = 2, 允许线程 B 打印
var = 2;
}
}
}
}, "A").start();
// 线程 B 的逻辑
new Thread(new Runnable() {
public void run() {
while (true) {
if (var == 2) {
System.out.println("B");
// 修改 var = 3, 允许线程 C 打印
var = 3;
}
}
}
}, "B").start();
// 线程 C 的逻辑
new Thread(new Runnable() {
public void run() {
while (true) {
if (var == 3) {
System.out.println("C");
// 修改 var = 1, 允许线程 A 打印
var = 1;
}
}
}
}, "C").start();
try {
Thread.sleep(10000000000L);
} catch (Exception e) {
}
}
}
写完运行,发现运行结果符合预期,内心平静了许多。
我: 面试官,可以了
面试官: 看了几秒,你这个有没有优化空间?
我: 内心一晃,优化空间?
✁ 事后,仔细研究这段代码,果然有问题!而且问题不小!
1.1 让代码再跑一会
让代码再多跑一会,会发现不再打印 ABC!! 但是线程依然在运行?
执行效果如下图所示:
控制台不再继续打印 ABC了
发现 ABC 三个线程依然在运行。(没有死锁)
那么问题出现在哪里呢?
1.2 到底哪个原因出了问题
分析代码, var 在每个线程看来都不满足条件。
线程 A 视角: var 不为 1
线程 B 视角: var 不为 2
线程 C 视角: var 不为 3
从线程 ABC 视角看,这一刻, var 不属于 [1,2,3]。
但凡有点 Java 经验的,肯定会说这个与 Java 线程内存模型有关系。其中一个线程修改了 var 的值,未更新到其他线程,导致其他线程读取不到新值。就像上面线程 ABC 那样,变量值出现了不可见问题。
☞ 那么 Java 内存模型又是怎么回事?,请接着往下看:
1.3 Java 内存模型
Java 线程之间的共享变量 存储在主内存中,每一个线程都有一个自己私有的本地内存 ,本地内存中存储了该变量的读/写共享变量的副本。
以一个案例来解释: " B 线程要读取到 A 线程最新的共享变量最新值"
- 线程 A 需要将本地内存 A 中的共享变量副本刷新到主内存中去
- 线程 B 去主内存读取线程 A 之前已更新过的共享变量
如下图所示:
主内存的值才是最新值。
简单来说:Java 内存模型是通过控制主内存与每个线程的本地内存之间的更新,来解决可见性问题的。
既然这样,给 var 添加一下 volatile 关键字。 它是解决共享变量的一种方式!
1.4 volatile 变量
在添加完成 volatile 关键字后再运行代码,它将一直循环打印 ABC ♻️
volatile 解决可见性算是一个常识; 但它的底层又是如何实现的呢?
✍︎对底层原理的探索是对问题的理解,而不是对问题的记忆!
1.5 volatile 背后的逻辑
volatile 是通过内存屏障(Memory Barrier)来实现的。内存屏障是一种CPU指令,它确保特定操作的执行顺序,并保证某些变量的内存可见性。
写入一个volatile
变量时,JMM(Java Memory Model)会在写操作前后分别插入StoreStore和StoreLoad屏障,确保在这次写操作之前的所有普通写操作都已完成,并且后续的读写操作都在此次写操作完成之后执行。
当一个线程定义为 volatile
变量时,所有其他线程都能立即看到这个变量的最新值。
JVM 会在每次读取 volatile
变量时,强制从主内存读取而不是从线程的工作内存(CPU 缓存)中读取。
volatile 变量在本案例中是一个赋值操作,属于原子操作,赋值操作是线程安全的; 如果对 volatile 进行++ 等操作,不具备原子性。
1.6 考察点是什么
那么这道编程题,它究竟考察的是什么:
- 线程之间的通信协作能力,看似简单其实不易,考察线程安全以及对线程工具的应用情况。
- 通过线程如何合理地利用资源
卖个关子: 为什么需要合理利用资源呢?
1.7 资源合理利用问题
面试官: 很显然,这三个线程一直处于 Running 状态,由于使用 while(true), 那么 CPU 几乎也是一直运行,
如果在线程不打印的时候,将线程状态 running 换成 sleeping、wait 等状态, 会更加节省资源。
同时想象一下,如果我们的线程数量特别多,像你这样,是不是比较消耗资源!
顿时如一声惊雷,如果我们的工作中都写这样的代码,再多的机器都不够用!
面试官: 需要它时唤醒它,不需要的时候让它休息
我: 确实有道理,像 RPC 这样的等待也是通过通知方式,事件监听方式实现。如果一直处于 Running 效率确实不高。
不妨看看上面代码运行的耗能情况。
1.8 性能消耗分析
因为是 mac 电脑,可以通过终端的 top 命令和后台监视查看资源使用情况
cpu 300%左右,相当于三个线程几乎独占三个 cpu 处理核数(逻辑上)
题外话: 运行一段时间,电脑嗡嗡发烫 :CPU在执行线程时会消耗更多电量;同时运行的线程越多,CPU的工作负载通常越大,消耗的电能也越多; 如果CPU长时间处于高负载状态,它会持续消耗大量电能,可能导致电池更快耗尽
通过 visualVM 查看资源使用情况:
三个线程运行态,使用 cpu 24.5%。( 注意:这里是所有CPU核数的占比 3(正在使用)/12(总共核数) 约 25% )
只是简单打印 ABC,需要使用这么多资源,这种写法肯定不合格!
✎ 既然在线程不需要打印的时候,处于 sleep、wait 等其他状态,那么确实有必要对线程的状态扭转做一个研究。
接下来,分析一下线程这几个状态,再看看改进方法
二、从线程状态到资源利用改进
2.1 线程状态
在 Java 中,线程状态定义了多种状态,状态之间的转换如下图所示:
简单解释一下状态情况:
状态 | 描述 | 是否使用cpu |
---|---|---|
初始(NEW) | 新建线程,未执行 start() 方法 | 否 |
运行(RUNNABLE) | 包含就绪(ready)和运行中(running)两种状态。就绪状态(ready): 未获取 cpu运行中(running):获得 cpu 时间片执行 | 线程在执行过程中需要CPU来处理指令; 但线程在运行过程中可能并不是一直占用CPU |
阻塞(BLOCKED) | 线程阻塞,通过 synchronized 关键字。,synchronized修饰的方法、代码块,同一时刻只允许一个线程执行,其他线程只能等待,进入等待线程的状态就会从RUNNABLE转换为BLOCKED状态 | 否 |
等待(WAITING) | 等待其他线程做出一些响应动作(通知或中断) | 否 |
超时等待(TIMED_WAITING) | 指定的时间后自行结束等待状态 | 否 |
终止(TERMINATED) | 线程已经执行完毕 | 否 |
对于处于 running 状态的线程,是使用 cpu 的状态。 (注意:运行期间可能并非一直独占)
从线程的状态分析,当其不执行打印的时候,进入 等待(WAITING)/ 超时等待(TIMED_WAITING) 是一种不错的选择,那么不妨朝着这个方向努力!
2.2 改变线程状态的 api
从图中,选择了几个 api 进行分析,看看使用这些 api 能不能达到效果!
api | 解释 | 影响线程 |
---|---|---|
Sleep() | 当前线程调用方法,只能是达到时间后改变状态,不能被其他线程通知结束 | 当前线程 |
wait() | 当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列;notify()/notifyAll()唤醒 | 当前线程 |
notify()/notifyAll() | 唤醒在此对象监视器上等待的单个线程 / 所有线程 | 对象锁上的等待队列线程,其他线程 |
LockSupport.park/LockSupport.unpark | 处理当前线程 | 当前线程/唤醒其他线程 |
影响其他线程,又能改变状态,下面方法是不错:
- wait、notify/notifyAll
- LockSupport.park/ LockSupport.unpark
2.3 是忘记还是不理解
在和面试官简单沟通后,应该要用通知机制来实现这个题目
😂 于是我努力回忆想用 synchronized、wait、notify,发现已经想不来了
面试官:过了不久,看我确实写不出来,问了一句有思路吗?
我: 还想说些什么,突然无话可说~~~
反思:为什么会忘记呢?
- 有很多东西不常用,时间久了,会忘记
- 知识点并没有从根本上理解,只想通过背诵的领悟,但题目一变一切就 gg 了。
😇 知识点不搞透,光考背诵记忆是记不长的,尤其是年纪大了以后~~
2.4 代码改进
通过分析,使用 wait、notifyAll 来实现,wait、notifyAll 是属于Object 中的方法。
特别注意: wait、notifyAll 必须在 synchronized 代码块中使用 (别急:原因后面会讲)
代码解释:
perl
线程A:
○ 获取锁(synchronized on lock)
○ 检查 state == 1,如果是,则打印 "A" 并将 state 设置为 2
○ 调用 notifyAll() 唤醒所有等待的线程
○ 如果 state != 1,则调用 wait() 放弃锁并进入等待状态
BC 线程相似
具体代码:
Java
public class SynNotifyWaitPrint {
// 同步对象
static final Object lock = new Object();
// 控制变量。
static volatile int state = 1;
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(new Runnable() {
public void run() {
// 需要将代码放在 synchronized 代码块里面
for (; ; ) {
synchronized (lock) {
while (state != 1) {
try {
// 当前线程进入等待状态
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("A");
state = 2;
lock.notifyAll();
}
}
}
}, "A");
Thread threadB = new Thread(new Runnable() {
public void run() {
// 需要将代码放在 synchronized 代码块里面
for (; ; ) {
synchronized (lock) {
while (state != 2) {
try {
// 当前线程进入等待状态
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("B");
state = 3;
lock.notifyAll();
}
}
}
}, "B");
Thread threadC = new Thread(new Runnable() {
public void run() {
// 需要将代码放在 synchronized 代码块里面
for (; ; ) {
synchronized (lock) {
while (state != 3) {
try {
// 当前线程进入等待状态
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("C");
state = 1;
lock.notifyAll();
}
}
}
}, "C");
threadA.start();
threadB.start();
threadC.start();
// ======= 等待结束 ======
threadA.join();
threadB.join();
threadC.join();
}
}
运行效果不错,看看 visualVM 怎么说
- cpu 的使用情况下降到 10% 左右
- 线程运行时间下降到 50% 以下
性能还是不错的。到这里,可能会认为这是比较好的答案。
但如果三个线程能够将整体时间平均下来是不是会更好一些。(每个线程:33.3% 左右占比最佳,但是上面的线程运行占比明显高于这个数字)
像上图那样,一个时间段只有一个线程在运行打印,这样是最理想的!
notifyAll 会通知唤醒所有同步等待的线程,如果我们只定向唤醒一个是不是会让程序性能更好!
于是我按照这个思路去改进我的代码,将 notifyAll 换成 notify
2.5 落地想法
将 notifyAll 改成定向通知 notify
- 线程 A 执行完通知线程 B
- 线程 B 执行完通知线程 C
- 线程 C 执行完通知线程 A
代码如下:
- 将同步对象 lock 修改成 ABC 三个对象
- 将 lock.notifyAll() 改成 notify 通知
Java
public class SynNotifyWaitPrint2 {
// 同步对象
static final Object lockA = new Object();
static final Object lockB = new Object();
static final Object lockC = new Object();
// 控制变量。
static volatile int state = 1;
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(new Runnable() {
public void run() {
// 需要将代码放在 synchronized 代码块里面
for (; ; ) {
synchronized (lockA) {
while (state != 1) {
try {
// 当前线程进入等待状态
lockA.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("A");
state = 2;
// 只通知 B 线程
lockB.notify();
}
}
}
}, "A");
Thread threadB = new Thread(new Runnable() {
public void run() {
// 需要将代码放在 synchronized 代码块里面
for (; ; ) {
synchronized (lockB) {
while (state != 2) {
try {
// 当前线程进入等待状态
lockB.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("B");
state = 3;
// 只通知 C 线程
lockC.notify();
}
}
}
}, "B");
Thread threadC = new Thread(new Runnable() {
public void run() {
// 需要将代码放在 synchronized 代码块里面
for (; ; ) {
synchronized (lockC) {
while (state != 3) {
try {
// 当前线程进入等待状态
lockC.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("C");
state = 1;
// 只通知 A 线程
lockA.notify();
}
}
}
}, "C");
threadA.start();
threadB.start();
threadC.start();
// ======= 等待结束 ======
threadA.join();
threadB.join();
threadC.join();
}
}
运行结果(报错了):
抛出异常:Exception in thread "A" java.lang.IllegalMonitorStateException
2.6 一探究竟
原因解释:出现 IllegalMonitorStateException
的原因是在调用 notify()
方法时,当前线程没有持有正确的锁。
在 Java 中,当线程调用 wait()
、notify()
或 notifyAll()
方法时,它必须拥有调用这些方法的对象的监视器锁(需要在 synchronized 方法中)
如果当前线程不是对象监视器的所有者,抛出 IllegalMonitorStateException
; 当前线程不是 lockB 的对象监视器所有者,所以报错了
ini
synchronized (lockA) {
while (state != 1) {
lockA.wait();
}
System.out.println("A");
state = 2;
lockB.notify(); // 这抛出 IllegalMonitorStateException
}
在调用同步对象的 wait() 和 notify() 系列方法时,"当前线程"必须拥有该对象的同步锁,wait() 和 notify() 系列方法需要在同步块中使用,否则 JVM 会抛出类似如下的异常
但还是有几个问题在脑海中萦绕
- 为什么一定需要 synchronized, 它起到什么作用
- notifyAll 为什么能够通知其他线程,它的实现机制是什么?
⏰ 别急,再看看原因:
wait()方法的作用:
- 当线程调用了lock(案例中的 lockA、lockB、lockC) 的 wait() 方法后,JVM会将当前线程加入 lock 监视器的WaitSet(等待集),等待被其他线程唤醒。
- 当前线程会释放 lock 对象监视器的 Owner 权利,让其他线程可以抢夺 lock 对象的监视器。
- 前线程等待,其状态变成WAITING。
只有进入同步代码块( synchronized ),获取监视器锁,只有获取了监视器锁才能调用wait方法
notify() 方法的作用:
- notify() 方法的主要作用是唤醒在等待的线程。notify() 方法与对象监视器紧密相关
- notify() 调用后,唤醒 lock 监视器等待集中的第一条等待线程;被唤醒的线程进入 EntryList,其状态从 WAITING 变成 BLOCKED
这段解释还是过于抽象了,结合图来阐述一下 wait、notify :
- 同步锁对象(图中可以看出 locko 对象)有两个队列,分别是 EntryList 和 WaitSet,一个是阻塞队列(EntryList),一个是等待队列(waitSet)。 (需要在 synchronized 代码块中)
- 当 synchronized 中的方法块执行到 wait() 方法,那么这个线程就会被放置到 WaitSet 集合中
- 当执行到 notify、notifyAll 时,WaitSet 中的等待线程会被移动到 EntryList 中。(线程状态从 waiting -> blocked)
- 在 EntryList 中的线程争夺同步锁对象的所有权。一旦获得,其状态从 blocked 变成 running 状态
在编译之后,synchronized 关键字会变成 monitorenter 指令和 monitorexit 指令。在程序运行时,monitorenter 指令会被解释成获取监视器锁,monitorexit 指令会被解释成释放获取的监视器锁。
wait与notify方法的交互逻辑如下:
- synchronized 包裹的代码块解析成 monitorenter、monitoerenter
- 获取监视器锁
- 执行 wait() 方法后,就按照如下图 1、2、3、4 往下执行了
简单梳理一下:
wait()
和notify()
方法的底层调用机制是通过对象的监视器(Monitor)实现的。 从而实现对线程的控制- Java虚拟机给每个对象和class字节码都设置了一个监听器 Monitor,用于检测并发代码的重入
2.7 灵魂问题
面试官: 面试官看我没有进展,说了一句;想一下能不能用其他的线程工具实现呢?
我: 额.......
上篇到处结束!