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

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

问题

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


目录
相关文章
|
2月前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
2月前
|
消息中间件 前端开发 NoSQL
面试官:线程池遇到未处理的异常会崩溃吗?
面试官:线程池遇到未处理的异常会崩溃吗?
74 3
面试官:线程池遇到未处理的异常会崩溃吗?
|
2月前
|
消息中间件 存储 前端开发
面试官:说说停止线程池的执行流程?
面试官:说说停止线程池的执行流程?
51 2
面试官:说说停止线程池的执行流程?
|
2月前
|
消息中间件 前端开发 NoSQL
面试官:如何实现线程池任务编排?
面试官:如何实现线程池任务编排?
33 1
面试官:如何实现线程池任务编排?
|
3月前
|
Java
【多线程面试题二十五】、说说你对AQS的理解
这篇文章阐述了对Java中的AbstractQueuedSynchronizer(AQS)的理解,AQS是一个用于构建锁和其他同步组件的框架,它通过维护同步状态和FIFO等待队列,以及线程的阻塞与唤醒机制,来实现同步器的高效管理,并且可以通过实现特定的方法来自定义同步组件的行为。
【多线程面试题二十五】、说说你对AQS的理解
|
3月前
|
Java
【多线程面试题十六】、谈谈ReentrantLock的实现原理
这篇文章解释了`ReentrantLock`的实现原理,它基于Java中的`AbstractQueuedSynchronizer`(AQS)构建,通过重写AQS的`tryAcquire`和`tryRelease`方法来实现锁的获取与释放,并详细描述了AQS内部的同步队列和条件队列以及独占模式的工作原理。
【多线程面试题十六】、谈谈ReentrantLock的实现原理
|
3月前
|
消息中间件 缓存 算法
Java多线程面试题总结(上)
进程和线程是操作系统管理程序执行的基本单位,二者有明显区别: 1. **定义与基本单位**:进程是资源分配的基本单位,拥有独立的内存空间;线程是调度和执行的基本单位,共享所属进程的资源。 2. **独立性与资源共享**:进程间相互独立,通信需显式机制;线程共享进程资源,通信更直接快捷。 3. **管理与调度**:进程管理复杂,线程管理更灵活。 4. **并发与并行**:进程并发执行,提高资源利用率;线程不仅并发还能并行执行,提升执行效率。 5. **健壮性**:进程更健壮,一个进程崩溃不影响其他进程;线程崩溃可能导致整个进程崩溃。
47 2
|
3月前
|
存储 安全 容器
【多线程面试题二十一】、 分段锁是怎么实现的?
这篇文章解释了分段锁的概念和实现方式,通过将数据分成多个段并在每段数据上使用独立锁,从而降低锁竞争,提高并发访问效率,举例说明了`ConcurrentHashMap`如何使用分段锁技术来实现高并发和线程安全。
【多线程面试题二十一】、 分段锁是怎么实现的?
|
3月前
|
安全 Java
【多线程面试题十九】、 公平锁与非公平锁是怎么实现的?
这篇文章解释了Java中`ReentrantLock`的公平锁和非公平锁的实现原理,其中公平锁通过检查等待队列严格按顺序获取锁,而非公平锁允许新线程有更高机会立即获取锁,两者都依赖于`AbstractQueuedSynchronizer`(AQS)和`volatile`关键字以及CAS技术来确保线程安全和锁的正确同步。
【多线程面试题十九】、 公平锁与非公平锁是怎么实现的?
|
3月前
|
存储 缓存 安全
Java多线程面试题总结(中)
Java内存模型(JMM)定义了程序中所有变量的访问规则与范围,确保多线程环境下的数据一致性。JMM包含主内存与工作内存的概念,通过8种操作管理两者间的交互,确保原子性、可见性和有序性。`synchronized`和`volatile`关键字提供同步机制,前者确保互斥访问,后者保证变量更新的可见性。多线程操作涉及不同状态,如新建(NEW)、可运行(RUNNABLE)等,并可通过中断、等待和通知等机制协调线程活动。`volatile`虽不确保线程安全,但能确保变量更新对所有线程可见。
19 0

相关实验场景

更多