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

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

问题

三个线程轮流打印,其中一个线程打印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;
                }
            }
        }
    }
}


目录
相关文章
|
3天前
|
并行计算 算法 安全
面试必问的多线程优化技巧与实战
多线程编程是现代软件开发中不可或缺的一部分,特别是在处理高并发场景和优化程序性能时。作为Java开发者,掌握多线程优化技巧不仅能够提升程序的执行效率,还能在面试中脱颖而出。本文将从多线程基础、线程与进程的区别、多线程的优势出发,深入探讨如何避免死锁与竞态条件、线程间的通信机制、线程池的使用优势、线程优化算法与数据结构的选择,以及硬件加速技术。通过多个Java示例,我们将揭示这些技术的底层原理与实现方法。
41 3
|
1月前
|
存储 缓存 算法
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
本文介绍了多线程环境下的几个关键概念,包括时间片、超线程、上下文切换及其影响因素,以及线程调度的两种方式——抢占式调度和协同式调度。文章还讨论了减少上下文切换次数以提高多线程程序效率的方法,如无锁并发编程、使用CAS算法等,并提出了合理的线程数量配置策略,以平衡CPU利用率和线程切换开销。
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
|
2天前
|
缓存 安全 Java
【JavaEE】——单例模式引起的多线程安全问题:“饿汉/懒汉”模式,及解决思路和方法(面试高频)
单例模式下,“饿汉模式”,“懒汉模式”,单例模式下引起的线程安全问题,解锁思路和解决方法
|
2天前
|
Java 调度
|
3月前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
|
3月前
|
消息中间件 前端开发 NoSQL
面试官:线程池遇到未处理的异常会崩溃吗?
面试官:线程池遇到未处理的异常会崩溃吗?
86 3
面试官:线程池遇到未处理的异常会崩溃吗?
|
3月前
|
消息中间件 存储 前端开发
面试官:说说停止线程池的执行流程?
面试官:说说停止线程池的执行流程?
57 2
面试官:说说停止线程池的执行流程?
|
3月前
|
消息中间件 前端开发 NoSQL
面试官:如何实现线程池任务编排?
面试官:如何实现线程池任务编排?
45 1
面试官:如何实现线程池任务编排?
|
4月前
|
Java
【多线程面试题二十五】、说说你对AQS的理解
这篇文章阐述了对Java中的AbstractQueuedSynchronizer(AQS)的理解,AQS是一个用于构建锁和其他同步组件的框架,它通过维护同步状态和FIFO等待队列,以及线程的阻塞与唤醒机制,来实现同步器的高效管理,并且可以通过实现特定的方法来自定义同步组件的行为。
【多线程面试题二十五】、说说你对AQS的理解
|
4月前
|
消息中间件 缓存 算法
Java多线程面试题总结(上)
进程和线程是操作系统管理程序执行的基本单位,二者有明显区别: 1. **定义与基本单位**:进程是资源分配的基本单位,拥有独立的内存空间;线程是调度和执行的基本单位,共享所属进程的资源。 2. **独立性与资源共享**:进程间相互独立,通信需显式机制;线程共享进程资源,通信更直接快捷。 3. **管理与调度**:进程管理复杂,线程管理更灵活。 4. **并发与并行**:进程并发执行,提高资源利用率;线程不仅并发还能并行执行,提升执行效率。 5. **健壮性**:进程更健壮,一个进程崩溃不影响其他进程;线程崩溃可能导致整个进程崩溃。
59 2

相关实验场景

更多