news 2026/11/3 2:25:50

Java线程交替打印问题:从核心原理到面试实操

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java线程交替打印问题:从核心原理到面试实操

一、为什么线程交替打印是经典面试题

线程交替打印问题,最常见的描述是:启动两个线程,一个线程打印奇数,另一个线程打印偶数,要求最终输出 1、2、3、4、5……100 这样连续递增的序列;或者两个线程轮流打印字母 A、B;再复杂一点,三个线程按顺序循环打印 A、B、C。这个问题看起来只是简单的打印,但它背后几乎串联了 Java 并发编程里最核心的一批知识。

很多面试官喜欢问这道题,并不是因为实际业务中真的需要两个线程互相“打印数字”,而是因为它能够非常快地区分候选人是否真正理解以下内容:线程如何启动和运行、多个线程之间如何共享变量、共享变量被修改后如何保证可见性、多个线程修改同一个变量时如何保证原子性、线程之间如何实现等待和通知、锁和条件队列的配合方式、Java 提供的并发工具类应该怎样选择。更重要的是,这道题还有一个非常明显的“陷阱属性”:不少候选人能写出大致代码,但一旦面试官追问“为什么 wait 要放在 while 里”“为什么不能用 if 判断”“notify 和 notifyAll 有什么区别”“这段代码会不会死锁”,很多人就会卡住。

从能力考察角度看,线程交替打印题主要考察四个层次。第一层是基础语法层:能否创建线程,能否使用 Runnable 或 Lambda 表达式启动线程,能否正确写出循环和条件判断。第二层是可见性与原子性层:是否知道普通 int 变量在多线程下存在缓存一致性问题,是否知道 i++ 这类操作不是原子操作,是否理解 volatile 可以解决可见性但不能解决复合操作的原子性。第三层是线程通信层:是否掌握 synchronized、wait、notify、notifyAll 的搭配规则,是否理解锁释放和条件队列之间的关系,是否知道 Lock 和 Condition 能做得更精细。第四层是工程实践层:是否了解 Semaphore、CountDownLatch、CyclicBarrier、Phaser、BlockingQueue、Exchanger、LockSupport 等并发工具,是否能够根据场景选择合适方案,并且能判断不同实现方式的性能和可维护性差异。

本文会从最基础的 synchronized 加 wait/notify 实现开始讲起,逐步延伸到 Lock、Condition、Semaphore、AtomicInteger、volatile、LockSupport、CountDownLatch、CyclicBarrier、Phaser、BlockingQueue、Exchanger、CompletableFuture 等多种实现方式。每一种实现都会给出可以运行的 Java 代码,同时分析它的运行流程、潜在问题、边界条件以及面试官可能追问的方向。文章还会扩展三个线程交替打印 ABC 的问题,并总结常见错误和性能对比。希望通过这一篇详解,你不仅在面试中能够熟练写出答案,也能真正理解并发编程中的线程通信本质。

二、问题定义与通用模型

我们首先把题目固定下来。基础题是:创建两个线程,线程 A 只打印奇数,线程 B 只打印偶数,数字范围是 1 到 100。要求最终控制台输出按自然数顺序显示为:1、2、3、4、5……99、100。也就是说,打印顺序不能是“1、3、2、4”或其他乱序,必须是严格递增的连续序列。这个约束实际上意味着,两个线程不能各自独立地疯狂打印,而必须形成一种“你打印一次,我打印一次”的协作关系。

从这个基础题可以抽象出通用的线程交替执行模型。模型中通常存在一个共享状态变量,比如 current、number 或 state,它记录了当前应该轮到谁执行。每个线程进入循环后,首先判断当前共享状态是否允许自己执行;如果不允许,就进入等待状态,把 CPU 和锁释放出去;如果允许,就执行自己的业务逻辑,然后修改共享状态,通知其他线程重新开始判断。整个“判断、执行、修改、通知”的过程必须保证在同一个锁的保护下完成,或者使用原子操作完成,否则就可能出现重复打印、遗漏打印、死锁等问题。

这套模型有三个关键点。第一,状态判断和状态修改必须原子化。如果线程先判断“当前是奇数”,然后再去修改 number,中间就可能被另一个线程插进来,导致两个线程同时对同一个数字进行判断。第二,等待条件必须放在 while 循环中重新判断,而不能用 if 判断一次就继续执行,因为线程被唤醒后,条件可能已经被其他线程改变,或者发生虚假唤醒。第三,线程之间必须存在明确的唤醒机制。一个线程打印完后,如果不通知另一个线程,另一个线程可能一直阻塞在等待状态,整个程序都会卡住。这三点是所有正确实现的基础,也是面试中最重要的追问点。

经典变体也非常多。除了两个线程交替打印奇偶数之外,还有两个线程交替打印字母 A 和 B;三个线程按 A、B、C、A、B、C 的顺序循环打印;两个线程交替打印“1A、2B、3C”这样的数字字母组合;四个线程分别打印甲乙丙丁;还有生产者消费者模型中的交替生产和消费。虽然变体很多,但核心模型没有变化,仍然是一个共享状态加等待通知机制。

三、核心前置知识

3.1 Java 内存模型与可见性

在正式写代码之前,需要先理解为什么多个线程同时访问一个普通变量会出问题。Java 内存模型规定,每个线程都有自己的工作内存,线程读取变量时,可能不会每次都去主内存读取,而是从自己的工作内存中读取缓存副本。线程修改变量后,修改结果首先写回自己的工作内存,至于什么时候刷新到主内存,以及什么时候被其他线程看到,并不由开发者直接控制。因此,一个线程把共享变量改成 2 之后,另一个线程可能仍然看到旧值 1,这就是可见性问题。

volatile 关键字可以解决可见性问题。被 volatile 修饰的变量,在写入后会立即刷新到主内存,读取时会直接从主内存读取,同时还禁止了一部分指令重排序。但 volatile 并不保证原子性。例如 volatile int number,number++ 这个操作实际上包含读取、加一、写入三个步骤。即使变量本身可见,也不能阻止两个线程同时读到同一个值,然后各自加一后写入,最终只增加一次。因此,在交替打印问题中,如果只使用 volatile 修饰 number,仍然需要配合锁或原子类来保证 number 的读取、判断和自增在整体上不会交错。

3.2 锁与条件队列

synchronized 是 Java 内置锁机制,它可以保证同一时刻只有一个线程进入被保护的代码块。synchronized 可以修饰方法,也可以修饰代码块。线程进入 synchronized 代码块之前需要先获得锁,执行完毕或异常退出时会释放锁。与锁配合使用的还有 Object 类提供的 wait、notify、notifyAll 三个方法。wait 方法会让当前线程释放锁,并进入等待队列阻塞,直到其他线程调用同一个对象上的 notify 或 notifyAll。notify 会随机唤醒一个等待线程,而 notifyAll 会唤醒所有等待线程。被唤醒的线程并不会立即继续执行,它需要重新竞争锁,拿到锁之后才从 wait 方法返回。

条件队列的关键规则是:调用 wait、notify、notifyAll 之前,线程必须已经持有对应对象的锁,否则会抛出 IllegalMonitorStateException。wait 方法会释放锁,而 sleep 方法不会释放锁,这是 wait 和 sleep 的重要区别。wait 方法通常放在 while 循环中,而不是 if 中。因为线程被唤醒后,共享条件可能已经不再满足,如果直接继续执行,就可能打印错误;更重要的原因是操作系统层面可能出现虚假唤醒,也就是线程在没有被其他线程明确 notify 的情况下被唤醒,所以必须再次检查条件。

Lock 和 Condition 是 JUC 包提供的更灵活的锁机制。ReentrantLock 支持可重入、可中断、可定时、公平锁等特性,Condition 则允许为同一个锁创建多个等待队列。每个 Condition 都支持 await、signal、signalAll 方法,类似 wait、notify、notifyAll,但可以精确唤醒某个等待队列上的线程。这在三个线程打印 ABC 的场景中特别有用:可以创建 conditionA、conditionB、conditionC 三个队列,打印完 A 后只唤醒等待在 conditionB 上的线程,而不是像 notifyAll 那样把所有线程都唤醒后让它们重新竞争。

3.3 原子类与 CAS

AtomicInteger、AtomicBoolean、AtomicLong 等原子类,底层基于 CAS,也就是比较并交换机制。CAS 操作会先比较内存中的当前值是否等于期望值,如果相等,就把内存值更新为新值,否则不做修改。这个比较和交换过程由 CPU 指令保证原子性,因此不需要加锁。在交替打印问题中,可以使用 AtomicInteger 作为共享变量,通过 get、getAndIncrement、compareAndSet 等方法实现无锁的交替打印。原子类不仅解决了原子性问题,同时也提供了可见性保证。

原子类的使用也有边界。单个原子方法是原子性的,但如果多个原子方法组合成一个复合操作,例如先 get 判断奇偶,再 getAndIncrement 自增,这两个方法之间仍然可能被其他线程插入。因此,即使使用 AtomicInteger,也要仔细分析判断和自增之间是否存在竞态,避免重复打印同一个数字。

3.4 常用并发工具类

Java 还提供了很多更高级的并发工具,可以帮助我们更轻松地实现交替打印。Semaphore 信号量维护一组许可,acquire 方法会获取一个许可,如果许可数量不足则阻塞,release 方法会释放一个许可。可以用两个信号量分别控制奇数和偶数线程的执行资格。CountDownLatch 是一个一次性计数器,await 方法会等待计数器归零,countDown 方法会把计数器减一,适合做启动门控,但在循环交替场景中需要不断创建新的门闩,使用起来比较别扭。CyclicBarrier 让一组线程互相等待,当所有线程都到达屏障后一起继续,适合“每一轮都同步推进”的模式。Phaser 是 CyclicBarrier 的增强版,支持可变数量的参与者。BlockingQueue 阻塞队列适合作为任务传递或令牌传递的载体。Exchanger 可以在两个线程之间交换数据。LockSupport 的 park 和 unpark 方法提供了更底层的线程阻塞与唤醒能力,甚至可以做到先 unpark 再 park 也不会丢许可证。

四、实现一:synchronized 加 wait/notify

最经典的写法是基于 synchronized、wait 和 notify。它的思路是:使用一个共享变量 number 记录当前要打印的数字,使用一个共享锁对象 lock 保证互斥访问。奇数线程获得锁后,判断当前 number 是否为奇数;如果是奇数,就打印并自增,然后调用 lock.notify 唤醒对方;如果不是奇数,就调用 lock.wait 释放锁并等待。偶数线程的逻辑对称,判断 number 是否为偶数。两个线程不断循环,直到 number 超过 100。

基础代码示例如下:

java

public class AlternatingPrintWaitNotify { private static final Object lock = new Object(); private static int number = 1; private static final int MAX = 100; public static void main(String[] args) { Thread odd = new Thread(() -> { synchronized (lock) { while (number <= MAX) { if (number % 2 == 0) { try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } else { System.out.println("奇数线程:" + number++); lock.notify(); } } } }, "odd-thread"); Thread even = new Thread(() -> { synchronized (lock) { while (number <= MAX) { if (number % 2 != 0) { try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } else { System.out.println("偶数线程:" + number++); lock.notify(); } } } }, "even-thread"); odd.start(); even.start(); } }

这个代码的核心是,number 的读取、判断和 number++ 全部都在 synchronized 代码块内部,所以一次只能有一个线程执行这些逻辑。当奇数线程发现 number 是偶数时,说明当前不应该由它打印,于是调用 lock.wait 释放锁并等待;偶数线程此时才能拿到锁,打印偶数后调用 lock.notify 唤醒奇数线程。反之亦然。

这个实现中有几个需要特别说明的地方。第一,wait 必须放在 while 循环中,而不是 if 中。虽然当前代码使用了 while 判断“number 是偶数”,但从严格意义上讲,每次被唤醒后重新进入 while 判断是必要的。第二,notify 在这里通常能正常工作,因为同一时刻只有一个线程可能处于等待状态。但如果有更多线程,或者线程池中同时有多个同类线程,notify 唤醒的线程可能不是你希望的那一个。第三,当 number 从 100 增加到 101 后,循环条件 number <= MAX 变为 false,线程会退出。但退出时可能没有唤醒对方,导致另一个线程永久阻塞。这种结束逻辑在不同机器上可能表现不同,面试时需要主动说明。

运行流程大致如下:假设奇数线程先获得锁。number 初始为 1,是奇数,奇数线程打印 1,number 变成 2,然后调用 lock.notify。此时偶数线程可能还在等待锁,奇数线程继续循环,发现 number 是偶数,于是调用 lock.wait 释放锁。偶数线程获得锁后,发现 number 是偶数,打印 2,number 变成 3,然后 lock.notify。之后偶数线程继续循环,发现 number 是奇数,调用 lock.wait 释放锁。如此循环,输出就会是 1、2、3、4……100。打印到 100 后,number 变成 101,下一次两个线程中任何一个再判断 while 条件都会退出。问题是,如果奇数线程已经因为 number 是偶数而 wait,偶数线程打印 100 后 notify,奇数线程被唤醒后再次判断 number 是 101,退出。但如果顺序刚好是奇数线程先退出,而偶数线程还没退出,可能偶数线程下一次 wait 后没有人唤醒它。因此更推荐使用 notifyAll,并且在线程退出前主动再通知一次对方。

面试官追问“为什么 wait 放在 while 里而不是 if 里”,标准答案是这样的:wait 被唤醒后,线程不一定能立即重新获得锁,在等待锁的过程中,其他线程可能已经改变了共享条件。即使最终获得了锁,之前判断的条件很可能已经不再成立。此外,操作系统可能在没有线程调用 notify 的情况下虚假唤醒等待线程。如果使用 if 判断,线程被唤醒后会直接继续执行后续代码,可能打印错误数字。使用 while 则可以重新检查条件,只有条件真正满足时才继续执行。

五、实现二:synchronized 加 notifyAll 和标志位

为了避免 notify 唤醒错误线程或结束逻辑不够健壮的问题,可以引入一个布尔标志位 oddTurn,用来明确表示当前是否轮到奇数线程。每次线程打印完成后,切换标志位,并使用 notifyAll 唤醒对方。因为只有一个锁上的等待队列,所以 notifyAll 会让另一个线程被唤醒,并且被唤醒的线程会用 while 再次检查 oddTurn,避免打印错误。

改进后的代码如下:

java

public class AlternatingPrintNotifyAll { private static final Object lock = new Object(); private static int number = 1; private static boolean oddTurn = true; private static final int MAX = 100; public static void main(String[] args) { Thread odd = new Thread(() -> { synchronized (lock) { while (number <= MAX) { while (!oddTurn && number <= MAX) { try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (number > MAX) { break; } if (number % 2 != 0) { System.out.println("奇数线程:" + number++); } oddTurn = false; lock.notifyAll(); } } }, "odd-thread"); Thread even = new Thread(() -> { synchronized (lock) { while (number <= MAX) { while (oddTurn && number <= MAX) { try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (number > MAX) { break; } if (number % 2 == 0) { System.out.println("偶数线程:" + number++); } oddTurn = true; lock.notifyAll(); } } }, "even-thread"); odd.start(); even.start(); } }

这个版本的好处是逻辑更加自解释:oddTurn 为 true 时只有奇数线程可以打印,打印后把它改为 false;oddTurn 为 false 时只有偶数线程可以打印,打印后把它改为 true。即使用 notifyAll 唤醒了多个线程,每个被唤醒的线程都会重新进入 while 判断 oddTurn 是否符合自己的身份,不符合的继续等待。这种写法可以扩展到三个甚至更多线程的交替打印场景,只要把标志位从布尔值改成状态值即可。

这个版本同样需要注意结束条件。线程在 wait 醒来后,可能 number 已经超过 MAX,所以 wait 的 while 条件中同时加入了 number <= MAX,这样当数字打印完毕后,wait 也会退出,不会死等。线程完成打印后,如果发现本线程已经完成了自己的部分,break 退出。由于每次打印后都调用 notifyAll,对方总是有机会被唤醒并看到 number 超过 MAX,从而退出。

面试中经常追问:notify 和 notifyAll 应该如何选择?简单场景、明确只有两个线程而且等待队列中不会有多个同类线程时,可以使用 notify。但只要有一点不确定,最好使用 notifyAll。原因在于,notify 只能随机唤醒一个等待线程,一旦等待队列中存在多个不同条件的线程,就可能唤醒到错误线程,导致整个协作流程卡住;notifyAll 会唤醒所有线程,虽然唤醒代价略高,但每个线程都会用 while 重新检查自己的条件,不满足条件的线程继续等待,因此正确性更有保证。在面试中,如果无法完全确定等待队列里的线程构成,优先选择 notifyAll,并说明它能避免“唤醒线程错配”带来的死锁风险。

六、实现三:ReentrantLock 加 Condition

synchronized 配合 wait/notify 虽然简单,但它只有一个隐式的等待队列。当线程数量增多、等待条件增多时,notify 很难精确唤醒到正确线程,notifyAll 又会造成无谓的线程唤醒。Java 5 引入的 ReentrantLock 和 Condition 解决了这个问题:同一个锁可以创建多个 Condition,每个 Condition 对应一个独立的等待队列,线程可以只等待在特定条件上,打印完成后也只唤醒需要被唤醒的那一个队列上的线程。

ReentrantLock 的主要特性包括:可重入,同一个线程可以多次获得同一把锁;可中断,等待锁的过程中可以响应中断;可定时,可以尝试在指定时间内获得锁;公平性可选择,构造时可以指定是否采用公平锁。Condition 则提供 await、signal、signalAll 三个核心方法。await 对应 wait,会让当前线程释放锁并进入该 Condition 的等待队列;signal 对应 notify,只唤醒该队列中的一个线程;signalAll 对应 notifyAll,唤醒该队列中的所有线程。在交替打印奇数偶数的场景中,可以创建 oddCondition 和 evenCondition 两个队列,奇数线程等待在自己的队列上,偶数线程等待在自己的队列上,奇数线程打印后只唤醒 evenCondition,偶数线程打印后只唤醒 oddCondition。

下面是基于 ReentrantLock 和 Condition 的完整实现:

java

import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class AlternatingPrintLockCondition { private static final ReentrantLock lock = new ReentrantLock(); private static final Condition oddCondition = lock.newCondition(); private static final Condition evenCondition = lock.newCondition(); private static int number = 1; private static final int MAX = 100; public static void main(String[] args) { Thread odd = new Thread(() -> { while (number <= MAX) { lock.lock(); try { while (number % 2 == 0 && number <= MAX) { oddCondition.await(); } if (number > MAX) { break; } System.out.println("奇数线程:" + number++); evenCondition.signal(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } }, "odd-thread"); Thread even = new Thread(() -> { while (number <= MAX) { lock.lock(); try { while (number % 2 != 0 && number <= MAX) { evenCondition.await(); } if (number > MAX) { break; } System.out.println("偶数线程:" + number++); oddCondition.signal(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } }, "even-thread"); odd.start(); even.start(); } }

这段代码的执行过程如下:奇数线程先进入循环并加锁。它首先判断 number 是否为偶数,如果 number 是奇数,就直接打印并自增,然后调用 evenCondition.signal 唤醒等待在 evenCondition 队列上的偶数线程。如果 number 是偶数,表示当前不该奇数线程执行,它调用 oddCondition.await 释放锁并进入自己的等待队列。偶数线程的逻辑正好相反。由于两个线程分别等待在不同的 Condition 队列上,signal 可以做到精确唤醒,不会出现“随机唤醒错线程”的问题。

使用 Lock 和 Condition 时有三点必须注意。第一,lock.lock 之后必须使用 try-finally 确保无论是否发生异常,最终都会调用 lock.unlock 释放锁,否则一旦 await 或打印逻辑抛出异常,锁被永久占用,程序会死锁。第二,await 和 signal 都必须在持有锁的前提下调用,这一点与 wait 和 notify 一致。第三,await 同样可能发生虚假唤醒,因此也必须放在 while 循环中反复判断条件,不能使用 if。这个版本在结构上比 synchronized 版本更清晰,也为后面三线程打印 ABC 提供了很好的基础。

七、实现四:Semaphore 信号量控制

Semaphore 是 JUC 包中的信号量工具,它通过“许可”数量来控制线程能否继续执行。acquire 方法会尝试获取一个许可,如果许可数量为零,线程就阻塞;release 方法会释放一个许可,并唤醒一个阻塞中的线程。Semaphore 的许可模型非常灵活,可以用它代替锁和等待通知,实现“谁有许可谁执行”的交替打印模式。

在奇数偶数交替打印问题中,可以创建两个 Semaphore:oddSemaphore 初始许可数为 1,表示一开始奇数线程可以执行;evenSemaphore 初始许可数为 0,表示偶数线程一开始必须等待。奇数线程先 acquire 自己的信号量,拿到许可后打印奇数,随后 release 偶数线程的信号量,表示“现在轮到偶数线程了”。偶数线程先 acquire 自己的信号量,由于初始许可为 0,会被阻塞,直到奇数线程 release 后才继续执行。偶数线程打印后,再 release 奇数线程的信号量,形成循环。

完整实现如下:

java

import java.util.concurrent.Semaphore; public class AlternatingPrintSemaphore { private static final Semaphore oddSemaphore = new Semaphore(1); private static final Semaphore evenSemaphore = new Semaphore(0); private static int number = 1; private static final int MAX = 100; public static void main(String[] args) { Thread odd = new Thread(() -> { while (number <= MAX) { try { oddSemaphore.acquire(); if (number > MAX) { evenSemaphore.release(); break; } System.out.println("奇数线程:" + number++); evenSemaphore.release(); } catch (InterruptedException e) { e.printStackTrace(); } } }, "odd-thread"); Thread even = new Thread(() -> { while (number <= MAX) { try { evenSemaphore.acquire(); if (number > MAX) { oddSemaphore.release(); break; } System.out.println("偶数线程:" + number++); oddSemaphore.release(); } catch (InterruptedException e) { e.printStackTrace(); } } }, "even-thread"); odd.start(); even.start(); } }

这个实现的运行流程非常直观。程序启动后,只有 oddSemaphore 有一个许可,因此只有奇数线程能够通过 acquire。奇数线程打印 1 后把 number 改为 2,然后 evenSemaphore.release 给偶数线程发放一个许可。此时奇数线程继续循环,再次 acquire oddSemaphore,但 oddSemaphore 的许可已经用完,奇数线程阻塞。偶数线程此时可以 acquire 到许可,打印 2,然后 oddSemaphore.release 唤醒奇数线程。如此循环,输出严格连续。打印到 100 之后,number 变为 101,其中一个线程在 acquire 后检查到 number 大于 MAX,会先给对方信号量补一个许可再 break,避免对方永久阻塞。

Semaphore 版本最大的优点是逻辑上更像“令牌传递”:谁拿到令牌谁工作,用完把令牌交给对方。缺点是 acquire 和 release 不像锁那样有明确的临界区边界,如果业务逻辑复杂,容易出现多用一次或少用一次许而导致的许可数量失衡。因此在简单交替打印场景中它很优雅,但工程中要谨慎控制许可的申请与释放次数。

八、实现五:AtomicInteger 无锁实现

前面几种方案都依赖锁或阻塞机制,线程在不能执行时需要让出 CPU。还有一种思路是使用 AtomicInteger 配合 CAS 实现无锁的交替打印。AtomicInteger 的 get 方法可以读取当前值,compareAndSet 方法可以原子地完成“比较并修改”。如果当前值等于期望值,就更新为新值并返回 true;如果不等,就不做任何修改并返回 false。这个操作由底层 CPU 指令保证原子性,不需要进入内核态加锁。

在交替打印问题中,奇数线程不断读取 number,如果当前值是奇数,就尝试用 compareAndSet 把它从 current 更新为 current + 1。更新成功后再打印 current,这样就保证了打印值不会被重复处理。即使两个线程同时读到 current,也只有一个线程的 compareAndSet 能成功,另一个线程会失败并进入下一轮循环重新读取。偶数线程同理,只处理偶数。

完整实现如下:

java

import java.util.concurrent.atomic.AtomicInteger; public class AlternatingPrintAtomic { private static final AtomicInteger number = new AtomicInteger(1); private static final int MAX = 100; public static void main(String[] args) { Thread odd = new Thread(() -> { while (number.get() <= MAX) { int current = number.get(); if (current % 2 != 0 && number.compareAndSet(current, current + 1)) { System.out.println("奇数线程:" + current); } } }, "odd-thread"); Thread even = new Thread(() -> { while (number.get() <= MAX) { int current = number.get(); if (current % 2 == 0 && number.compareAndSet(current, current + 1)) { System.out.println("偶数线程:" + current); } } }, "even-thread"); odd.start(); even.start(); } }

这里的关键是把“读取判断”和“更新”通过 compareAndSet 联系起来。虽然 get 和 compareAndSet 是两个独立调用,但因为 compareAndSet 会重新校验当前值是否仍然是 current,所以可以避免重复处理同一个数字。例如两个奇数线程同时读到 current 为 3,都判断它是奇数,然后都尝试 compareAndSet(3, 4),最终只有一个线程成功,另一个线程失败后重新读取,此时 number 已经是 4,它会跳过。最终每个数字只会被打印一次。

无锁实现的优缺点都很明显。优点是避免了锁的开销和线程上下文切换,在竞争适中的情况下吞吐量可能更好。缺点是这里采用的仍然是循环重试的方式,也就是自旋等待。当轮到对方线程工作而本线程频繁进入循环读取时,会持续占用 CPU,形成忙等。在数字范围很小的时候问题不大,但如果业务操作耗时很长,忙等会浪费大量 CPU 资源。因此无锁方案更适合极短临界区的场景。面试时需要主动补充这一点,说明不是在所有情况下无锁都比有锁好。如果希望减少忙等,可以在 compareAndSet 失败后调用 Thread.yield 提示调度器让出 CPU,或者短暂睡眠。

九、实现六:LockSupport 底层实现

LockSupport 是 JUC 包中很底层的线程阻塞原语,核心方法是 park 和 unpark。park 会让当前线程阻塞,除非它已经持有“许可证”;unpark 会给指定线程发放一个许可证,即使 unpark 先于 park 调用,park 也只会消费掉这个许可并立即返回。这与 wait/notify 的一个重要区别是:notify 如果先于 wait 执行,通知就丢失了;而 unpark 先执行也没有问题,因为许可会被保留。

在交替打印问题中,可以让两个线程互相引用对方线程对象。奇数线程打印完奇数后,调用 LockSupport.unpark(evenThread) 给偶数线程发放许可,然后如果轮到对方,就调用 LockSupport.park 阻塞自己。偶数线程对称处理。每个线程在打印完自己该处理的数字后,先用 unpark 唤醒对方,再由对方打印并唤醒自己,从而形成交替。

完整实现如下:

java

import java.util.concurrent.locks.LockSupport; public class AlternatingPrintLockSupport { private static Thread oddThread; private static Thread evenThread; private static int number = 1; private static final int MAX = 100; public static void main(String[] args) { oddThread = new Thread(() -> { while (number <= MAX) { if (number % 2 != 0) { System.out.println("奇数线程:" + number++); LockSupport.unpark(evenThread); if (number <= MAX) { LockSupport.park(); } } } LockSupport.unpark(evenThread); }, "odd-thread"); evenThread = new Thread(() -> { while (number <= MAX) { if (number % 2 == 0) { System.out.println("偶数线程:" + number++); LockSupport.unpark(oddThread); if (number <= MAX) { LockSupport.park(); } } } LockSupport.unpark(oddThread); }, "even-thread"); oddThread.start(); evenThread.start(); } }

这个版本的执行过程是:奇数线程启动后打印 1,number 变为 2,然后 unpark evenThread。因为 evenThread 可能还没有执行到 park,所以这个许可会被保留。奇数线程随后判断 number 仍然小于等于 MAX,于是把自己 park 阻塞。偶数线程启动后打印 2,number 变为 3,然后 unpark oddThread;由于 oddThread 之前已经 park,这个许可会立即让 oddThread 恢复执行。偶数线程再把自己 park。两个线程就这样交替唤醒对方。打印完成后,每个线程退出循环前还会再 unpark 对方一次,确保对方能正常退出而不会永久阻塞。

LockSupport 版本的特点是不需要显式锁,也不需要 synchronized 代码块。它的 park 和 unpark 可以在任意位置调用。但缺点也明显:代码中直接依赖线程对象,内存可见性和指令重排序需要特别小心。在上面的示例中,number 没有加 volatile 修饰,严格来说在多线程下存在可见性风险,面试时如果使用这个写法,最好把 number 声明为 volatile,或者进一步分析 JVM 对 park/unpark 和变量可见性的交互。LockSupport 更适合作为构建高层并发工具的基础,而不是业务代码的首选。

十、实现七:CountDownLatch、CyclicBarrier 与 Phaser

这三个工具类都可以在一定程度上实现线程之间的协调,但它们在交替打印问题中的适配度并不一样。CountDownLatch 是一个一次性计数器,强调“等待一组任务完成”。它最典型的用法是让主线程等待若干子线程初始化完毕后再继续,或者模拟多个线程同时起跑。它不能直接支持循环往复的交替打印,因为计数器归零后不会自动重置。要用它做交替打印,每一轮都必须重新创建一个 CountDownLatch,代码会变得非常绕,所以一般不推荐。

CyclicBarrier 可以让一组线程互相等待:当指定数量的线程都到达屏障点时,所有线程才会同时继续。名字中的 Cyclic 表示屏障可以循环使用。表面上看,它似乎能实现“一轮一轮同步推进”,但交替打印要求的是一个严格的先后顺序,而不是“大家一起等到齐”。如果单纯用 CyclicBarrier 让两个线程每一轮都互相等待,线程到达屏障的顺序和数量不受严格控制,很容易出现打印顺序错乱或一方提前多次到达的情况。因此 CyclicBarrier 更适合分阶段并行计算,例如把一个大任务拆成多个子任务,每完成一个阶段就通过屏障对齐一次,再一起进入下一阶段。

Phaser 是 CyclicBarrier 的增强版,支持动态增删参与者,也支持分阶段推进,在复杂任务编排中非常有用。但同样地,它设计的初衷并不是解决两个线程交替打印这类严格顺序问题。面试时如果被问到“能不能用 CyclicBarrier 实现交替打印”,更稳妥的回答是:可以先说明它在语义上更适合阶段同步,再说明如果强行使用会有哪些问题,最后给出条件锁或信号量这类更贴合交替语义的方案。这样既展示了工具类的理解深度,也不会陷入强行套用工具的错误。

十一、实现八:BlockingQueue 与 Exchanger

BlockingQueue 阻塞队列天然适合生产者消费者模型。当一个队列满时,put 操作会阻塞;当队列为空时,take 操作会阻塞。利用这个特性,可以让一个线程不断向队列放入数字,另一个线程从队列取出并打印,从而让生产速度和消费速度自动匹配。不过严格来说,这是两个线程之间基于队列的数据传递,而不是两个线程各自打印一部分。它可以满足“最终按照顺序完整输出”的要求,但通常不被称为经典的“交替打印”实现。

示例代码如下:

java

import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; public class AlternatingPrintBlockingQueue { private static final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(1); private static final int MAX = 100; public static void main(String[] args) { Thread producer = new Thread(() -> { for (int i = 1; i <= MAX; i++) { try { queue.put(i); } catch (InterruptedException e) { e.printStackTrace(); } } }, "producer"); Thread consumer = new Thread(() -> { while (true) { try { Integer value = queue.take(); System.out.println("消费者:" + value); if (value == MAX) { break; } } catch (InterruptedException e) { e.printStackTrace(); } } }, "consumer"); producer.start(); consumer.start(); } }

这个示例里,生产者线程负责生成数字并放入队列,消费者线程负责从队列取出并打印。因为队列容量为 1,所以每放入一个数字,消费者必须取走才能继续放入下一个,从而实现顺序输出。

Exchanger 用于两个线程之间交换数据。它的 exchange 方法会阻塞当前线程,直到另一个线程也调用 exchange,然后双方交换各自传入的对象并继续执行。Exchanger 可以用于两个线程在某个同步点交换缓冲区或状态,但用它来实现交替打印需要仔细设计交换的内容,通常不如条件锁、信号量或 LockSupport 直观。因此在实际面试中,如果被问到 Exchanger 是否能实现交替打印,可以回答“理论上可以,但并不是它的典型用法,更适合成对线程之间的数据交换场景”。

十二、三线程交替打印 ABC

在三线程交替打印 ABC 的问题中,要求三个线程按照 A、B、C、A、B、C……的顺序循环打印,直到达到指定轮数或字符数。使用 ReentrantLock 和 Condition 是最清晰的实现方式:创建三个 Condition 队列,分别对应 A、B、C 三个线程;每个线程在自己的条件上等待,打印后只唤醒下一个线程对应的条件。

简化实现如下:

java

import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class PrintABC { private static final ReentrantLock lock = new ReentrantLock(); private static final Condition conditionA = lock.newCondition(); private static final Condition conditionB = lock.newCondition(); private static final Condition conditionC = lock.newCondition(); private static int state = 0; // 0:A, 1:B, 2:C private static final int ROUNDS = 10; public static void main(String[] args) { Thread threadA = new Thread(() -> print("A", 0, conditionA, conditionB), "A"); Thread threadB = new Thread(() -> print("B", 1, conditionB, conditionC), "B"); Thread threadC = new Thread(() -> print("C", 2, conditionC, conditionA), "C"); threadA.start(); threadB.start(); threadC.start(); } private static void print(String name, int myState, Condition myCondition, Condition nextCondition) { for (int i = 0; i < ROUNDS; i++) { lock.lock(); try { while (state % 3 != myState) { myCondition.await(); } System.out.print(name); state++; nextCondition.signal(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } } }

三个线程分别等待在 conditionA、conditionB、conditionC 上。每个线程打印完成后,把 state 加一,并只唤醒下一个线程对应的条件。这样既保证了严格顺序,又避免了 notifyAll 带来的无谓唤醒,是三线程交替打印的推荐写法。

十三、常见错误与性能对比

在实际面试和工程中,交替打印问题常见的错误有几类。第一,wait 放在 if 而不是 while 中,导致虚假唤醒后条件不再成立时仍然继续执行。第二,使用 notify 而不是 notifyAll,当等待队列中存在多个不同条件的线程时,可能唤醒到错误的线程,导致死锁或顺序错乱。第三,线程退出时没有唤醒对方,导致对方永久阻塞。第四,使用 AtomicInteger 时,get 和 compareAndSet 之间没有正确地重新读取当前值,导致重复打印或漏打。第五,使用 Semaphore 时 acquire 和 release 次数不匹配,导致许可数量失衡。第六,使用 LockSupport 时忘记处理 unpark 先于 park 的情况,或者没有在退出前唤醒对方。

从性能角度看,不同方案的侧重点不同:synchronized + wait/notify 语义最简单,JVM 已经做了大量优化,适合绝大多数场景;ReentrantLock + Condition 唤醒更精确,但代码略复杂;Semaphore 更像令牌传递,适合简单交替;AtomicInteger 无锁但存在忙等,适合极短临界区;LockSupport 最底层,适合构建基础工具而不是业务代码。面试时如果能说清楚每种方案的适用边界和代价,就已经超过了大多数候选人。

十四、总结

回到最初的面试题:两个线程交替打印奇数偶数。

一个完整的回答应该包含四层:

  1. 问题建模:共享状态 + 等待通知,核心是判断、执行、修改、通知四个动作。

  2. 基础实现:synchronized + wait/notify,注意 while 循环、notifyAll、退出时唤醒对方。

  3. 进阶实现:ReentrantLock + Condition、Semaphore、AtomicInteger、LockSupport,各自适用边界。

  4. 工程判断:三线程 ABC 用 Condition 精确唤醒,CyclicBarrier/Phaser 更适合阶段同步,BlockingQueue 更适合生产者消费者。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 2:21:26

Nexe 打包排错指南:7 类高频报错的排查路径

Nexe 打包排错指南&#xff1a;7 类高频报错的排查路径 【免费下载链接】nexe &#x1f389; create a single executable out of your node.js apps 项目地址: https://gitcode.com/gh_mirrors/ne/nexe 你刚敲下 nexe my-app.js&#xff0c;本以为 Node.js 打包会一气呵…

作者头像 李华