这是一个非常经典的并发面试题,它能很好地考察面试者对线程通信和同步机制的理解与运用。解决这个问题的核心思想是:让两个线程交替执行,并通过一个共享的状态变量来协调它们的执行权。
有多种方法可以实现,下面我将为您介绍几种最典型、最能体现不同技术深度的方法,从基础的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
中。
在面试中,通常能够清晰地写出第一种或第二种方法,并解释清楚其原理,就已经非常出色了。如果能提到第三种,则更能展示知识的广度。
这是一个非常经典的、能深度考察面试者对Java内存模型(JMM)理解的“陷阱”问题。这个问题直接命中了双重检查锁定(DCL)单例模式的核心。
简短的回答是:volatile
在这里不是为了解决原子性或可见性问题(synchronized
已经解决了),而是为了解决由“指令重排序”可能导致的、获取到“半初始化”对象的问题。
我们先来看一下经典的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;
}
}
synchronized
已经解决了什么?synchronized (Singleton.class)
代码块确实保证了:
instance = new Singleton();
这行代码及其内外包裹的if
判断,在同一时刻只会被一个线程执行。instance
变量的修改(即把它从null
变成一个对象引用)会立即刷新到主内存,对其他线程可见。那么,既然如此,为什么还需要volatile
呢?
instance = new Singleton();
不是原子操作问题的关键在于,instance = new Singleton();
这行看似简单的代码,在JVM层面,它并不是一个原子操作。它大致可以分为三个步骤:
memory = allocate();
:在堆上分配对象的内存空间。ctorInstance(memory);
:调用Singleton
的构造函数,初始化对象的成员变量。instance = memory;
:将instance
引用指向分配好的内存地址。在没有volatile
的情况下,由于性能优化,JVM的JIT编译器或CPU可能会对这三个步骤进行指令重排序。一个可能的、致命的重排序结果是:
1 -> 3 -> 2
我们来模拟一下在这种重排序下,多线程会发生什么:
synchronized
代码块,开始执行instance = new Singleton();
。instance
引用指向了这块刚刚分配、但还未初始化的内存。此时,instance
已经 不为null
了。getInstance()
方法。if (instance == null)
。instance
现在已经不为null
了。所以,这个if
判断为false
。return instance;
,它获取到了一个指向合法内存地址、但该内存地址上的对象还完全没有被初始化的“半成品”。当线程B使用这个“半成品”对象时,比如调用它的某个方法,就可能会因为成员变量还未初始化而导致NullPointerException
或其他不可预知的严重错误。
volatile
如何解决这个问题?volatile
关键字在这里起到了两个至关重要的作用,但最关键的是第二个:
synchronized
也能保证,这里算是双重保障)。synchronized
无法替代的核心作用)。当instance
变量被volatile
修饰后,JMM会确保在volatile
写操作(instance = ...
)前后插入内存屏障,这会强制要求:
volatile
写之前的操作(包括步骤1和步骤2)必须全部完成。volatile
写之后的操作必须在它之后执行。这就保证了new Singleton()
的三个步骤必须按照1 -> 2 -> 3的顺序严格执行,杜绝了任何“半初始化”对象被其他线程看到的可能性。
所以,在DCL单例模式中,synchronized
负责保证**“只有一个线程能进行实例化”(原子性),而volatile
则负责保证“实例化的过程不会被重排序”(有序性),从而确保其他线程在任何时候看到的instance
要么是null
,要么是一个完整初始化**的对象。两者缺一不可,共同保证了DCL的线程安全。
CountDownLatch
(最佳实践)CountDownLatch
,中文叫“倒计时门闩”,它的设计初衷就是为了解决“一个或多个线程等待其他一组线程完成”这类问题。
思路:
CountDownLatch
实例。这个计数值代表了我们需要等待的子线程数量。CountDownLatch latch = new CountDownLatch(3);
latch.countDown()
方法。这个方法会将计数器减一。latch.await()
方法。这个方法会阻塞主线程,直到CountDownLatch
的内部计数器被减到0。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
)都非常直观地表达了“等待-倒数”的意图。Thread.join()
(传统方法)这是Java早期提供的一种比较基础的方法,也可以实现这个需求。
思路:
Thread
对象引用。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()
则更多地作为理解线程基础生命周期的一个知识点。
面试官您好,这是一个非常好的问题。对于“两个线程并发读写同一个整型变量,初始值为0,每个线程加50次”这个场景,最终的结果将是一个不确定的、小于等于100的整数,但最可能的结果是小于100,而几乎不可能恰好是100。
要理解为什么会这样,我们需要剖析i++
这个操作在底层到底发生了什么。
i++
操作的非原子性在Java中,i++
这行看似简单的代码,它并不是一个原子操作。在底层,它至少包含了三个独立的步骤:
i
的当前值,并加载到当前线程的工作内存(CPU缓存)中。正是因为这三个步骤之间存在间隙,当多个线程同时执行i++
时,就会产生竞态条件 (Race Condition),导致更新丢失。
我们可以来模拟一下最典型的更新丢失场景:
假设当前变量i
的值是 10。
i
的值是 10。i
的值也是10。i
的值更新为了11,并写回主内存。最终结果:虽然两个线程都执行了一次加法操作,但变量i
的值最终只从10变成了11,有一次加法操作的效果丢失了。
为什么结果小于等于100?
因为总共只有50 + 50 = 100
次“写入新值”的机会,所以结果不可能超过100。
由于上面描述的“更新丢失”现象,多次加法操作可能只产生一次有效写入,所以最终结果很可能小于100。
为什么结果几乎不可能是100?
要得到100,必须保证每一次的“读-改-写”三步操作都恰好不被另一个线程打断。在现代多核CPU和抢占式操作系统调度下,这种“完美错开”的概率极低,几乎为零。
结果可能是0吗?
理论上,如果发生了极端情况,比如一个线程完成了49次更新,在第50次读取了旧值后,另一个线程一口气完成了50次更新,然后第一个线程再写入它的结果,那么结果就会非常小。但结果为0的可能性极小,除非两个线程的执行完全交错,每次都是一个线程读完,另一个线程完成整个读改写,然后再轮到第一个线程写,这种情况在现实中几乎不会发生。
要确保最终结果是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