8月的Java面试战场上,多线程几乎是所有大厂必考、中小厂重点关注的题目,而且它通常不是一道独立的题,而是会连环追问:volatile 怎么保证可见性?synchronized 底层原理是什么?线程池参数怎么设置?AQS 是什么?你会发现,背得再熟的八股文,也架不住面试官一句"那你说说它底层怎么实现的"。
为什么多线程这么重要?因为它是 Java 后端开发绕不开的并发基石。无论你是写业务接口、消息队列消费者,还是做中间件、基础组件,只要系统一上量,并发问题就会暴露出来。面试官问多线程,表面是考知识点,实际是看你能不能写出健壮的并发代码,能不能处理线上出现的死锁、OOM、线程阻塞这类生产事故。
这篇文章会围绕多线程面试中最常考的板块来写:基础概念、JMM 与 volatile、synchronized 与锁升级、Lock 与 AQS、ThreadLocal、线程池、CompletableFuture、死锁排查,以及一份可以直接照着做的 3 天冲刺计划。全文提供可复制的代码示例、答题思路和避坑清单,适合秋招、社招和跳槽前临时抱佛脚,也适合刚学完 Java 基础想系统整理并发知识的同学。
1. 这篇文章真正要解决的问题
先给一个判断:多线程面试题已经在从"背概念"转向"考原理、考场景、考排错"。
以前面试多线程,问"创建线程有几种方式""sleep 和 wait 的区别"就结束了。现在不行了。现在面试官喜欢把问题串起来,从一个点跳到另一个点,比如:
- 从 volatile 问到 JMM,再问到 happens-before。
- 从 synchronized 问到锁升级,再问偏向锁和轻量级锁的区别。
- 从线程池参数问到核心线程数怎么定,再问线上线程池队列满了该怎么办。
- 从 ThreadLocal 问到内存泄漏,再问为什么要用弱引用。
所以这篇文章要解决的不只是"记住答案",而是帮你建立一张多线程知识网络,知道每个知识点在真实项目中解决什么问题。3 天时间能做什么?我的判断是:前 1 天搞定基础概念和线程安全核心机制,第 2 天搞定线程池、JUC 工具类、异步编程,第 3 天专门练死锁排查、高频题自测和模拟答题。每天都能产出"能讲出来的知识",而不是"看着眼熟的知识"。
这里要特别提醒一句:不要试图 3 天看完所有并发内容。像 ForkJoinPool 的底层工作窃取算法、Disruptor 的无锁队列这类高阶内容,面试中出现的概率低,性价比不高。先把高频、成体系的内容吃透,比零散扫一百篇博客更有用。
2. 多线程基础:概念、状态与创建方式
2.1 线程与进程到底差在哪
进程是操作系统分配资源的基本单位,线程是 CPU 调度的基本单位。一个进程内有多个线程,线程共享进程的堆和方法区,各自拥有独立的程序计数器、虚拟机栈和本地方法栈。
面试官爱问的一个变体是:"为什么线程切换比进程切换代价小?"答案核心是:同一进程的线程共享地址空间,切换时不需要切换页表、刷新 TLB;而进程切换需要切换独立的地址空间,代价更高。这个问题虽然基础,但答得完整能加分。
2.2 并发与并行不要混成一件事
并发是多个任务交替执行,宏观上同时发生,微观上还是轮流使用 CPU;并行是多个任务在多个 CPU 核心上真正同时执行。单核 CPU 上只能并发,多核 CPU 上才有可能并行。很多同学把这两个概念写反,面试时一紧张就说错,建议在纸上多写几遍。
2.3 线程状态转换是必考题
Java 线程在任意时刻处于以下六种状态之一:
NEW(新建) RUNNABLE(可运行) BLOCKED(阻塞) WAITING(等待) TIMED_WAITING(定时等待) TERMINATED(终止)这里最容易被问的是 BLOCKED 和 WAITING 的区别。BLOCKED 是线程在争夺 synchronized 锁时,没抢到锁而进入阻塞;WAITING 是线程主动调用 wait()、join() 或 LockSupport.park() 而进入等待,需要其他线程唤醒。用一句话记忆:BLOCKED 是被动等锁,WAITING 是主动等通知。
2.4 创建线程的四种方式
创建线程看似简单,但面试官会追问"这几种方式本质上有什么区别"。
// 方式一:继承 Thread class MyThread extends Thread { @Override public void run() { System.out.println("继承 Thread 创建线程"); } } // 方式二:实现 Runnable class MyRunnable implements Runnable { @Override public void run() { System.out.println("实现 Runnable 创建线程"); } } // 方式三:实现 Callable + FutureTask class MyCallable implements Callable<String> { @Override public String call() throws Exception { return "实现 Callable 创建线程,有返回值"; } } // 方式四:线程池 ExecutorService pool = Executors.newFixedThreadPool(2); pool.execute(() -> System.out.println("线程池创建线程"));四种方式本质上都是把任务交给线程去执行。继承 Thread 的方式耦合度高,不建议使用;实现 Runnable 适合无返回值的任务;Callable 有返回值、能抛异常,配合 FutureTask 或线程池使用;线程池则是最推荐的日常开发方式,能复用线程、控制并发数。
回答这个题时,建议主动补充:"我们项目里基本只用线程池 + Runnable/Callable,不会裸写 Thread。"这会让面试官觉得你有工程意识。
3. 线程安全与 JMM:volatile、synchronized 与锁升级
3.1 JMM 到底在讲什么
Java 内存模型(JMM,Java Memory Model)规定:所有变量存储在主内存中,每个线程有自己的工作内存,线程对变量的读写必须在工作内存中进行,不能直接操作主内存。
这带来三个经典问题:可见性、原子性、有序性。
- 可见性:一个线程修改了变量,另一个线程不一定能立刻看到。
- 原子性:一组操作是不可分割的,比如 i++ 在字节码层面是"读-改-写"三步,不是原子的。
- 有序性:编译器和 CPU 可能对指令进行重排序。
3.2 volatile 的可见性和有序性,但没有原子性
volatile 是面试出镜率极高的修饰符。它有两个作用:保证变量在不同线程间的可见性;禁止指令重排序。
public class VolatileDemo { private static volatile boolean flag = false; public static void main(String[] args) throws InterruptedException { Thread writer = new Thread(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } flag = true; System.out.println("writer 将 flag 置为 true"); }); Thread reader = new Thread(() -> { while (!flag) { // 如果 flag 不加 volatile,这里是死循环 } System.out.println("reader 感知到 flag 变化"); }); reader.start(); writer.start(); reader.join(); writer.join(); } }如果把 flag 去掉 volatile,reader 线程可能一直看不到 flag 的变化,因为在没有同步机制的情况下,JMM 不保证这种跨线程共享变量的可见性。加了 volatile 后,写入操作会立即刷新到主内存,读取操作会从主内存重新读取。
但 volatile 不保证原子性。经典反例是多个线程同时对 volatile 变量执行 count++,最终结果小于预期值。原因是 count++ 是"读-改-写"三步操作,volatile 只保证了读和写的可见性,没有保证这三步不会被其他线程插队。要保证原子性,要么用 AtomicInteger,要么用 synchronized 或 Lock。
面试加分点:volatile 还常用于双重检查锁的单例模式中,用来保证单例实例的可见性和禁止指令重排序。
3.3 synchronized 的三种用法和锁升级
synchronized 是 Java 内置的关键字,可以修饰实例方法、静态方法、代码块。它的互斥性来自监视器锁(Monitor),每个对象关联一个监视器,线程进入同步代码块前需要获得锁,执行完或抛出异常时释放锁。
Java 6 之后 synchronized 做了很多优化,锁有四种状态:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
- 偏向锁:同一个线程多次获取同一把锁时,锁会偏向该线程,减少无竞争情况下的 CAS 开销。
- 轻量级锁:当有第二个线程竞争时,偏向锁升级为轻量级锁,线程通过自旋等锁。
- 重量级锁:自旋失败后升级为重量级锁,线程进入 BLOCKED 状态,由操作系统调度。
面试官常问"synchronized 和 ReentrantLock 怎么选"。我的回答框架是:如果不需要中断、超时、公平锁这些高级能力,优先用 synchronized,因为它是 JVM 原生支持,随着 JVM 优化(如锁粗化、锁消除)性能已经很好,而且代码更简洁;需要可打断获取锁、超时获取锁、公平锁、多个 Condition 时,才用 ReentrantLock。
3.4 锁的代码示例
public class SynchronizedDemo { private int count = 0; // 修饰实例方法,锁是当前对象 public synchronized void increment() { count++; } // 修饰代码块,锁是指定对象 public void incrementWithBlock() { synchronized (this) { count++; } } // 修饰静态方法,锁是当前类的 Class 对象 public static synchronized void staticMethod() { System.out.println("static synchronized"); } }注意:修饰静态方法和修饰实例方法用的不是同一把锁。静态方法锁的是 Class 对象,实例方法锁的是实例对象,这两者在并发执行时并不会互相阻塞。这个细节面试里经常用来挖坑。
4. 显式锁与 AQS:Lock、Semaphore、CountDownLatch 的底层统一
4.1 ReentrantLock 基础用法
ReentrantLock 是 java.util.concurrent.locks 包下的可重入锁,本质是通过 AbstractQueuedSynchronizer(AQS)实现的。它有公平锁和非公平锁两种模式,默认是非公平锁。
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class LockDemo { private final Lock lock = new ReentrantLock(); private int count = 0; public void increment() { lock.lock(); try { count++; } finally { lock.unlock(); } } }使用 Lock 时,unlock() 必须放在 finally 里,否则一旦业务代码抛出异常,锁就永远无法释放,最终导致线程死锁。这一点是面试里最容易白送的踩分点。
值得强调的是可重入性:同一个线程可以重复获得已经持有的锁,锁内部有持有计数,每重入一次计数加一,每释放一次减一,直到归零才真正释放。synchronized 同样可重入,所以"可重入"并不是 ReentrantLock 独有的特点。
4.2 CountDownLatch:等所有线程就绪
CountDownLatch 是"AQS 的共享锁"思想,用于一个线程等待多个线程完成操作。
import java.util.concurrent.CountDownLatch; public class CountDownLatchDemo { public static void main(String[] args) throws InterruptedException { int workerCount = 3; CountDownLatch latch = new CountDownLatch(workerCount); for (int i = 0; i < workerCount; i++) { new Thread(() -> { try { System.out.println(Thread.currentThread().getName() + " 开始执行任务"); Thread.sleep(1000); System.out.println(Thread.currentThread().getName() + " 执行完成"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }, "worker-" + i).start(); } latch.await(); System.out.println("所有 worker 执行完成,主线程继续"); } }注意 CountDownLatch 的计数不能重置,所以它是一次性的。如果需要反复等待多个线程完成,要考虑 CyclicBarrier 或自行实现。
4.3 CyclicBarrier:线程互相等待,然后一起放行
CyclicBarrier 和 CountDownLatch 的区别是面试高频题。可以这样记忆:CountDownLatch 是"倒数门闩",一个线程等到 N 个 countDown 后继续;CyclicBarrier 是"循环栅栏",N 个线程互相等,都到齐后一起执行,而且可以重置重复使用。
import java.util.concurrent.BrokenBarrierException; import java.util.concurrent.CyclicBarrier; public class CyclicBarrierDemo { public static void main(String[] args) { int parties = 3; CyclicBarrier barrier = new CyclicBarrier(parties, () -> System.out.println("所有线程已到达栅栏,放行!")); for (int i = 0; i < parties; i++) { new Thread(() -> { try { System.out.println(Thread.currentThread().getName() + " 正在执行"); Thread.sleep((long) (Math.random() * 1000)); System.out.println(Thread.currentThread().getName() + " 到达栅栏"); barrier.await(); System.out.println(Thread.currentThread().getName() + " 继续执行之后的任务"); } catch (InterruptedException | BrokenBarrierException e) { Thread.currentThread().interrupt(); } }, "thread-" + i).start(); } } }CyclicBarrier 有一个需要留意的异常:BrokenBarrierException。如果某个线程在等待时被中断或超时,栅栏会进入 broken 状态,其他等待线程会收到 BrokenBarrierException。生产环境里如果使用 CyclicBarrier,建议对栅栏状态做兜底处理,避免线程永远卡在 await。
4.4 Semaphore:控制并发访问数
Semaphore 是最容易理解的限流工具:acquire()获取许可证,release()释放许可证。比如数据库连接池最多只允许 5 个连接同时使用,可以用 Semaphore(5) 来控制。
import java.util.concurrent.Semaphore; public class SemaphoreDemo { private static final int PERMITS = 2; public static void main(String[] args) { Semaphore semaphore = new Semaphore(PERMITS); for (int i = 0; i < 5; i++) { new Thread(() -> { try { semaphore.acquire(); System.out.println(Thread.currentThread().getName() + " 获取许可证,开始工作"); Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { System.out.println(Thread.currentThread().getName() + " 释放许可证"); semaphore.release(); } }, "task-" + i).start(); } } }这个例子中,Semaphore 设置为 2,5 个线程同时竞争,同一时刻最多只有 2 个线程在工作,其余线程阻塞在 acquire。release 同样要放在 finally 中,防止线程异常导致许可证丢失。
4.5 画 AQS 的统一视角
AQS(AbstractQueuedSynchronizer)是 ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 这些同步器的共同底座。核心设计是:一个 volatile int state 代表同步状态,一个 CLH 变体双向队列存放等待线程,通过 CAS 修改 state 来获得和释放同步状态。
面试回答 AQS 时,记住这几句话就够用了:
- AQS 维护一个 state 和一个等待队列。
- 获取锁时,通过 CAS 尝试将 state 从 0 改为 1,成功则获得锁;失败则把线程封装成 Node 放入等待队列并挂起。
- 释放锁时,将 state 改回 0,唤醒队列中的下一个节点。
- ReentrantLock 把 state 当作可重入计数,Semaphore 把 state 当作剩余许可证数,CountDownLatch 把 state 当作剩余倒数计数。
这个统一视角能帮你把散乱的并发工具串成一条线,面试时显得全局观很强。
5. ThreadLocal:原理与内存泄漏
5.1 ThreadLocal 解决什么问题
ThreadLocal 提供了线程局部变量,每个线程拥有自己独立的变量副本,不会互相干扰。最经典的例子是 SimpleDateFormat 的线程安全问题,还有用户登录信息、请求 ID 的透传。
public class ThreadLocalDemo { private static final ThreadLocal<String> USER_CONTEXT = new ThreadLocal<>(); public static void main(String[] args) { for (int i = 0; i < 2; i++) { int userId = i; new Thread(() -> { USER_CONTEXT.set("user-" + userId); System.out.println(Thread.currentThread().getName() + " 设置值: " + USER_CONTEXT.get()); // 使用完成后必须 remove,防止内存泄漏 USER_CONTEXT.remove(); }, "thread-" + i).start(); } } }面试追问点通常是:ThreadLocal 的底层结构是什么?答案是 Thread 内部有 ThreadLocalMap,key 是 ThreadLocal 对象,value 是 set 进去的值。每个线程访问 ThreadLocal 时,实际上是访问自己 ThreadLocalMap 里对应的 entry。
5.2 ThreadLocal 内存泄漏问题
ThreadLocalMap 的 key 是 ThreadLocal 的弱引用,value 是强引用。当外部没有强引用指向 ThreadLocal 对象时,key 会被 GC 回收,但 value 仍然被 ThreadLocalMap 中的 Entry 强引用着。如果线程长期存活(比如线程池里的核心线程),value 就无法被回收,造成内存泄漏。
解决方式很明确:在线程结束前或使用完成后,调用 ThreadLocal.remove() 清除当前线程的变量。尤其在线程池场景中,线程会复用,如果不 remove,下一次执行任务时还能读取到上一次任务遗留的脏数据。
面试答题模板:先讲 ThreadLocal 存值取值流程,再讲 Entry 的 key 是弱引用、value 是强引用,最后一定要说出"使用完必须 remove"。能主动提到这个坑,面试官通常会有好感。
6. 线程池:核心参数、执行流程与拒绝策略
6.1 为什么不建议用 Executors 创建线程池
很多入门教程会写 Executors.newFixedThreadPool(10) 或 Executors.newCachedThreadPool()。但实际生产环境,尤其是阿里编码规范里,明确建议不要用 Executors 的快捷方法,因为它们有些默认值有坑。
newFixedThreadPool 和 newSingleThreadExecutor 用的是无界 LinkedBlockingQueue,任务堆积过多时可能 OOM。newCachedThreadPool 的最大线程数是 Integer.MAX_VALUE,如果任务执行很慢,会创建大量线程,也可能 OOM。newScheduledThreadPool 同样是最大线程数极大。
所以默认做法是使用 ThreadPoolExecutor 的完整构造方法,明确核心线程数、最大线程数、队列容量、拒绝策略。
6.2 ThreadPoolExecutor 的核心参数
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolDemo { public static void main(String[] args) { ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, // 核心线程数 4, // 最大线程数 60L, // 非核心线程空闲存活时间 TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), // 有界任务队列 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 ); for (int i = 0; i < 10; i++) { final int taskId = i; executor.execute(() -> { try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId); }); } executor.shutdown(); } }参数含义对应:
| 参数 | 含义 |
|---|---|
| corePoolSize | 核心线程数,即使空闲也不会被回收 |
| maximumPoolSize | 最大线程数,非核心线程超过空闲时间会被回收 |
| keepAliveTime | 非核心线程空闲存活时间 |
| unit | 存活时间单位 |
| workQueue | 任务队列,线程不够时任务先进队列 |
| threadFactory | 线程工厂,建议自定义命名 |
| handler | 拒绝策略,队列和最大线程都满时的处理方式 |
6.3 线程池执行流程
这个流程几乎是必考题,一定要按顺序说:
- 提交任务时,如果当前线程数小于 corePoolSize,创建核心线程执行任务。
- 如果当前线程数不小于 corePoolSize,把任务放入 workQueue。
- 如果 workQueue 已满,且当前线程数小于 maximumPoolSize,创建非核心线程执行任务。
- 如果当前线程数已达到 maximumPoolSize,执行拒绝策略。
记忆口诀:先核心,再队列,再最大,最后拒绝。
6.4 四种拒绝策略
| 策略 | 行为 |
|---|---|
| AbortPolicy | 直接抛 RejectedExecutionException,默认策略 |
| CallerRunsPolicy | 交给调用者线程执行,降低任务提交速度 |
| DiscardPolicy | 静默丢弃任务 |
| DiscardOldestPolicy | 丢弃队列头部最老的任务,重新提交当前任务 |
面试中经常问"如果队列满了怎么办",很多人只能答出 AbortPolicy。我会建议在项目中根据业务选择:核心场景用 CallerRunsPolicy 做降级,或者自定义拒绝策略记录日志、发送告警、写入 MQ 补偿。这样答,面试官会认为你真正处理过生产问题。
6.5 核心线程数怎么设置
这是经典开放题,没有标准答案,但面试官想看你的权衡。
- CPU 密集型任务:核心线程数可以设置为 CPU 核数 + 1,因为这类任务主要在计算,线程太多反而增加上下文切换。
- IO 密集型任务:核心线程数可以设置得大一些,比如 CPU 核数 * 2,因为线程在等待 IO 时会释放 CPU,允许更多线程在阻塞期间交替运行。
- 更稳妥的方式:结合压测调整,设置合理的队列容量和拒绝策略,观察 CPU 使用率、线程数、任务积压情况后再调参。
不要背死公式。更高质量的答案是:线上服务有压测环境,通过阶梯式调整找到最优参数,同时给每个线程池配置监控告警。
6.6 线程池监控
生产环境中线程池暴露的问题往往滞后。建议至少记录以下指标:
- 当前线程数、核心线程数、最大线程数。
- 活跃线程数。
- 队列积压任务数。
- 已执行任务数、拒绝任务数。
可以通过 ThreadPoolExecutor 的 getPoolSize()、getActiveCount()、getQueue().size()、getCompletedTaskCount() 等方法来采集指标,配合监控平台上报,或者简单地定时打印日志。
7. CompletableFuture 与异步编程
7.1 为什么面试开始问 CompletableFuture
很多项目已经在用 CompletableFuture 做异步编排,比如一个接口需要并行调用用户服务、订单服务、商品服务,最后聚合结果返回。相比 FutureTask,CompletableFuture 支持回调、组合、异常恢复,代码更简洁。
面试问 CompletableFuture,考的不是 API 背诵,而是"怎么把多个异步结果组合起来"以及"async 后缀的方法到底用的哪个线程池"。
7.2 核心场景示例:并行查询然后聚合
import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; public class CompletableFutureDemo { public static void main(String[] args) throws ExecutionException, InterruptedException { CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> { // 模拟远程调用用户服务 return "用户信息"; }); CompletableFuture<String> orderFuture = CompletableFuture.supplyAsync(() -> { // 模拟远程调用订单服务 return "订单信息"; }); CompletableFuture<String> combineFuture = userFuture .thenCombine(orderFuture, (userInfo, orderInfo) -> userInfo + " + " + orderInfo); System.out.println(combineFuture.get()); } }thenCombine 会把两个异步结果合并后做进一步处理。如果是多个独立任务同时执行,还可以使用 allOf 等待全部完成,anyOf 等待任意一个完成。异常处理用 exceptionally 或 handle。
7.3 默认线程池的坑
CompletableFuture.supplyAsync 不传线程池时,使用的是 ForkJoinPool.commonPool()。在大批量异步任务场景中,commonPool 的线程数等于 CPU 核心数减一,任务多时容易排队,而且如果任务里有阻塞操作,很容易把公共线程池打满,影响 JVM 内其他使用 commonPool 的组件。
所以生产环境建议统一使用自定义线程池,别依赖默认实现。这几乎是我见过的线上异步问题中出现频率最高的一条。
ExecutorService asyncPool = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500), new ThreadPoolExecutor.CallerRunsPolicy() ); CompletableFuture.supplyAsync(() -> { // 业务逻辑 return "result"; }, asyncPool);8. 死锁:代码复现、排查与预防
8.1 死锁产生的四个必要条件
面试常问死锁条件,但更常问的是"线上怎么排查死锁"。先记四个条件:
- 互斥:资源同时只能被一个线程占用。
- 持有并等待:线程持有一个资源,同时等待另一个资源。
- 不可剥夺:资源只能由持有者主动释放。
- 循环等待:多个线程形成环形等待链。
预防的核心是破坏其中一个条件,实际工程中最常用的是破坏"循环等待",即对所有锁按固定顺序加锁。
8.2 死锁代码复现
public class DeadLockDemo { private static final Object LOCK_A = new Object(); private static final Object LOCK_B = new Object(); public static void main(String[] args) { new Thread(() -> { synchronized (LOCK_A) { System.out.println("线程1 持有 LOCK_A,等待 LOCK_B"); try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } synchronized (LOCK_B) { System.out.println("线程1 获取到 LOCK_B"); } } }, "thread-1").start(); new Thread(() -> { synchronized (LOCK_B) { System.out.println("线程2 持有 LOCK_B,等待 LOCK_A"); try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } synchronized (LOCK_A) { System.out.println("线程2 获取到 LOCK_A"); } } }, "thread-2").start(); } }这段代码运行后,两个线程会互相持有对方需要的锁,程序卡住不会打印后面的信息。如果加上 sleep 让两个线程先分别拿到一把锁,死锁的概率会大大增加,实际生产中也有类似时序问题。
8.3 线上排查死锁的步骤
首选三板斧:
- 使用
jps找到 Java 进程 PID。 - 使用
jstack <pid>打印线程快照,搜索 "Found one Java-level deadlock"。 - 根据 jstack 输出的线程栈定位到具体代码行,找出两个线程持锁和等锁的位置。
jps -l jstack 12345实际输出中会明确标记类似 "thread-1" 持有锁对象并被 "thread-2" 等待,同时 "thread-2" 持有另一个锁并被 "thread-1" 等待。根据线程栈中的类名、方法名和行号,直接回到代码里调整加锁顺序。
排查时需要注意:死锁可能导致线上服务卡死但又不会崩溃,如果只是少量线程死锁,系统可能还有响应,但线程数会持续增长,CPU 占用不一定高。所以线上监控线程状态时要关注大量线程长期处于 BLOCKED 或 WAITING 的情况。
9. 3天复习计划、答题框架与高频题清单
9.1 3天冲刺计划
很多同学的问题不是内容多,而是不知道每天该干嘛。我按面试常考点做了一个可执行的分日安排。
| 天数 | 学习主题 | 核心产出 |
|---|---|---|
| 第1天 | 基础概念 + 线程状态 + 创建方式 + JMM + volatile + synchronized | 能画出线程状态转换图,能讲清 volatile 与 synchronized 区别 |
| 第2天 | Lock 与 AQS + ThreadLocal + 线程池 + CompletableFuture | 能手动写线程池参数,能说出拒绝策略选择,能写出 CompletableFuture 组合代码 |
| 第3天 | 死锁排查 + JUC 工具类 + 高频题模拟 + 手写代码训练 | 能 jstack 排查死锁,能完整回答高频连环题 |
每天的学习方式建议是"先读原理,再手写代码,最后口头讲一遍"。口头讲是最容易发现知识空洞的环节,讲不顺的地方就是需要补的地方。
9.2 面试答题框架:结论先行,再展开原理
面试答题有一个被验证有效的结构:结论 + 原理 + 场景 + 踩坑。比如被问到 volatile 时:
- 结论:volatile 保证可见性和有序性,不保证原子性。
- 原理:写操作立即刷新主内存,读操作从主内存读;通过内存屏障禁止重排序。
- 场景:适合状态标志位、双重检查锁单例。
- 踩坑:不适合 count++ 这类复合操作,要保证原子性需要用 AtomicInteger、synchronized 或 Lock。
这个结构能让面试官快速 get 到你的答案重点,也方便你自己组织语言。建议把每个高频考点都整理成"结论-原理-场景-踩坑"四行笔记。
9.3 高频题自测清单
下面这些是过去一年 Java 多线程面试中出现频率很高的题目,可以作为第 3 天的自测列表:
| 问题 | 回答要点 |
|---|---|
| 线程有哪些状态?BLOCKED 和 WAITING 区别 | 六种状态;等锁 vs 等通知 |
| volatile 能保证原子性吗 | 不能,只保证可见性和有序性 |
| synchronized 底层原理 | Monitor 监视器锁,锁升级过程 |
| synchronized 与 ReentrantLock 区别 | 是否可中断、超时、公平锁、Condition |
| 什么是 AQS | volatile state + CLH 队列 + CAS |
| ThreadLocal 内存泄漏怎么解决 | key 弱引用、value 强引用,使用后 remove |
| 线程池执行流程 | 先核心,再队列,再最大,最后拒绝 |
| 线程池拒绝策略有哪些 | AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy |
| CountDownLatch 与 CyclicBarrier 区别 | 等待方向、是否可以复用、BrokenBarrierException |
| 死锁怎么排查 | jps + jstack,搜索 deadlock |
| CompletableFuture 的默认线程池 | ForkJoinPool.commonPool,生产建议自定义 |
每道题都要做到能对着人讲 2 到 3 分钟不卡壳,能达到这个程度,面试基本够用了。
10. 工程最佳实践与后续学习路线
10.1 多线程代码的工程建议
第一,创建线程池时一定要自定义线程名称。用 ThreadFactory 给每个线程起一个带业务含义的名字,比如 "order-async-pool-thread-1"。线上排错时,jstack 里能看到业务语义的线程名,能瞬间定位问题归属;如果全是 pool-1-thread-1,排查效率会低很多。
第二,锁的粒度要尽量小。能用代码块就不用整个方法,能用读写锁就不要一锁到底。锁的粒度会直接影响系统的吞吐量,面试中聊到性能优化时,这也是一个很实际的加分点。
第三,异步任务必须做超时控制和失败兜底。CompletableFuture.get() 如果不带超时时间,可能因为下游服务慢导致线程无限等待。建议调用时加上超时时间,并捕获 TimeoutException 做降级处理。
第四,共享变量优先考虑不可变对象。很多并发问题其实可以通过设计规避,比如使用 final 字段、不暴露 setter、使用 ImmutableList 等。线程安全不只是加锁,还包括尽可能消除共享可变状态。
10.2 后续学习的优先级
如果 3 天冲刺结束后还有时间,建议按以下顺序深入学习:
- 再读一遍 JMM 规范和 happens-before 规则,这是理解并发本质的钥匙。
- 手写一个简单的线程池,理解 Worker 和阻塞队列的协作。
- 阅读 ReentrantLock、CountDownLatch 的源码,理解 AQS 的独占和共享模式。
- 学习并发容器,比如 ConcurrentHashMap、CopyOnWriteArrayList,因为面试很可能从多线程延伸到集合类。
- 系统性看一遍 Java 并发编程实战中的"活跃度与性能"章节,对死锁、饥饿、活锁的理解会更透彻。
多线程不是背一背就能过的知识点,它需要你真正想清楚"数据在哪里共享、锁保护的是什么、线程之间如何协作"。当你把这些问题想明白后,面试题只是同一个知识网络在不同场景下的投影。
最后提醒一句:不管看了多少篇面试题整理,一定要在面试前亲手把线程池、死锁、CompletableFuture 这三段代码敲一遍。面试时能写出干净、健壮的并发代码,比任何口头回答都更有说服力。