被“三个线程循环打印”吊打后的深入研究报告(上篇)

🎞 故事:那是一个周三晚上的面试,从六点下班后就早早回到了出租屋,焦急的等待大厂面试官视频来电。

面试官:看你简历上写着熟悉多线程,我也不废了,先来写道编程题。

从后台拉出一道编程题:"使用三个线程,按顺序循环打印ABC......"

我: 这题熟悉,不过这题还是上一次面试的时候背过(三年前),顿时心里没了底气,但是还是强装镇定,打开编辑器,开始敲下了键盘⌨️......

在故事开始之前,做一些说明 :

📄 阅读须知:

  • 本文适合面试找工作的同学,以及对多线程感兴趣的同学。
  • 本文以面试题为契机,从原理上理解这个题目,而不是简简单单给出一个参考答案
  • 提供 8 种方法的实现,内容有点长,但力求理解到位

📋 其他说明

  • Mac 笔记本 (cpu物理核数为6;逻辑核数 12)
  • VisualVM 2.1.10 (用于观测代码运行的性能,主要是线程运行情况和 cpu 使用率)
  • 本文代码使用的代码地址:gitee.com/uzongn/thre...

于是故事就正式拉开帷幕🎥

一、十分钟逼出来的垃圾代码

✏️ 前2分钟脑子里面都是懵的, 越是懵,越是紧张! 无奈,写下一段 "垃圾" 代码。

代码逻辑:

  1. 定义公共变量 var,初始值为 1
  2. 线程 A 判断 var = 1,则打印,打印完成后修改成 var = 2; 这个时候 B 线程就能打印,打印完成后修改 var = 3; 然后线程 C 打印,完成后,再修改 var = 1
  3. 通过修改 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 线程最新的共享变量最新值"

  1. 线程 A 需要将本地内存 A 中的共享变量副本刷新到主内存中去
  2. 线程 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,发现已经想不来了

面试官:过了不久,看我确实写不出来,问了一句有思路吗?

我: 还想说些什么,突然无话可说~~~

反思:为什么会忘记呢?

  1. 有很多东西不常用,时间久了,会忘记
  2. 知识点并没有从根本上理解,只想通过背诵的领悟,但题目一变一切就 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 :

  1. 同步锁对象(图中可以看出 locko 对象)有两个队列,分别是 EntryList 和 WaitSet,一个是阻塞队列(EntryList),一个是等待队列(waitSet)。 (需要在 synchronized 代码块中)
  2. 当 synchronized 中的方法块执行到 wait() 方法,那么这个线程就会被放置到 WaitSet 集合中
  1. 当执行到 notify、notifyAll 时,WaitSet 中的等待线程会被移动到 EntryList 中。(线程状态从 waiting -> blocked)
  1. 在 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 灵魂问题

面试官: 面试官看我没有进展,说了一句;想一下能不能用其他的线程工具实现呢?

我: 额.......
上篇到处结束!

相关推荐
Violet_YSWY1 分钟前
domain文件夹
java
最贪吃的虎1 分钟前
JVM扫盲:内存模型
java·运维·jvm·后端
weixin_439706251 分钟前
如何使用JAVA进行MCP服务创建以及通过大模型进行调用
java·开发语言
AAA简单玩转程序设计1 分钟前
Java 进阶基础:这 3 个知识点,新手到高手的必经之路!
java
Maxkim2 分钟前
「✍️JS原子笔记 」一文搞懂 call、apply、bind 特征及手写实现
前端·javascript·面试
ONExiaobaijs2 分钟前
基于Spring Boot的校园闲置物品交易系统
java·spring boot·后端
Penge6667 分钟前
Go JSON 序列化大整数丢失精度分析
后端·go
爬山算法7 分钟前
Hibernate(2)Hibernate的核心组件有哪些?
java·后端·hibernate
码界奇点7 分钟前
基于Spring Boot和Vue的多通道支付网关系统设计与实现
vue.js·spring boot·后端·毕业设计·鸿蒙系统·源代码管理
IT 行者8 分钟前
Spring Boot 升级之HTTP客户端调整:HttpExchange 与 Feign Client 深度对比分析
spring boot·后端·http