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

书接上篇:juejin.cn/post/742655... (建议先读)

三、如何才能实现设想

🧐 对于按照线程均分思想,如何才能实现了?那就看看 ReentrantLock 吧

ReentrantLock: 可以创建多个Condition对象,每个Condition对象可以绑定一个或多个线程,实现对不同线程的精确控制,用于实现线程间的条件等待和唤醒

ReentrantLock、Condition 完全切合我的想法。 那么用 ReentrantLock 实现看看。

Java 复制代码
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockPrint {
    static volatile int state = 1;
    // 所有同步逻辑需要放在 lock.lock ~~lock.unlock 之间。类似于 Synchronized 
    static ReentrantLock lock = new ReentrantLock();
    // 每个线程绑定一个条件
    static Condition conditionA = lock.newCondition();
    static Condition conditionB = lock.newCondition();
    static Condition conditionC = lock.newCondition();

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
            for (; ; ) {
                lock.lock();
                try {
                    while (state != 1) {
                        try {
                            // 标配,如果不符合则 await()
                            conditionA.await();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    System.out.println("A");
                    state = 2;
                    // 唤醒线程 B
                    conditionB.signal();
                } finally {
                    lock.unlock();
                }
            }
        }, "A");

        Thread threadB = new Thread(() -> {

            for (; ; ) {
                lock.lock();
                try {
                    while (state != 2) {
                        try {
                            conditionB.await();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    System.out.println("B");
                    state = 3;
                    // 唤醒线程 C
                    conditionC.signal();
                } finally {
                    lock.unlock();
                }
            }

        }, "B");

        Thread threadC = new Thread(() -> {
            for (; ; ) {
                lock.lock();
                try {
                    while (state != 3) {
                        try {
                            conditionC.await();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    System.out.println("C");
                    state = 1;
                    // 唤醒线程 C
                    conditionA.signal();
                } finally {
                    lock.unlock();
                }
            }

        }, "A");

        threadA.start();
        threadB.start();
        threadC.start();

        threadA.join();
        threadB.join();
        threadC.join();
    }

}

通过上面的方式,实现了构想。接下来看看性能使用情况:

  • CPU 使用率大约 10%
  • 线程运行时间下降到平均值

从结果看是不错的。 当线程不打印时,停止运行,需要时被唤醒执行打印,那么 ReentrantLock 是如何实现这个能力的呢?

3.2 ReentrantLock 原理是什么

ReentrantLock 实现了 Lock 接口获取锁与释放锁的相关方法,定义了同步器 Sync。

Sync继承了AbstractQueuedSynchronizer,是 AQS 的具体实现。

Sync有两个子类:NonfairSync(非公平锁同步器)与FairSync(公平锁同步器)。

NonfairSync与FairSync重写了lock方法与tryAcquire方法。

ReentrantLock 借助了 AQS 的能力(AQS 被使用得太多,后续会单独章节讲解),从而实现了上面功能。

当我再一次看线程的状态流转时,还有一组 API 可能也适合需求,那就是 LockSupport 类中的接口。

Java 复制代码
import java.util.concurrent.locks.LockSupport;

public class LockSupportPrint {
    // 定义三个常量。 通过  LockSupport.unpark() 唤醒不同的线程从而实现循环
    public static Thread threadA, threadB, threadC;

    public static void main(String[] args) throws InterruptedException {
        threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                for (; ; ) {
                    // 这里的代码和 B、C 有一点点差异
                    // System.out.println("-----------");
                    System.out.println("A");
                    // 唤醒 B 线程
                    LockSupport.unpark(threadB);
                    LockSupport.park();

                }
            }
        }, "A");

        threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                for (; ; ) {
                    // 当前线程等待,直到被唤醒
                    LockSupport.park();
                    System.out.println("B");
                    // 唤醒线程 C
                    LockSupport.unpark(threadC);
                }
            }
        }, "B");

        threadC = new Thread(new Runnable() {
            @Override
            public void run() {
                for (; ; ) {
                    // 当前线程等待,直到被唤醒
                    LockSupport.park();

                    System.out.println("C");
                    // 唤醒线程 A
                    LockSupport.unpark(threadA);
                }

            }
        }, "C");

        threadA.start();
        threadB.start();
        threadC.start();

        // main 等 ABC 线程执行结束
        threadA.join();
        threadB.join();
        threadC.join();
    }
}

LockSupport 使用 private static final sun.misc.Unsafe UNSAFE 能力实现线程挂起和唤醒。

LockSupport 代码是最好理解的,逻辑清晰易懂,是不错的方式。看看性能消耗情况:

  • CPU 使用下降,约 10%
  • 线程运行时间下降了,33.3% 以下

显然 LockSupport 是一个很好的方案!

到这里我发现了一个规律:如果在当前线程中能让其线程阻塞,那么循环打印 ABC 是一件理论上可行的事情!

带着猜想,看了一下并发包中的一些工具类,于是发现了一些有趣的代码

四、阻塞等待规律探究落地

按照阻塞等待的思路,来实现代码

先前写了《闲谈一下 Semaphore》和 《闲谈一下 CountDownLatch》,那么用 semaphore 、countDownLatch 试试看。

4.1 Semaphore (信号量)

关键 api:acquire() 如果不能获取,则等待

代码实现:

  • 定义三个 Semaphore, 每个线程绑定一个
  • 每个线程通过 acquire() 方法等待
  • 通过上一个线程 调用 release() 释放许可,让本线程获取执行机会。
  • 初始化 Semaphore 为 0,都等待; 在 main 线程 semaphoreA.release(), 使用线程开始运行
Java 复制代码
import java.util.concurrent.Semaphore;

public class SemaphorePrint {

    static Semaphore semaphoreA = new Semaphore(0);
    static Semaphore semaphoreB = new Semaphore(0);
    static Semaphore semaphoreC = new Semaphore(0);

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(new Runnable() {
            public void run() {
                for (; ; ) {
                    try {
                        // 阻塞等待,知道获取许可
                        semaphoreA.acquire();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println("A");
                    // 让线程B 获得许可
                    semaphoreB.release();
                }
            }
        }, "A");

        Thread threadB = new Thread(new Runnable() {
            public void run() {
                for (; ; ) {
                    try {
                        // 阻塞等待,知道获取许可
                        semaphoreB.acquire();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println("B");
                    // 让线程C 获得许可
                    semaphoreC.release();
                }
            }
        }, "B");

        Thread threadC = new Thread(new Runnable() {
            public void run() {
                for (; ; ) {
                    try {
                        // 阻塞等待,知道获取许可
                        semaphoreC.acquire();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println("C");
                    // 让线程A 获得许可
                    semaphoreA.release();
                }
            }
        }, "C");

        // 启动
        semaphoreA.release();

        threadA.start();
        threadB.start();
        threadC.start();

        threadA.join();
        threadB.join();
        threadC.join();
    }
}

分析 cpu 使用情况、以及 running 使用时间

性能消耗:

  • CPU 使用下降,约 10%
  • 线程运行时间下降了,33.3% 以下

验证结果,发现效果还是不错。接下来看看 CountDownLatch

Java 复制代码
import java.util.concurrent.CountDownLatch;

public class CountDownLatchPrint {

    // 通过三个 CountDownLatch 来控制打印
    static CountDownLatch countDownLatchA = new CountDownLatch(1);
    static CountDownLatch countDownLatchB = new CountDownLatch(1);
    static CountDownLatch countDownLatchC = new CountDownLatch(1);

    public static void main(String[] args) throws InterruptedException {

        Thread threadA = new Thread(() -> {
            for (; ; ) {
                try {
                    // 等待
                    countDownLatchA.await();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                // 重置 countDownLatchA
                countDownLatchA = new CountDownLatch(1);

                // System.out.println("-----------");
                System.out.println("A");

                countDownLatchB.countDown();
            }
        }, "A");


        Thread threadB = new Thread(() -> {
            for (; ; ) {
                try {
                    // 等待
                    countDownLatchB.await();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                // 重置 countDownLatchB
                countDownLatchB = new CountDownLatch(1);

                System.out.println("B");

                countDownLatchC.countDown();
            }
        }, "B");


        Thread threadC = new Thread(() -> {
            for (; ; ) {
                try {
                    // 等待
                    countDownLatchC.await();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                // 重置 countDownLatchC
                countDownLatchC = new CountDownLatch(1);

                System.out.println("C");

                countDownLatchA.countDown();
            }
        }, "C");

        threadA.start();
        threadB.start();
        threadC.start();

        // 启动打印
        countDownLatchA.countDown();

        threadA.join();
        threadB.join();
        threadC.join();

    }
}

分析性能消耗:

  • cpu 使用率低
  • 线程运行时间低

唯一不足:就是需要反复创建新的 countDownLatch 对象


4.3 CyclicBarrier

CyclicBarrier 和 CountDownLatch 很相似。 CyclicBarrier 当计数器为 0 后会重置为最初值,所以不用重新复制。

代码如下:

Java 复制代码
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierPrint {
    
    // 控制变量
    static volatile int state = 1;
    // 可以循环使用
    static CyclicBarrier cyclicBarrier = new CyclicBarrier(3);
    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
            for (; ; ) {
                while (state == 1) {
                    // System.out.println("-------");
                    System.out.println("A");
                    state = 2;
                    try {
                        cyclicBarrier.await();
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        }, "A");

        Thread threadB = new Thread(() -> {
            for (; ; ) {
                while (state == 2) {
                    System.out.println("B");
                    state = 3;
                    try {
                        cyclicBarrier.await();
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        }, "B");

        Thread threadC = new Thread(() -> {
            for (; ; ) {
                while (state == 3) {
                    System.out.println("C");
                    state = 1;
                    try {
                        cyclicBarrier.await();
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        }, "C");

        threadA.start();
        threadB.start();
        threadC.start();

        threadA.join();
        threadB.join();
        threadC.join();
    }

}

但是 CyclicBarrier 比较特殊。分析代码执行情况:

当 state = 1 的时候; A 线程停止; BC都在运行

当 state = 2 的时候;B 线程停止,同时 A 线程依然停止;只有 C继续运行

当 state = 3 的时候,满足 CyclicBarrier 的机制; ABC 三个线程又恢复运行

因此从理论上:C 线程 running 状态时间最长 (目前只能使用 CyclicBarrier 写出这样的代码)

性能消耗情况:

  • CPU 使用 17% 左右。即 2 核
  • C 线程使用占比100%,其他线程低于50%

相比其他几个工具类,耗能偏高了。


难道阻塞就可以?

那么试试阻塞队列吧

4.4 阻塞队列

以 LinkedBlockingDeque 为例子,核心api:

  • put(E e):如果双端队列未满,立即插入元素 e;如果双端队列已满, 阻塞 等待直到有空间
  • take():如果双端队列非空,移除并返回头部元素;如果双端队列为空, 阻塞 等待直到有元素可用

代码如下:

  • 设置每个阻塞队列容量为 1
  • A 线程往 B的阻塞队列插入元素;B 往 C 插入元素;C 往 A 插入元素。 每个线程从各自的阻塞队列取元素。如果没有元素就阻塞。
  • 如果队列中有1个元素就阻塞
Java 复制代码
import java.util.concurrent.LinkedBlockingDeque;

public class LinkedBlockingDequePrint {
    // 使用阻塞队列完成
    static LinkedBlockingDeque<String> dequeA = new LinkedBlockingDeque<>(1);
    static LinkedBlockingDeque<String> dequeB = new LinkedBlockingDeque<>(1);
    static LinkedBlockingDeque<String> dequeC = new LinkedBlockingDeque<>(1);

    public static void main(String[] args) throws InterruptedException {

        Thread threadA = new Thread(() -> {
            for (; ; ) {
                try {
                    // 阻塞获取
                    String take = dequeA.take();
                    System.out.println("----------");
                    System.out.println(take);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                try {
                    // 放入 B 队列
                    dequeB.put("B");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }

        }, "A");

        Thread threadB = new Thread(() -> {
            for (; ; ) {
                try {
                    String take = dequeB.take();
                    System.out.println(take);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                try {
                    // 放入 C 队列
                    dequeC.put("C");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }

        }, "B");

        Thread threadC = new Thread(() -> {
            for (; ; ) {
                try {
                    String take = dequeC.take();
                    System.out.println(take);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                try {
                    dequeA.put("A");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

            }
        }, "C");

        threadA.start();
        threadB.start();
        threadC.start();

        // 启动
        dequeA.put("A");

        threadA.join();
        threadB.join();
        threadC.join();
    }
}

SynchronousQueue 、DelayQueue 等阻塞队列的相关阻塞方法也是可以的

耗能分析

  • cpu 使用率小于 10%
  • 线程 running 时间小于 30%

通过阻塞方式,可以协助线程完成等待。通过条件的修改,使得多个线程可以按照特定顺序进行打印。

到目前为止,已经初步得到一个结论。可以阻塞其他线程都可以被用来实现 ABC 的循环打印。

特别说明:本次比较是基于上面的代码,有可能你通过这些工具能将代码写得更好!

4.5 方法汇总比较

简单比较

注意上面的写法不是标准答案,仅作参考。

五、最后

5.1 其他问题

问题一:Thread 调用 run() 方法而不是 start() 方法会怎么样?

答:可以调用,调用 run() 方法是同步调用,属于同一个线程,而 start() 是另外一个线程去执行方法,是两个线程

问题二: Thread 调用两次 start() 方法会怎么样?

答:会报错,每一个阶段都有一个自己的线程状态,调用 start() 方法会将线程状态从 new 编程 runnable。每次调用 start() 都会进行状态检测,所以报错

Java 复制代码
if (threadStatus != 0)
        throw new IllegalThreadStateException();

5.2 总结

通过深入的了解,以及各种性能的比较,对线程之间的协作有了更深入的理解;如果再遇到这道题的时候,至少不会手足无措了。

📽 故事结尾:很遗憾,那次面试很糟糕,但下次应该不会了。

相关推荐
on the way 1238 分钟前
java.io.IOException: Too many open files
java·开发语言
大鲤余19 分钟前
rust 中if let、match -》 options和Result枚举类型
开发语言·后端·rust
m0_5719575823 分钟前
Java | Leetcode Java题解之第538题把二叉搜索树转换为累加树
java·leetcode·题解
CAORENZHU25 分钟前
Java NIO 核心知识.下
java
程序员徐师兄27 分钟前
基于 JavaWeb 的宠物商城系统(附源码,文档)
java·vue·springboot·宠物·宠物商城
小鸡脚来咯1 小时前
java 中List 的使用
java·开发语言
南棱笑笑生1 小时前
20241105编译Rockchip原厂的Android13并给荣品PRO-RK3566开发板刷机
java·开发语言·前端
Erorrs1 小时前
Android13 系统/用户证书安装相关分析总结(二) 如何增加一个安装系统证书的接口
android·java·数据库
余华余华1 小时前
Ngnix
java
昂子的博客1 小时前
通过mybatis和mybatis plus 实现用户注册功能和基础的增删改查
java·开发语言·mybatis