JUC面试题之交替打印
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类中的1. 线程初始化与状态
WaitNotify对象: 被初始化时,flag为 1,loopCount为 5。- 三个线程:
- Thread 1 (打印 "a"): 期望的
waitFlag是 1,执行后将flag设为nextFlag2。- Thread 2 (打印 "b"): 期望的
waitFlag是 2,执行后将flag设为nextFlag3。- Thread 3 (打印 "c"): 期望的
waitFlag是 3,执行后将flag设为nextFlag1。2. 顺序执行流程 (互斥与等待/通知)
所有对共享对象
WaitNotify的操作都通过synchronized (this)块进行,确保同一时间只有一个线程可以访问或修改flag。
- 初始状态:
flag= 1。- 启动: 所有线程进入
synchronized块,但只有 Thread 1 满足while (waitFlag != flag)的条件(1 == 1 不成立),因此它会继续执行。- Thread 1 第一次执行:
- 打印 "a"。
- 将
flag更新为nextFlag2。- 调用
this.notifyAll()唤醒所有等待的线程(即 Thread 2 和 Thread 3,如果它们已经到达wait())。- 释放锁,进入下一次循环或结束。
- Thread 2 抢到锁: 此时
flag= 2。
- Thread 2 满足条件(2 == 2 不成立),继续执行。
- Thread 3 不满足条件(3 \neq 2),调用
this.wait()释放锁并进入等待状态。- Thread 2 打印 "b"。
- 将
flag更新为nextFlag3。- 调用
this.notifyAll()唤醒所有等待的线程(主要唤醒 Thread 3 和 Thread 1)。- 释放锁。
- Thread 3 抢到锁: 此时
flag= 3。
- Thread 3 满足条件(3 == 3 不成立),继续执行。
- Thread 3 打印 "c"。
- 将
flag更新为nextFlag1。- 调用
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. 线程协作流程
- 线程启动与等待:
- 三个线程启动后,它们都会进入
- 每个线程都会先调用
lock()获取锁。- 它们立即调用各自的
waitCondition.await():
- T1 (
a): 调用a.await(),释放锁,进入 Conditiona的等待队列。- T2 (
b): 调用b.await(),释放锁,进入 Conditionb的等待队列。- T3 (
c): 调用c.await(),释放锁,进入 Conditionc的等待队列。- 此时,所有线程都在等待,主线程持有锁。
- 启动信号:
- 主线程在
Thread.sleep(1000)后,调用awaitSignal.lock()获取锁。- 执行
a.signal()。这会唤醒 Conditiona队列中的一个线程,即 T1。- 主线程
unlock()释放锁。- 循环打印执行:
- T1 (打印 "a"): 被唤醒后抢到锁,从
a.await()返回。
- 打印 "a"。
- 调用
b.signal()唤醒 Conditionb队列中的线程,即 T2。unlock()释放锁。- T2 (打印 "b"): 被 T1 唤醒后抢到锁,从
b.await()返回。
- 打印 "b"。
- 调用
c.signal()唤醒 Conditionc队列中的线程,即 T3。unlock()释放锁。- T3 (打印 "c"): 被 T2 唤醒后抢到锁,从
c.await()返回。
- 打印 "c"。
- 调用
a.signal()唤醒 Conditiona队列中的线程,即 T1,完成一个循环。unlock()释放锁。- 重复: T1、T2、T3 线程会重复这个过程
loopNumber(5) 次,最终输出结果是abcabcabcabcabc。使用 Lock/Condition 的优势
相较于传统的
synchronized+wait()/notifyAll()机制,使用ReentrantLock和Condition具有以下核心优势: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/notify ReentrantLock + 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. 线程协作流程
- 线程启动与初始状态:
- 主线程创建并启动了 t1, t2, t3 三个线程。
- 在
main方法执行到LockSupport.unpark(t1)之前,t1, t2, t3 线程都会进入各自的- 线程进入阻塞:
- 每个线程进入
for循环后,第一步就是调用LockSupport.park()。- 由于线程刚启动,它们的许可都是不可用的,所以 t1, t2, t3 都会立即阻塞(挂起)。
- 启动信号:
- 主线程调用
LockSupport.unpark(t1)。这将把 t1 的许可设置为可用。- t1 被唤醒,从
park()处继续执行。- 循环打印执行:
- t1 第一次执行:
- 从
park()返回(消耗了许可)。- 打印 "a"。
- 调用
LockSupport.unpark(t2),唤醒下一个线程 t2。- 进入下一轮循环,再次调用
park()阻塞。- t2 第一次执行:
- 被唤醒后,从
park()返回。- 打印 "b"。
- 调用
LockSupport.unpark(t3),唤醒下一个线程 t3。- 进入下一轮循环,再次调用
park()阻塞。- t3 第一次执行:
- 被唤醒后,从
park()返回。- 打印 "c"。
- 调用
LockSupport.unpark(t1),唤醒下一个线程 t1,完成一个循环。- 进入下一轮循环,再次调用
park()阻塞。- 重复: 这个
unpark -> print -> park的过程会持续 5 次,最终输出结果仍然是abcabcabcabcabc。
使用 LockSupport 的优势
相较于前两种方法 (
wait/notify和Condition),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等)的底层基础。它通常直接映射到操作系统提供的高效线程阻塞原语,因此性能非常高。