多线程打印奇偶数,怎么控制打印的顺序
这是一个非常经典的并发面试题,它能很好地考察面试者对线程通信和同步机制的理解与运用。解决这个问题的核心思想是:让两个线程交替执行,并通过一个共享的状态变量来协调它们的执行权。
有多种方法可以实现,下面我将为您介绍几种最典型、最能体现不同技术深度的方法,从基础的synchronized
+wait/notify
到更现代的Lock
+Condition
。
方法一:使用 synchronized
+ wait()
/ notifyAll()
(经典基础)
这是最经典、最能考察Java底层同步原语理解的方法。
思路:
- 创建一个共享的锁对象(比如一个
Object
实例)。 - 创建一个共享的计数器(比如
int count
),从1开始。 - 创建两个线程:奇数线程和偶数线程。
- 两个线程都在一个
synchronized
块内进行循环,循环条件是count
没有超出打印范围。 - 奇数线程:
- 在同步块内,检查
count
是否为偶数。如果是,说明轮不到自己打印,调用lock.wait()
释放锁并进入等待。 - 如果
count
是奇数,就打印它,然后count++
,最后调用lock.notifyAll()
唤醒可能在等待的偶数线程。
- 偶数线程:
- 逻辑与奇数线程相反。检查
count
是否为奇数,如果是,就wait()
。 - 如果
count
是偶数,就打印,count++
,然后notifyAll()
。
- 为什么用
while
循环检查条件而不是if
?这是wait/notify
机制的最佳实践,为了防止“虚假唤醒”。 - 为什么用
notifyAll()
而不是notify()
?在这个只有两个线程的场景下,notify()
也行。但在更复杂的场景中,notifyAll()
更健壮,能避免信号丢失导致死锁。
代码实现:
public class OddEvenPrinter {
private final Object lock = new Object();
private volatile int count = 1;
private final int max;
public OddEvenPrinter(int max) {
this.max = max;
}
public void print() {
Thread oddThread = new Thread(() -> {
while (count <= max) {
synchronized (lock) {
// 使用while防止虚假唤醒
while (count % 2 == 0) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
if (count <= max) {
System.out.println(Thread.currentThread().getName() + ": " + count++);
}
lock.notifyAll(); // 唤醒偶数线程
}
}
}, "奇数线程");
Thread evenThread = new Thread(() -> {
while (count <= max) {
synchronized (lock) {
while (count % 2 != 0) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
if (count <= max) {
System.out.println(Thread.currentThread().getName() + ": " + count++);
}
lock.notifyAll(); // 唤醒奇数线程
}
}
}, "偶数线程");
oddThread.start();
evenThread.start();
}
public static void main(String[] args) {
new OddEvenPrinter(100).print();
}
}
方法二:使用 ReentrantLock
+ Condition
(现代推荐)
这是对方法一的升级版,使用了JUC包提供的更强大、更灵活的工具。Condition
可以实现更精准的线程等待和唤醒。
思路:
- 创建一个
ReentrantLock
。 - 从这个
Lock
对象中创建两个Condition
对象:一个给奇数线程用(oddCondition
),一个给偶数线程用(evenCondition
)。这实现了等待队列的分离。 - 奇数线程:
- 获取锁。如果当前
count
是偶数,调用oddCondition.await()
等待。 - 如果是奇数,打印,
count++
,然后调用evenCondition.signal()
精准地唤醒偶数线程。
- 偶数线程:
- 获取锁。如果当前
count
是奇数,调用evenCondition.await()
等待。 - 如果是偶数,打印,
count++
,然后调用oddCondition.signal()
精准地唤醒奇数线程。
- 最后都在
finally
块中释放锁。
代码实现(关键部分):
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// ...
private final Lock lock = new ReentrantLock();
private final Condition oddCondition = lock.newCondition();
private final Condition evenCondition = lock.newCondition();
// ...
// 奇数线程的run方法核心逻辑
lock.lock();
try {
while (count <= max) {
if (count % 2 != 0) {
System.out.println(Thread.currentThread().getName() + ": " + count++);
evenCondition.signal(); // 唤醒偶数线程
} else {
oddCondition.await(); // 等待自己被唤醒
}
}
} finally {
lock.unlock();
}
// 偶数线程的run方法核心逻辑
lock.lock();
try {
while (count <= max) {
if (count % 2 == 0) {
System.out.println(Thread.currentThread().getName() + ": " + count++);
oddCondition.signal(); // 唤醒奇数线程
} else {
evenCondition.await(); // 等待自己被唤醒
}
}
} finally {
lock.unlock();
}
// ...
优点:使用Condition
可以避免notifyAll()
带来的“惊群效应”,并且语义更清晰,是更推荐的现代做法。
方法三:使用 Semaphore
(信号量)
这是一种更巧妙的思路,利用信号量来控制执行许可。
思路:
- 创建两个信号量:
oddSemaphore
初始许可证为1,evenSemaphore
初始许可证为0。 - 奇数线程:
- 循环中,首先尝试获取
oddSemaphore
的许可(acquire()
)。第一次会成功。 - 打印数字。
- 然后,释放
evenSemaphore
的许可(release()
),把执行权交给偶数线程。
- 偶数线程:
- 循环中,首先尝试获取
evenSemaphore
的许可。第一次会阻塞,直到奇数线程释放许可。 - 获取到许可后,打印数字。
- 然后,释放
oddSemaphore
的许可,把执行权交还给奇数线程。
这种方式代码更简洁,因为它把等待和唤醒的逻辑都封装在了acquire/release
中。
在面试中,通常能够清晰地写出第一种或第二种方法,并解释清楚其原理,就已经非常出色了。如果能提到第三种,则更能展示知识的广度。
单例模型既然已经用了synchronized,为什么还要再加volatile?
这是一个非常经典的、能深度考察面试者对Java内存模型(JMM)理解的“陷阱”问题。这个问题直接命中了双重检查锁定(DCL)单例模式的核心。
简短的回答是:volatile
在这里不是为了解决原子性或可见性问题(synchronized
已经解决了),而是为了解决由“指令重排序”可能导致的、获取到“半初始化”对象的问题。
1. DCL单例模式的代码回顾
我们先来看一下经典的DCL单例代码:
public class Singleton {
// 【关键点】如果去掉volatile,就可能出问题
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
// 第一次检查:避免每次都进入同步块,提升性能
if (instance == null) {
// 第二次检查:保证只有一个线程能创建实例
synchronized (Singleton.class) {
if (instance == null) {
// 问题就出在这一行代码
instance = new Singleton();
}
}
}
return instance;
}
}
2. synchronized
已经解决了什么?
synchronized (Singleton.class)
代码块确实保证了:
- 原子性:
instance = new Singleton();
这行代码及其内外包裹的if
判断,在同一时刻只会被一个线程执行。 - 可见性:当一个线程成功创建实例并退出同步块时,它对
instance
变量的修改(即把它从null
变成一个对象引用)会立即刷新到主内存,对其他线程可见。
那么,既然如此,为什么还需要volatile
呢?
3. 问题根源:instance = new Singleton();
不是原子操作
问题的关键在于,instance = new Singleton();
这行看似简单的代码,在JVM层面,它并不是一个原子操作。它大致可以分为三个步骤:
memory = allocate();
:在堆上分配对象的内存空间。ctorInstance(memory);
:调用Singleton
的构造函数,初始化对象的成员变量。instance = memory;
:将instance
引用指向分配好的内存地址。
4. “指令重排序”带来的风险
在没有volatile
的情况下,由于性能优化,JVM的JIT编译器或CPU可能会对这三个步骤进行指令重排序。一个可能的、致命的重排序结果是:
1 -> 3 -> 2
我们来模拟一下在这种重排序下,多线程会发生什么:
- 线程A 进入了
synchronized
代码块,开始执行instance = new Singleton();
。 - 它按照1 -> 3 -> 2的顺序执行:
- 先执行了步骤1,分配了内存。
- 然后跳过步骤2,先执行了步骤3,将
instance
引用指向了这块刚刚分配、但还未初始化的内存。此时,instance
已经 不为null
了。 - 在它正准备执行步骤2(初始化对象)之前,CPU时间片切换了。
- 线程B 此刻调用
getInstance()
方法。 - 它执行到第一次检查
if (instance == null)
。 - 由于线程A已经执行了步骤3,
instance
现在已经不为null
了。所以,这个if
判断为false
。 - 线程B直接
return instance;
,它获取到了一个指向合法内存地址、但该内存地址上的对象还完全没有被初始化的“半成品”。
当线程B使用这个“半成品”对象时,比如调用它的某个方法,就可能会因为成员变量还未初始化而导致NullPointerException
或其他不可预知的严重错误。
5. volatile
如何解决这个问题?
volatile
关键字在这里起到了两个至关重要的作用,但最关键的是第二个:
- 保证可见性(
synchronized
也能保证,这里算是双重保障)。 - 禁止指令重排序(这才是
synchronized
无法替代的核心作用)。
当instance
变量被volatile
修饰后,JMM会确保在volatile
写操作(instance = ...
)前后插入内存屏障,这会强制要求:
- 所有在
volatile
写之前的操作(包括步骤1和步骤2)必须全部完成。 - 所有在
volatile
写之后的操作必须在它之后执行。
这就保证了new Singleton()
的三个步骤必须按照1 -> 2 -> 3的顺序严格执行,杜绝了任何“半初始化”对象被其他线程看到的可能性。
结论
所以,在DCL单例模式中,synchronized
负责保证**“只有一个线程能进行实例化”(原子性),而volatile
则负责保证“实例化的过程不会被重排序”(有序性),从而确保其他线程在任何时候看到的instance
要么是null
,要么是一个完整初始化**的对象。两者缺一不可,共同保证了DCL的线程安全。
3个线程并发执行,1个线程等待这三个线程全部执行完在执行,怎么实现?
方法一:使用 CountDownLatch
(最佳实践)
CountDownLatch
,中文叫“倒计时门闩”,它的设计初衷就是为了解决“一个或多个线程等待其他一组线程完成”这类问题。
思路:
- 创建一个计数值为3的
CountDownLatch
实例。这个计数值代表了我们需要等待的子线程数量。
CountDownLatch latch = new CountDownLatch(3);
- 创建并启动3个子线程。在每个子线程任务的最后,都必须调用
latch.countDown()
方法。这个方法会将计数器减一。 - 在主线程(等待线程)中,调用
latch.await()
方法。这个方法会阻塞主线程,直到CountDownLatch
的内部计数器被减到0。 - 当3个子线程都执行完毕,并都调用了
countDown()
之后,计数器变为0,await()
方法就会返回,主线程被唤醒,继续执行它后续的逻辑。
代码实现:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class MainThreadWaitsForSubThreads {
public static void main(String[] args) throws InterruptedException {
// 1. 创建一个计数值为3的CountDownLatch
final int THREAD_COUNT = 3;
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
System.out.println("主线程:开始分配任务给3个子线程...");
// 2. 创建并启动3个子线程
for (int i = 1; i <= THREAD_COUNT; i++) {
final int threadNum = i;
new Thread(() -> {
try {
System.out.println("子线程 " + threadNum + " 开始执行...");
// 模拟耗时任务
TimeUnit.SECONDS.sleep((long) (Math.random() * 5));
System.out.println("子线程 " + threadNum + " 执行完毕!");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 任务执行完毕,计数器减一
latch.countDown();
}
}).start();
}
System.out.println("主线程:任务已分配完毕,开始等待所有子线程完成...");
// 3. 主线程调用await(),进入阻塞等待
latch.await();
// 4. 所有子线程完成后,主线程被唤醒
System.out.println("主线程:所有子线程均已执行完毕,主线程继续执行!");
}
}
为什么这是最佳方法?
- 语义清晰:
CountDownLatch
的名字和API(await
,countDown
)都非常直观地表达了“等待-倒数”的意图。 - 解耦:主线程不关心子线程具体是谁,也不关心它们做了什么,它只关心“任务完成”这个事件的数量。子线程也不需要持有主线程的引用。
- 高效:底层基于AQS实现,性能非常高。
方法二:使用 Thread.join()
(传统方法)
这是Java早期提供的一种比较基础的方法,也可以实现这个需求。
思路:
- 创建并启动3个子线程,并保留它们的
Thread
对象引用。 - 在主线程中,依次对这3个
Thread
对象调用join()
方法。 thread.join()
方法会阻塞当前线程(主线程),直到thread
这个子线程执行结束。- 主线程会一个一个地
join
,直到所有3个子线程都结束后,才能继续执行。
代码实现:
import java.util.concurrent.TimeUnit;
public class MainThreadWaitsWithJoin {
public static void main(String[] args) throws InterruptedException {
System.out.println("主线程:开始分配任务给3个子线程...");
// 1. 创建线程并保留引用
Thread t1 = new Thread(() -> {
// ... 任务逻辑同上 ...
}, "子线程1");
Thread t2 = new Thread(() -> {
// ... 任务逻辑同上 ...
}, "子线程2");
Thread t3 = new Thread(() -> {
// ... 任务逻辑同上 ...
}, "子线程3");
// 启动线程
t1.start();
t2.start();
t3.start();
System.out.println("主线程:任务已分配完毕,开始等待所有子线程完成...");
// 2. 依次调用join()
t1.join();
System.out.println("主线程:检测到子线程1完成。");
t2.join();
System.out.println("主线程:检测到子线程2完成。");
t3.join();
System.out.println("主线程:检测到子线程3完成。");
// 3. 所有join()都返回后,主线程继续
System.out.println("主线程:所有子线程均已执行完毕,主线程继续执行!");
}
}
join()
方法的局限性:
- 不够灵活:
join()
必须等待线程完全终止。而CountDownLatch
的countDown()
可以在线程的任何地方调用,代表一个阶段性任务的完成,不一定非得是线程结束。 - 强耦合:主线程必须持有所有子线程的
Thread
对象引用。 - 扩展性差:如果需要等待的线程数量是动态的,或者任务是由线程池管理的,使用
join()
会变得非常困难和混乱。
结论
在面试和实际开发中,当遇到“等待多个任务完成”的场景时,CountDownLatch
无疑是更专业、更灵活、更推荐的首选方案。而join()
则更多地作为理解线程基础生命周期的一个知识点。
假设两个线程并发读写同一个整型变量,初始值为零,每个线程加 50 次,结果可能是什么?
面试官您好,这是一个非常好的问题。对于“两个线程并发读写同一个整型变量,初始值为0,每个线程加50次”这个场景,最终的结果将是一个不确定的、小于等于100的整数,但最可能的结果是小于100,而几乎不可能恰好是100。
要理解为什么会这样,我们需要剖析i++
这个操作在底层到底发生了什么。
1. i++
操作的非原子性
在Java中,i++
这行看似简单的代码,它并不是一个原子操作。在底层,它至少包含了三个独立的步骤:
- 读取 (Read):从主内存中读取变量
i
的当前值,并加载到当前线程的工作内存(CPU缓存)中。 - 修改 (Modify):在当前线程的工作内存中,对这个值进行加1操作。
- 写入 (Write):将修改后的新值,写回到主内存中。
2. 并发场景下的“竞态条件”
正是因为这三个步骤之间存在间隙,当多个线程同时执行i++
时,就会产生竞态条件 (Race Condition),导致更新丢失。
我们可以来模拟一下最典型的更新丢失场景:
假设当前变量i
的值是 10。
- T1 (时间点1):线程A执行第一步“读取”,它读到的
i
的值是 10。 - T2 (时间点2):此时,发生了一次线程切换,线程B开始执行。它也执行第一步“读取”,由于线程A还没有把新值写回去,所以线程B读到的
i
的值也是10。 - T3 (时间点3):线程B继续执行第二步“修改”(10+1=11)和第三步“写入”。它成功地将
i
的值更新为了11,并写回主内存。 - T4 (时间点4):线程A重新获得CPU,它从自己之前已经完成的“读取”操作开始继续执行。它基于它在时间点1读到的旧值10,执行第二步“修改”,得到的结果也是11。
- T5 (时间点5):线程A执行第三步“写入”,将11再次写回主内存。
最终结果:虽然两个线程都执行了一次加法操作,但变量i
的值最终只从10变成了11,有一次加法操作的效果丢失了。
3. 最终结果的分析
为什么结果小于等于100?
因为总共只有
50 + 50 = 100
次“写入新值”的机会,所以结果不可能超过100。由于上面描述的“更新丢失”现象,多次加法操作可能只产生一次有效写入,所以最终结果很可能小于100。
为什么结果几乎不可能是100?
要得到100,必须保证每一次的“读-改-写”三步操作都恰好不被另一个线程打断。在现代多核CPU和抢占式操作系统调度下,这种“完美错开”的概率极低,几乎为零。
结果可能是0吗?
理论上,如果发生了极端情况,比如一个线程完成了49次更新,在第50次读取了旧值后,另一个线程一口气完成了50次更新,然后第一个线程再写入它的结果,那么结果就会非常小。但结果为0的可能性极小,除非两个线程的执行完全交错,每次都是一个线程读完,另一个线程完成整个读改写,然后再轮到第一个线程写,这种情况在现实中几乎不会发生。
4. 如何保证结果是100?
要确保最终结果是100,我们必须保证i++
这个复合操作的原子性。有多种方法可以实现:
- 使用
synchronized
关键字:将i++
操作放入一个synchronized
代码块或方法中,确保同一时间只有一个线程能执行它。
public synchronized void increment() {
i++;
}
- 使用
ReentrantLock
:与synchronized
类似,通过显式加锁和解锁来保证互斥。
lock.lock();
try {
i++;
} finally {
lock.unlock();
}
- 使用
AtomicInteger
(最佳选择):这是针对单个整型变量原子操作的最佳实践。
AtomicInteger atomicI = new AtomicInteger(0);
// 在线程中调用
atomicI.incrementAndGet();
AtomicInteger
底层使用了CAS(Compare-And-Swap) 这种无锁技术,性能通常比前两种加锁的方式要高得多。
通过这些同步手段,我们就能保证每次“读-改-写”操作的完整性,从而确保最终结果是我们期望的100。
参考小林coding和JavaGuide