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. 单例模式:如何用 volatilesynchronized 实现?(双重校验锁)]]

单例模式的双重检查锁(DCL)中,既然已经加了 synchronized,为什么变量 instance 还需要加 volatile

候选人: “加 volatile 主要是为了防止 ‘指令重排序’ 导致的 ‘半成品对象’ 问题。

1. new 一个对象并不是原子操作 在 Java 中,执行 instance = new Singleton(); 这行代码,底层其实分成了 3 个步骤

  1. 分配内存: 给对象分配一块内存空间。

  2. 初始化对象: 调用构造函数,初始化成员变量。

  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个线程等待这三个线程全部执行完在执行,怎么实现?

6.CountDownLatch 是做什么的讲一讲?

CountDownLatch(最经典、最推荐)

CountDownLatch(倒计时锁)是 JUC 包下专门用于解决“一个线程等待其他 N 个线程”场景的工具。

核心原理:

  1. 创建一个初始值为 3 的 CountDownLatch

  2. 等待线程 调用 latch.await(),进入阻塞状态。

  3. 3 个工作线程 任务结束时,分别调用 latch.countDown(),计数器减 1。

  4. 当计数器减为 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 指令:

  1. LOAD:i 从内存读到 CPU 寄存器。

  2. INC: 在寄存器中执行 +1 操作。

  3. 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,我们需要更极端的交错。

    • 修正后的最小值路径:

      1. 线程 A 读取 0。

      2. 线程 B 跑完 49 次,写入 49。

      3. 线程 A 写入 1(覆盖了 49)。

      4. 线程 B 读取 1(这是它的第 50 次操作),它算出了 2。

      5. 线程 A 跑完剩下的 49 次,写入 50。

      6. 线程 B 写入 2(覆盖了 50)。

    • 最终结果是 2。”