字节三面中遇到的算法题是用多线程交替打印问题,总得来说是一道线程通信问题。这类问题有如下形式:
- 三个线程T1、T2、T3轮流打印ABC,打印n次,如ABCABCABCABC…….
- 两个线程交替打印1-100的奇偶数
- N个线程循环打印1-100
本博文代码对应题目为:两个线程交替打印 1-10 的奇偶数。连带着复习一下线程通信的方式。
线程通信的方式
首先需要明确一个前置问题 —— 线程间通信的方式有哪些?
synchronized + wait()/notify()
- 拿到锁后,看是不是轮到本线程?
- 如果没有轮到本线程,调用
wait()
转为 wait 状态,并释放锁。此时其他线程可以拿到锁。 - 如果轮到本线程,执行输出,结束后调用
notify()
,让等待状态的线程判断是否轮到自己。
- 如果没有轮到本线程,调用
public class Solution1 {
// synchronized 单锁方案
private static int count = 1;
private static final int THREAD_COUNT = 2;
private static final Object LOCK = new Object();
private static class TestRunnable implements Runnable {
private int sign;
public TestRunnable(int sign) {
this.sign = sign;
}
@Override
public void run() {
synchronized (LOCK) {
while (count <= 10) {
while (count % THREAD_COUNT != sign) {
// 1 还没有轮到这个线程的时候,用 wait() 释放锁,让该线程陷入 wait 状态,避免了空转
try {
LOCK.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// 2 轮到该线程
if (count <= 10) {
System.out.println(Thread.currentThread().getName() + ":" + count++);
}
// 3 唤醒其他 wait 状态的线程
LOCK.notifyAll();
}
}
}
}
public static void main(String[] args) {
new Thread(new TestRunnable(1), "Thread1").start();
new Thread(new TestRunnable(0), "Thread2").start();
}
}
Lock 接口
Lock 接口是 JUC 下的核心接口,其实现类 ReentrantLock 同样可以解决这类题目:
只使用一个 Lock 实际上和使用 synchronized 进行空转是一致的,这样解决的话,空转次数太多,会造成空转时资源浪费。
public class Solution_Lock {
private static final Lock lock = new ReentrantLock();
private static int count = 1;
private static final int THREAD_COUNT = 2;
private static class WaitThread extends Thread{
private int sign;
public WaitThread(int sign) {
this.sign = sign;
}
@Override
public void run() {
while (count <= 10) {
lock.lock();
if (count <= 10 && count % THREAD_COUNT == sign) {
System.out.println(Thread.currentThread().getName() + ":" + count);
count++;
}
lock.unlock();
}
}
}
public static void main(String[] args) {
new WaitThread(1).start();
new WaitThread(0).start();
}
}
Lock + Condition 通信
首先明确 ReentrantLock
的 Condition
对应于 synchronized
的 wait()/notify()
机制,调用 condition.await()
同样会释放锁,让原线程陷入 wait 状态。
它们的不同是,synchronized 的通信机制是一个 synchronized 锁对应一组通信,而每一个 ReentrantLock 可以 new 出多个 Condition,实现更加精准的通信。下面是用 condition 机制实现的代码,基本原理和 synchronized
的实现思路一致:
public class Solution_Condition {
private static final Lock LOCK = new ReentrantLock();
private static final Condition C1 = LOCK.newCondition();
private static final Condition C2 = LOCK.newCondition();
private static final int MAX_COUNTER = 10;
private static int count = 1;
private static class TestRunnable implements Runnable {
private int sign;
private Condition currCondition;
private Condition nextCondition;
public TestRunnable(int sign, Condition currCondition, Condition nextCondition) {
this.sign = sign;
this.currCondition = currCondition;
this.nextCondition = nextCondition;
}
@Override
public void run() {
LOCK.lock();
try {
while (count <= MAX_COUNTER) {
while (count % 2 != sign) currCondition.await();
if (count > MAX_COUNTER) break;
System.out.println(Thread.currentThread().getName() + ":" + count++);
nextCondition.signal();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
LOCK.unlock();
}
}
}
public static void main(String[] args) {
new Thread(new TestRunnable(1, C1, C2), "thread1").start();
new Thread(new TestRunnable(0, C2, C1), "thread2").start();
}
}
JUC 工具类 —— Semaphore
Semaphore 是 Java 并发工具包 (java.util.concurrent) 中的一个基于计数的同步工具,用于控制同时访问特定资源的线程数量。Semaphore内部维护了一个计数器,其值为可以访问的共享资源的 permit 个数。
基本概念
- 它内部维护了一组许可(permit),可以看作是发放给线程的“许可证”。
- 线程需要 permit 才能访问共享资源。
- 如果没有可用的 permit,线程会阻塞直到有许可可用。
构造方法
允许用户指定初始许可数(permits)以及公平性策略(fair)。
public Semaphore(int permits) {}
public Semaphore(int permits, boolean fair) {}
- @param permits 初始可用的许可数量。该值可以为负数,在这种情况下,必须先执行
release()
释放许可, 否则acquire()
将不会被授予许可。 - @param fair 如果
true
,则信号量在竞争情况下保证先进先出(FIFO)顺序授予许可; 如果false
,则不保证公平性(默认行为)。
两个核心方法
- semaphore.acquire() 获取许可,没有许可时线程会阻塞。
- semaphore.release() 释放许可,使其他线程可以继续执行。
值得注意的是,这两个函数都可以加入参 permits ,设置每次获取和释放需要的许可数。
总得来说,Semaphore
这个工具类提供了对共享资源/锁的许可量管理,允许对许可数量进行设置。
用这个工具类同样可以解决多线程交替打印问题,解决思路:
- 初始化 Semaphore 变量,以奇数开始,所以 oddSemaphore 初始就有 1 个 permit。
- 在奇数线程输出 1 后,给偶数线程对应的 evenSemaphore 释放 1 个 permit。
public class Solution_Semaphore {
private static final Semaphore oddSemaphore = new Semaphore(1);
private static final Semaphore evenSemaphore = new Semaphore(0);
private static int count = 1;
private static class TestRunnable implements Runnable {
private Semaphore currSemaphore;
private Semaphore nextSemaphore;
public TestRunnable( Semaphore s1, Semaphore s2) {
this.currSemaphore = s1;
this.nextSemaphore = s2;
}
@Override
public void run() {
while (count <= 10) {
try {
currSemaphore.acquire();
if (count > 10) break;
System.out.println(Thread.currentThread().getName() + ":" + count++ + " ");
nextSemaphore.release();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public static void main(String[] args) {
new Thread(new TestRunnable(oddSemaphore, evenSemaphore), "thread-odd").start();
new Thread(new TestRunnable(evenSemaphore, oddSemaphore), "thread-even").start();
}
}
值得注意的是,除了 Semaphore ,JUC 还提供了另外两种常用的工具类,CountDownLatch 和 CyclicBarrier。但是这两个工具类不适合用在这种交替打印的场景,理由如下:
- CountDownLatch 的设计初衷是用来等待一个或多个线程完成某个事件,一旦计数器降为零,就不能重置或再次使用。适合用于一次性的协调,比如等待多个线程初始化完成或等待任务全部结束。
- Barriers 的设计初衷是让一组线程在某个同步点(屏障)相互等待,直到所有线程都到达这个点后,再一起继续执行。与 CountDownLatch 一次性协调的特点不同,Barriers(如 CyclicBarrier 和 Phaser)可以反复使用,适用于多阶段或迭代性的同步任务。
它们两个的场景是线程之间的协调,一个是递减的计数器(一次性),一个是递增的计数器(可重用),不适合于线程交替执行的场景。