面试热点详解 —— 三个线程轮流打印

简介: 面试热点详解 —— 三个线程轮流打印

问题

三个线程轮流打印,其中一个线程打印a,一个打印b,最后一个打印c。最终形成abcabcabc…这样的字符串

考虑点:

  1. 字符串的第一个字符必须是a,很多方案在代码层级按ABC启动了线程,尽管大概率会遵循代码的先后顺序,但实际上谁先启动成功或谁先获得锁,逻辑上仍然是无法确定的,这也是目前很多网传方案的一大问题,我们在考虑多线程竞争时,必须达成逻辑严谨。而不是依靠代码顺序,或sleep来推断
  2. 此处是三个线程,如果是26个线程,分别打印a b c d … z 呢?
  3. 能不能用多种方案实现

思考

这种题目主要考查的就是多线程之间的通信问题,当然,这题也可以用一些“歪点子”,比如以时间为轴,各个线程只在特定的时间运行那么一下,达到轮流的效果。只是这样的方式恐怕不会令面试官满意。所以最好还是回到线程通信的范围里来。

一般来讲,广义上线程的通信无非两种,

  • 一种是直接的线程操作,比如 a 线程直接去操作 b 线程的运行、中断、停止等;

另一种则是共享内存,两个线程都可以访问某内存位置,根据该位置的值或者状态,每个线程再来决定自己该做什么,即通过共享来达成“消息传递”,当然,这里的内存位置就必须支持并发安全了,你可以使用volatile 或者是 同步结构 来保证并发安全

如果还有其他方式,则一般是上述两者的结合或变种

回答

方法一:线程 suspend 与 resume

我们一开始选用最符合直觉的,线程间直接执行操作,但是我们知道线程如果被停止,就进入终态,无法恢复了,而使用中断标记,只能为别的线程设置中断,却无法为别的线程取消中断,因此一个相对可行的方法是使用suspend 和 resume,达到令其停止执行代码的目的。

方法要点:

  • 通信方法 —— 因为采用的线程直接操作,至少需要对线程有停止 和 启动 两种相反的操作同时还不能令线程进入终态,因此选用的 suspend(挂起) 和 resume(恢复)【两种方法均被废弃,因此不太推荐】
  • 循环问题 —— 因为直接引用的方式需要每个线程都持有下个线程的引用,但代码写在前面的线程无法获取到后面线程的引用。所以此处用 List 把线程都存起来,这样所有线程都能取到引用了。
  • 输出顺序 —— 主线程启动A线程,A线程启动B线程。需要特别注意的是,尽管有这种启动顺序,但不代表会有同样的输出顺序,比如A启动B,但B可能比A先输出了。因此每个线程必须先完成第一次输出,再启动下个线程,当所有线程都运行起来后,再由通信方法保证输出顺序
public class ThreeThread {
    public static void main(String[] args) {
        List<Thread> list = new ArrayList<>();
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    System.out.print("a");
                    list.get(1).resume();
                    if (!Objects.equals(list.get(1).getState(), Thread.State.RUNNABLE)) {
                        list.get(1).start();
                    }
                    Thread.currentThread().suspend();
                }
            }
        });
        list.add(threadA);
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    System.out.print("b");
                    list.get(2).resume();
                    if (!Objects.equals(list.get(2).getState(), Thread.State.RUNNABLE)) {
                        list.get(2).start();
                    }
                    Thread.currentThread().suspend();
                }
            }
        });
        list.add(threadB);
        Thread threadC = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    System.out.print("c");
                    list.get(0).resume();
                    Thread.currentThread().suspend();
                }
            }
        });
        list.add(threadC);
        threadA.start();
    }
} 

方法二:volatile 变量

如果选用一个变量,来做线程通信的媒介,选用volatile变量肯定是个很好的办法,因为它的改变对所有线程可见。当然,我们需要注意,这种变量毕竟不是锁,因此不能对其并发修改,需要由代码层面控制,一次只有一个线程对其修改

方法要点:

  • 通信方法 —— 采用volatile变量,利用了volatile变量对所有线程实时可见的特性
  • 输出顺序 —— 线程的启动顺序虽然是ABC,但哪个线程先执行其实未可知,但是没有关系,我们使用了变量的值来做判断,即使B线程先执行了,因为flag初始值为0,所以线程B也只能空循环,直到A线程将flag改为1,线程B才能进行打印
public class ThreeThread2 {
    static volatile int flag = 0;
    public static void main(String[] args) {
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    if (flag == 0) {
                        System.out.print("a");
                        flag = 1;
                    }
                }
            }
        });
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    if (flag == 1) {
                        System.out.print("b");
                        flag = 2;
                    }
                }
            }
        });
        Thread threadC = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    if (flag == 2) {
                        System.out.print("c");
                        flag = 0;
                    }
                }
            }
        });
        threadA.start();
        threadB.start();
        threadC.start();
    }
}

方法三:synchronized 锁 (一)

除了利用一个基本类型变量来通信,我们也可以利用对象来通信,最好的对象当然是支持同步的锁了,所以我们不妨使用jdk自带的关键字synchronized 来实现,每个线程都唤醒下个线程,然后自己沉睡。但我们首先得明白synchronized 的不足:synchronized只有一个等待队列,且每次唤醒只能唤醒队列的头节点线程。这意味着,当线程B第一次进入同步代码块,想唤醒下个线程C时,第三个线程C此时却不在等待队列中,它的唤醒只会把刚刚进入队列的线程A唤醒,这样就成了A -> B -> A了,不符合要求的ABC顺序。因此,必须对线程B的第一次同步代码块特殊处理

方法要点:

  • 通信方法 —— 利用synchronized 同步代码块里的 notify(唤醒) 和 wait(等待)

循环问题 —— 因为多个线程使用synchronized时,无法确定谁会获得锁,所以采用线程嵌套启动的方式来按顺序启动所有线程,并使其按该顺序第一次获得锁,所以启动下一线程的代码需写在同步代码块中!另外,代码写在前面的线程无法获取到后面线程的引用,所以此处用 List 把线程都存起来,这样所有线程都能取到引用了。


  • 输出顺序 —— 虽然一开始所有线程能够按顺序获取锁,但是获取锁后却无法唤醒下一个线程,因为此时下一个线程不在等待队列里。所以中间的线程第一次不能执行唤醒操作,只有当所有线程都按顺序在等待队列中排号后,利用了等待队列的FIFO特性,才能正常唤醒下个线程
public class ThreeThread3 {
    public static void main(String[] args) {
        Object o = new Object();
        List<Thread> list = new ArrayList<>();
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    synchronized (o) {
                        try {
                            System.out.print("a");
                            o.notify();
                            if (Objects.equals(list.get(1).getState(), Thread.State.NEW)) {
                                list.get(1).start();
                            }
                            o.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        });
        list.add(threadA);
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                // 中间的线程都需要特殊处理,我们的目的是使所有线程一开始都按启动顺序进入等待队列中,然后才开始循环
                synchronized (o) {
                    try {
                        System.out.print("b");
                        if (Objects.equals(list.get(2).getState(), Thread.State.NEW)) {
                            list.get(2).start();
                        }
                        o.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                while (true) {
                    synchronized (o) {
                        try {
                            System.out.print("b");
                            o.notify();
                            o.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        });
        list.add(threadB);
        Thread threadC = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    synchronized (o) {
                        try {
                            System.out.println("c");
                            o.notify();
                            o.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        });
        list.add(threadC);
        threadA.start();
    }
}

方法四:synchronized 锁 (二)

方法三我们利用了synchronized 的等待队列的FIFO的顺序来保证循环输出,但我们也可以不利用其内部队列的FIFO来实现顺序,而是利用多个锁的嵌套。三个线程就建三个锁,例如线程A只有同时获得锁A和锁B后,才能正常输出,而后把B锁让给B线程,自己进入A锁的等待队列


bbac17a0995d4e0bb6889d1a16535d80.png

public class ThreeThread4 {
    public static void main(String[] args) {
        List<Thread> list = new ArrayList<>();
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    // 同时获得A B两把锁才打印
                    synchronized (list.get(0)){
                        synchronized (list.get(1)){
                            System.out.print("a");
                            if (Objects.equals(list.get(1).getState(), Thread.State.NEW)) {
                                list.get(1).start();
                            }
                            // 准备放弃B锁,需要先唤醒其他线程(此处是线程B)来接盘
                            list.get(1).notifyAll();
                        }
                        try {
                            // 进入A锁的等待队列
                            list.get(0).wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        });
        list.add(threadA);
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    synchronized (list.get(1)){
                        synchronized (list.get(2)){
                            System.out.print("b");
                            if (Objects.equals(list.get(2).getState(), Thread.State.NEW)) {
                                list.get(2).start();
                            }
                            list.get(2).notifyAll();
                        }
                        try {
                            list.get(1).wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        });
        list.add(threadB);
        Thread threadC = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    synchronized (list.get(2)){
                        synchronized (list.get(0)){
                            System.out.println("c");
                            list.get(0).notifyAll();
                        }
                        try {
                            list.get(2).wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        });
        list.add(threadC);
        threadA.start();
    }
}

方法五:Lock 与 Condition

除了synchronized 锁,我们还可以用Lock 与 Condition 来实现,虽然同synchronized 一样,我们没法指定唤醒哪个线程,但是我们可以选择唤醒哪条条件队列,只要保证一个条件队列只要有一个线程即可。因此,使用Lock 与 Condition 可以获得比方法三和四更简单的代码

public class ThreeThread5 {
    public static void main(String[] args) {
        List<Thread> list = new ArrayList<>();
        Lock lock = new ReentrantLock();
        Condition conditionA = lock.newCondition();
        Condition conditionB = lock.newCondition();
        Condition conditionC = lock.newCondition();
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    try {
                        lock.lock();
                        System.out.print("a");
                        if (Objects.equals(list.get(1).getState(), Thread.State.NEW)) {
                            list.get(1).start();
                        }
                        conditionB.signal();
                        conditionA.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        list.add(threadA);
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    try {
                        lock.lock();
                        System.out.print("b");
                        if (Objects.equals(list.get(2).getState(), Thread.State.NEW)) {
                            list.get(2).start();
                        }
                        conditionC.signal();
                        conditionB.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        list.add(threadB);
        Thread threadC = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    try {
                        lock.lock();
                        System.out.println("c");
                        conditionA.signal();
                        conditionC.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        list.add(threadC);
        threadA.start();
    }
}

优化

我们提供的五个方法仅仅是一部分,需要强调的是,不是由主线程依次启动线程ABC,就代表线程A就一定比线程B先执行,所以网传类似的方法大多有不严谨的地方。

另外,我们现在仅仅是三个线程,所以代码里采用的都是朴素的new Thread方法,如果有几十个线程呢,不可能每个线程都需要我们手写其中的run方法吧,所以我们可以使用继承Thread类的方式来完成更多线程的顺序输出,以下,我们以方案二为例子写代码

public class ThreeThread6 {
    static volatile Integer flag = 0;
    public static void main(String[] args) {
        MyThread threadA = new MyThread(0 , 1, "a");
        MyThread threadB = new MyThread(1 , 2, "b");
        MyThread threadC = new MyThread(2 , 0, "c");
        threadA.start();
        threadB.start();
        threadC.start();
    }
    // 写一个内部类来减少重复代码
    private static class MyThread extends Thread {
        Integer myFlag;
        Integer nextFlag;
        String myStr;
        public MyThread(Integer myFlag, Integer nextFlag, String myStr) {
            this.myFlag = myFlag;
            this.nextFlag = nextFlag;
            this.myStr = myStr;
        }
        @Override
        public void run() {
            while (true) {
                if (flag.equals(myFlag) ) {
                    System.out.print(myStr);
                    flag = nextFlag;
                }
            }
        }
    }
}


目录
相关文章
|
4天前
|
存储 调度 C++
【操作系统】进程与线程的区别及总结(非常非常重要,面试必考题,其它文章可以不看,但这篇文章最后的总结你必须要看,满满的全是干货......)
【操作系统】进程与线程的区别及总结(非常非常重要,面试必考题,其它文章可以不看,但这篇文章最后的总结你必须要看,满满的全是干货......)
25 1
|
18天前
|
缓存 Java 调度
java面试题之个人对线程池的理解
java面试题之个人对线程池的理解
|
4天前
|
缓存 安全 Java
Java线程面试题含答案
Java线程面试题含答案
|
4天前
|
算法 安全 网络协议
java高级面试题_java面试题大全带答案_线程面试题_java面试宝典2019
java高级面试题_java面试题大全带答案_线程面试题_java面试宝典2019
|
4天前
|
安全 算法 Java
java线程面试题_2019java面试题库
java线程面试题_2019java面试题库
|
13天前
|
安全 Java 程序员
Java基础18-一文搞懂Java多线程使用方式、实现原理以及常见面试题(二)
Java基础18-一文搞懂Java多线程使用方式、实现原理以及常见面试题(二)
32 4
|
13天前
|
Java 程序员 调度
Java基础18-一文搞懂Java多线程使用方式、实现原理以及常见面试题(一)
Java基础18-一文搞懂Java多线程使用方式、实现原理以及常见面试题(一)
29 0
Java基础18-一文搞懂Java多线程使用方式、实现原理以及常见面试题(一)
|
13天前
|
设计模式 NoSQL Java
网易面试:SpringBoot如何开启虚拟线程?
虚拟线程(Virtual Thread)也称协程或纤程,是一种轻量级的线程实现,与传统的线程以及操作系统级别的线程(也称为平台线程)相比,它的创建开销更小、资源利用率更高,是 Java 并发编程领域的一项重要创新。 > PS:虚拟线程正式发布于 Java 长期支持版(Long Term Suort,LTS)Java 21(也就是 JDK 21)。 虚拟线程是一种在 Java 虚拟机(JVM)层面实现的逻辑线程,不直接和操作系统的物理线程一一对应,因此它可以减少上下文切换所带来的性能开销。 操作系统线程、普通线程(Java 线程)和虚拟线程的关系如下: ![image.png](https:
25 0
网易面试:SpringBoot如何开启虚拟线程?
|
25天前
|
消息中间件 监控 Java
滴滴面试:谈谈你对Netty线程模型的理解?
Netty 线程模型是指 Netty 框架为了提供高性能、高并发的网络通信,而设计的管理和利用线程的策略和机制。 **Netty 线程模型被称为 Reactor(响应式)模型/模式,它是基于 NIO 多路复用模型的一种升级,它的核心思想是将 IO 事件和业务处理进行分离,使用一个或多个线程来执行任务的一种机制。** ## 1.**Reactor三大组件** Reactor 包含以下三大组件: ![image.png](https://cdn.nlark.com/yuque/0/2024/png/92791/1717079218890-89000a00-48bc-4a1a-b87e-e1b6
30 2
|
4天前
|
Oracle Java 关系型数据库
面试知识点:notify是随机唤醒线程吗(唤醒线程顺序)?
面试知识点:notify是随机唤醒线程吗(唤醒线程顺序)?
10 0

相关实验场景

更多