Java并发面试系列开坑:系统攻克多线程核心考点
为什么并发是Java工程师的必考题?
Java并发编程是大厂面试的硬核考点,也是开发高性能系统的关键能力。但很多开发者对并发的理解停留在表面,遇到深度问题就难以应对。
第二期内容
1. wait/sleep、notify/notifyAll 区别
这两个都是用于线程间协作和控制的机制,但存在本质区别。
| 特性 | Object.wait() / notify() | Thread.sleep() |
|---|---|---|
| 所属类 | java.lang.Object 的实例方法 | java.lang.Thread 的静态方法 |
| 作用域 | 必须在同步代码块或同步方法(synchronized)中使用 | 可以在任何地方使用 |
| 锁的行为 | 释放当前持有的监视器锁(monitor lock) | 不释放任何锁,抱着锁睡觉 |
| 用途 | 线程间通信和协调。一个线程等待某个条件成立,另一个线程在条件成立后通知它。 | 仅仅让当前线程暂停执行指定的时间,与线程间通信无关。 |
| 唤醒方式 | 只能由其他线程通过 notify() 或 notifyAll() 唤醒,或者被中断(interrupt())。 | 睡眠时间到期后自动唤醒,或者睡眠期间被中断(interrupt())。 |
| 异常 | 调用 wait() 时,需要捕获 InterruptedException | 调用 sleep() 时,需要捕获 InterruptedException |
核心总结:wait/notify 是用于线程间协作的机制,且与锁紧密绑定;sleep 是用于让线程自身暂停执行的简单工具,与锁无关。
2. 什么是“虚假唤醒”(Spurious Wakeup)?如何在代码中防范?
虚假唤醒:指的是一个正在等待的线程(例如调用了 wait())在没有被其他线程通过 notify() 或 notifyAll() 显式唤醒,也没有被中断的情况下,莫名其妙地自行恢复了运行。
原因:这种现象是由于底层操作系统线程调度的高度复杂性导致的,在 Linux 和 Windows 等主流平台上都有可能发生。Java 语言规范(JLS)允许这种行为,以平衡性能和语义的精确性。
防范方法:永远不要在条件判断中使用 if 语句,而应该使用 while 循环。这样,即使线程被虚假唤醒了,它也会再次检查等待条件是否真正满足。如果不满足,它会继续等待。
正确代码模式:
synchronized (lock) {
// 使用 while 循环检查条件,而不是 if
while (/* 条件不满足 */) {
lock.wait();
}
// 条件满足,执行后续逻辑
}
3. Condition 接口与 Object 的监视器方法 (wait/notify) 有什么异同?
相同点:
- 基本功能:两者都提供了让线程等待(
await()/wait())和唤醒(signal()/notify(),signalAll()/notifyAll())的能力。 - 使用前提:都必须先持有对应的锁才能调用。
- 等待时释放锁:调用
await()或wait()时,当前线程都会释放已持有的锁。 - 唤醒后重获锁:被唤醒的线程在返回前都需要重新竞争锁。
不同点:
| 特性 | Object 监视器方法 | Condition 接口 |
|---|---|---|
| 绑定对象 | 与一个对象的监视器锁(synchronized)绑定。一个锁只能有一个等待队列。 | 与 Lock 绑定。一个 Lock 可以创建多个 Condition 实例。 |
| 等待队列 | 只有一个不区分条件的等待队列。 | 可以有多个等待队列,每个 Condition 实例管理一个独立的队列。 |
| 灵活性 | 低。notify() 会随机唤醒一个等待线程,无法精确控制。 | 高。可以使用不同的 Condition 来区分等待不同条件的线程组,实现精确通知(例如,conditionA.signalAll() 只会唤醒所有在 conditionA 上等待的线程)。 |
| 功能扩展 | 功能基础。 | 提供更多功能,如: - awaitUninterruptibly():不可中断的等待。- awaitNanos(long):带超时时间的等待。- 支持公平锁的公平唤醒策略。 |
核心总结:Condition 是 Lock 体系下对传统 wait/notify 机制的增强和替代,它通过多个等待队列提供了更精细、更灵活的线程调度和控制能力。
4. 什么是线程池,如何实现的?
是什么:线程池是一种池化技术,用于统一管理、调度和复用线程。它预先创建一定数量的线程并放入“池”中,当有任务需要执行时,就从池中分配一个空闲线程来运行它,任务完成后线程并不销毁,而是返回池中等待下一个任务。
为什么:频繁地创建和销毁线程开销很大(系统资源、调度开销)。线程池通过复用线程,避免了这些开销,提高了响应速度,并且可以方便地控制并发线程的数量,防止服务器因资源耗尽而崩溃。
如何实现:Java 中线程池的核心实现是 ThreadPoolExecutor 类。其内部工作原理可以概括为:
- 它维护了一个工作线程集合(
HashSet<Worker>)和一个任务队列(BlockingQueue<Runnable>)。 - 每个
Worker是一个封装了线程和任务的内部类,它会不断地从任务队列中获取任务并执行(一个while循环)。 - 当提交新任务时,线程池会根据当前池中线程数量、核心线程数、最大线程数等参数,决定是直接分配给空闲线程、放入队列,还是创建新线程,或者拒绝任务。
5. ThreadPoolExecutor 有哪些核心参数?分别代表什么意义?
创建 ThreadPoolExecutor 最完整的构造函数需要7个参数:
-
corePoolSize(核心线程数):- 线程池中长期维持的线程数量,即使这些线程是空闲的也不会被回收(除非设置了
allowCoreThreadTimeOut)。
- 线程池中长期维持的线程数量,即使这些线程是空闲的也不会被回收(除非设置了
-
maximumPoolSize(最大线程数):- 线程池允许创建的最大线程数量。
-
keepAliveTime(线程空闲时间):- 当线程数超过
corePoolSize时,多余的空闲线程在终止前等待新任务的最长时间。
- 当线程数超过
-
unit(时间单位):keepAliveTime的时间单位(如TimeUnit.SECONDS)。
-
workQueue(工作队列):- 用于保存等待执行的任务的阻塞队列(如
ArrayBlockingQueue,LinkedBlockingQueue,SynchronousQueue)。
- 用于保存等待执行的任务的阻塞队列(如
-
threadFactory(线程工厂):- 用于创建新线程的工厂。可以用于设置线程名、优先级、守护线程状态等,便于排查问题。
-
handler(拒绝策略处理器):- 当线程池和队列都已饱和,无法处理新提交的任务时,执行的拒绝策略(如抛出异常、直接丢弃等)。
6. 说说你知道的拒绝策略有哪些,分别用在什么场景?
ThreadPoolExecutor 提供了四种内置策略,都实现了 RejectedExecutionHandler 接口:
-
AbortPolicy(默认策略):- 行为:直接抛出
RejectedExecutionException异常。 - 场景:这是最稳妥的策略,可以确保提交失败的行为被调用者感知,以便做后续处理(如重试、记录日志等)。适用于关键业务。
- 行为:直接抛出
-
CallerRunsPolicy:- 行为:不抛弃任务,也不抛异常,而是将任务回退给调用者线程执行(即谁提交的任务,谁自己去运行)。
- 场景:这是一种温柔的反馈机制。它会让提交任务的客户端线程忙于执行这个任务,从而降低新任务的提交速度,给线程池一个喘息的机会。适用于允许降低吞吐量的场景。
-
DiscardPolicy:- 行为:静默地直接丢弃无法处理的任务,不做任何通知。
- 场景:不关心任务是否完成的场景。可能会丢失数据,需谨慎使用。
-
DiscardOldestPolicy:- 行为:丢弃队列中最老的(即下一个即将被执行的)任务,然后尝试重新提交当前任务。
- 场景:允许丢弃一些旧任务以尝试执行新任务。适用于发布消息、心跳等场景,新的消息比老的消息更重要。
7. 线程池的工作流程是怎样的?
当一个任务被提交 (execute(Runnable command)) 时,流程如下:
- 核心线程分配:如果当前运行的线程数 <
corePoolSize,则立即创建新线程(核心线程)来执行任务。 - 入队等待:如果运行的线程数 >=
corePoolSize,则尝试将任务放入工作队列 (workQueue)。 - 创建非核心线程:如果队列已满,且当前运行的线程数 <
maximumPoolSize,则创建新的非核心线程来执行任务。 - 拒绝策略:如果以上步骤都失败(队列已满且线程数已达最大值),则触发拒绝策略 (
handler) 来处理这个任务。
流程图简化记忆:
提交任务 -> 核心线程? -> 入队? -> 创建非核心线程? -> 执行拒绝策略
8. 如何合理配置线程池的大小?
这是一个经验公式,需要根据任务类型进行调整:
-
CPU 密集型任务:任务大部分时间在计算,很少进行I/O操作。
- 公式:
线程数 = CPU核心数 + 1 - 原因:过多的线程会导致频繁的上下文切换,反而降低性能。
+1是为了在某个线程因页缺失等意外原因暂停时,能有一个替补,保证CPU时钟周期不被浪费。
- 公式:
-
I/O 密集型任务:任务大部分时间在等待I/O操作(如数据库查询、网络请求、文件读写)。
- 公式:
线程数 = CPU核心数 * (1 + 平均等待时间 / 平均计算时间) - 经验值:通常可以设置为
CPU核心数 * 2或者更高。目的是在某个线程等待I/O时,CPU可以切换到其他线程去执行计算,最大化利用CPU资源。具体的数值需要通过压测来找到最佳点。
- 公式:
通用策略:如果不确定,可以先设置为 CPU核心数 * 2,然后通过监控系统的线程池运行情况(队列长度、活跃线程数、CPU负载等)进行动态调整。
9. 线程同步的方式有哪些
线程同步是为了解决多线程访问共享资源时的数据不一致问题。主要方式有:
-
synchronized关键字:- 机制:JVM 内置锁,基于
monitorenter和monitorexit指令实现。可修饰代码块或方法。 - 特点:自动加锁、解锁,简单易用,支持锁升级,不可中断。
- 机制:JVM 内置锁,基于
-
java.util.concurrent.locks.Lock接口(如ReentrantLock):- 机制:API 级别的锁,需要显式地调用
lock()和unlock()。 - 特点:功能更丰富,支持公平锁、可中断的锁获取、尝试非阻塞获取锁 (
tryLock())、多个条件变量 (Condition)。
- 机制:API 级别的锁,需要显式地调用
-
** volatile 关键字**:
- 机制:保证变量的可见性和禁止指令重排序,但不保证操作的原子性。
- 场景:适用于一写多读的简单状态标志位。
-
原子类 (
java.util.concurrent.atomic.*):- 机制:通过 CAS (Compare-And-Swap) 操作保证单个变量的原子性操作(如
AtomicInteger)。 - 场景:适用于计数器、累加器等场景,性能通常比锁高。
- 机制:通过 CAS (Compare-And-Swap) 操作保证单个变量的原子性操作(如
-
不可变对象:
- 机制:使用
final关键字创建一旦创建就不能被修改的对象(如String)。 - 场景:本质上是无同步,因为对象不可变,所以可以被安全地共享和访问。
- 机制:使用
10. synchronized 锁升级的过程是怎样的?
为了在性能与开销之间取得平衡,synchronized 的锁状态会随着竞争情况而升级,其过程是不可逆的。锁的四种状态从低到高为:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
- 无锁状态:对象刚创建,还没有任何线程访问。
- 偏向锁:
- 场景:假设在大多数情况下,锁不仅不存在竞争,而且总是由同一线程多次获得。
- 过程:当一个线程第一次访问同步块时,会在对象头和栈帧中记录偏向的线程ID。以后该线程再进入和退出同步块时,不需要进行CAS操作来加锁和解锁,只需简单测试一下对象头里是否存储着指向当前线程的偏向锁。
- 优点:消除无竞争情况下的同步开销。
- 轻量级锁:
- 场景:当有另一个线程来竞争这个锁时,偏向锁就会升级为轻量级锁。
- 过程:竞争的线程会通过CAS操作去尝试获取锁(在栈中创建锁记录空间,拷贝对象头中的Mark Word),而不会直接阻塞。如果成功,则使用轻量级锁;如果失败,表示存在竞争,会进行自旋(循环尝试)一小段时间。
- 优点:避免线程在用户态和内核态之间切换,对于执行时间很短的程序块来说,自旋等待的代价往往小于阻塞。
- 重量级锁:
- 场景:如果轻量级锁自旋失败(超过一定次数或自旋线程数超过CPU核心数的一半),锁就会膨胀为重量级锁。
- 过程:此时,未抢到锁的线程会被阻塞,进入操作系统内核的等待队列,等待被唤醒。这个过程中涉及到用户态到内核态的切换,开销最大。
- 机制:重量级锁依赖于操作系统底层的
mutex lock实现。
11. ReentrantLock 是如何实现可重入的?
ReentrantLock 的可重入性是通过内部同步器 Sync (继承自 AbstractQueuedSynchronizer,即AQS) 和一个 state 计数器来实现的。
-
state字段:在AQS中,state表示同步状态。- 当
state == 0:表示锁未被任何线程持有。 - 当
state > 0:表示锁被某个线程持有,并且数值表示该线程重复获取此锁的次数(即重入次数)。
- 当
-
加锁过程 (
lock()):- 当线程A第一次调用
lock()时,会通过CAS操作将state从0设置为1,并记录当前锁的持有者为线程A。 - 线程A再次调用
lock()时,发现锁的持有者就是自己,于是简单地将state值+1(无需CAS),即可成功获得锁。
- 当线程A第一次调用
-
解锁过程 (
unlock()):- 线程A每次调用
unlock(),都会将state值-1。 - 只有当
state减到 0 时,才表示线程A已经完全释放了锁,此时才会唤醒等待队列中的其他线程来竞争锁。
- 线程A每次调用
通过这种 state 计数器的方式,ReentrantLock 可以精确地记录同一个线程重入锁的次数,从而实现可重入性。
12. ReadWriteLock 和 StampedLock 有什么区别?
两者都是用于提高读多写少场景下的并发性能的锁。
| 特性 | ReadWriteLock (如 ReentrantReadWriteLock) | StampedLock |
|---|---|---|
| 锁模式 | 两种模式:读锁(共享)、写锁(排他)。 | 三种模式:写锁(排他)、悲观读锁(共享)、乐观读(无锁)。 |
| 乐观读 | 不支持。读操作直接加读锁,是悲观策略。 | 核心特性。尝试进行一次乐观读,不阻塞写线程。读完后需要验证(validate)期间是否有写操作发生,如果发生则升级为悲观读锁重试。 |
| 可重入 | 是。ReentrantReadWriteLock 是可重入的。 | 否。StampedLock 不是可重入锁,调用者需要防止死锁。 |
| 条件变量 | 支持。写锁可以提供 Condition。 | 不支持。 |
| 饥饿问题 | 如果读线程非常多,可能导致写线程饥饿(一直无法获取写锁)。 | 通过提供一个不遵守读写锁约定的“乐观读”模式,在一定程度上缓解了写线程饥饿。 |
| API复杂度 | 相对简单。 | 更复杂。需要关注戳(stamp)的管理和验证。 |
| 性能 | 在Java 8及以后,StampedLock 的吞吐量通常远高于 ReadWriteLock,特别是在读非常多的情况下。 |
选择建议:
- 如果你需要可重入、条件变量等复杂功能,或者代码简单易懂更重要,选择
ReadWriteLock。 - 如果你追求极致的读性能,并且是Java 8+,能处理好 stamp 的验证和锁的不可重入性,选择
StampedLock。

被折叠的 条评论
为什么被折叠?



