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

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

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

从后台拉出一道编程题:"使用三个线程,按顺序循环打印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 灵魂问题

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

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

相关推荐
drebander17 分钟前
使用 Java Stream 优雅实现List 转化为Map<key,Map<key,value>>
java·python·list
乌啼霜满天24920 分钟前
Spring 与 Spring MVC 与 Spring Boot三者之间的区别与联系
java·spring boot·spring·mvc
tangliang_cn25 分钟前
java入门 自定义springboot starter
java·开发语言·spring boot
程序猿阿伟26 分钟前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
Grey_fantasy36 分钟前
高级编程之结构化代码
java·spring boot·spring cloud
新知图书37 分钟前
Rust编程与项目实战-模块std::thread(之一)
开发语言·后端·rust
弗锐土豆43 分钟前
工业生产安全-安全帽第二篇-用java语言看看opencv实现的目标检测使用过程
java·opencv·安全·检测·面部
Elaine20239143 分钟前
零碎04 MybatisPlus自定义模版生成代码
java·spring·mybatis
盛夏绽放1 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
小小大侠客1 小时前
IText创建加盖公章的pdf文件并生成压缩文件
java·pdf·itext