JUC面试题之交替打印

在juc笔试题中很容易考到一种题型就是多个线程交替打印,其实这个问题有很多的解题办法。我这里都打个样。

题目:3个线程,分别打印a,b,c。但是要求三个线程依次打印。循环五次。

方法一:使用wait-notify

package com.hanserwei.juc;

import lombok.AllArgsConstructor;

public class Test30 {
    public static void main(String[] args) {
        WaitNotify waitNotify = new WaitNotify(1, 5);
        new Thread(() -> {
            waitNotify.print("a", 1, 2);
        }).start();
        new Thread(() -> {
            waitNotify.print("b", 2, 3);
        }).start();
        new Thread(() -> {
            waitNotify.print("c", 3, 1);
        }).start();
    }
}

@AllArgsConstructor
class WaitNotify {
    private int flag;
    private int loopCount;

    public void print(String str, int waitFlag, int nextFlag) {
        for (int i = 0; i < loopCount; i++) {
            synchronized (this) {
                while (waitFlag != flag) {
                    try {
                        this.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.print(str);
                flag = nextFlag;
                this.notifyAll();
            }
        }
    }
}

💻 代码工作原理

这段代码的核心在于 WaitNotify 类中的 print 方法以及三个线程的协作:

1. 线程初始化与状态

  • WaitNotify 对象: 被初始化时,flag1loopCount5
  • 三个线程:
    • Thread 1 (打印 "a"): 期望的 waitFlag1,执行后将 flag 设为 nextFlag 2
    • Thread 2 (打印 "b"): 期望的 waitFlag2,执行后将 flag 设为 nextFlag 3
    • Thread 3 (打印 "c"): 期望的 waitFlag3,执行后将 flag 设为 nextFlag 1

2. 顺序执行流程 (互斥与等待/通知)

所有对共享对象 WaitNotify 的操作都通过 synchronized (this) 块进行,确保同一时间只有一个线程可以访问或修改 flag

  1. 初始状态: flag = 1
  2. 启动: 所有线程进入 synchronized 块,但只有 Thread 1 满足 while (waitFlag != flag) 的条件(1 == 1 不成立),因此它会继续执行。
  3. Thread 1 第一次执行:
    • 打印 "a"
    • flag 更新为 nextFlag 2
    • 调用 this.notifyAll() 唤醒所有等待的线程(即 Thread 2 和 Thread 3,如果它们已经到达 wait())。
    • 释放锁,进入下一次循环或结束。
  4. Thread 2 抢到锁: 此时 flag = 2
    • Thread 2 满足条件(2 == 2 不成立),继续执行。
    • Thread 3 不满足条件(3 \neq 2),调用 this.wait() 释放锁并进入等待状态。
    • Thread 2 打印 "b"
    • flag 更新为 nextFlag 3
    • 调用 this.notifyAll() 唤醒所有等待的线程(主要唤醒 Thread 3 和 Thread 1)。
    • 释放锁。
  5. Thread 3 抢到锁: 此时 flag = 3
    • Thread 3 满足条件(3 == 3 不成立),继续执行。
    • Thread 3 打印 "c"
    • flag 更新为 nextFlag 1
    • 调用 this.notifyAll() 唤醒所有等待的线程。
    • 释放锁。

3. 循环与结果

这个过程会重复进行,直到所有线程的循环次数(loopCount = 5)都完成。由于 flag 的值在 1 -> 2 -> 3 -> 1 之间循环切换,这保证了线程的执行顺序是 a -> b -> c -> a -> b -> c...

最终输出结果将是: abcabcabcabcabc (共 5 组)。

这样写的好处

这种使用 wait() / notifyAll() 配合 while 循环(称为**“条件等待”**)和 共享变量 (flag) 的设计模式,主要有以下几个优点:

1. 实现精确的线程协作和顺序执行

  • 这是最大的优点。它将无序并发执行的线程,通过共享变量 flag 严格地串行化为一个固定的执行序列(a -> b -> c)。
  • 这在需要任务依赖(例如:步骤 A 必须在步骤 B 之前完成)或资源需要按顺序访问的场景中非常有用。

2. 避免死锁和资源浪费

  • wait() 的作用是让不满足执行条件的线程释放锁,并进入 等待池 挂起。这避免了线程在锁内进行忙等待(Busy Waiting),从而节省了 CPU 资源
  • 线程只有在被 notifyAll() 唤醒后,才会重新尝试获取锁并检查 while 条件,保证了高效的资源利用。

3. 处理“虚假唤醒” (Spurious Wakeups) 的安全机制

  • wait() 的调用外部使用 while (waitFlag != flag) 循环检查条件是 Java 并发编程中的最佳实践
  • 即使线程因为操作系统或其他原因被“虚假唤醒”(没有收到 notify),它在重新获得锁后也会再次进入 while 循环进行条件检查。如果条件仍不满足,它会再次调用 wait(),确保程序逻辑的健壮性。

4. 清晰的状态管理

  • 通过一个简单的整数 flag,就能清晰地表示系统当前的执行状态下一个被允许执行的线程。这比使用复杂的信号量或其他锁机制在逻辑上更直观。

方法二:使用Reentralock的await-signal

package com.hanserwei.juc;

import lombok.Getter;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

@Slf4j
public class Test31 {
    @SneakyThrows
    public static void main(String[] args) {
        AwaitSignal awaitSignal = new AwaitSignal(5);
        Condition a = awaitSignal.newCondition();
        Condition b = awaitSignal.newCondition();
        Condition c = awaitSignal.newCondition();
        new Thread(() -> {
            awaitSignal.print("a", a, b);
        }).start();
        new Thread(() -> {
            awaitSignal.print("b", b, c);
        }).start();
        new Thread(() -> {
            awaitSignal.print("c", c, a);
        }).start();
        Thread.sleep(1000);
        awaitSignal.lock();
        try {
            System.out.println("开始!");
            a.signal();
        } finally {
            awaitSignal.unlock();
        }
    }
}

@Getter
class AwaitSignal extends ReentrantLock {
    private final int loopNumber;

    public AwaitSignal(int loopNumber) {
        this.loopNumber = loopNumber;
    }

    /**
     * 线程打印
     *
     * @param str           打印的字符串
     * @param waitCondition 等待的condition
     * @param nextCondition 下一个condition
     */
    public void print(String str, Condition waitCondition, Condition nextCondition) {
        for (int i = 0; i < loopNumber; i++) {
            lock();
            try {
                try {
                    waitCondition.await();
                    System.out.print(str);
                    nextCondition.signal();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } 
            } finally {
                unlock();
            }
        }
    }

}

这段代码与使用 wait()notifyAll() 的例子功能相同,都是用于实现三个线程的循环、有序协作打印(即打印 "abcabc...")。

但这段代码使用 Java ReentrantLock 及其配套的 Condition 对象来实现线程间的协作和同步。这是一种更现代、更灵活的同步机制。


代码工作原理 (Lock/Condition)

1. 核心组件

  • AwaitSignal 类: 继承自 ReentrantLock。这意味着它自带了互斥锁的功能,确保同一时间只有一个线程可以执行临界区代码。
  • Condition 对象 (a, b, c): 每个 Condition 对象都相当于一个特定的等待队列。线程可以在一个 Condition 上等待,并在另一个 Condition 上发出信号,实现精确唤醒
    • Condition a 对应线程 1 (打印 "a") 的等待队列。
    • Condition b 对应线程 2 (打印 "b") 的等待队列。
    • Condition c 对应线程 3 (打印 "c") 的等待队列。

2. 线程协作流程

  1. 线程启动与等待:
    • 三个线程启动后,它们都会进入 print 方法。
    • 每个线程都会先调用 lock() 获取锁。
    • 它们立即调用各自的 waitCondition.await():
      • T1 (a): 调用 a.await(),释放锁,进入 Condition a 的等待队列。
      • T2 (b): 调用 b.await(),释放锁,进入 Condition b 的等待队列。
      • T3 (c): 调用 c.await(),释放锁,进入 Condition c 的等待队列。
    • 此时,所有线程都在等待,主线程持有锁。
  2. 启动信号:
    • 主线程在 Thread.sleep(1000) 后,调用 awaitSignal.lock() 获取锁。
    • 执行 a.signal()。这会唤醒 Condition a 队列中的一个线程,即 T1
    • 主线程 unlock() 释放锁。
  3. 循环打印执行:
    • T1 (打印 "a"): 被唤醒后抢到锁,从 a.await() 返回。
      • 打印 "a"
      • 调用 b.signal() 唤醒 Condition b 队列中的线程,即 T2
      • unlock() 释放锁。
    • T2 (打印 "b"): 被 T1 唤醒后抢到锁,从 b.await() 返回。
      • 打印 "b"
      • 调用 c.signal() 唤醒 Condition c 队列中的线程,即 T3
      • unlock() 释放锁。
    • T3 (打印 "c"): 被 T2 唤醒后抢到锁,从 c.await() 返回。
      • 打印 "c"
      • 调用 a.signal() 唤醒 Condition a 队列中的线程,即 T1,完成一个循环。
      • unlock() 释放锁。
  4. 重复: T1、T2、T3 线程会重复这个过程 loopNumber (5) 次,最终输出结果是 abcabcabcabcabc

使用 Lock/Condition 的优势

相较于传统的 synchronized + wait()/notifyAll() 机制,使用 ReentrantLockCondition 具有以下核心优势:

1. 实现精确唤醒 (Targeted Notification)

  • wait/notify 机制中,notifyAll() 会唤醒所有等待在对象监视器上的线程,即使它们并不满足执行条件,这可能导致上下文切换的浪费
  • 使用 Condition,你可以创建多个等待队列 (a, b, c),并通过 signal() 仅唤醒下一个需要执行的线程(例如,T1 明确唤醒 T2)。这极大地提高了效率和性能

2. 更灵活的锁机制

  • ReentrantLock 提供了比 synchronized 更强大的功能,例如:
    • 可中断锁: lockInterruptibly()
    • 定时锁: tryLock(long timeout, TimeUnit unit),避免无限等待。
    • 公平性: 可以构造一个公平锁 new ReentrantLock(true),保证线程按照请求锁的顺序获得锁。

3. 分离锁与等待条件

  • synchronized 中,一个对象只有一个等待队列。
  • ReentrantLock 中,一个锁可以关联任意多个 Condition 对象,每个 Condition 都可以作为一组特定的等待条件。这使得代码结构更清晰,更容易管理复杂的同步场景。
特性synchronized + wait/notifyReentrantLock + Condition
唤醒方式只能 notify() (随机唤醒一个) 或 notifyAll() (唤醒所有)。可以通过特定的 Condition.signal() 实现精确唤醒
等待队列只有一个(对象监视器等待集)。可以有多个,每个 Condition 对应一个等待队列。
功能扩展较少。丰富(可中断、定时、公平锁)。

方法三:使用park与unpark

package com.hanserwei.juc;

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.locks.LockSupport;

@Slf4j
public class Test32 {
    static Thread t1;
    static Thread t2;
    static Thread t3;

    public static void main(String[] args) {
        ParkUnpark parkUnpark = new ParkUnpark(5);
        t1 = new Thread(() -> {
            parkUnpark.print("a", t2);
        });
        t2 = new Thread(() -> {
            parkUnpark.print("b", t3);
        });
        t3 = new Thread(() -> {
            parkUnpark.print("c", t1);
        });
        t1.start();
        t2.start();
        t3.start();

        LockSupport.unpark(t1);
    }
}

class ParkUnpark {
    private final int loopNumber;

    public ParkUnpark(int loopNumber) {
        this.loopNumber = loopNumber;
    }

    public void print(String str, Thread nextThread) {
        for (int i = 0; i < loopNumber; i++) {
            LockSupport.park();
            System.out.print(str);
            LockSupport.unpark(nextThread);
        }
    }
}

这段代码是实现三个线程循环有序打印的第三种方法,它使用了 Java 并发包中最底层的同步工具之一:LockSupport.park()LockSupport.unpark()


代码工作原理 (LockSupport)

LockSupport 是一个用于创建锁和其他同步类的实用工具。它的核心机制是许可 (Permit),每个线程都有一个与之关联的许可。许可只有两种状态:可用(有许可)或不可用(无许可)。

1. 核心机制

  • LockSupport.park(): 如果当前线程的许可不可用,线程会阻塞(挂起);如果许可可用,它会立即消耗掉这个许可,然后继续运行。
  • LockSupport.unpark(Thread t): 将目标线程 t 的许可设置为可用。如果目标线程正在 park() 中阻塞,它会被唤醒;如果线程没有阻塞,这个许可会被存储起来,使得下一次 park() 调用能立即返回。

2. 线程协作流程

  1. 线程启动与初始状态:
    • 主线程创建并启动了 t1, t2, t3 三个线程。
    • main 方法执行到 LockSupport.unpark(t1) 之前,t1, t2, t3 线程都会进入各自的 print 方法。
  2. 线程进入阻塞:
    • 每个线程进入 for 循环后,第一步就是调用 LockSupport.park()
    • 由于线程刚启动,它们的许可都是不可用的,所以 t1, t2, t3 都会立即阻塞(挂起)。
  3. 启动信号:
    • 主线程调用 LockSupport.unpark(t1)。这将把 t1 的许可设置为可用。
    • t1 被唤醒,从 park() 处继续执行。
  4. 循环打印执行:
    • t1 第一次执行:
      • park() 返回(消耗了许可)。
      • 打印 "a"
      • 调用 LockSupport.unpark(t2),唤醒下一个线程 t2。
      • 进入下一轮循环,再次调用 park() 阻塞。
    • t2 第一次执行:
      • 被唤醒后,从 park() 返回。
      • 打印 "b"
      • 调用 LockSupport.unpark(t3),唤醒下一个线程 t3。
      • 进入下一轮循环,再次调用 park() 阻塞。
    • t3 第一次执行:
      • 被唤醒后,从 park() 返回。
      • 打印 "c"
      • 调用 LockSupport.unpark(t1),唤醒下一个线程 t1,完成一个循环。
      • 进入下一轮循环,再次调用 park() 阻塞。
  5. 重复: 这个 unpark -> print -> park 的过程会持续 5 次,最终输出结果仍然是 abcabcabcabcabc

使用 LockSupport 的优势

相较于前两种方法 (wait/notifyCondition),LockSupport 主要有以下优势:

1. 无需持有锁 (No Monitor/Lock Required)

  • wait()notify() 必须synchronized 块内调用。
  • Condition.await()Condition.signal() 必须在持有 ReentrantLock 锁的情况下调用。
  • LockSupport.park()unpark() 可以在任何地方调用,不需要获取或释放任何监视器或锁。这使得代码更简洁,避免了因忘记释放锁而导致的死锁风险。

2. 克服“先唤醒后等待”问题 (Permit Mechanism)

  • wait() / notify() 机制中,如果 notify() 先于 wait() 调用,这个 notify() 信号会丢失,导致线程永远阻塞(死锁)。
  • LockSupport 使用许可机制。即使 unpark(t) 先于 park() 调用(即“先唤醒后等待”),unpark() 也会将许可存储起来。当线程 t 随后调用 park() 时,它会发现有可用许可,立即消耗掉并继续执行,不会阻塞。这极大地简化了线程启动时的同步逻辑。

3. 底层的、高效的阻塞机制

  • LockSupport 是 Java 并发框架 (J.U.C) 中许多高级同步组件(如 ReentrantLock, Semaphore 等)的底层基础。它通常直接映射到操作系统提供的高效线程阻塞原语,因此性能非常高。