Java八股文——并发编程「场景篇」

发布于:2025-06-09 ⋅ 阅读:(23) ⋅ 点赞:(0)

多线程打印奇偶数,怎么控制打印的顺序

这是一个非常经典的并发面试题,它能很好地考察面试者对线程通信和同步机制的理解与运用。解决这个问题的核心思想是:让两个线程交替执行,并通过一个共享的状态变量来协调它们的执行权。

有多种方法可以实现,下面我将为您介绍几种最典型、最能体现不同技术深度的方法,从基础的synchronized+wait/notify到更现代的Lock+Condition

方法一:使用 synchronized + wait() / notifyAll() (经典基础)

这是最经典、最能考察Java底层同步原语理解的方法。

思路

  1. 创建一个共享的锁对象(比如一个Object实例)。
  2. 创建一个共享的计数器(比如int count),从1开始。
  3. 创建两个线程:奇数线程和偶数线程。
  4. 两个线程都在一个synchronized块内进行循环,循环条件是count没有超出打印范围。
  5. 奇数线程
  • 在同步块内,检查count是否为偶数。如果是,说明轮不到自己打印,调用lock.wait()释放锁并进入等待。
  • 如果count是奇数,就打印它,然后count++,最后调用lock.notifyAll()唤醒可能在等待的偶数线程。
  1. 偶数线程
  • 逻辑与奇数线程相反。检查count是否为奇数,如果是,就wait()
  • 如果count是偶数,就打印,count++,然后notifyAll()
  1. 为什么用while循环检查条件而不是if?这是wait/notify机制的最佳实践,为了防止“虚假唤醒”。
  2. 为什么用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可以实现更精准的线程等待和唤醒。

思路

  1. 创建一个ReentrantLock
  2. 从这个Lock对象中创建两个Condition对象:一个给奇数线程用(oddCondition),一个给偶数线程用(evenCondition)。这实现了等待队列的分离。
  3. 奇数线程
  • 获取锁。如果当前count是偶数,调用oddCondition.await()等待。
  • 如果是奇数,打印,count++,然后调用evenCondition.signal()精准地唤醒偶数线程。
  1. 偶数线程
  • 获取锁。如果当前count是奇数,调用evenCondition.await()等待。
  • 如果是偶数,打印,count++,然后调用oddCondition.signal()精准地唤醒奇数线程。
  1. 最后都在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 (信号量)

这是一种更巧妙的思路,利用信号量来控制执行许可。

思路

  1. 创建两个信号量:oddSemaphore初始许可证为1,evenSemaphore初始许可证为0。
  2. 奇数线程
  • 循环中,首先尝试获取oddSemaphore的许可(acquire())。第一次会成功。
  • 打印数字。
  • 然后,释放evenSemaphore的许可(release()),把执行权交给偶数线程。
  1. 偶数线程
  • 循环中,首先尝试获取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层面,它并不是一个原子操作。它大致可以分为三个步骤:

  1. memory = allocate(); :在堆上分配对象的内存空间。
  2. ctorInstance(memory); :调用Singleton的构造函数,初始化对象的成员变量。
  3. instance = memory; :将instance引用指向分配好的内存地址。

4. “指令重排序”带来的风险

在没有volatile的情况下,由于性能优化,JVM的JIT编译器或CPU可能会对这三个步骤进行指令重排序。一个可能的、致命的重排序结果是:

1 -> 3 -> 2

我们来模拟一下在这种重排序下,多线程会发生什么:

  • 线程A 进入了synchronized代码块,开始执行instance = new Singleton();
  • 它按照1 -> 3 -> 2的顺序执行:
  1. 先执行了步骤1,分配了内存。
  2. 然后跳过步骤2,先执行了步骤3,将instance引用指向了这块刚刚分配、但还未初始化的内存。此时,instance已经 不为null 了。
  3. 在它正准备执行步骤2(初始化对象)之前,CPU时间片切换了。
  • 线程B 此刻调用getInstance()方法。
  • 它执行到第一次检查 if (instance == null)
  • 由于线程A已经执行了步骤3,instance现在已经不为null了。所以,这个if判断为false
  • 线程B直接 return instance;,它获取到了一个指向合法内存地址、但该内存地址上的对象还完全没有被初始化的“半成品”

当线程B使用这个“半成品”对象时,比如调用它的某个方法,就可能会因为成员变量还未初始化而导致NullPointerException或其他不可预知的严重错误。

5. volatile如何解决这个问题?

volatile关键字在这里起到了两个至关重要的作用,但最关键的是第二个:

  1. 保证可见性synchronized也能保证,这里算是双重保障)。
  2. 禁止指令重排序(这才是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,中文叫“倒计时门闩”,它的设计初衷就是为了解决“一个或多个线程等待其他一组线程完成”这类问题。

思路

  1. 创建一个计数值为3的CountDownLatch实例。这个计数值代表了我们需要等待的子线程数量。
CountDownLatch latch = new CountDownLatch(3);
  1. 创建并启动3个子线程。在每个子线程任务的最后,都必须调用latch.countDown()方法。这个方法会将计数器减一。
  2. 在主线程(等待线程)中,调用latch.await()方法。这个方法会阻塞主线程,直到CountDownLatch的内部计数器被减到0。
  3. 当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早期提供的一种比较基础的方法,也可以实现这个需求。

思路

  1. 创建并启动3个子线程,并保留它们的Thread对象引用
  2. 在主线程中,依次对这3个Thread对象调用join()方法。
  3. thread.join()方法会阻塞当前线程(主线程),直到thread这个子线程执行结束
  4. 主线程会一个一个地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()必须等待线程完全终止。而CountDownLatchcountDown()可以在线程的任何地方调用,代表一个阶段性任务的完成,不一定非得是线程结束。
  • 强耦合:主线程必须持有所有子线程的Thread对象引用。
  • 扩展性差:如果需要等待的线程数量是动态的,或者任务是由线程池管理的,使用join()会变得非常困难和混乱。
结论

在面试和实际开发中,当遇到“等待多个任务完成”的场景时,CountDownLatch无疑是更专业、更灵活、更推荐的首选方案。而join()则更多地作为理解线程基础生命周期的一个知识点。

假设两个线程并发读写同一个整型变量,初始值为零,每个线程加 50 次,结果可能是什么?

面试官您好,这是一个非常好的问题。对于“两个线程并发读写同一个整型变量,初始值为0,每个线程加50次”这个场景,最终的结果将是一个不确定的、小于等于100的整数,但最可能的结果是小于100,而几乎不可能恰好是100。

要理解为什么会这样,我们需要剖析i++这个操作在底层到底发生了什么。

1. i++ 操作的非原子性

在Java中,i++ 这行看似简单的代码,它并不是一个原子操作。在底层,它至少包含了三个独立的步骤:

  1. 读取 (Read):从主内存中读取变量i的当前值,并加载到当前线程的工作内存(CPU缓存)中。
  2. 修改 (Modify):在当前线程的工作内存中,对这个值进行加1操作。
  3. 写入 (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++这个复合操作的原子性。有多种方法可以实现:

  1. 使用 synchronized 关键字:将i++操作放入一个synchronized代码块或方法中,确保同一时间只有一个线程能执行它。
public synchronized void increment() {
    i++;
}
  1. 使用 ReentrantLock:与synchronized类似,通过显式加锁和解锁来保证互斥。
lock.lock();
try {
    i++;
} finally {
    lock.unlock();
}
  1. 使用 AtomicInteger (最佳选择):这是针对单个整型变量原子操作的最佳实践
AtomicInteger atomicI = new AtomicInteger(0);
// 在线程中调用
atomicI.incrementAndGet();

AtomicInteger底层使用了CAS(Compare-And-Swap) 这种无锁技术,性能通常比前两种加锁的方式要高得多。

通过这些同步手段,我们就能保证每次“读-改-写”操作的完整性,从而确保最终结果是我们期望的100。

参考小林coding和JavaGuide