Java多线程面试核心:从JMM、锁升级到并发容器与实战调优
1. 项目概述为什么多线程面试题是Java工程师的“必答题”干了这么多年Java开发也面过不少人我发现一个挺有意思的现象甭管你是面初级、中级还是高级面试官手里那张问题清单上多线程相关的问题几乎从不缺席。这玩意儿就像武侠小说里的内功心法你说它平时开发用得多吧好像也不是天天都在写Thread和Lock但你说它不重要吧但凡系统出点性能瓶颈、响应变慢、数据错乱之类的“玄学”问题最后追查下去十有八九都能跟多线程扯上关系。所以面试官问多线程本质上不是在考你死记硬背几个概念而是在考察你解决复杂并发问题的底层思维和实战经验。一个对多线程理解透彻的程序员写出来的代码在健壮性、扩展性和性能上跟只会写单线程业务的程序员完全是两个层次。这篇文章我就从一个面试官和一线开发者的双重角度跟你聊聊Java多线程那些高频、核心的面试题。我不会只给你干巴巴的答案那样你背了也容易忘。我会把每个问题背后的“为什么”讲清楚结合我实际踩过的坑和优化的案例让你不仅知道“是什么”更能理解“怎么用”以及“为什么这么用”。无论你是正在准备面试还是想系统梳理一下自己的知识体系相信这篇长文都能给你带来实实在在的帮助。2. 核心概念与底层原理从JVM视角看线程2.1 线程的生命周期与状态流转很多朋友背“新建、就绪、运行、阻塞、死亡”这五种状态背得很熟但一到线上问题排查就懵。其实从Java代码和JVM的层面看线程的状态要更精细一些。java.lang.Thread.State这个枚举类定义了六种状态NEW新建new Thread()之后但还没调用start()方法。这时候它就是个普通的Java对象操作系统层面还没对应的线程。RUNNABLE可运行调用了start()方法。注意这个状态包含了操作系统线程调度中的“就绪”和“运行”两种。也就是说正在CPU上执行的线程和正在等待CPU时间片的线程在JVM看来都是RUNNABLE。这是最容易误解的一点。BLOCKED阻塞线程在等待进入一个synchronized同步块或方法的监视器锁monitor lock时进入此状态。比如线程A持有了锁线程B也想获取同一把锁那么线程B就会进入BLOCKED状态。关键点这个状态只与synchronized关键字竞争锁相关。WAITING无限期等待线程进入此状态后需要等待其他线程显式地通知notify或中断interrupt才能唤醒。触发方式包括Object.wait()不传超时参数Thread.join()不传超时参数LockSupport.park()TIMED_WAITING限期等待和WAITING类似但设定了超时时间。时间一到即使没有被通知线程也会自动返回RUNNABLE状态。常见方法Thread.sleep(long millis)Object.wait(long timeout)Thread.join(long millis)LockSupport.parkNanos(long nanos)TERMINATED终止线程执行完毕run()方法正常退出或因异常而终止。面试避坑点当被问到“sleep()和wait()有什么区别”时不要只停留在“所属类不同、是否释放锁”这个层面。可以从状态机角度深入sleep()会让线程进入TIMED_WAITING状态不释放任何锁而wait()会释放当前对象锁然后线程进入WAITING或TIMED_WAITING状态等待被notify()唤醒后重新去竞争锁。2.2 Java内存模型JMM与线程安全的核心这是多线程最难也最核心的部分。JMM定义了一种抽象规范它规定了线程如何以及何时可以看到其他线程修改过的共享变量以及如何同步地访问共享变量。它的核心是围绕主内存和工作内存的交互。主内存可以粗略理解为堆内存存储所有共享变量。工作内存每个线程私有的存储了该线程使用到的变量的主内存副本。线程对变量的所有操作读、写都必须在工作内存中进行不能直接读写主内存。不同线程之间也无法直接访问对方工作内存中的变量线程间变量值的传递必须通过主内存来完成。这套机制带来的直接问题就是可见性和有序性。可见性问题线程A修改了共享变量flag的值但只是写回了自己的工作内存还没来得及刷新到主内存。此时线程B从自己的主内存副本还是旧值中读取flag就看不到A的修改。volatile关键字、synchronized和Lock锁都能保证可见性因为它们都会在释放锁前将工作内存的修改刷新到主内存并在获取锁后从主内存重新读取最新值。有序性问题为了性能编译器和处理器可能会对指令进行重排序。在单线程下这种重排序不会影响最终结果遵循as-if-serial语义。但在多线程下重排序可能导致意想不到的结果。最经典的例子就是“双重检查锁定DCL”单例模式如果不用volatile修饰实例变量可能会得到一个“半初始化”的对象。public class Singleton { private static volatile Singleton instance; // 必须加volatile private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); // 问题所在 } } } return instance; } }instance new Singleton()这行代码在JVM中大致分为三步1. 分配内存空间2. 初始化对象3. 将引用指向内存地址。步骤2和3可能被重排序。如果线程A执行了1和3后此时instance已非空但对象未初始化线程B进入第一个if (instance null)判断会发现instance不为空直接返回一个尚未初始化的对象导致程序出错。volatile关键字通过禁止指令重排序确保了写操作之前的指令不会重排到写操作之后从而解决了这个问题。2.3 synchronized的锁升级过程synchronized以前被称为“重量级锁”性能差。但在JDK 1.6之后Java团队对它进行了大规模优化引入了“偏向锁”、“轻量级锁”等概念其性能已经非常不错。了解这个升级过程能让你在回答“synchronized原理”时脱颖而出。锁的状态保存在对象头的Mark Word中。升级过程是单向的目的是减少获得锁和释放锁带来的性能消耗。无锁状态一个新创建的对象。偏向锁大多数情况下锁不仅不存在多线程竞争而且总是由同一线程多次获得。偏向锁就是为了在只有一个线程执行同步块时提高性能。当线程第一次进入同步块时JVM会使用CAS操作将线程ID记录到对象头的Mark Word中并将锁标志位设为“01”偏向模式。之后该线程再进入和退出同步块时不需要进行CAS操作来加锁和解锁只需简单测试一下对象头的Mark Word里是否存储着自己的线程ID。如果测试成功表示线程已经获得了锁。轻量级锁当有第二个线程尝试获取锁时发生了竞争偏向锁就会升级为轻量级锁。JVM会在当前线程的栈帧中创建一个名为锁记录Lock Record的空间用于存储锁对象目前的Mark Word拷贝。然后JVM会尝试使用CAS操作将对象头的Mark Word替换为指向锁记录的指针。如果成功当前线程获得锁如果失败表示其他线程竞争锁当前线程会尝试使用自旋来获取锁空循环等待。重量级锁如果轻量级锁的竞争加剧比如自旋超过一定次数或者等待的线程数超过一个阈值轻量级锁就会膨胀为重量级锁。此时锁标志位变为“10”Mark Word中存储的是指向操作系统互斥量mutex的指针等待锁的线程会进入BLOCKED状态由操作系统负责调度。这个状态下的性能开销最大。实操心得理解锁升级后你就明白为什么说“在低竞争场景下synchronized性能并不差”。同时也要知道锁升级是不可逆的。如果你的应用是明确的高并发场景锁竞争激烈那么一开始就使用ReentrantLock这类显式锁可能更能满足你对超时、中断等复杂控制的需求。3. 核心工具类与并发容器详解3.1 AQS并发包的心脏java.util.concurrent.locks.AbstractQueuedSynchronizerAQS是JUCJava并发工具包的基石。像ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock等内部都有一个静态内部类继承自AQS。理解AQS就等于拿到了理解JUC并发工具的一把万能钥匙。AQS的核心思想是如果被请求的共享资源空闲则将当前请求资源的线程设置为有效的工作线程并且将共享资源设置为锁定状态。如果共享资源被占用那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制。这个机制AQS是用一个**双向队列CLH队列的变种**来实现的将暂时获取不到锁的线程加入到队列中。AQS内部维护了一个volatile int state变量和一个FIFO线程等待队列。state的具体含义由子类去定义和实现。例如在ReentrantLock中state表示重入次数。在Semaphore中state表示剩余的许可证数量。在CountDownLatch中state表示倒计数的数值。子类需要重写AQS提供的几个protected方法如tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared等来定义如何获取和释放资源。AQS的模板方法如acquire、release会调用这些钩子方法。以ReentrantLock的非公平锁为例线程调用lock()时会直接尝试用CAS修改state从0改为1。如果成功就获取了锁并设置当前线程为独占线程。这体现了“非公平”性新来的线程可能插队。如果CAS失败说明锁已被占用。则调用AQS的acquire(1)方法。acquire会先调用子类的tryAcquire再次尝试获取非公平锁的tryAcquire逻辑里依然有直接插队抢锁的尝试如果失败则将当前线程包装成节点加入等待队列并进入自旋或阻塞状态。3.2 ConcurrentHashMap的演进与分段思想HashMap是线程不安全的Hashtable用synchronized修饰所有方法性能堪忧。ConcurrentHashMap是并发编程中的明星容器。JDK 1.7的实现分段锁 它将数据分成一段一段Segment的存储每一段数据配一把锁。当一个线程访问其中一段数据时其他段的数据也能被其他线程访问实现了真正的并发访问。Segment继承自ReentrantLock。这种设计降低了锁的粒度提升了并发度。但缺点是在定位元素时需要进行两次哈希计算先找到Segment再找到Segment中的桶稍显复杂。JDK 1.8及之后的实现CAS synchronized 放弃了分段锁采用了和HashMap更相似的结构数组链表/红黑树。锁的粒度进一步细化到了每个数组桶bucket的头节点。插入如果桶为空直接用CAS操作设置头节点。更新如果桶不为空则使用synchronized锁住这个桶的头节点再进行链表或红黑树的插入、更新操作。扩容支持多线程协同扩容设计非常精妙。为什么用synchronized替代ReentrantLock因为JDK 1.6后synchronized已经做了大量优化性能不差且synchronized是JVM原生支持能享受JVM后续的持续优化而ReentrantLock是API级别需要额外的内存开销。在锁粒度极细锁住单个桶的场景下synchronized是更轻量、更合适的选择。3.3 线程池的七大参数与工作流程“说一下线程池的构造参数”这几乎是送分题但能说清楚工作流程和拒绝策略的才是高手。ThreadPoolExecutor executor new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 空闲线程存活时间 unit, // 时间单位 workQueue, // 工作队列 threadFactory, // 线程工厂 handler // 拒绝策略 );工作流程务必理解并画出来提交一个任务。如果当前运行的线程数 corePoolSize则创建新线程核心线程来处理任务。如果运行的线程数 corePoolSize则将任务放入workQueue。如果workQueue已满且运行的线程数 maximumPoolSize则创建新线程非核心线程来处理任务。如果workQueue已满且运行的线程数 maximumPoolSize则触发handler拒绝策略处理该任务。四种拒绝策略AbortPolicy默认直接抛出RejectedExecutionException异常。CallerRunsPolicy让调用者线程比如主线程自己执行该任务。这提供了一个简单的反馈机制可以降低新任务提交的速度。DiscardPolicy直接丢弃任务不做任何处理。DiscardOldestPolicy丢弃队列中最老的一个任务然后尝试重新提交当前任务。踩坑实录线上曾有一个服务用了FixedThreadPoolExecutors.newFixedThreadPool(n)它的队列是LinkedBlockingQueue默认长度是Integer.MAX_VALUE。在流量洪峰时任务不断堆积在无界队列里导致内存飙升最终OOM。所以阿里巴巴开发规范强制要求使用ThreadPoolExecutor构造函数来创建线程池就是为了让开发者明确指定有界队列和合理的拒绝策略。4. 高级主题与实战场景剖析4.1 原子类与CAS的ABA问题java.util.concurrent.atomic包下的原子类如AtomicInteger是保证线程安全的高性能工具。其底层核心是CASCompare-And-Swap操作一种CPU级别的原子指令。CAS操作包含三个操作数内存位置V、预期原值A和新值B。当且仅当V的值等于A时处理器才会用B更新V的值否则不执行更新。整个操作是一个原子过程。但CAS存在一个经典问题ABA问题。线程1读取内存值V为A此时线程2将V从A改为B然后又从B改回A。接着线程1执行CAS发现V还是A于是操作成功。尽管线程1的CAS操作成功了但这个过程可能已经产生了非预期的副作用比如链表结构发生了变化。解决方案加版本号AtomicStampedReference。它内部维护了一个Pair对象包含对象引用和一个int类型的版本戳stamp。每次更新时版本戳1。CAS时同时比较引用和版本戳。加布尔标记AtomicMarkableReference。与AtomicStampedReference类似但版本戳简化为一个boolean标记。适用场景原子类适用于计数器、状态标志等简单的同步场景。对于复杂的复合操作比如“先检查后执行”可能需要结合锁或使用更高级的并发容器。4.2 ThreadLocal的内存泄漏隐患ThreadLocal提供了线程局部变量每个线程都有自己独立的变量副本避免了共享。它是实现线程隔离的利器常用于存储用户会话信息、数据库连接等。原理每个Thread对象内部都有一个ThreadLocal.ThreadLocalMap类型的变量threadLocals。这个Map的key是ThreadLocal实例本身弱引用value是存储的值。当线程访问ThreadLocal变量时实际上是在访问自己线程内部的这个Map。内存泄漏风险 由于ThreadLocalMap的key是弱引用WeakReferenceThreadLocal而value是强引用。当ThreadLocal实例没有外部强引用时比如被置为null在GC时key会被回收但value不会被回收。这就导致了一个key为null但value有值的Entry存在于Map中无法被访问到也无法被回收。如果线程是线程池中的核心线程生命周期很长这个泄漏就会一直持续最终可能引发OOM。正确使用姿势务必在finally块中调用remove()在使用完ThreadLocal变量后手动调用ThreadLocal.remove()方法清除当前线程的Map中对应的Entry。尽量使用private static final修饰将ThreadLocal变量声明为静态的这样它的生命周期就和类一样长可以避免因实例被回收而导致的不可预测行为虽然弱引用能回收key但静态引用能保证key一直存在逻辑更清晰。private static final ThreadLocalUserContext userContextHolder new ThreadLocal(); try { userContextHolder.set(currentUser); // ... 执行业务逻辑 } finally { userContextHolder.remove(); // 关键 }4.3 死锁的诊断、避免与解决死锁是指两个或两个以上的线程在执行过程中因争夺资源而造成的一种互相等待的现象若无外力干涉它们都将无法推进下去。死锁产生的四个必要条件必须同时满足互斥条件资源一次只能被一个线程使用。请求与保持条件一个线程因请求资源而阻塞时对已获得的资源保持不放。不剥夺条件线程已获得的资源在未使用完之前不能被其他线程强行剥夺。循环等待条件若干线程之间形成一种头尾相接的循环等待资源关系。如何定位死锁使用jstack命令jstack pid可以打印出线程堆栈信息。在输出的最后JVM通常会有一个“Found one Java-level deadlock”部分清晰地指出哪些线程在等待哪些锁形成了循环等待。使用JConsole或VisualVM这些可视化工具可以连接上Java进程直接查看线程状态并检测死锁。如何避免和解决死锁破坏“请求与保持”条件一次性申请所有需要的资源。例如在获取锁的时候按照一个全局固定的顺序去申请锁排序。这是最常用、最有效的预防策略。破坏“不剥夺”条件使用Lock接口的tryLock(long time, TimeUnit unit)方法在尝试获取锁时设置超时时间。如果超时还未获取到就释放自己已经持有的锁然后进行重试或回退。破坏“循环等待”条件同样可以通过锁排序来实现。实战案例转账操作需要锁定两个账户。// 错误的写法可能死锁 public void transfer(Account from, Account to, int amount) { synchronized(from) { synchronized(to) { // ... 转账逻辑 } } } // 如果线程A: transfer(account1, account2, 100) 和线程B: transfer(account2, account1, 200) 同时执行就可能死锁。 // 正确的写法通过锁排序破坏循环等待 public void transfer(Account from, Account to, int amount) { Account firstLock from.id to.id ? from : to; Account secondLock from.id to.id ? to : from; synchronized(firstLock) { synchronized(secondLock) { // ... 转账逻辑 } } }5. 面试高频问题深度解析与回答思路5.1 volatile关键字的作用与原理作用保证可见性当一个线程修改了volatile变量的值新值会立即被刷新到主内存。当其他线程需要读取这个变量时它会从主内存重新读取而不是使用自己工作内存中的旧值。禁止指令重排序通过插入内存屏障Memory Barrier指令确保编译器和处理器不会对volatile变量读写操作前后的指令进行重排序。原理volatile的底层实现依赖于CPU的内存屏障指令和缓存一致性协议如MESI。写操作在写一个volatile变量时JVM会向处理器发送一条Lock前缀的指令或类似效果的指令这个指令会将当前处理器缓存行的数据写回到系统内存并使其他CPU里缓存了该内存地址的数据无效。读操作在读一个volatile变量时JVM会从主内存中重新读取数据到工作内存。与synchronized的区别特性volatilesynchronized原子性仅保证单次读/写的原子性如long/double不保证复合操作如i保证整个同步块是原子的可见性保证保证有序性保证禁止重排序保证as-if-serial但同步块内可重排序阻塞不会造成线程阻塞会未获取锁的线程会进入BLOCKED状态使用场景状态标志、一次性安全发布如DCL单例多步骤复合操作、需要互斥访问的临界区5.2 实现线程安全的单例模式这是考察对类加载机制、内存模型、锁理解的综合题。除了最经典的DCLDouble-Checked Locking外还有更优雅的写法。1. 饿汉式线程安全类加载时就初始化浪费内存。public class Singleton { private static final Singleton INSTANCE new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }2. 懒汉式DCL volatile线程安全上文已分析需注意volatile。public class Singleton { 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; } }3. 静态内部类推荐利用类加载机制保证线程安全且实现了懒加载。public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; // 只有在调用此方法时Holder类才会被加载INSTANCE才会被初始化 } }4. 枚举最安全、最简洁Effective Java作者Josh Bloch推荐的方式。能防止反射攻击和序列化破坏单例。public enum Singleton { INSTANCE; public void doSomething() { ... } } // 使用Singleton.INSTANCE.doSomething();5.3 对“锁”的深度理解可重入锁ReentrantLock指同一个线程在外层方法获取锁之后在进入内层方法时会自动获取锁前提是锁对象是同一个。synchronized和ReentrantLock都是可重入锁。这避免了线程因等待自己已持有的锁而造成的死锁。公平锁 vs 非公平锁公平锁多个线程按照申请锁的顺序来获取锁先到先得。ReentrantLock(true)。非公平锁多个线程获取锁的顺序并不是按照申请锁的顺序有可能后申请的线程比先申请的线程优先获取锁。synchronized和ReentrantLock()默认都是非公平的。性能对比非公平锁的吞吐量通常高于公平锁。因为公平锁需要维护一个有序队列唤醒线程是严格按顺序的上下文切换开销大。非公平锁允许“插队”减少了线程挂起和唤醒的开销但可能导致某些线程“饥饿”。读写锁ReadWriteLock允许多个读线程同时访问但写线程访问时所有读线程和其他写线程均被阻塞。适用于“读多写少”的场景能显著提升性能。ReentrantReadWriteLock是其实现。乐观锁 vs 悲观锁悲观锁认为并发冲突是常态每次操作数据前都先加锁。synchronized和ReentrantLock就是悲观锁思想的实现。乐观锁认为并发冲突不常发生只在提交更新时去检查数据是否被其他线程修改过。通常使用版本号或CAS机制实现。数据库的version字段、AtomicInteger的incrementAndGet()都是乐观锁思想的应用。6. 性能调优与监控实战6.1 如何合理配置线程池参数这是一个没有标准答案但必须能自圆其说的问题。核心思路是根据任务类型和系统资源来估算。任务类型分析CPU密集型任务主要消耗CPU资源如计算、逻辑判断。线程数不宜过多一般设置为CPU核心数 1。1是为了防止线程因页缺失或其他原因阻塞时能有替补线程利用CPU。IO密集型任务大部分时间在等待IO网络、磁盘、数据库。此时CPU经常空闲可以设置较多的线程。一个经验公式是CPU核心数 * (1 平均等待时间 / 平均计算时间)。如果等待时间远大于计算时间如Web应用可以设置为CPU核心数 * 2或更高。更精确的做法是通过压测来寻找性能拐点。参数设置实战corePoolSize根据上述分析设定。对于需要快速响应的服务可以设置得和maximumPoolSize一样大避免队列排队。maximumPoolSize在corePoolSize的基础上考虑系统资源内存、句柄数等。设置过大可能导致线程切换开销剧增甚至OOM。workQueue强烈建议使用有界队列如ArrayBlockingQueue。队列大小需要权衡太小容易触发拒绝策略太大可能掩盖问题导致响应时间变长。handler根据业务容忍度选择。CallerRunsPolicy是一个不错的兜底策略能让提交任务的线程慢下来起到负反馈作用。动态调整Apache Dubbo、Netty等框架的线程池支持动态调整核心和最大线程数。在监控到队列持续积压时可以适当调大参数。6.2 多线程上下文切换开销与优化线程数不是越多越好。每个线程都需要占用一定的内存栈空间并且操作系统在线程间切换上下文切换时需要保存和恢复寄存器、程序计数器等状态这是一个昂贵的操作。频繁的上下文切换会导致CPU将大量时间花在调度上而非实际执行任务。如何监控上下文切换Linux命令vmstat 1查看cscontext switch列。Java工具使用pidstatpidstat -t -p pid 1或perf等更专业的工具。优化方向减少锁竞争锁是导致线程挂起、触发上下文切换的主要原因。可以通过缩小锁粒度、使用读写锁、无锁数据结构如ConcurrentLinkedQueue、乐观锁等方式减少竞争。使用协程用户态线程协程的切换在用户态完成开销远小于操作系统线程的切换。Java原生不支持但可以通过Quasar纤维库或Project Loom预览特性来体验。优化线程池配置避免创建过多线程尤其是CPU密集型任务。6.3 线上死锁/高CPU问题排查流程当收到告警发现某个应用CPU飙高或线程池满可以按以下步骤排查定位问题进程top命令找到CPU或内存占用高的Java进程IDPID。定位问题线程top -Hp pid查看该进程内所有线程的CPU占用情况记录下占用高的线程ID十进制。将线程ID转换为十六进制printf “%x\n” 线程ID。分析线程堆栈jstack pid thread_dump.log导出线程快照。在thread_dump.log文件中搜索上一步得到的十六进制线程IDnid0x...找到对应的线程堆栈。查看它在执行什么代码是否卡在某个锁、某个IO操作或死循环中。结合其他信息死锁jstack输出末尾通常会直接标明。锁竞争激烈大量线程处于BLOCKED状态且等待同一个锁。CPU空转线程状态为RUNNABLE且堆栈显示在频繁执行某个循环或计算。使用Arthas等在线诊断工具无需下载日志直接连接线上JVM执行thread -b查找阻塞线程、thread nid查看指定线程、monitor方法监控等命令效率更高。我个人的经验是多线程问题的根因往往不在多线程代码本身而在于对共享资源数据、连接、文件的访问设计。在设计和评审代码时多问一句“这段代码被多个线程同时访问会怎样”能避免很多线上问题。多线程编程就像走钢丝平衡性能和正确性需要深厚的功底和不断的实践希望这篇长文能成为你行走在这根钢丝上的一根可靠扶手。