ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

爆肝50道面试题,彻底帮你搞定多线程面试!

爆肝50道面试题,彻底帮你搞定多线程面试!

写在前面:为什么多线程面试这么重要

多线程与并发编程几乎是 Java 后端面试的必考内容。无论是大厂一面、二面还是系统设计环节,面试官都喜欢从「线程创建方式」一路追问到「AQS 实现原理」「线程池参数如何设置」「ConcurrentHashMap 为什么高效」。很多求职者背了很多八股,却经不起追问,根源在于没有把知识串成体系。

本文整理了 50 道高频多线程面试题,按「基础概念、线程创建与生命周期、锁与同步、并发容器与工具、线程池、实战优化」六大板块系统讲解。每一道题都尽量从面试官视角给出「标准回答」和「追问应对」,并配合可运行的代码示例。建议先收藏,再按章节逐步吃透。

第 1 章:基础概念与 Java 内存模型

第 1 题:什么是进程?什么是线程?两者有什么区别?

进程是操作系统进行资源分配和调度的基本单位,是程序的一次执行过程。每个进程都有独立的内存地址空间、文件描述符等资源,进程之间相互隔离,一个进程崩溃通常不会影响其他进程。

线程是 CPU 调度和执行的最小单位,一个进程内可以包含多个线程。同一个进程内的线程共享进程的堆内存和方法区,但每个线程拥有独立的程序计数器、虚拟机栈和本地方法栈。由于线程间共享内存,通信成本低,但也带来了线程安全问题。

两者的核心区别可以归纳为四点:一是资源占用不同,进程拥有独立资源,线程共享所属进程的资源;二是调度成本不同,线程切换成本远低于进程切换;三是健壮性不同,进程间天然隔离,线程中一个未捕获异常可能影响整个进程;四是地址空间不同,进程地址空间独立,线程共享进程地址空间。

面试追问往往落在「为什么线程切换比进程切换开销小」以及「线程共享哪些资源、独享哪些资源」上,回答时需要准确区分堆、方法区与虚拟机栈、程序计数器的归属。

第 2 题:什么是并发?什么是并行?

并发是指在一段时间内,多个任务交替执行,宏观上看起来像是在同时运行。例如单核 CPU 通过时间片轮转,让多个线程快速切换执行,这就是并发。

并行是指在同一时刻,多个任务真正同时执行,需要多核 CPU 支持。例如四核 CPU 同时运行四个线程,这是并行。

两者的核心区别在于是否「同一时刻同时执行」。并发强调逻辑上的同时,并行强调物理上的同时。面试中常让候选人用「咖啡机煮咖啡」的例子说明:一个人同时处理煮咖啡和收邮件是并发,两个人分别处理两件事是并行。此外,并发编程的目标是充分利用 CPU 资源、提升吞吐量,而并行则是并发的一种实现形式。

第 3 题:什么是线程安全?如何保证线程安全?

线程安全是指当多个线程同时访问某个对象或共享数据时,无论操作系统如何调度这些线程,也无论这些线程如何交替执行,程序都能得到正确的结果,不会出现数据不一致或状态被破坏的情况。

保证线程安全的常用手段包括:一是使用 synchronized 关键字或 Lock 显式锁对共享资源加锁,保证同一时刻只有一个线程访问临界区;二是使用 volatile 保证变量的可见性和禁止指令重排序,但 volatile 不保证复合操作的原子性;三是使用原子类如 AtomicInteger、AtomicLong,底层依赖 CAS 保证原子操作;四是使用并发容器如 ConcurrentHashMap、CopyOnWriteArrayList;五是使用 ThreadLocal 为每个线程维护独立副本;六是采用无状态设计或不可变对象,从根源上消除共享可变状态。

面试时要能举例说明:SimpleDateFormat 不是线程安全的,多线程解析日期可能得到错误结果;而 String、Integer 等不可变对象天然线程安全。

第 4 题:什么是 Java 内存模型,即 JMM?

Java 内存模型,Java Memory Model,是一种规范,定义了 Java 程序中多线程之间如何通过内存进行交互。它解决的核心问题是:在多线程环境下,一个线程对共享变量所做的修改,何时以及如何被其他线程看到。

JMM 规定所有共享变量都存储在主内存中,每个线程有自己的工作内存。线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存。线程之间无法直接访问对方的工作内存,变量值的传递必须通过主内存完成。

JMM 围绕三个特性展开:原子性、可见性和有序性。它通过一系列规则如 volatile、synchronized、final、happens-before 等,为程序员提供了在多线程环境下编写正确程序的基础。面试中常把 JMM 与 JVM 内存结构混淆,需要明确区分:JVM 内存结构描述的是堆、栈、方法区的划分,而 JMM 描述的是多线程访问共享变量时的可见性、原子性和有序性规则。

第 5 题:什么是原子性、可见性、有序性?

原子性是指一个操作或多个操作要么全部执行并且执行过程不会被任何因素打断,要么就都不执行。典型的非原子操作是 i++,它包含「读值、加一、写回」三个步骤,多线程下可能丢失更新,因此需要 synchronized、Lock 或 AtomicInteger 来保证。

可见性是指当一个线程修改了共享变量的值后,其他线程能够立即得知这个修改。JMM 中每个线程都有工作内存,修改可能只停留在工作内存中未刷新到主内存,导致其他线程读到旧值。volatile、synchronized、final 都可以保证可见性。

有序性是指程序执行的顺序按照代码的先后顺序执行。但在 Java 中,编译器和处理器可能对指令进行重排序,以提高性能。重排序在单线程下不影响最终结果,但可能导致多线程下的问题。volatile 通过内存屏障禁止特定情况下的指令重排序,synchronized 通过锁保证临界区内代码的有序性。

面试时通常会要求分别举一个违反原子性、可见性、有序性的例子,并说明对应的解决方案。

第 6 题:什么是 happens-before 规则?

happens-before 是 JMM 中用来描述两个操作之间偏序关系的规则,是判断数据是否存在竞争、线程是否安全的主要依据。如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果对第二个操作可见,且第一个操作按顺序排在第二个操作之前。

happens-before 不代表时间上的先后,而是内存可见性的保证。常见的 happens-before 规则包括:程序顺序规则,同一线程内前面的操作 happens-before 后面的操作;监视器锁规则,一个 unlock 操作 happens-before 后续对同一把锁的 lock 操作;volatile 规则,对一个 volatile 变量的写 happens-before 后续对这个变量的读;传递性规则,如果 A happens-before B,B happens-before C,那么 A happens-before C;线程启动规则,Thread 对象的 start 方法 happens-before 该线程内的每个动作;线程中断与终结规则。

面试官常追问「happens-before 和时间先后有什么区别」,正确回答是 happens-before 强调可见性和顺序性的保证,时间上的先后不意味着内存可见,happens-before 是判断线程安全的依据,而不是真实执行顺序。

第 7 题:volatile 关键字的作用和原理是什么?

volatile 是 Java 提供的轻量级同步机制,主要有两个作用。第一,保证共享变量的可见性:当一个线程修改 volatile 变量后,新值会立即刷新到主内存,其他线程读取时会从主内存拿到最新值,而非使用工作内存中的旧副本。第二,禁止指令重排序:通过插入内存屏障,阻止 volatile 读写操作前后的指令被随意重排。

volatile 的典型使用场景包括状态标志位,例如控制线程退出的布尔标记;以及双重检查锁单例模式中,防止实例对象的初始化指令重排导致其他线程拿到未完全构造的对象。

需要强调的是,volatile 不保证原子性。对于 i++ 这类复合操作,volatile 无法避免竞态条件。它的底层实现依赖 CPU 的缓存一致性协议和内存屏障,写操作会触发 StoreStore 和 StoreLoad 屏障,读操作会触发 LoadLoad 和 LoadStore 屏障。面试中必须说明 volatile 与 synchronized 的区别:volatile 只能修饰变量、保证可见性和有序性,synchronized 可以修饰方法和代码块、保证原子性、可见性和有序性。

第 8 题:什么是指令重排?它会造成什么问题?

指令重排是指编译器和处理器为了优化程序性能,在不改变单线程执行结果的前提下,对指令的执行顺序进行重新排序。重排序分为编译器重排序、指令级并行重排序和内存系统重排序三类。

在单线程环境下,由于数据依赖的存在,重排序不会改变最终执行结果。但在多线程环境下,指令重排可能造成严重问题。最经典的例子是双重检查锁单例模式中的半初始化对象问题:对象的构造过程分为分配内存、初始化对象、将引用指向内存地址三步,如果第二步和第三步被重排,另一线程可能拿到尚未初始化完成的对象引用。

解决重排序问题的主要手段是使用内存屏障。volatile 的写和读分别加入不同屏障,final 字段在构造器中被正确处理后,也能保证引用逃逸时的初始化安全。面试中要能画出双重检查锁的代码,并解释 volatile 在其中如何禁止「分配地址」和「初始化」的重排序。

第 2 章:线程创建、状态与常用方法

第 9 题:创建线程有哪几种方式?

Java 中创建线程主要有四种方式。第一种是继承 Thread 类并重写 run 方法,然后调用 start 启动线程。第二种是实现 Runnable 接口,将 Runnable 实例传给 Thread 构造器。第三种是实现 Callable 接口,配合 FutureTask 使用,Callable 的 call 方法可以有返回值、可以抛出异常。第四种是通过线程池 ExecutorService 提交任务,这是生产环境中最推荐的方式。

Runnable 与 Callable 的核心区别在于:Runnable 的 run 方法没有返回值和受检异常,Callable 的 call 方法可以返回结果并抛出异常。Runnable 与 Thread 的核心区别在于:继承 Thread 会占用唯一继承名额,扩展性差,而实现 Runnable 接口可以让任务与线程解耦,同一任务对象可以被多个线程复用。

面试高频追问是「为什么启动线程用 start 而不是 run」,以及「线程池为什么能重复利用线程」。前者答案见下一题,后者的关键在于线程池中的 Worker 线程通过循环从任务队列取任务执行,线程本身不销毁。

第 10 题:run 方法和 start 方法有什么区别?

start 方法用于启动一个新的线程,使线程进入就绪状态,等待 CPU 调度。当线程获得 CPU 时间片后,JVM 会自动调用该线程的 run 方法执行任务。start 方法只能调用一次,重复调用会抛出 IllegalThreadStateException。

run 方法只是 Thread 类中的一个普通方法,如果直接调用 run,它会在当前调用线程中同步执行,不会创建新线程,也不会产生多线程并发的效果。

两者的本质区别在于:start 负责向操作系统申请线程资源并进入调度队列,run 负责定义线程要执行的业务逻辑。面试时可以用一段简单代码演示:直接调用 run 时线程名是 main,调用 start 时线程名是新建的线程名。同时要说明 start 方法的本质是调用 native 的 start0 方法,最终通过 JVM 与操作系统交互创建内核线程。

第 11 题:Java 线程有哪些状态?

Java 线程在 Thread.State 枚举中定义了六种状态。NEW 是新建状态,线程对象创建后、调用 start 之前。RUNNABLE 是运行状态,包括操作系统层面的就绪和运行两种状态,Java 统一称为可运行。BLOCKED 是阻塞状态,线程等待获取一个排他锁,例如进入 synchronized 块但锁被其他线程持有。WAITING 是无限期等待状态,线程主动等待,需要其他线程显式唤醒,例如调用 Object.wait、Thread.join、LockSupport.park。TIMED_WAITING 是限期等待状态,与 WAITING 类似但带有超时时间,例如 sleep、带超时的 wait 和 join、LockSupport.parkNanos。TERMINATED 是终止状态,线程执行完毕或异常退出。

值得强调的状态转换:sleep 不会释放锁,wait 会释放锁;线程从 BLOCKED 状态在获得锁后转为 RUNNABLE,从 WAITING 状态在收到 notify 或中断后需要重新竞争锁,竞争不到会先进入 BLOCKED。面试官常问「调用 sleep 后线程进入什么状态」「wait 和 sleep 的区别」,需要准确区分。

第 12 题:sleep 方法和 wait 方法有什么区别?

sleep 是 Thread 类的静态方法,wait 是 Object 类的方法。sleep 让当前线程暂停指定时间,不会释放持有的锁资源,时间到后自动恢复就绪状态;wait 会让当前线程释放对象锁并进入该对象的等待队列,直到被 notify、notifyAll 唤醒或等待超时。

第二个重要区别是使用场景:sleep 可以在任何位置调用,而 wait 必须在同步代码块或同步方法中调用,否则会抛出 IllegalMonitorStateException。这是因为 wait 语义上依赖监视器锁,只有持有锁的线程才有资格释放锁并等待。

第三个区别是唤醒机制:sleep 无需唤醒,时间到自动恢复;wait 需要其他线程调用同一对象的 notify 或 notifyAll 来唤醒。同时两者都会抛出 InterruptedException,响应线程中断。面试中可结合「wait 释放锁、sleep 不释放锁」这一核心差异,说明为什么等待条件时要用 while 循环而非 if 判断。

第 13 题:yield 方法和 join 方法的作用是什么?

yield 是 Thread 的静态方法,作用是提示线程调度器当前线程愿意让出 CPU 时间片,从运行状态转为就绪状态。但 yield 只是一个建议,调度器可以选择忽略,线程可能继续运行。它不会释放锁,也无法保证其他线程一定获得执行机会。

join 是实例方法,作用是让当前线程等待调用 join 的线程执行完毕后再继续执行。join 的底层实现基于 wait 机制,当被等待的线程终止时,会调用自身的 notifyAll 唤醒所有等待该线程对象的线程。join 可以带超时参数,超时后当前线程不再等待。

两者的典型应用场景:yield 用于希望协调线程执行顺序但不想引入复杂同步时,不过实际生产中很少使用,因为它不可靠;join 用于主线程需要等待子线程计算结果再继续执行的场景,例如多线程分片计算后汇总。面试中常问 join 的底层原理,回答要落到 wait 和 notifyAll 上。

第 14 题:线程中断机制是怎么实现的?

线程中断是 Java 提供的一种协作式机制,用于请求线程停止当前工作。它不会强制终止线程,而是给目标线程打个中断标记,由目标线程自行决定如何响应。Thread 类的 interrupt 方法用于设置中断标记,isInterrupted 判断标记状态,静态方法 interrupted 判断标记状态并清除标记。

当线程处于阻塞状态,例如正在调用 sleep、wait、join 或 LockSupport.park,此时调用 interrupt 会抛出 InterruptedException,并清除中断标记。当线程处于运行状态,interrupt 只会设置中断标记,不会中断线程执行。

正确的停止线程方式应该是协作式中断:在线程的循环条件中检查中断标志,捕获 InterruptedException 后退出循环。不建议使用已废弃的 stop 方法,因为 stop 会立即释放所有锁并强制停止线程,可能导致共享数据处于不一致状态。面试时最好能写一个带中断检查的线程示例,并说明为什么 catch 到 InterruptedException 后通常需要再次调用 interrupt 恢复标记,以便上层调用者感知中断。

第 15 题:什么是守护线程?

守护线程,Daemon Thread,是一种为其他线程提供服务的线程,例如 JVM 的垃圾回收线程、后台监控线程。当 JVM 中只剩下守护线程而没有非守护线程时,JVM 会退出,所有守护线程随之终止。

守护线程通过 Thread 的 setDaemon 方法设置,且必须在 start 之前调用,否则抛出 IllegalThreadStateException。守护线程创建的子线程默认也是守护线程。守护线程的典型应用包括日志记录、缓存维护、连接池心跳检测等不影响主业务完成的辅助任务。

需要注意两点:一是不要在守护线程中执行可能导致数据不一致的关键任务,因为守护线程可能在任何时候被 JVM 强制终止,比如正在写文件时进程退出导致数据损坏;二是守护线程中 finally 块不保证执行,即使代码里写了 finally,JVM 退出时也可能不会执行。面试中常把守护线程和用户线程的区别作为基础考点,回答时突出 JVM 退出行为即可。

第 16 题:ThreadLocal 的原理、使用场景和内存泄漏问题

ThreadLocal 为每个使用它的线程提供独立的变量副本,每个线程只能访问自己的副本,互不干扰。它的核心实现是每个 Thread 对象内部维护一个 ThreadLocalMap,键为 ThreadLocal 实例,值为线程的局部变量。存入的 key 是弱引用,value 是强引用。

典型使用场景包括:数据库连接、Session 管理、事务上下文、SimpleDateFormat 的线程安全封装、链路追踪中的 TraceId 传递等。使用时通常声明为 static final,避免重复创建同一个 ThreadLocal 实例。

内存泄漏问题的根源在于 ThreadLocalMap 的 key 是弱引用、value 是强引用。当 ThreadLocal 对象失去外部强引用被 GC 回收后,key 会变成 null,但 value 仍然被 Entry 强引用而无法回收。如果线程长期存活,例如线程池中的核心线程,这些 key 为 null 的脏 Entry 会不断累积,最终导致内存泄漏。

解决办法有三点:一是使用完毕后主动调用 remove 方法删除 Entry,remove 会同时清理 key 为 null 的过期 Entry;二是尽量把 ThreadLocal 声明为 static final,保证 key 不会被轻易回收;三是避免在线程池中长期保存大对象。面试中要能解释「弱引用 key + 强引用 value」这一组合为什么会导致泄漏,以及为什么调用 remove 是根本解法。

第 3 章:锁与同步

第 17 题:synchronized 的底层原理是什么?

synchronized 是 JVM 内置的同步机制,依赖对象头中的 Mark Word 和 Monitor 监视器实现。对象被加锁时,JVM 会在对象头的 Mark Word 中记录锁标识、锁状态、持有锁的线程 ID 等信息。

依据竞争剧烈程度,synchronized 会经历锁升级过程:无锁、偏向锁、轻量级锁、重量级锁。偏向锁适用于单线程反复进入同一同步块,只在 Mark Word 中记录线程 ID,几乎无额外开销;当出现竞争后升级为轻量级锁,通过线程栈中的 Lock Record 和 CAS 交换 Mark Word 实现自旋重试;自旋失败则升级为重量级锁,依赖操作系统互斥量,线程真正阻塞并进入等待队列。JDK 6 之后加入的分代锁优化,使 synchronized 性能显著提升。

代码层面,同步代码块由 monitorenter 和 monitorexit 两条字节码指令实现,同步方法则通过方法标志 ACC_SYNCHRONIZED 实现。面试追问常集中在「锁升级的过程和条件」「为什么偏向锁在竞争后要撤销」,回答时结合 Mark Word 的状态变化说明即可。

第 18 题:synchronized 和 ReentrantLock 有什么区别?

第一,实现层面不同:synchronized 是 JVM 层面的关键字,由虚拟机实现;ReentrantLock 是 JDK 提供的 API 层面的锁,需要手动创建对象。

第二,功能丰富度不同:ReentrantLock 支持可中断获取锁、带超时的 tryLock、公平锁,还可以绑定多个 Condition 实现分组等待唤醒;synchronized 只能使用一个隐式监视器条件。

第三,释放方式不同:synchronized 在同步块执行完毕或异常后自动释放锁;ReentrantLock 必须在 finally 中显式调用 unlock,否则可能造成锁泄漏。第四,性能方面,JDK 6 对 synchronized 深度优化后,两者在大多数场景下性能接近,不必再因性能问题无脑选择 ReentrantLock。

实战建议是:默认优先使用 synchronized,代码更简洁、不易出错;当需要超时、可中断、公平性、多条件协作等高级能力时,再使用 ReentrantLock。

第 19 题:AQS 是什么?它的核心原理是什么?

AQS,即 AbstractQueuedSynchronizer,抽象队列同步器,是 JUC 包中 ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 等锁和同步器的底层框架。

它的核心可以概括为「一个状态变量 + 一个等待队列」。volatile int state 表示同步状态,通过 getState、setState、compareAndSetState 维护;当线程竞争资源失败时,会被封装成 Node 节点放入一个类似 CLH 变体的双向队列中,底层通过 LockSupport.park 阻塞,等待前驱节点唤醒。

AQS 采用模板方法模式,已经实现好 acquire、release 等通用流程,子类只需实现 tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared 等钩子方法。独占模式在同一时刻只允许一个线程通过,如 ReentrantLock;共享模式允许多个线程同时通过,如 Semaphore 和 CountDownLatch。面试中要能说清 state 的含义在不同实现中如何复用,例如 ReentrantLock 中 state 表示重入次数,Semaphore 中表示剩余许可数。

第 20 题:ReentrantLock 是如何实现可重入的?

ReentrantLock 的可重入是通过 state 字段记录重入次数实现的。当线程第一次获取锁时,通过 CAS 把 state 从 0 改为 1,并在内部记录当前持锁线程引用。同一线程再次尝试获取锁时,判断当前持锁线程是自己,就把 state 继续加 1;释放锁时 state 减 1,只有当 state 重新降到 0 时,才表示该线程真正释放锁。

可重入的意义在于避免同一线程在嵌套调用同步方法或递归加锁时把自己阻塞住,进而造成死锁。synchronized 本身也是可重入的,其底层同样通过 Monitor 中的重入计数器实现。面试追问可延伸到「可重入锁如何避免死锁」以及「非可重入锁会遇到什么问题」。

第 21 题:什么是公平锁?什么是非公平锁?

公平锁是指多个线程按照申请锁的先后顺序排队获取锁,新来的线程会先检查同步队列中是否已有等待者,若有则乖乖入队,不会插队。非公平锁则允许新线程先尝试直接抢锁,抢不到再进入队列排队。

ReentrantLock 默认是非公平锁,构造时传入 true 才会创建公平锁。非公平锁的优势是吞吐量更高,因为新线程有机会直接获取锁,避免频繁的线程上下文切换;缺点是可能导致队列中等待已久的线程饥饿,迟迟拿不到锁。公平锁严格保障先到先得,但整体性能通常更低。

synchronized 是非公平锁,且不支持指定公平策略。面试中结合「公平性与吞吐量的权衡」作答即可。

第 22 题:什么是独占锁?什么是共享锁?

独占锁又称写锁或排他锁,同一时刻只允许一个线程持有,其他线程必须等待,例如 ReentrantLock 和 synchronized。共享锁又称读锁,同一时刻允许多个线程同时持有,例如 ReentrantReadWriteLock 中的读锁、Semaphore 的许可。

在读写锁场景下,读锁和读锁可以共存,因为并发读取不会破坏数据一致性;但读写之间、写写之间必须互斥,否则会出现读到中间状态或数据被并发修改的问题。

AQS 通过 tryAcquire 支持独占模式,通过 tryAcquireShared 支持共享模式。面试重点在于说明「读读共享、读写互斥、写写互斥」的规则,以及为什么写锁必须排他而读锁可以共享。

第 23 题:ReentrantReadWriteLock 的原理是什么?

ReentrantReadWriteLock 内部维护一对锁:读锁和写锁。读锁是共享锁,写锁是独占锁,遵循「读读共享、读写互斥、写写互斥」的原则。其核心是把 AQS 的 state 拆成两部分使用,高 16 位表示读锁持有数量,低 16 位表示写锁重入次数。

写锁可以降级为读锁,即在持有写锁期间继续获取读锁,然后释放写锁,这样可以保证数据在可见性上不出现缝隙;但读锁不能升级为写锁,因为多个线程同时持有读锁时都想升级,会造成死锁。

ReentrantReadWriteLock 适合读多写少的场景,但并非没有开销,读锁每次获取和释放都涉及 state 的 CAS 更新,高并发下可能成为性能瓶颈,因此 JDK 8 又引入了 StampedLock,通过乐观读进一步优化。面试追问常问「锁降级有什么意义」「为什么不支持锁升级」。

第 24 题:什么是 CAS?它的原理是什么?什么是 ABA 问题?

CAS,即 Compare And Swap,比较并交换,是一种无锁的原子算法。它包含三个操作数:内存值 V、预期原值 A、新值 B。当且仅当 V 等于 A 时,才把 V 更新为 B,否则不做任何操作并返回失败,通常配合自旋重试。

Java 的 AtomicInteger、AtomicLong 等原子类底部就是依赖 CAS 实现,底层对应 CPU 的 cmpxchg 指令或锁总线机制。CAS 的优点是无需加锁、线程不阻塞、性能高;缺点是存在 ABA 问题、自旋循环可能消耗 CPU、只能保证单个共享变量的原子操作。

ABA 问题是指:一个变量初始为 A,被其他线程改成 B 后又改回 A,此时使用 CAS 判断的线程发现值仍然是 A,认为它没有被修改过,但实际上中间已经发生过变化。解决方式是引入版本号,Java 提供 AtomicStampedReference 和 AtomicMarkableReference,在比较值的同时也比较版本号。

java

AtomicInteger count = new AtomicInteger(0); int newValue = count.incrementAndGet(); // 内部基于 CAS 自旋实现安全的 i++

第 25 题:死锁产生的必要条件是什么?如何避免死锁?

死锁产生的四个必要条件是:互斥条件,资源不能同时被多个线程使用;请求与保持条件,线程已经持有一个资源,又请求其他资源且不肯释放已有资源;不可剥夺条件,线程已获得的资源在使用完之前不能被强制剥夺;循环等待条件,多个线程之间形成头尾相接的资源等待环。

只要破坏其中任意一个条件,就能预防死锁。实战中最常用的是破坏循环等待条件,即让所有线程按照固定顺序获取锁,避免交叉加锁;还可以使用 tryLock 设置超时时间,获取不到就释放已持有的锁;或者降低锁粒度、尽量减少同步范围、一次性获取所有需要的资源。

线上排查可以通过 jstack 命令打印线程栈,搜索 Found one Java-level deadlock 关键字定位死锁线程,再根据持锁和等待的锁信息修正加锁顺序。面试中最好能现场画出一个简单的死锁代码示例,并说明死锁四个条件如何同时成立。

第 4 章:并发容器与工具

第 26 题:ConcurrentHashMap 为什么高效?

JDK 7 中 ConcurrentHashMap 采用分段锁设计,内部维护一个 Segment 数组,每个 Segment 继承自 ReentrantLock,锁粒度是一个分段,不同分段之间可以并行写入。

JDK 8 做了重大优化,放弃 Segment,改用 CAS + synchronized 实现。插入元素时,如果目标桶为空,直接用 CAS 写入;如果目标桶不为空,则对桶的头节点加 synchronized 锁,锁粒度细化到单个数组桶。同时当链表长度超过阈值时转为红黑树,降低查询复杂度。读操作基本不加锁,因为 Node 的 value 和 next 字段都声明为 volatile,保证可见性。

计算容量时也不再频繁锁全表,而是通过 baseCount 加上多个 CounterCell 分段计数,分散竞争。整体思路可以概括为「用更细的锁粒度、volatile 可见性、CAS 无锁写入和红黑树查询优化,换取高并发吞吐」。面试时要能区分 JDK 7 和 JDK 8 的实现差异。

第 27 题:HashMap 为什么线程不安全?

JDK 7 的 HashMap 在扩容时使用头插法转移链表,多线程并发扩容可能把链表倒序成环,导致后续查询进入死循环,这是最著名的线程安全问题。JDK 8 改为尾插法,解决了成环问题,但 HashMap 仍然不是线程安全的。

JDK 8 中依然存在数据覆盖问题:例如两个线程同时 put 不同 key 都落在同一个空桶上,先执行 CAS 或直接赋值的线程写入后,后执行赋值的线程可能覆盖前者的结果;再比如两个 key 相同或哈希冲突时,并发写入可能丢失更新。size 字段的累加也不是原子操作,会出现统计不准。

因此并发场景下应使用 ConcurrentHashMap,而不是 HashMap。面试追问可让候选人画出 JDK 7 环形链表的形成过程,说明尾插法如何避免成环,以及为什么 JDK 8 的 ConcurrentHashMap 不再使用分段锁。

第 28 题:CopyOnWriteArrayList 的原理和适用场景是什么?

CopyOnWriteArrayList 的核心思想是写时复制。读操作完全不加锁,直接读取当前数组;写操作会先把原数组复制一份,在副本上修改,修改完成后用一个 volatile 引用原子地指向新数组,整个过程通过 ReentrantLock 保证写写互斥。

这种设计的优点是读操作无锁且不需要复制,多个读者可以并发读取;迭代器基于快照数组,迭代过程中即使容器被修改也不会抛 ConcurrentModificationException。缺点是写操作要复制整个数组,内存和 CPU 开销大,并且读到的可能是旧数据,存在弱一致性。

因此它只适合读多写极少的小数据量场景,例如配置项、黑名单、监听器列表等。面试中要能对比 CopyOnWriteArrayList 和加锁 ArrayList 的区别,说明为什么写多场景下不适合使用。

第 29 题:ConcurrentHashMap 的 size 是如何计算的?

JDK 8 的 ConcurrentHashMap 为了在并发写场景下高效统计容量,采用 baseCount 加 CounterCell 数组的方式。正常情况下 put 后先通过 CAS 更新 baseCount;如果更新失败,说明存在竞争,就把计数分散到某个 CounterCell 中,后续可能继续扩容 CounterCell 数组。

size 方法会累加 baseCount 和所有 CounterCell 的值,得到容量。这种设计避免了全局锁,让不同线程的计数更新分散到不同单元。需要注意的是,size 返回的是瞬时估计值,在并发修改过程中并不保证完全精确。

第 30 题:什么是阻塞队列?常见的阻塞队列有哪些?

阻塞队列是支持阻塞式插入和移除操作的队列。当队列为空时,从队列获取元素的线程会被阻塞,直到队列中有元素;当队列已满时,向队列插入元素的线程会被阻塞,直到队列出现空位。它常用于生产者和消费者模型中解耦生产与消费速度。

常见实现包括:ArrayBlockingQueue,基于数组的有界阻塞队列;LinkedBlockingQueue,基于链表、默认容量接近无界的阻塞队列;PriorityBlockingQueue,支持优先级排序的无界队列;DelayQueue,支持延迟获取的无界队列;SynchronousQueue,内部没有存储容量的队列,生产者的元素必须被消费者立即取走,否则阻塞,常用于线程池任务传递;LinkedTransferQueue,结合了无界和直接传递能力。

面试中常结合线程池考察任务队列的选择,例如 FixedThreadPool 使用 LinkedBlockingQueue 无界队列,可能造成任务无限堆积。

第 31 题:CountDownLatch 和 CyclicBarrier 有什么区别?

CountDownLatch 是一个一次性计数器。初始化一个计数,其他线程执行完调用 countDown 使计数减一,等待线程调用 await 阻塞,直到计数减到零才继续。计数减到零后不能重置,适合一个或多个线程等待其他线程完成任务的场景,例如主线程等待多个子任务全部完成。

CyclicBarrier 是一道可循环使用的屏障。多个线程互相等待,所有线程都调用 await 到达屏障后,才会一起继续执行,屏障计数到达目标后会自动重置,可以重复使用。适合多线程分阶段并行、最后统一汇总的场景。

java

CountDownLatch latch = new CountDownLatch(3); // 三个子线程完成任务后调用 latch.countDown() // 主线程调用 latch.await() 继续执行 CyclicBarrier barrier = new CyclicBarrier(3); // 三个线程分别调用 barrier.await(),都到达后一起继续

第 32 题:Semaphore 的原理和作用是什么?

Semaphore 是信号量,用来控制同一时刻访问某个资源的线程数量。它基于 AQS 的共享模式实现,构造时传入许可数 permits,acquire 方法尝试获取许可,如果许可不足则阻塞,release 方法释放许可并唤醒等待线程。

典型场景是限流:例如数据库连接池、接口并发访问控制,把 permits 设置为允许的最大并发数。Semaphore 也支持公平和非公平两种获取方式。面试中要能说出它与锁的区别:锁只能保证一个线程进入临界区,Semaphore 可以允许多个线程同时进入。

第 33 题:CompletableFuture 是什么?它解决了什么问题?

CompletableFuture 是 JDK 8 提供的异步编程工具,实现了 Future 和 CompletionStage 接口。它解决传统 Future 的两个痛点:一是 get 方法会阻塞等待结果,二是多个异步任务之间难以组合编排。

CompletableFuture 提供丰富的回调式 API,例如 thenApply、thenAccept、thenCompose、thenCombine、allOf、anyOf 等,可以链式编排多个异步任务,还可以通过 exceptionally、handle 处理异常。它默认使用 ForkJoinPool.commonPool 执行异步任务,也支持传入自定义线程池。

面试中常对比 Future 和 CompletableFuture:前者只能阻塞获取结果,后者支持非阻塞回调和任务组合,更适合复杂的异步编排场景。

第 5 章:线程池

第 34 题:为什么需要线程池?

线程池的核心价值有四点:一是复用线程,避免频繁创建和销毁线程带来的系统开销;二是控制并发数量,防止无限制创建线程导致资源耗尽;三是统一管理线程的生命周期,提供监控、统计和拒绝策略;四是解耦任务提交与任务执行,让调用方只需要提交任务,不必关心线程创建细节。

如果每个任务都 new 一个 Thread,在高并发下会导致线程数量暴涨,CPU 频繁切换、内存占用飙升,甚至引发 OOM。线程池通过核心线程、任务队列、最大线程数和拒绝策略的组合,把并发量控制在系统可承受的范围内。

第 35 题:线程池的核心参数有哪些?

ThreadPoolExecutor 的构造器有七个核心参数:

  • corePoolSize:核心线程数,即使空闲也不会被回收(除非开启 allowCoreThreadTimeOut)。

  • maximumPoolSize:最大线程数,核心线程 + 非核心线程的上限。

  • keepAliveTime:非核心线程的空闲存活时间。

  • unit:keepAliveTime 的时间单位。

  • workQueue:任务队列,用于缓存待执行任务。

  • threadFactory:线程工厂,用于自定义线程名称、优先级、是否为守护线程。

  • handler:拒绝策略,当队列满且线程数达到最大值时如何处理新任务。

理解这七个参数是回答线程池问题的第一步,面试官通常会接着追问「任务提交后线程池的处理流程」,也就是下一题。

第 36 题:线程池的任务提交流程是怎样的?

当调用 execute 提交任务时,线程池的处理流程如下:

  1. 如果当前运行线程数小于 corePoolSize,直接创建新线程执行任务,即使其他核心线程空闲。

  2. 如果运行线程数已达到 corePoolSize,把任务放入 workQueue 排队。

  3. 如果队列已满且线程数小于 maximumPoolSize,创建非核心线程执行任务。

  4. 如果队列已满且线程数达到 maximumPoolSize,执行拒绝策略。

这个流程有一个容易被忽视的细节:先入队,再扩容。也就是说,只有当队列满了之后才会创建非核心线程,而不是核心线程满了就立刻扩容。这解释了为什么使用无界队列时 maximumPoolSize 形同虚设,因为任务永远进得了队列,不会触发扩容。

第 37 题:线程池有哪些拒绝策略?

JDK 内置四种拒绝策略:

  • AbortPolicy:默认策略,直接抛出 RejectedExecutionException,调用方需要捕获处理。

  • CallerRunsPolicy:由提交任务的线程自己执行该任务,起到反压作用,降低任务提交速度。

  • DiscardPolicy:直接丢弃任务,不抛异常,适合对任务丢失不敏感的场景。

  • DiscardOldestPolicy:丢弃队列中最老的任务,再把当前任务加入队列。

实际项目中还可以自定义拒绝策略,例如把被拒绝的任务写入数据库或消息队列,后续补偿执行。面试时要能说出四种策略的区别,并说明生产环境通常不会使用默认的 AbortPolicy,而是根据业务特点选择合适的策略并配合监控。

第 38 题:如何合理设置线程池参数?

线程池参数设置的核心是判断任务的类型:

  • CPU 密集型任务:线程数建议设置为 CPU 核数 + 1,避免过多线程导致上下文切换开销。

  • IO 密集型任务:线程数可以设置为 CPU 核数 × 2 或更多,因为线程大部分时间在等待 IO,CPU 利用率不高。也可以用「CPU 核数 / (1 - 阻塞系数)」估算,阻塞系数通常在 0.8 到 0.9 之间。

  • 混合型任务:可以拆分成 CPU 密集和 IO 密集两部分,分别用不同的线程池处理。

更严谨的做法是通过压测找到最佳参数:先设一个初始值,再观察 CPU 使用率、任务队列长度、拒绝次数、响应时间等指标,逐步调优。同时要配合有界队列和合理的拒绝策略,避免任务无限堆积。

第 39 题:Executors 提供的四种线程池有什么问题?

Executors 提供了 newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor、newScheduledThreadPool 四种快捷创建方式,但阿里巴巴 Java 开发手册明确禁止使用它们,原因是:

  • newFixedThreadPool和newSingleThreadExecutor使用 LinkedBlockingQueue 无界队列,任务可能无限堆积,导致 OOM。

  • newCachedThreadPool使用 SynchronousQueue,最大线程数为 Integer.MAX_VALUE,可能创建大量线程,导致 OOM 或系统资源耗尽。

  • newScheduledThreadPool的最大线程数同样是 Integer.MAX_VALUE,存在类似风险。

推荐做法是直接使用 ThreadPoolExecutor 构造器,显式指定核心参数,尤其是使用有界队列和合理的拒绝策略。面试中要能说出「为什么禁用 Executors」,并给出正确的替代方案。

第 40 题:线程池的线程是如何复用的?

线程池复用的核心在于 Worker 线程的 run 方法是一个循环。每个 Worker 启动后会不断从任务队列中获取任务并执行,任务执行完毕后并不退出,而是继续循环等待下一个任务。

当队列为空时,Worker 通过阻塞队列的 take 或 poll 方法等待,核心线程会一直阻塞等待新任务,非核心线程在 keepAliveTime 内没有任务则退出。这样线程本身得以长期存活,避免频繁创建和销毁。

面试中常追问「线程池中的线程会一直存活吗」,答案是要区分核心线程和非核心线程:核心线程默认一直存活,非核心线程超过 keepAliveTime 空闲时间后被回收。

第 41 题:线程池的状态有哪些?

ThreadPoolExecutor 内部用 ctl 字段的高 3 位表示线程池状态,低 29 位表示线程数量。五种状态是:

  • RUNNING:运行状态,接受新任务并处理队列中的任务。

  • SHUTDOWN:调用 shutdown 后进入,不再接受新任务,但会处理完队列中的任务。

  • STOP:调用 shutdownNow 后进入,不再接受新任务,不处理队列任务,并中断正在执行的任务。

  • TIDYING:所有任务已终止,线程数为 0,准备执行 terminated 钩子方法。

  • TERMINATED:terminated 方法执行完毕,线程池彻底终止。

面试中常问「shutdown 和 shutdownNow 的区别」,答案就在 SHUTDOWN 和 STOP 状态的区别上。

第 42 题:如何监控线程池的运行状态?

ThreadPoolExecutor 提供了若干方法可以用于监控:

  • getPoolSize:当前线程数量。

  • getActiveCount:正在执行任务的线程数量。

  • getQueue:任务队列,可查看队列长度。

  • getCompletedTaskCount:已完成任务总数。

  • getTaskCount:已提交任务总数。

  • getLargestPoolSize:曾达到的最大线程数。

生产环境通常通过定时任务或指标上报,把这些数据推送到监控系统,并设置告警阈值。关键指标包括队列长度、活跃线程数、拒绝次数、任务平均等待时间和执行时间。面试时能说出具体方法名和指标含义,会显得非常扎实。

第 6 章:实战与优化

第 43 题:多线程下如何保证集合的安全?

常见做法有四种:一是使用 Collections.synchronizedList 等包装方法,把普通集合包装成同步集合,所有方法加 synchronized,性能一般;二是使用 CopyOnWriteArrayList,读多写少场景下性能好;三是使用 ConcurrentHashMap,高并发读写场景下性能优秀;四是使用线程私有集合,或者通过 ThreadLocal 避免共享。

不同场景选择不同:读多写极少用 CopyOnWriteArrayList;读多写多用 ConcurrentHashMap;如果只是为了临时使用一次,可以用同步包装或加锁。面试中要能根据场景给出具体选择。

第 44 题:多线程下如何保证单例的线程安全?

常见的单例写法有五种:

  • 饿汉式:类加载时即创建实例,天然线程安全,但没有延迟加载。

  • 懒汉式加 synchronized:延迟加载,但每次获取实例都加锁,性能差。

  • 双重检查锁:在同步块外先判断,进入同步块后再判断,配合 volatile 防止指令重排,性能好且线程安全。

  • 静态内部类:利用 JVM 类加载机制保证线程安全,同时实现延迟加载。

  • 枚举:天然线程安全,且能防止反射和反序列化破坏单例。

生产环境推荐静态内部类或枚举写法。面试中如果要求手写双重检查锁,必须记得 volatile 关键字。

第 45 题:什么是伪共享?如何解决?

伪共享(False Sharing)是指多个线程访问同一缓存行中的不同变量时,由于 CPU 缓存以缓存行为单位加载和失效,一个线程修改变量会导致其他线程缓存的整个缓存行失效,从而造成性能下降。

解决办法是缓存行填充:在变量前后填充额外字段,保证每个变量独占一个缓存行。JDK 8 提供了 @Contended 注解,可以自动做填充,但需要开启 JVM 参数 -XX:-RestrictContended。Disruptor、LongAdder 等高性能组件都使用了缓存行填充。

面试中要能解释「为什么明明访问不同变量还会互相影响」,答案在于 CPU 缓存以缓存行为最小单位。

第 46 题:什么是偏向锁?为什么 JDK 15 废弃了偏向锁?

偏向锁是 synchronized 锁升级的第一个阶段,适用于单线程反复进入同一个同步块的场景。它会在对象头 Mark Word 中记录第一个获取锁的线程 ID,后续该线程再进入同步块时,只要检查 Mark Word 中的线程 ID 是自己,就直接通过,几乎没有任何额外开销。

JDK 15 废弃偏向锁的主要原因是:偏向锁的撤销成本很高,需要在安全点进行,会暂停所有线程;现代应用大多使用线程池,同一个锁往往被多个线程反复获取,偏向锁的收益很低甚至为负;此外偏向锁的代码维护成本高,还妨碍了其他 JVM 优化。因此 JDK 15 默认关闭偏向锁,后续版本彻底移除。

第 47 题:什么是线程饥饿?如何避免?

线程饥饿是指某个或多个线程长时间无法获得 CPU 时间片或所需资源,导致迟迟不能执行。常见原因包括:线程优先级设置不当,低优先级线程长期得不到调度;使用非公平锁时,新线程不断插队,队列中的线程拿不到锁;某些线程长期占用资源不释放,其他线程一直等待。

避免方法有:避免随意设置线程优先级;使用公平锁保证先到先得;合理设置线程池大小和任务队列;对于可能长时间等待的资源,设置超时时间并配合重试;对于竞争激烈的资源,考虑拆分或使用无锁结构。

第 48 题:线程数设置多少合适?有没有通用公式?

没有放之四海而皆准的公式,但有常用的估算方法:

  • CPU 密集型:N + 1,N 为 CPU 核数。+1 是为了在某个线程偶尔缺页或暂停时仍能保持 CPU 利用率。

  • IO 密集型:2N 或 N / (1 - 阻塞系数),阻塞系数一般在 0.8 到 0.9 之间。因为线程大部分时间在等待 IO,可以多开一些线程提高 CPU 利用率。

实际设置时要结合压测和监控:先按公式给一个初始值,然后观察 CPU 使用率、任务队列长度、响应时间等指标,逐步调整。面试中不要背死公式,而要强调「先估算、再压测、持续调优」的思路。

第 49 题:如何排查 CPU 飙高的问题?

排查步骤可以归纳为五步:

  1. 定位进程:用 top 或 ps 找到 CPU 占用高的 Java 进程 PID。

  2. 定位线程:用 top -Hp PID 找到该进程内 CPU 占用最高的线程 TID,或使用 pidstat。

  3. 转换线程 ID:把 TID 转为十六进制,例如 printf "%x\n" TID。

  4. 打印堆栈:用 jstack PID 打印线程栈,搜索上一步得到的十六进制 TID,定位具体代码位置。

  5. 分析原因:常见原因包括死循环、大量线程上下文切换、频繁 GC、锁竞争激烈、正则回溯等。

如果怀疑是 GC 导致的 CPU 飙高,可以结合 jstat -gcutil PID 观察 GC 频率和耗时,必要时用 jmap dump 堆快照分析内存。

第 50 题:如何排查线程死锁?

排查死锁主要有三种方式:

  1. jstack:执行 jstack PID,如果存在死锁,输出中会出现 Found one Java-level deadlock 段落,并列出互相等待的线程和锁信息。

  2. jconsole 或 VisualVM:图形化工具可以直接检测死锁,并展示每个线程的堆栈和持有的锁。

  3. ThreadMXBean:程序内可以通过 ManagementFactory.getThreadMXBean().findDeadlockedThreads() 主动检测死锁并告警。

定位到死锁后,修复思路通常是统一加锁顺序、使用 tryLock 加超时、缩小锁范围或改用无锁结构。面试中最好能结合实际案例说明定位过程。

总结

多线程与并发是 Java 后端面试的重头戏。这 50 道题覆盖了从 JMM 基础到线程池实战的完整链路,但真正拉开差距的不是背了多少题,而是能否在面试官追问时讲清楚:

  • 为什么:每个机制背后的设计动机和权衡。

  • 边界:每个方案的适用范围和限制条件。

  • 实战:生产环境中真正踩过的坑和解决方案。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进