拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java并发编程面试高频考点:线程池、锁与AQS原理全解析

Java并发编程面试高频考点:线程池、锁与AQS原理全解析

上周有位准备跳槽的朋友找我聊天,说他背了两周的Java面试题,结果被一个“线程池参数”问得说不出话。我问他怎么准备的,他说就是照着网上的八股文背,背完就忘,一追问就露馅。Java并发编程面试题这块,恰恰是最不能靠死记硬背应对的。它考的不是“你知道这个名词”,而是“你有没有真的在项目里踩过它的坑”。我整理过一份123道的Java并发编程面试题合集,写了答案,也标注了哪些题是“送分题”、哪些是“送命题”。这篇博文就是从那堆题里挑出最高频、最容易被追问的考点,把答题逻辑和背后的原理一次性掰开讲清楚。不管你是准备校招、社招,还是想系统梳理一遍并发知识,按这个思路去复习,效率比自己盲背高出好几倍。

1. 并发编程面试题,到底在考什么

1.1 面试官出并发题的真实意图

很多人把并发编程当成一块独立的“背题模块”,这是最大的误区。面试官问并发题,真正想判断的不是你记了多少概念,而是你有没有在实际开发中遇到过线程安全、性能瓶颈、资源竞争这类问题,并且真的思考过解决方案。

同样是问“synchronized和ReentrantLock的区别”,初级选手能把字面区别背出来,比如“synchronized是关键字,ReentrantLock是类”“ReentrantLock支持公平锁”——这些都没错,但一个在真实项目里处理过并发的人,会主动补上一条:在JDK 6以后,synchronized经过锁升级优化,性能已经不输ReentrantLock,所以选型时要优先考虑synchronized,只有在需要尝试获取锁、超时获取锁、中断响应等能力时才引入ReentrantLock。你看,这就是“背过”和“懂”的区别。

面试官对并发题的考核也分三个层次。第一层是概念层,考察你知不知道线程状态有哪些、volatile和synchronized有什么区别;第二层是原理层,考察你懂不懂JMM、AQS、锁升级的底层机制;第三层是实战层,考察你在什么场景下做过多线程编程、有没有写过线程池、线上死锁怎么排查。大部分人卡在第二层,但拉开差距的往往是第三层。你的复习顺序应该是“概念打底,原理支撑,实战收尾”。

1.2 高频考点全景:从123道题库里筛出的六大块

我整理那123道题的时候,其实发现总有那么二三十道是反复出现的,换着花样出,本质考点都一样。我把它们归成六大块,这基本就是Java并发面试的全部家当。

知识域高频题目类型掌握程度要求
线程基础线程状态转换、sleep/wait/join区别、创建线程方式必须熟练到脱口而出
锁与同步synchronized原理、锁升级、死锁条件、Lock体系能讲出底层机制与场景对比
JMM与volatile可见性、原子性、有序性、happens-before规则原理层必须通透
JUC并发工具AQS、CountDownLatch、CyclicBarrier、Semaphore会画流程,能谈源码
线程池七大参数、执行流程、拒绝策略、核心线程数配置必考中的必考
并发容器与异步ConcurrentHashMap、ThreadLocal、CompletableFuture会说场景,能讲坑

这六大块环环相扣。比如你讲ConcurrentHashMap,绕不开CAS和synchronized;讲CAS,绕不开JMM和volatile;讲volatile,绕不开happens-before。面试官最喜欢干的事就是从一个问题链式追问下去,你要是知识点是割裂的,问两轮就露馅了。以我的经验,复习时别按题目背,按“知识链”去串,效果完全不一样。

2. 线程基础题:别让“简单题”变成送命题

2.1 线程状态这道题,答到什么程度才算合格

线程状态是所有并发面试题的地基,面试官几乎必问,而且经常作为开场题。Java中线程有六种状态,分别是NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。注意,很多人会答“就绪”“运行中”,那是老版本或者操作系统的概念,在Java里RUNNABLE状态统一涵盖了“就绪”和“正在运行”,这一点一定要说清楚。

完整的状态流转是这样的:new一个Thread对象之后,线程处于NEW;调用start()后进入RUNNABLE;等待synchronized锁或者显式锁时进入BLOCKED;调用wait()、join()、LockSupport.park()进入WAITING;调用sleep(long)、wait(long)、join(long)进入TIMED_WAITING;线程执行完进入TERMINATED。这里面最容易被追问的是BLOCKED和WAITING的区别:BLOCKED是线程在被动地等一把锁,而WAITING是线程主动放弃CPU,等待其他线程唤醒或通知。

答这道题想拿高分,光背六种状态是不够的,要能说出Java为什么把“就绪”和“运行中”合并成一个RUNNABLE状态。原因其实很简单——Java的线程调度依赖底层操作系统,而操作系统并不保证能精确区分“线程已就绪但还没分到CPU时间片”和“正在执行”。Java干脆把这两种统一了,这也从侧面说明Java线程本质是操作系统线程的映射,JVM自己并不做真正的调度。能答出这一层,面试官会觉得你是理解而不是背题。

2.2 wait/sleep/join/yield,四个方法一次分清

这四个方法每年都在面试题里出现,形式还是那种“请简述区别”的送分题。但你别小看它,很多人上来就答“sleep不释放锁,wait释放锁”,这没错,但太单薄。我建议你用一个对比维度更全的答法。

对比维度sleepwaitjoinyield
归属Thread静态方法Object实例方法Thread实例方法Thread静态方法
是否释放锁不释放释放不释放不释放
进入状态TIMED_WAITINGWAITING或TIMED_WAITINGWAITING或TIMED_WAITINGRUNNABLE
是否需要唤醒到时间自动恢复需要notify/notifyAll目标线程执行完自动恢复让出CPU后重新参与竞争
是否可中断会抛InterruptedException会抛InterruptedException会抛InterruptedException不响应中断

重点说两个容易忽略的细节。第一,sleep是Thread类的静态方法,作用在当前线程上,跟“让哪个线程对象睡”没有关系,所以正确写法永远是Thread.sleep(xxx)而不是thread.sleep(xxx)。第二,wait必须放在同步代码块或同步方法里,因为它释放锁的前提是当前线程已经持有了锁,这算是语法层面的硬约束;而sleep没有这个要求,任何地方都能调。

另外建议把join的本质理解透:join底层是调用了wait,所以在join等待期间锁是释放的。很多人不知道这一点,面试被问到“join会不会释放锁”就懵了。你想,join的实现里有while (isAlive()) { wait(0); }这样的逻辑,本质上就是让当前线程阻塞等待目标线程结束。理解了这一层,你顺便也就明白了为什么join会抛InterruptedException——因为wait会响应中断。

2.3 创建线程的四种方式,怎么答才能体现经验

创建线程有四种方式:继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask、通过线程池创建。这道题本身不难,但面试官几乎必然会追问一句“你实际开发中更喜欢用哪种”,以及“为什么不用继承Thread”。

这里我建议你给出一个有层次感的答案顺序。先讲继承Thread:直接重写run方法,简单粗暴,但Java是单继承,一旦继承Thread就不能继承其他类,而且把任务代码和线程绑定在一起,不利于解耦,所以实际项目中几乎不用。再讲实现Runnable:把任务从线程中分离出来,可以配合线程池使用,这是最常用的方式,但run方法没有返回值,也不能抛受检异常。然后讲实现Callable:call方法有返回值,可以抛异常,但需要通过FutureTask包装才能交给Thread或线程池执行,适合需要异步获取结果的场景。最后是线程池:本质上是一种复用线程的资源管理方式,并不是与前面三种并列的“新机制”,而是在更高层面管理线程。

如果面试官继续追问,你还可以补一个未来感更强的方案:JDK 8之后的CompletableFuture以及JDK 19引入的虚拟线程(Virtual Threads),这能体现你持续关注Java演进。但注意别炫技过头,虚拟线程的底层调度逻辑跟传统平台线程差异很大,如果没深入研究,简单提一句“这是后续演进方向”就够了。

3. synchronized与锁升级:最体现功底的一块

3.1 synchronized的本质:Monitor机制

synchronized是Java并发面试里绕不开的话题,它出现的频率高、深度深,从基础用法能一路问到锁升级、Monitor、对象头。你准备这块内容时,建议直接以“从对象头到Monitor”的完整链路为主干,因为面试官很容易顺着这条链路往下追。

先说对象头。Java对象在内存中分为对象头、实例数据和对齐填充三部分。对象头里有一个Mark Word,它记录了对象的哈希码、GC分代年龄,以及锁相关的状态信息。锁的标志位就存在Mark Word里,从无锁到偏向锁、轻量级锁、重量级锁,Mark Word的内容会随之变化。这是整个锁机制的地基。

再说Monitor。每个对象都有一个Monitor与之关联,在HotSpot虚拟机里对应ObjectMonitor对象。synchronized加锁的本质,就是让线程去竞争目标对象的Monitor的所有权。Monitor内部维护着持有者线程、等待队列等数据结构。当一个线程进入synchronized代码块时,它尝试获取Monitor,如果获取成功就继续执行,失败就进入阻塞状态。

最后补一个面试官爱问的点:synchronized是可重入锁。什么叫可重入?就是同一个线程对同一个对象锁可以反复获取。比如一个synchronized方法内部调用了另一个被同一个锁保护的synchronized方法,线程不会把自己锁死,因为Monitor记录着持有线程信息,发现是同一个线程就直接放行。这个机制的底层实现就是Monitor内部有计数器,每次重入计数加一,全部退出后减到零才真正释放锁。

3.2 锁升级过程,每一步都别讲错

从JDK 6开始,HotSpot对synchronized做了大量优化,引入锁升级机制,让锁在不同竞争程度下选择不同的实现。升级路径是:无锁状态 -> 偏向锁 -> 轻量级锁 -> 重量级锁。这里有一句关键的话必须记住:锁只能升级,不能降级。也就是说一旦膨胀成重量级锁,不会自动降回来。

偏向锁解决的是“只有一个线程访问同步块”的场景。初次访问时,锁对象的Mark Word里记录线程ID,同一个线程再次进入时直接判断是不是自己,就无需再做同步操作。这一步非常快,几乎没有开销。如果另一个线程来竞争,偏向模式就撤销,升级成轻量级锁。

轻量级锁用CAS操作实现,适合“线程交替执行同步块,但竞争不激烈”的场景。线程进入临界区之前,先在栈帧中创建锁记录(Lock Record),然后尝试通过CAS把Mark Word复制到锁记录中并更新对象头。如果成功,获取轻量级锁成功;如果失败,说明存在多线程竞争,锁就会膨胀。

重量级锁就是传统意义上的Monitor锁,依赖操作系统底层的mutex互斥量实现。这里有个关键点:涉及用户态和内核态的切换,上下文切换开销很大,所以重量级锁被认为是最低效的。但在高并发竞争场景下,线程如果没有抢到锁就进入阻塞,反而避免了自旋空转的CPU浪费。

这里有一个很容易被忽视的现实变化,我必须提醒你:偏向锁在JDK 15中默认被禁用,JDK 18之后被标记为废弃。原因是现代应用普遍竞争比较激烈,偏向锁的撤销逻辑反而带来额外开销。所以你回答锁升级时,如果主动提一句“不过偏向锁在新版本JDK中已经不再默认开启”,面试官会觉得你是真的关注版本变更,而不是只背了老的教学资料。

3.3 volatile和JMM,和synchronized配合着考的问题

Java内存模型(Java Memory Model,简称JMM)是理解并发原理的底层框架。JMM规定所有变量存在主内存,每个线程又有自己的工作内存,线程对变量的所有操作都必须先在工作内存中执行,再刷回主内存。这个模型直接引出了并发三大特性:原子性、可见性、有序性。

volatile解决的是可见性和有序性。可见性上,volatile修饰的变量,每次被修改后会立即刷回主内存,每次读取时从主内存读,不缓存在线程工作内存里。有序性上,volatile通过内存屏障禁止编译器重排序和CPU重排序,保证指令执行顺序符合程序语义。但volatile不解决原子性,比如i++这种“读-改-写”操作,即使变量是volatile,多线程下依然不安全,因为volatile没法保证“读取、自增、写回”这三步作为一个整体不可分割。

面试题常考的经典场景是双重检查锁(Double-Checked Locking)的单例模式,为什么instance变量必须用volatile修饰?原因是new操作不是原子的,底层有三步:分配内存、初始化对象、将引用指向内存。如果不加volatile,编译器或CPU可能把第二和第三步重排序,导致一个线程在对象还没初始化完成时就以为创建好了,另一个线程拿到一个半初始化的对象引用去用,然后出问题。volatile通过禁止重排序避免了这种错乱。

回答这里时,建议主动关联happens-before原则。JMM通过happens-before规则保证程序的正确同步,volatile变量的写-读之间有happens-before关系,这意味着一个线程写volatile变量之后的任何操作,对后续读取该变量的线程都是可见的。AQS里的state变量就声明为volatile,就是依赖这个语义来保证锁状态在线程间的可见性。

4. AQS与JUC工具:源码层面的“加分点”

4.1 AQS设计思路:一次看懂模板方法

AQS(AbstractQueuedSynchronizer)是Java并发包中最核心的支撑类,ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock等都依赖它。面试题如果考到“你了解哪些并发工具”,铺垫一句“这些都是基于AQS实现的”,然后讲清楚AQS的核心机制,立刻就能把回答提升一个档次。

AQS的核心有三样东西。第一是state,一个volatile修饰的int类型变量,表示同步状态,不同子类对state的含义不同:ReentrantLock里state表示持有锁的次数,Semaphore里表示剩余许可数量,CountDownLatch里表示还需要等多少个事件。第二是CLH队列变种,AQS内部维护了一个双向链表队列,竞争不到锁的线程会被封装成节点放入队列尾部,然后阻塞等待。第三是模板方法设计模式,AQS定义好了acquire和release的骨架流程,把tryAcquire和tryRelease这类具体操作留给子类实现。

拿ReentrantLock加锁举例:线程调用lock(),进入AQS的acquire方法,首先执行tryAcquire尝试获取锁(这一步由ReentrantLock实现,利用CAS对state做修改),获取成功就拿到锁;失败则把当前线程封装成Node节点加入等待队列尾部,然后使用LockSupport.park把自己挂起。释放锁时,AQS执行tryRelease把state减掉,然后唤醒队列头部的后继节点。整个过程逻辑统一,子类只需要关心怎么修改state。

面试官大概率会追问一个问题:什么时候会用到AQS?你可以举一个自定义同步工具的例子。比如实现一个只允许同时通过两个线程的限流器,继承AQS,重写tryAcquire判断state是否小于2,小于则CAS加一并返回true,否则返回false。AQS已经把入队、唤醒、中断响应这些最复杂最易错的逻辑处理好了,你只需要关心状态判断,这就是AQS的设计哲学。

4.2 ReentrantLock与synchronized怎么选

这道题的完整答法应该是先讲两者的共同点:都是可重入锁,都保证了互斥性。再讲差异点,最后落到场景选型。差异点用一个表格来说最直观。

对比维度synchronizedReentrantLock
实现机制JVM层面,基于MonitorJDK层面,基于AQS
是否支持尝试获取锁不支持支持tryLock
是否支持超时等待不支持支持tryLock(timeout)
是否支持中断响应不支持,等待锁时不可中断支持lockInterruptibly
是否支持公平锁不支持支持,构造参数传true
条件变量只能配合wait/notify支持多个Condition精确唤醒
获取释放方式语法自动完成必须手动lock/unlock,要放finally

我在项目里的选型原则很简单:默认用synchronized,因为不需要手动释放锁,出错的概率低,而且JDK 6之后性能和ReentrantLock基本持平。但遇到这三种情况,我会切换到ReentrantLock:第一,需要限时等待锁,比如请求外呼接口时不能一直阻塞等待;第二,需要可中断响应,比如用户点击取消任务时线程要能及时退出等待;第三,需要多个条件队列,比如生产者消费者模型里要分别唤醒生产者和消费者。

这里插一个面试技巧:回答完选择逻辑之后,可以抛出一个自己踩过的坑。比如说说你曾经手动unlock放错位置导致锁未被释放,后来才改成try-finally里统一的写法。这种真实经历比背条条框框更容易打动人。

4.3 CountDownLatch、CyclicBarrier、Semaphore,别只背定义

这三个并发工具经常放在一起考,因为都涉及“多个线程协同”。面试官最反感的就是你背定义:“CountDownLatch是倒计时器,CyclicBarrier是循环栅栏,Semaphore是信号量。”背定义没有任何价值,你必须结合场景讲。

CountDownLatch适合“一个或多个线程等待其他线程完成”的场景。我在项目中用它做过主从库数据校验:启动多个线程分别校验不同分片的数据,主线程用CountDownLatch等待所有校验线程结束,最后汇总校验结果。用法核心就两步:构造时设置计数器count,每个子线程完成一阶段工作后调用countDown(),主线程调用await()阻塞等待,直到计数器归零。

CyclicBarrier适合“一批线程互相等待,都到达屏障点后再一起继续”的场景。经典案例是批量导入数据:开5个线程分别读取5个文件,等5个线程都读完后,再同时执行后面的入库操作。注意CyclicBarrier是可以循环使用的,所有线程到达屏障后,计数器会重置,所以能支撑多轮协同。

Semaphore就是许可数量控制,本质是个计数器,每次acquire消耗一个许可,每次release归还一个许可。它最适合做限流。我做过一个网关接口,同一时间最多允许100个请求同时执行,就用Semaphore(100)控制,超过的直接返回“系统繁忙”。不过要提醒一点:Semaphore并不保证线程安全性之外的事情,它只管数量,状态的正确性还得靠其他手段保证。

4.4 CAS与原子类:为什么能比锁快

CAS,全称Compare-and-Swap,是并发编程里的“无锁化”核心操作。它的流程是:拿到内存中的值V,和预期值A做比较,如果相等,就把新值B写入;如果不相等,说明期间有其他线程改过,就重新读取,再次尝试,直到成功。整个过程由CPU的硬件指令保证原子性,Java中通过Unsafe类的compareAndSwapInt实现。

保证原子性的方案有两种,锁是“悲观策略”,CAS是“乐观策略”。锁认为冲突一定发生,所以先锁住资源再操作;CAS认为冲突偶尔发生,所以先尝试操作,失败再重试。在高并发下锁会频繁阻塞唤醒线程,带来巨大的上下文切换开销,而CAS是无阻塞算法,靠CPU指令和循环,所以一些场景下性能更好。

面试必问CAS的ABA问题。想象一个栈,内存中链头是A,线程1想弹出A,它读取到当前值是A。线程2先弹出A,又压入一个A。线程1执行CAS时发现内存值还是A,以为没有变化,就弹出了A。但栈的内容其实已经被动过,A下面的节点可能已经变了。解决ABA问题的标准方案是加版本号,Java里的AtomicStampedReference就是干这个的,通过比较引用和版本戳,能识别出这中间发生过修改。

顺便说一句,Java并发包里的原子类,比如AtomicInteger、AtomicLong、AtomicBoolean,底层全是CAS。面试被问“AtomicInteger为什么线程安全”时,不要简单说“因为它用了CAS”,而要补一句:CAS没有锁,所以它不会让线程进入阻塞状态,在高竞争下可能因为自旋循环而消耗CPU,因此在竞争极激烈时,可能反而比锁更慢。能讲出CAS的劣势,说明你不是只懂夸它。

5. 线程池:面试必问的“硬通货”

5.1 七大参数与执行流程,一条线讲完

线程池是Java并发面试题里性价比最高的一个知识点,几乎每场面试都有它的位置,而且从七大参数到拒绝策略到执行流程,环环相扣。我建议先背熟ThreadPoolExecutor的七个参数,然后顺着执行流程把它们串起来。

七个参数分别是corePoolSize、maximumPoolSize、keepAliveTime、TimeUnit、workQueue、threadFactory、handler。corePoolSize是核心线程数,默认常驻;maximumPoolSize是最大线程数;keepAliveTime和TimeUnit共同决定非核心线程空闲多久后被回收;workQueue是任务队列;threadFactory是创建线程的工厂,可以自定义线程名;handler是任务满员时的拒绝策略。

线程池提交一个任务后的执行流程,用一个四步走就能讲清楚。第一步,当前线程数小于corePoolSize时,创建核心线程执行任务。第二步,线程数大于等于corePoolSize,但任务队列还没满,任务加入队列等待。第三步,队列满了,线程数小于maximumPoolSize,创建非核心线程去处理任务。第四步,队列满了且线程数达到maximumPoolSize,触发拒绝策略。

这里有一个常见的记忆误区:线程池不是“先加非核心线程,再往队列里放任务”,而是“先让核心线程跑,再让队列缓存,最后才拉满最大线程”。很多教材里把它简化成“核心线程不够就进队列,队列不够就加线程”,这个顺序千万别搞反。实际开发时这个微妙的顺序会影响你判断一个任务到底会排队还是会立即执行,进而影响你对系统吞吐量的评估。

5.2 四种拒绝策略:场景决定选择

当线程池的任务队列满了、线程数也到最大值时,新提交的任务会交给RejectedExecutionHandler处理。JDK内置了四种拒绝策略,面试常考,但更多时候是给你一个场景让你选。

策略行为适用场景
AbortPolicy直接抛RejectedExecutionException默认策略,任务不能丢,需要立即感知
CallerRunsPolicy提交任务的线程自己去执行被拒绝的任务不想丢任务,允许放慢提交速度
DiscardPolicy静默丢弃,不抛异常可接受任务丢失,不影响核心业务
DiscardOldestPolicy丢弃队列中最旧的任务,再尝试提交新任务处理新任务优先级高于旧任务的场景

我在实际项目中,用得最多的是CallerRunsPolicy。因为它的思想是“把任务反推给提交者”,线程池满员时,提交任务的主线程自己会去执行任务,相当于变相限流,让生产速度降下来,又不丢任务。比如日志异步写入,宁可让业务线程自己写日志,也不能把日志丢掉。AbortPolicy在不想静默掩盖问题、希望快速报警时也不错,但它会直接把异常抛到提交方,处理不当可能影响主流程。

其实面试时选哪个策略并不关键,关键是解释为什么。比如你选CallerRunsPolicy,要说清楚它是不是有“慢提交”的副作用,以及为什么你能接受这个副作用。能把这个逻辑讲通,比告诉你“用哪个”有用得多。

5.3 核心线程数怎么定,不能只背公式

线程池核心线程数的配置,是面试里最容易引出“实战经验”的题目。常见的说法是CPU密集型设置为CPU核数加一,IO密集型设置为CPU核数乘二,或者用“CPU核数除以(1-阻塞系数)”这个公式。这些说法有用,但不能生搬硬套。

CPU密集型任务(比如大量计算、图像处理)几乎不阻塞,线程一直在占用CPU,设置成CPU核数或多一个就可以,多了反而因为频繁上下文切换降低效率。IO密集型任务(比如读写文件、请求接口)的大量时间在等待IO,线程被阻塞时不占CPU,所以可以设置更多线程。阻塞系数通常取0.8到0.9,经验值就是CPU核数的两倍左右。

但实际开发你千万别一上来就套公式。我自己的做法是:先用一个保守值跑起来,比如CPU核数加一,配合压测工具观察指标,看CPU利用率、请求响应时间、拒绝任务数量。如果CPU利用率一直很低,可能增加线程数;如果线程大量在等待IO且响应很慢,就加大IO密集任务的线程数。通过oom或者性能监控工具持续观察,比拍脑袋套公式靠谱得多。

这里还要补一个细节:threadFactory最好自定义,给线程池里的线程起个有业务含义的名字,比如“user-order-pool-thread-1”。这样以后查问题看到线程名字就大概知道是哪个业务的线程池在处理,排查效率会高出不少。这个细节特别容易被忽视,但面试官很吃这一套。

5.4 execute与submit之间的坑

线程池提交任务有两种方式,execute(Runnable)和submit(Callable或Runnable)。这道题几乎必考,而且跟着一个非常经典的坑:submit能吞掉异常导致你的监控系统根本不知道任务失败了。

execute最终执行的是Runnable,如果run方法里抛了异常,这个异常会由线程池的UncaughtExceptionHandler处理,如果你没有设置,异常会被打印到控制台,线程被回收或替换。submit不一样,它把任务包装成FutureTask,异常被捕获后存储起来,等调用future.get()的时候才会重新抛出。如果代码里从不调用get(),异常就永远没机会抛出来,问题被彻底吞掉了。

所以我的建议是:如果任务执行失败需要立刻感知,优先用execute或者在submit后立刻get()拿到结果;如果任务本身允许失败,而且你不在意结果的获取,submit也不是不行,但必须想清楚异常去向问题。还有一点,submit返回的Future可以配合超时等待,比如future.get(5, TimeUnit.SECONDS),任务超时未返回就主动失效,这在调用外部服务时尤其有用。

注意:线程池使用结束后记得主动调用shutdown(),不然核心线程会一直驻留,应用无法正常退出。不过如果你用的是Spring的ThreadPoolTaskExecutor,容器销毁时会自动处理,这里不用担心。

6. ThreadLocal、并发容器和Future:容易出陷阱的进阶题

6.1 ThreadLocal的内存泄漏,是面试官最爱引用的坑

ThreadLocal的面试题十道里有八道会问内存泄漏。先说清楚它的原理,再谈泄漏原因。ThreadLocal本身不存值,值存在当前线程的ThreadLocalMap里,key是ThreadLocal对象,value是你set进去的对象。所以“每个线程有一份独立变量副本”的本质就是:线程内部有一个Map,以ThreadLocal作为key。

内存泄漏的根源在 ThreadLocalMap.Entry 的继承结构:Entry继承WeakReference,它的key(即ThreadLocal对象)是弱引用,而value是强引用。弱引用碰到GC就会被回收,如果外部没有强引用指向ThreadLocal对象,它被GC回收后,Entry的key变成null,但value依然被Entry引用着,不会被回收。只要线程一直存活(线程池里的线程往往长时间复用),这些key为null的Entry就永远占用内存,逐渐堆积,最终造成内存泄漏。

解决方式也很简单,ThreadLocal用完后务必调用remove(),把key为null的Entry一并清理掉。我在项目里看到过一些同事只在set之后不做清理,时间一长,内存爬升得很明显。不要心存侥幸,觉得“我这是一个很小的变量不会泄漏”,在高频创建和使用ThreadLocal的场景里,堆积速度比你想象得快。

实际使用场景中ThreadLocal很有价值,最常见的是在线程池里存请求上下文、租户ID、traceId之类的东西。比如一个接口处理链路要从入口一直传递一个跟踪ID到最底层,如果每层方法都手动传参,代码会非常难看,ThreadLocal刚好能优雅地解决。但正因为线程池线程会复用,如果不清理,下一个任务就会读到上一个任务的脏数据,这个坑比内存泄漏更隐蔽,项目里一旦出现,排查起来相当痛苦。

6.2 ConcurrentHashMap从JDK7到JDK8的演进

并发容器题目中,ConcurrentHashMap是绝对的C位。它跟HashMap、HashTable的区别要能一句话说清:HashMap线程不安全,HashTable全表加锁性能极差,ConcurrentHashMap用细粒度锁和无锁技术实现线程安全和高效并存。

JDK 7的ConcurrentHashMap采用分段锁(Segment)设计。整个Map被分成若干Segment,每个Segment就是一个小型HashMap,并且使用独立的ReentrantLock锁保护。不同线程操作不同Segment时互不干扰,锁粒度是Segment级别,并发度等于Segment的数量。JDK 8完全重构了实现:抛弃Segment,改为使用数组加链表加红黑树的结构,锁粒度细化到单个数组桶(bucket),利用CAS加synchronized实现线程安全。

JDK 8的逻辑值得好好说:插入元素时,先通过key的哈希算出桶的位置,如果桶为空,直接CAS设置头节点,不需要加锁;如果桶不为空,就synchronized锁定这个桶的头节点,再往链表或红黑树里插入。链表长度超过8个且数组容量达到64时,链表转为红黑树,避免查找性能退化。所以JDK 8的并发度比JDK 7高很多,它锁的只是一个桶而不是一整个分段,而且用CAS避免了大部分加锁开销。

还有一个常考的点:ConcurrentHashMap的size()方法为什么“不准”?因为这是一个并发场景,计算size时其他线程随时可能修改Map。JDK 8的实现里,size()的返回值是一个估计值,它先尝试无锁累加CounterCell数组中的计数,如果竞争激烈还会CAS累加baseCount,最终返回的值是尽量接近真实的快照值。你可以理解成它给出的更像“当前时刻的近似值”,而不是严格一致的数值。这个特性在面试中值得主动提一句,说明你理解并发里的“一致性与性能之间的取舍”。

6.3 CompletableFuture:把异步编排讲出亮点

CompletableFuture在JDK 8引入,面试中越来越常见。它本质上是Future的增强版,解决了传统Future的两个痛点:一是future.get()会阻塞当前线程,二是多个Future之间的依赖、组合关系写起来很痛苦。

CompletableFuture最核心的价值是链式编排。你可以通过supplyAsync提交一个有返回值的异步任务,然后用thenApply对结果做转换,用thenCompose把两个有依赖关系的异步任务串联起来,用thenAccept消费结果而不需要返回值,用whenComplete做收尾处理。整个调用链不需要手动get()阻塞等待,完成回调自动触发。这就像一条流水线,上个工序加工完自动传给下个工序,而不是每道工序都要人盯着。

业务上最常见的组合是每次请求外部服务时可能需要并发调用两个接口再合并结果,这时可以用CompletableFuture.allOf把所有任务组合起来,等全部完成后统一处理结果。任一个任务发生异常,还可以用exceptionally或handle设置兜底值,避免异常在链式调用里被吞掉。anyOf则适合“多个任务谁先完成就取谁的结果”的场景,比如同时请求两个相同功能的第三方服务,哪个先返回用哪个,做故障降级。

面试时想拿高分,可以补充一句:CompletableFuture提交任务时如果不指定线程池,默认使用ForkJoinPool.commonPool。这个公共线程池被很多地方共用,一旦某个任务阻塞,会影响其他公共池里的任务。所以生产环境建议显式传入自定义线程池,避免互相干扰。这种经验性细节是很多背题的人根本想不到的。

7. 死锁与线上排查:让面试官觉得你“实战过”

7.1 死锁的四个必要条件与如何避免

死锁是面试中出现频率极高而且最后很容易变成“加分题”的内容。要答好死锁,先把四个必要条件背到滚瓜烂熟,再谈避免手段,最后如果能现场演示一个死锁代码,效果最好。

死锁四个必要条件是:互斥条件,至少有一个资源只能被一个线程独占;持有并等待,一个线程持有资源的同时又在等待其他线程占用的资源;不可剥夺,线程已持有的资源在自己使用完前不能被强行抢走;循环等待,多个线程之间形成环形等待依赖关系。四个条件同时成立,死锁才会发生。

避免死锁的思路就是从条件入手。最简单有效的手段是破坏“循环等待”和“持有并等待”。破坏循环等待的做法是规定所有线程按同一个全局顺序加锁,比如先锁A再锁B,锁的顺序固定,就不会出现环形依赖。破坏持有并等待的做法是使用tryLock带超时去获取锁,获取不到就释放自己已经持有的资源,过会再重试,这样资源和线程不会无限期占死。

演示死锁的代码其实很短:两个线程各自持有锁A和锁B,线程1先锁A再请求B,线程2先锁B再请求A,两边都卡在等对方的锁上,程序就永久阻塞。我在给团队分享的时候经常现场写这段代码,然后跑一次给他们看,效果比讲十分钟理论都直观。面试时能写出来,就证明你是真的写过,不是只背了定义。

7.2 线上死锁排查一条龙

死锁不只在面试题里出现,生产环境真的会遇到。我经历过一次比较典型的案例:两个线程池互相调用,一个持有了A事务锁又去请求B锁,另一个反着来,结果线上接口集体超时,应用基本不可用。定位过程很有代表性,分享给你。

第一步用jps找到目标Java进程的PID。第二步用jstack PID导出线程快照。在jstack输出中搜索“deadlock”关键字,如果存在死锁,JVM会明确打出一段描述,告诉你哪些线程在等待哪把锁,并列出锁的所有者。第三步根据线程栈里的类名、方法名、行号,定位到业务的哪段代码造成了锁竞争。

用jstack看线程快照有两个技巧。第一,不要只搜deadlock,还要看大量线程是否都卡在同一个位置,比如几十个线程同时停在“waiting to lock xxx”,说明这是一个热点锁。第二,要会看线程的当前状态,大量线程处于BLOCKED或WAITING,通常意味着锁竞争、死锁或者线程池耗尽。排查这类问题,除了jstack,也可以辅助用jconsole或jvisualvm的可视化线程面板,但命令行方式在任何环境都能用,这是底线技能。

注意:jstack导出的只是一瞬间的快照,死锁往往是偶发的,一次快照可能抓不到。建议连续导出几次,或者结合实际线程数指标一起看。线上排查时多保留几份不同时刻的快照,对比分析覆盖率会高很多。

另外补充一个排查思路:如果没法直接用命令行,可以给应用加一个定期输出线程快照的脚本或开关,在系统异常时自动保留现场。我在一些关键服务里配置了定时诊断线程,每30秒检查是否有线程长时间处于BLOCKED,一旦发现就自动dump线程栈并告警。这个方案不算复杂,但能在故障发生时留下最关键的现场数据,对事后定位帮助极大。

7.3 让“并发四问”成为你的万能答题框架

其实不管是线程状态、synchronized、AQS还是线程池,面试官追问的风格几乎一致,就四件事:是什么、为什么、什么时候用、有什么坑。把这四件事在脑子里过一遍,任何并发问题都能答出层次感。

比如面试官问“你了解synchronized吗”,你就可以按这个框架来:先说是什么,它是Java提供的内置锁,基于Monitor实现。再说为什么,JVM在JDK 6后做了锁升级优化,让它在不同竞争场景下都有不错的性能。再说什么场景用它,默认加锁场景都用synchronized,除非需要超时、中断、公平锁等能力。最后说什么坑,它不能尝试获取锁,等待时不能响应中断,在竞争极其激烈时重量级锁的上下文切换开销很大。

我们在准备Java并发编程面试题的时候,很多人有个误区,觉得题目越多越好,刷完一本又一本。实际上真正的核心考点非常集中,你与其痛苦地把一道题的答案死背下来,不如把背后的机制和场景想透。我给面试者做模拟面试的时候,最明显的感受就是:能主动讲出“为什么”和“踩过的坑”的候选人,评价往往远超那些把所有八股文背得滚瓜烂熟的人。因为知识可以被遗忘,但你解决问题的思路和理解深度不会。希望这套从123道并发面试题里提炼出来的核心考法,能帮你少走点弯路,把这些高频考点真正变成自己的东西。

返回列表