1.多线程打印奇偶数,怎么控制打印的顺序
| 步骤 | 动作 | 代码关键字 |
|---|---|---|
| 1. 进门 | 拿到锁 | synchronized(lock) |
| 2. 检查 | 不是我的数?睡觉 | if (count % 2 != 0) wait() |
| 3. 干活 | 打印并加一 | System.out.println(count++) |
| 4. 叫人 | 喊醒对方 | lock.notify() |
| 5. 出门 | 释放锁 | 代码块结束 |
public class OddEvenPrinter {
// 1. 共享变量,从 1 开始
private static int count = 1;
// 2. 锁对象
private static final Object lock = new Object();
public static void main(String[] args) {
// 偶数线程
new Thread(() -> {
while (count <= 100) {
synchronized (lock) {
// 如果是奇数,我就等 (偶数线程不干活)
if (count % 2 != 0) {
try {
lock.wait(); // 释放锁,陷入等待
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
// 是偶数,打印 -> 计数 -> 唤醒对方
System.out.println("偶数线程: " + count++);
lock.notify(); // 唤醒正在 wait 的奇数线程
}
}
}
}).start();
// 奇数线程
new Thread(() -> {
while (count <= 100) {
synchronized (lock) {
// 如果是偶数,我就等 (奇数线程不干活)
if (count % 2 == 0) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
// 是奇数,打印 -> 计数 -> 唤醒对方
System.out.println("奇数线程: " + count++);
lock.notify();
}
}
}
}).start();
}
}2.单例模型既然已经用了synchronized,为什么还要在加volatile?
Spring
20.voliatle关键字有什么作用?
[[12.设计模式#1-单例模式如何用-volatile-和-synchronized-实现双重校验锁|1. 单例模式:如何用 volatile 和 synchronized 实现?(双重校验锁)]]
单例模式的双重检查锁(DCL)中,既然已经加了 synchronized,为什么变量 instance 还需要加 volatile?
候选人: “加 volatile 主要是为了防止 ‘指令重排序’ 导致的 ‘半成品对象’ 问题。
1. new 一个对象并不是原子操作 在 Java 中,执行 instance = new Singleton(); 这行代码,底层其实分成了 3 个步骤:
-
分配内存: 给对象分配一块内存空间。
-
初始化对象: 调用构造函数,初始化成员变量。
-
指向引用: 将
instance变量指向刚才分配的内存地址(此时instance != null)。
2. 问题的根源:指令重排序 JVM 为了优化性能,可能会把上面的步骤 2 和 3 调换顺序(变成 1 → 3 → 2)。
-
也就是先 ‘指向引用’(此时对象还没初始化好,但已经不为 null 了),然后再 ‘初始化’。
-
在单线程下,这没问题。但在多线程下,问题就来了。
3. 事故现场(如果不加 volatile)
-
线程 A 抢到了锁,开始执行
new操作。发生了重排序:执行了步骤 1 和 3,还没执行步骤 2(对象是空的,但引用有了)。 -
此时 线程 B 进来了,在第一层检查
if (instance == null)时,发现instance居然 不为 null(因为线程 A 已经执行了步骤 3)。 -
结果: 线程 B 以为对象好了,直接拿走
instance去使用。但实际上这个对象还没初始化(步骤 2 还没跑),导致线程 B 程序报错或数据异常。
4. 结论 加上 volatile 后,会通过 内存屏障 禁止这行代码发生指令重排序,保证必须按照 1 → 2 → 3 的顺序执行。只有当对象完全初始化完成后,instance 才会变成非 null。”
代码详解
为了让面试官看得更清楚,可以写出这段经典的 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) {
// 问题就出在这里!
// 如果没有 volatile,这里可能发生 1->3->2 的重排序
instance = new Singleton();
}
}
}
return instance;
}
}3.3个线程并发执行,1个线程等待这三个线程全部执行完在执行,怎么实现?
CountDownLatch(最经典、最推荐)
CountDownLatch(倒计时锁)是 JUC 包下专门用于解决“一个线程等待其他 N 个线程”场景的工具。
核心原理:
-
创建一个初始值为 3 的
CountDownLatch。 -
等待线程 调用
latch.await(),进入阻塞状态。 -
3 个工作线程 任务结束时,分别调用
latch.countDown(),计数器减 1。 -
当计数器减为 0 时,
await()阻塞解除,等待线程继续执行。
代码实现:
import java.util.concurrent.CountDownLatch;
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
// 1. 创建倒计时锁,设置计数为 3
CountDownLatch latch = new CountDownLatch(3);
// 2. 创建并启动 3 个线程
for (int i = 1; i <= 3; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 正在运行...");
try {
Thread.sleep(1000); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + " 执行完毕");
// 3. 核心:每跑完一个,计数器减 1
latch.countDown();
}
}, "子线程-" + i).start();
}
System.out.println("主线程正在等待...");
// 4. 核心:主线程阻塞,直到计数器变为 0
latch.await();
System.out.println("三个线程全部执行完毕,主线程开始执行后续逻辑!");
}
}4.假设两个线程并发读写同一个整型变量,初始值为零,每个线程加 50 次,结果可能是什么?
22.volatile可以保证线程安全吗?中提到 i++这种操作不属于原子操作,属于复合操作,需要锁来实现线程安全
“结果的范围是 [2, 100]。
1. 为什么最大值是 100? 这是最理想的情况。
-
线程 A 和线程 B 串行执行(或者运气好,没有任何指令交错)。
-
线程 A 加了 50 次,
i变成 50。 -
线程 B 接着读到 50,又加了 50 次,
i变成 100。
2. 为什么会小于 100?(原子性问题) 这是因为 Java 中的 i++ 操作不是原子的。它在底层对应三条 CPU 指令:
-
LOAD: 把
i从内存读到 CPU 寄存器。 -
INC: 在寄存器中执行 +1 操作。
-
STORE: 把结果写回内存。
- 丢失更新: 如果线程 A 读到了 0,还没来得及写回,线程 B 也读到了 0。两个线程都算出了 1,都写回 1。虽然执行了两次加法,但实际上
i只增加了 1。
3. 为什么最小值是 2?(极端覆盖) 这是最极端的并发交错情况:
-
第一阶段: 线程 A 读取了 0,然后卡住了(未写入)。此时线程 B 疯狂运行,执行了 49 次,把
i加到了 49。 -
第二阶段: 线程 B 在执行第 50 次时,读取了 49,然后卡住了(未写入)。
-
第三阶段: 线程 A 醒了,手里还捏着刚才读到的 0,它计算
0+1=1,把 1 写入内存(线程 B 之前的 49 次全白干了)。 -
第四阶段: 线程 A 继续执行剩下的 49 次,在
1的基础上加到了 50。 -
第五阶段: 线程 B 醒了,手里捏着刚才读到的 49,它计算
49+1=50,把 50 写入内存(线程 A 刚才的 49 次也白干了)。 -
第六阶段: 此时两个线程都只剩最后一次操作。
-
线程 A 读取 50,写入 51?不,为了得到最小值 2,我们需要更极端的交错。
-
修正后的最小值路径:
-
线程 A 读取 0。
-
线程 B 跑完 49 次,写入 49。
-
线程 A 写入 1(覆盖了 49)。
-
线程 B 读取 1(这是它的第 50 次操作),它算出了 2。
-
线程 A 跑完剩下的 49 次,写入 50。
-
线程 B 写入 2(覆盖了 50)。
-
-
最终结果是 2。”
-