☰
Java多线程面试核心考点与底层原理深度解析
2026/10/13 1:45:11 网站建设 项目流程

干了这么多年Java,面过别人也被别人面过,我发现一个挺残酷的事实:多线程这块,基本上是个人都得被问到。从校招到社招,从初级到资深,面试官总能在多线程里找到一块能把你问住的角落。牛客上翻了翻面经,Java多线程的“八股”问法来来回回就那么几类,但每一类都能往深了追,追到源码级别你也不一定扛得住。今天这篇就结合我自己的面试和带队经验,把Java多线程面试里最常碰到的知识点、追问路径和答题思路完整过一遍,希望能帮你把这块硬骨头啃下来。

这篇内容不追求“大全”,而是聚焦在面试真正会考的核心逻辑上:从进程线程的区别,到JMM和volatile,再到synchronized、Lock、线程池和并发工具类,最后加上几道经典手撕题和线上排查实战。不管你是准备校招、跳槽,还是单纯想把并发基础补扎实,都可以拿这篇当主线,照着往下挖源码。


1. 多线程基础:面试第一问就是它

1.1 进程和线程的区别,别只背定义

面试开场的经典问题:“说说进程和线程的区别”。很多同学能答出“进程是资源分配的最小单位,线程是CPU调度的最小单位”,但再往下问就卡壳了。面试官真正想听的往往不是定义,而是为什么线程切换比进程切换开销小,以及这个差异在实际系统里是怎么体现的。

从底层来看,进程有独立的地址空间、独立的文件描述符表、独立的信号处理机制,线程则共享进程的地址空间和大部分资源。进程切换时,操作系统要切换页表、刷新TLB、保存完整的CPU上下文,而线程切换只需要保存线程自己的寄存器状态和程序计数器,因为它们共享同一个地址空间,页表都不用换。这也是为什么我们把“并发执行”作为提高系统吞吐量的主要手段,而不是随便开一堆进程。

这里还有个容易被追问的点:线程之间共享什么、不共享什么。共享的是堆内存、方法区、文件描述符、信号处理器;不共享的是线程栈、程序计数器、寄存器、局部变量。面试时你可以顺手提一句“局部变量是线程私有的,所以无状态方法天然线程安全”,这句话能把话题引到后面的线程安全上去,显得你有体系化的认知。

1.2 并发和并行的区别,以及多线程要解决什么问题

并发(Concurrency)和并行(Parallelism)的区分也是高频题,而且很多人搞混。并发是同一时间段内多个任务交替执行,强调“看起来同时”;并行是同一时刻多个任务真正同时执行,强调“真的同时”。单核CPU只能并发,多核CPU才能并行。用大白话说,并发是一个人同时干几件事来回切换,并行是几个人各干各的互不干扰。

但面试里光答这个不够,经典追问是:“多线程到底解决了什么问题?”我一般会分三点答:

  • 充分利用CPU多核,把计算并行化,提升吞吐量。
  • 提高响应速度,像Tomcat处理请求,一个请求一个线程,避免单个慢请求阻塞其他请求。
  • 异步化,把耗时操作(比如发邮件、写日志)丢到后台线程,主流程不用傻等。

然后面试官通常会接一句:“那多线程会带来什么问题?”这就要引出原子性、可见性、有序性三大问题了。这块是后面JMM的铺垫,建议主动展开。

1.3 线程生命周期六状态,别再背错转换图

线程状态是八股中的八股,但你真拿张纸让候选人画状态转换图,很多人画不对。Java线程一共六个状态,定义在Thread.State枚举里:NEW(新建)、RUNNABLE(可运行)、BLOCKED(阻塞)、WAITING(等待)、TIMED_WAITING(限时等待)、TERMINATED(终止)。

容易出错的地方有两个。第一个是RUNNABLE状态其实包含了OS层面的Ready和Running,Java里没有单独的Running状态,所以“运行中”的线程也是RUNNABLE。第二个是BLOCKED和WAITING的区别:BLOCKED是指线程在等锁,也就是等synchronized的监视器锁;WAITING是指线程主动进入等待状态,比如调了wait()、join()、LockSupport.park()。一个是被动等锁,一个是主动等待,这个区别面试官很爱抠。

状态转换的关键路径:

  • NEW调用start()后进入RUNNABLE;
  • 高并发下等synchronized锁进入BLOCKED,拿到锁回到RUNNABLE;
  • 调wait()或join()进入WAITING,被notify()/notifyAll()唤醒回到RUNNABLE;
  • 调sleep(ms)或带超时的wait(ms)进入TIMED_WAITING,超时后自动回到RUNNABLE;
  • 需要知道的是BLOCKED状态只能由synchronized触发,ReentrantLock的阻塞等待不会让线程进入BLOCKED,它会进入WAITING状态,因为底层用的是LockSupport.park(),这是一个很隐蔽但很加分的细节。

2. 线程创建和启动:四选一,得会说为什么

2.1 四种创建方式,最后一种才是企业级

线程创建方式面试基本必问:继承Thread、实现Runnable、实现Callable、使用线程池。考的是你知不知道每种方式的适用场景。继承Thread这种方式最直观,但Java是单继承,继承了Thread就不能继承别的类,而且把任务和线程耦合在一起,不推荐;实现Runnable把任务和线程解耦,更灵活,但run()没有返回值,也不能抛受检异常。

Callable是Runnable的增强版,call()方法有返回值,还能抛异常。它不能直接传给Thread,需要包装成FutureTask,或者丢给线程池的submit()。但面试时你要能说清楚:FutureTask本质是Runnable的一个实现,它的run()方法内部会去调用call(),并把结果存起来,所以Future.get()才能拿到返回值。

第四种方式是重点:实际开发中不应该裸用new Thread()。原因很直接,每次新建和销毁线程的成本高,线程数量失控会导致系统资源耗尽,而且不方便统一管理。正确的做法是用ThreadPoolExecutor,后面第5章会完整展开。面试时这么说,面试官会觉得你是有线上经验的,而不是只会写demo。

2.2 run()和start()的区别,以及start()两次会怎样

这个看似简单,但每年都有人翻车。start()才是真正创建新线程并执行run()的入口,它会让线程从NEW进入RUNNABLE,由JVM去调度执行;直接调run()就是普通方法调用,在当前线程里串行执行,不会新起线程。

追问“start()两次会怎样”?答案是会抛IllegalThreadStateException,因为线程状态不是NEW了,start()内部会校验线程状态。底层是Thread.start()里有一个threadStatus != 0的检查,nativeCreate之前会判断。另外还要知道**start()一次之后线程就废了吗?** 不是,线程跑完run()会进入TERMINATED,但不能再被start()了,只能重新new一个线程对象。

2.3 sleep、wait、join、yield的对比

这组对比是多线程面试的“钉子户”。我建议你按这个表来记,并且能现场画出来:

方法所属类是否释放锁调用后状态备注
Thread.sleep(ms)Thread静态不释放锁TIMED_WAITING持有锁睡,容易造成性能问题
Object.wait()Object成员释放锁WAITING/TIMED_WAITING必须在同步块中调用
Thread.join()Thread成员不释放锁WAITING底层是wait,但持锁等待
Thread.yield()Thread静态不释放锁RUNNABLE让出CPU,但不保证让出成功

这里有几个坑。wait()和notify()必须在synchronized代码块或方法中调用,否则抛IllegalMonitorStateException,原理是它要操作对象的monitor,没有持有monitor就没有资格控制这个对象上的等待队列。sleep()不需要在同步块中调用,它和锁没有任何关系。join()底层是wait(),所以join()可以被中断,中断后抛InterruptedException。

还有一个高频变体:wait和sleep都能让线程暂停,为什么wait要释放锁而sleep不释放?从语义上讲,wait()是用来做线程间协作的,调用它的线程通常是在等某个条件成立,如果还握着锁不放手,其他线程就进不来改条件,等于死等,所以必须释放锁;sleep()只是单纯让线程休息,不存在协作语义,自然不用释放锁。


3. JMM与三大特性:理解线程安全的底层逻辑

3.1 可见性、原子性、有序性是怎么产生的

所有并发问题的根源就是这三个性质被破坏。原子性指一个操作或多个操作要么全部执行且不被打断,要么全部不执行。i++为什么不是原子的?因为它实际是读、加、写三个步骤,任何一步都可能被其他线程插进来。可见性指一个线程修改了共享变量,另一个线程能否立刻看到。由于CPU缓存和指令重排的存在,一个线程修改了变量可能还停留在自己的高速缓存或写缓冲里,没有刷回主内存,其他线程读到旧值。有序性指程序执行的顺序可能被编译器或CPU重排,a=1; b=2可能先执行b=2再执行a=1,这在单线程内不影响结果,但在多线程下就会出幺蛾子。

面试时最好结合JMM(Java内存模型)来讲。JMM规定所有变量存在主内存,每个线程有自己的工作内存(类比CPU缓存),线程对变量的操作必须在工作内存中进行,再同步回主内存。这正好对应了可见性问题的根源。JMM通过volatile、synchronized、final以及一些内存屏障机制来保证内存可见性和有序性。能讲到这里,说明你不是在死背概念。

3.2 volatile的两重身份:可见性和禁止重排

volatile是面试重点,考察点非常集中:它保证可见性和有序性,但不保证原子性。为什么能保证可见性?因为JMM规定,对volatile变量的写操作,会立即刷新到主内存;对volatile变量的读操作,会从主内存重新读取,而不是读缓存。底层实现是在写volatile变量时插入StoreStore和StoreLoad内存屏障,在读时插入LoadLoad和LoadStore屏障,禁止了相关重排。

但volatile i++;依然不是线程安全的,因为自增是“读-改-写”三个步骤,volatile只保证每一步的可见性,无法把这个复合操作变成原子的。想让自增原子,得用AtomicInteger或者加锁。这个概念是面试官最喜欢的“钓鱼”问题,基本都会问。

volatile的典型使用场景有两个:

  • 状态标志位:一个线程设置flag = true,另一个线程循环读这个flag,用volatile保证标志可见。比如关闭线程的stop标志。
  • 双重检查锁(DCL)单例:instance = new Singleton()其实有三步——分配内存、初始化对象、把引用指向内存,如果没有volatile禁止重排,另一个线程可能拿到“尚未初始化完成”的实例。这个例子面试出现频率极高,必须会写。

3.3 synchronized的锁升级机制

synchronized是Java中最基础的锁,JDK6之后它的性能被大幅优化,核心就是引入了锁升级。理解锁升级的关键是理解对象头Mark Word。无锁状态下,Mark Word存储对象的hashcode和GC分代年龄;当线程抢锁时,锁状态会沿着“无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁”的方向升级,并且锁只能升级不能降级(这里注意,偏向锁在JDK15开始被默认禁用,JDK21已经移除了偏向锁,所以新版JDK实际是“无锁 -> 轻量级锁 -> 重量级锁”)。

各个锁的含义和适用场景:

  • 偏向锁:同一个线程反复进入同步块,锁会偏向这个线程,省去CAS操作。但现在偏向锁已经退出历史舞台,因为它的维护成本在某些场景下反而高于收益。
  • 轻量级锁:多个线程交替竞争,但没有真正同时竞争时,通过CAS自旋获取锁,避免线程挂起和唤醒。自旋会消耗CPU,所以又引入了自适应自旋,JVM根据上次自旋的成功率自动调整自旋次数。
  • 重量级锁:当CAS自旋失败次数过多,或等待线程太多,锁会升级为重量级锁,底层依赖操作系统互斥量,线程会进入BLOCKED状态。

synchronized的底层是靠monitorenter和monitorexit字节码指令来加锁解锁的,每个对象都有一个monitor监视器。这里有一个加分细节:JDK9之后,monitorenter和monitorexit在JIT编译时会由偏向锁/轻量级锁的CAS代码替代,不再仅仅依赖monitor。

ReentrantLock的等待会让线程停在WAITING状态,而synchronized抢锁失败是BLOCKED状态。这个细节我在1.3已经提过,面试时说出来,面试官会眼前一亮。

3.4 CAS与原子类:无锁编程的大杀器

CAS(Compare And Swap)也是必考点。它的语义是:只有当内存当前值等于预期的旧值时才把值更新为新值,整个操作是原子的。Java里的AtomicInteger、LongAdder、ConcurrentHashMap(部分场景)底层都依赖CAS。

AtomicInteger怎么做到原子自增的?看源码会发现incrementAndGet()里有一个compareAndSetInt的native方法,利用处理器底层的cmpxchg指令实现原子比较并交换。CAS相比加锁的好处是无锁、无阻塞,避免了线程上下文切换;坏处是如果竞争激烈,CAS一直失败,会一直自旋重试,白白消耗CPU。另外CAS只能保证一个共享变量的原子操作,多个变量要原子更新就得用锁。

CAS还有个经典问题:ABA问题。线程1读到A,线程2改成B又改回A,线程1的CAS会发现值还是A,比较成功,但实际中间被改过了。解决方式是加版本号,AtomicStampedReference就是干这个的,用[reference, stamp]作为比较对象。不过说实话,现实中ABA问题出现概率不高,但面试官问你能不能说出解决方案,你是必须知道的。

3.5 AQS:JUC锁的基石

AQS(AbstractQueuedSynchronizer)算是并发包里最核心的类了,ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock全都基于它。面试官问AQS,就是想看你对JUC体系有没有全局认识。

AQS核心是两个东西:一个volatile的int状态值state,一个双向等待队列(CLH变体)。state的含义由子类自己定义,比如ReentrantLock里state表示锁被重入的次数,CountDownLatch里state表示剩余计数。获取锁失败时,线程会被封装成Node节点挂到等待队列尾部,通过CAS设置尾节点;释放锁时,会唤醒队列头部的后继节点。

AQS提供了几组模板方法:独占式获取/释放(acquire/release)和共享式获取/释放(acquireShared/releaseShared)。子类需要实现tryAcquire、tryRelease等方法来定义自己的加锁逻辑。面试时你可以说:“AQS把‘抢锁失败后怎么排队、怎么唤醒’这些通用逻辑都封装好了,子类只需要关心state的含义和获取/释放条件。”这样一句话,面试官就知道你读过源码。


4. 锁的进阶:ReentrantLock、公平锁、读写锁

4.1 ReentrantLock与synchronized怎么选

ReentrantLock是面试里的另一个重点。它和synchronized的对比几乎是必问,常见的对比点:

  • synchronized是隐式锁,加锁解锁由JVM管理;ReentrantLock需要手动lock()和unlock(),必须在finally里释放,否则死锁。
  • ReentrantLock支持公平锁,synchronized只有非公平锁。
  • ReentrantLock支持可中断地获取锁,lockInterruptibly(),线程在等锁时可以响应中断;synchronized不行。
  • ReentrantLock支持超时获取锁,tryLock(timeout, unit),抢不到锁可以在指定时间后放弃,避免无限等待。
  • ReentrantLock可以创建多个Condition,实现精确唤醒某个等待条件的线程,比wait/notify强大。

但要注意一点:JDK6对synchronized做了极大优化后,两者的性能基本在一个量级。所以我给团队的建议一直是:能用synchronized就用synchronized,只有当你需要公平性、可中断、多条件队列这些高级能力时才换ReentrantLock。这也是很多大厂面试官想听到的答案,因为说明你有实际取舍经验,而不是无脑用Lock。

4.2 公平锁与非公平锁的底层差异

公平锁就是“先来后到”,线程获取锁的顺序和请求锁的顺序一致,依靠AQS队列的FIFO实现。非公平锁允许“插队”,新来的线程会先尝试一次CAS抢锁,抢不到再进队列排队。

面试必问:非公平锁为什么吞吐量更高?答案要分两层。第一层,刚释放锁的线程和刚被唤醒的线程之间存在一个时间窗,非公平锁让新线程直接抢锁,省去了唤醒和上下文切换的开销,减少了线程挂起和恢复的次数。第二层,公平锁的队列唤醒机制是比较慢的,一个线程被唤醒到真正获取锁,中间可能经历多次调度,如果这段时间没有新线程抢锁,CPU就空转了。非公平锁就是利用这个窗口“塞”新线程,让CPU尽量不被浪费。

ReentrantLock默认是非公平锁,new ReentrantLock(true)才能启用公平锁。为什么默认非公平?因为绝大多数场景下,保证吞吐比保证严格公平更重要。但也要注意,非公平锁在极端高并发下可能造成“线程饥饿”,少数线程长时间抢不到锁。

4.3 读写锁:读多写少场景的优化利器

ReentrantReadWriteLock面试也会碰到,核心是读读共享、读写互斥、写写互斥。它把锁拆分成了读锁和写锁,读锁是共享锁,多个线程可以同时持有;写锁是独占锁,同一时间只能有一个线程持有。

底层原理很有意思:ReentrantReadWriteLock用一个int类型的state同时表示读锁和写锁的状态,高16位表示读锁计数,低16位表示写锁重入次数。获取写锁时,只有当读锁计数为0且写锁未被占用时才能成功。这个设计很巧妙,面试时说一句“一个state拆成高低位用”,能体现源码阅读能力。

但读写锁也有坑:如果读锁长期被持有,写锁会一直等不到,极端情况下写线程会饿死。所以JDK8引入了StampedLock,它支持乐观读,读的时候不加锁,仅在写回时校验版本,适合读多写少且追求极端吞吐的场景。这一串讲下来,面试官会觉得你不只是会用API。


5. 线程池:面经重灾区,也是线上事故重灾区

5.1 七个参数和执行流程的“灵魂次序”

线程池是Java多线程面试里占比最高的一块,几乎每场面试都跑不掉。ThreadPoolExecutor构造方法的七个参数必须滚瓜烂熟:

  • corePoolSize:核心线程数,即使空闲也不会被回收(除非allowCoreThreadTimeOut(true))。
  • maximumPoolSize:最大线程数。
  • keepAliveTime+unit:非核心线程空闲存活时间。
  • workQueue:任务队列,核心线程满了之后任务先进队列。
  • threadFactory:线程工厂,设置线程名、是否daemon等。
  • handler:拒绝策略。

执行流程是重中之重,很多人背错顺序。正确的顺序是:

  1. 提交任务后,如果当前线程数小于corePoolSize,创建核心线程执行任务。
  2. 如果线程数达到corePoolSize,任务进入workQueue排队。
  3. 如果workQueue已满,且当前线程数小于maximumPoolSize,创建临时(非核心)线程执行任务。
  4. 如果线程数已经达到maximumPoolSize,执行拒绝策略。

注意一个细节:核心线程是懒创建的,不是线程池一启动就建好所有核心线程。只有第一个任务提交时才会创建第一个线程。如果希望启动时就创建好所有核心线程,可以在初始化后调用prestartAllCoreThreads()。这个细节面试官一问一个准。

还有个容易混淆的点:任务进入队列后,什么情况下才会创建非核心线程?一定是队列满了才触发。如果你用的是无界队列(比如LinkedBlockingQueue默认无界),那么队列永远不会满,非核心线程永远不会创建,maximumPoolSize和拒绝策略形同虚设,这是很多线上OOM事故的根源。

5.2 四种拒绝策略,项目里该选哪个

RejectedExecutionHandler有四种内置策略:

策略行为风险
AbortPolicy直接抛RejectedExecutionException默认策略,任务直接丢失,可能影响业务
CallerRunsPolicy提交任务的线程自己执行该任务能保证任务不丢,但可能阻塞调用方
DiscardPolicy静默丢弃任务丢得无影无踪,诊断困难
DiscardOldestPolicy丢弃队列里最老的任务,然后重试提交老任务丢失,新任务有机会执行

我见过很多项目直接用默认的AbortPolicy,结果是高峰期任务一多,线上疯狂抛异常。更推荐的做法是根据业务容忍度来选:日志、监控类非关键任务可以DiscardPolicy;核心业务绝不能丢任务,就用CallerRunsPolicy,让提交线程自己兜底。但CallerRunsPolicy有个副作用,如果提交线程本身就是Tomcat的工作线程,那这个工作线程会被拖住,可能导致Tomcat线程池被打满,所以还要结合队列大小和最大线程数一起来设计。

5.3 核心线程数到底怎么定

这是面试里最开放的问题,没有标准答案,但你要能说出一套推理过程。常见思路是分两类:

  • CPU密集型任务:核心线程数设为CPU核数 + 1。加1是为了防止某个线程因缺页中断、系统调用等偶尔阻塞时,还有一个线程能顶上,保证CPU利用率。
  • IO密集型任务:参考公式CPU核数 / (1 - 阻塞系数),阻塞系数一般在0.8~0.9之间,所以常见结论是CPU核数 * 2左右。

但注意,这只是理论参考。真正上线前一定要压测,通过观察线程池活跃线程数、队列积压量、RT和吞吐量来调整。我在团队里通常的做法是:先用上面的公式算个初始值,然后压测时把corePoolSize和maximumPoolSize设置成不同值,用监控看队列是否积压、线程是否空转,再决定往上加还是往下减。面试时你能说出这套思路,比报一个数字强得多。

业界还有一个更务实的建议:动态化线程池参数。核心线程数、最大线程数、队列容量做成配置项,支持运行时修改,通过监控系统自动调整。这个点放到“场景题”里讲,会让面试官对你另眼相看。

5.4 为什么阿里规范禁止用Executors创建线程池

《阿里巴巴Java开发手册》里明确有一条:线程池不允许使用Executors去创建,要通过ThreadPoolExecutor的方式。面试官很喜欢问为什么。原因有两个:

  • Executors.newFixedThreadPool()和newSingleThreadExecutor()使用了无界的LinkedBlockingQueue,任务可以无限堆积,高峰期会导致OOM。
  • newCachedThreadPool()的最大线程数是Integer.MAX_VALUE,意味着线程数可以无限增长,大量创建线程直接把内存和CPU耗尽。

更本质的原因是通过Executors创建线程池时,你失去了对关键参数的掌控,无法根据业务去调整队列策略、拒绝策略和线程数。而直接new ThreadPoolExecutor(...),每个参数你都得自己拍板,这个过程会强迫你思考业务模型。

不过话说回来,也不是说Executors完全不能用,如果你的任务量可控、队列长度不会炸,用它确实省事。但面试时一定要站队“不用Executors”,并说出OOM的完整链路。我见过不少候选人只说“规范不让用”,但说不出为什么,这种回答拿不到加分。

5.5 SpringBoot的请求是多线程的吗

这个热词经常出现在牛客面经里,属于“看似简单,其实在考Web容器模型”的问题。答案是:SpringBoot默认使用内嵌的Tomcat容器,Tomcat 9默认的请求处理模型是线程池模型,每个请求会由一个线程来处理。所以从请求处理的维度看,SpringBoot确实是多线程的。

但从应用代码的维度看,更关键的是:SpringMVC的Controller默认是单例的,所有请求共享同一个Controller实例,所以Controller里的成员变量如果被多个请求修改,就会有并发问题。这引出一个高频面试连环问:

  • 线程安全的三要素是什么?原子性、可见性、有序性。
  • Controller里能不能定义成员变量?能,但如果是可变状态,必须保证线程安全。
  • 怎么保证?尽量用无状态设计,或者用ThreadLocal存放请求级别的变量。
  • ThreadLocal会内存泄漏吗?在线程池场景下,如果线程没有remove,ThreadLocal里的value会一直强引用,导致内存泄漏。所以用完一定要remove()。

这段回答下来,既能展示你在并发方法论上的积累,又能体现对生产环境的认知。面试官最喜欢这种“从框架机制拉到代码实践”的回答路径。


6. JUC并发工具类:不只是会用,要会说原理

6.1 CountDownLatch、CyclicBarrier、Semaphore三件套

这三个同步器的对比是中级面试的高频题。统一回答模板是:先说业务场景,再说底层基于AQS的哪种模式。

CountDownLatch是倒计时器,一个或多个线程等待其他N个线程完成操作后再继续。典型场景是主线程等待多个子线程并行执行完任务再汇总。它的state初始化为N,每调用一次countDown(),state减1,减到0时释放所有等待线程。注意:CountDownLatch是一次性的,用完不能重置。

CyclicBarrier是循环屏障,N个线程互相等待,当所有线程都到达屏障点后,一起继续执行。典型场景是并行计算中多个线程需要对齐到同一个进度点。它底层是ReentrantLock + Condition实现的,而不是AQS的共享模式。CyclicBarrier可以重用,reset()后能再来一轮,这是和CountDownLatch最大的区别。

Semaphore是信号量,用来控制同时访问某个资源的线程数,也就是限流。它内部也是AQS,state表示可用许可证数,acquire()拿许可证,release()还许可证。典型场景是数据库连接池、接口限流。在限流场景下,Semaphore只能控制并发数,但控制不了QPS,这个细节能答出来是加分项。

6.2 ConcurrentHashMap:分段锁到CAS+局部锁

ConcurrentHashMap也是Java并发面试的常青树,而且追问很深。核心要讲清楚JDK7和JDK8的实现差异。

JDK7的ConcurrentHashMap用的是分段锁设计:内部维护一个Segment数组,每个Segment继承自ReentrantLock,每个Segment下面又挂了HashEntry数组。不同Segment的读写互不干扰,线程只需锁住对应的Segment,并发度等于Segment数组长度,默认是16。定位元素时先hash定位到Segment,再定位到具体的HashEntry。

JDK8的ConcurrentHashMap抛弃了Segment,底层改为数组 + 链表 + 红黑树,并发控制用CAS + synchronized。具体来说:

  • 数组为空时,通过CAS将Node放入数组,实现无锁插入;
  • 如果桶位已经有节点,用synchronized锁住这个桶的头节点,然后执行插入;
  • 链表长度超过8(且数组长度超过64)时,链表转红黑树。

JDK8的做法把锁粒度从Segment级别降到了桶级别,并发度更高,而且避免了Segment带来的内存浪费。面试时只要能把这两代的演进逻辑讲清楚,基本就能拿下这道题。

6.3 ThreadLocal:好用但要防泄漏

ThreadLocal在面经中出现的频率非常高,而且常常和Spring、线程池、内存泄漏串在一起问。核心机制是:每个Thread内部有一个ThreadLocalMap,key是ThreadLocal实例的弱引用,value是实际存储的对象。

为什么用弱引用做key?因为ThreadLocal对象本身如果被外部置空,且没有其他强引用指向它,那么key就能被GC回收,避免ThreadLocal对象无法回收。但这里有个陷阱:value是强引用,key被回收后value还留在ThreadLocalMap里,如果线程一直存活(线程池场景),value就永远无法被访问和回收。这就是ThreadLocal内存泄漏的根因,所以正确的使用姿势是用完在finally里调用remove()。

面试还爱问“为什么ThreadLocal能实现线程隔离”?因为在ThreadLocalMap中,同一个ThreadLocal实例在不同线程中存储在不同的ThreadLocalMap里,每个线程只操作自己的Map,自然就隔离了。Spring的RequestContextHolder、MyBatis的SqlSession、日志框架的MDC,都是基于ThreadLocal实现的,这些例子随便举出来一个都能加分。


7. 多线程手撕题:死锁、生产消费、交替打印

7.1 死锁的四个必要条件与排查手段

手撕题之前通常会先问理论:死锁产生的四个必要条件,教科书上写得很清楚:

  1. 互斥条件:资源只能被一个线程独占使用。
  2. 持有并等待:一个线程持有至少一个资源,同时等待获取其他线程持有的资源。
  3. 不可剥夺:资源只能由持有者主动释放,不能被其他线程强行抢占。
  4. 循环等待:存在一个线程与线程之间的循环等待链,比如A等B、B等A。

面试不只是让背定义,还会要求写一个死锁demo,然后问怎么排查。排查有两个常用命令:jps找到Java进程id,再用jstack <pid>打印线程栈。如果存在死锁,jstack输出末尾会明确出现Found one Java-level deadlock,并列出死锁线程和锁的持有关系。另外jconsole、jvisualvm借助图形化界面也能直观看出死锁。

避免死锁的策略,面试常考:尽量保证加锁顺序一致、使用tryLock(timeout)超时放弃、减少锁持有时间、用无锁数据结构替代锁。比如一些金融系统里,所有转账操作都按账户id排序后再加锁,就是通过破坏“循环等待”来防死锁。

7.2 生产者消费者:wait/notify到BlockingQueue的三级演进

生产者消费者是手撕题里的“常客”,也是考察线程协作最好的题目。建议按三个层次来准备:

第一层:synchronized + wait/notify。用Object.wait()和notifyAll()实现。注意必须在while循环里检查条件,而不是用if,否则会有“虚假唤醒”问题——线程被唤醒后发现条件又不满足了,还需要重新等待。

第二层:Lock + Condition。用ReentrantLock的两个Condition,一个表示“缓冲不为空”,一个表示“缓冲不为满”,生产者等待“不满”,消费者等待“不空”。这种写法可以精确唤醒对应类型的线程,比notifyAll更高效。

第三层:BlockingQueue。用ArrayBlockingQueue或LinkedBlockingQueue,生产者调put(),消费者调take(),队列本身处理阻塞和唤醒逻辑。企业级开发基本都用这种,代码最少、最不容易出错。

面试时如果你能把三个层次都讲出来,说明你既有理论深度,又懂工程实践,这个印象分很重要。

7.3 交替打印:线程协作的代码题经典

交替打印有两种常见变体:两个线程交替打印1~100(奇偶)和A1B2C3这种字母数字交替。这道题看起来简单,但它考的其实是wait/notify、Lock/Condition、volatile三种方案的切换能力。

最朴素的写法是用一个共享锁对象和wait/notify:每个线程打印前检查是否轮到自己,不是就wait,打印完唤醒对方。更好的写法是用Lock + Condition,两个线程各用一个Condition,精确唤醒,避免无效竞争。还有一种更“轻”的方案:用一个volatile boolean标志位控制轮转,但要注意可见性,必须用volatile,否则线程读不到最新值。

这些代码网上很多,我不贴完整demo了,但建议你手写至少两遍,因为面试现场写出来的速度和正确率,只能靠肌肉记忆。写的时候注意把条件检查放到循环里,防止虚假唤醒,以及finally里释放锁。


8. 高频场景题与线上排查经验

8.1 Redis到底是单线程还是多线程

牛客热词里有个很扎眼的:“redis是多线程还是单线程?(回答单线程的请回吧)”。说实话,这个问题被某些面经带偏了,但确实值得认真回答。Redis 6.0之前,命令执行是单线程的,但整个进程并不完全是单线程,比如fork子进程做RDB持久化、AOF重写,这些后台操作是有额外线程的。Redis 6.0之后引入了多线程I/O,用多个线程处理网络读写和协议解析,但核心命令的串行执行这一设计没变。

为什么Redis命令执行要单线程?因为Redis基于内存,数据操作极快,真正的瓶颈往往是网络I/O而不是CPU。单线程模型省去了锁竞争、上下文切换、线程间同步的开销,配合I/O多路复用(epoll),在高并发下依然能保持极低延迟。而引入多线程IO,是因为网络读写数据量的增长超过了单线程能承受的上限,把I/O解析这部分放给多线程,命令执行依然串行,从而既保持原子性又提升吞吐。

这个回答的意义在于:它说明“多线程不一定好,单线程不一定坏”,关键要看场景瓶颈在哪。面试时你能说出这套逻辑,就能从“背书”上升到“思考”。当然这个主题可能涉及具体的服务器配置和优化,不同版本表现不同,建议结合你实际用的版本去验证,不要凭空记忆。

8.2 线程池+CAS+锁,怎么串成一条线去答

面试到了后半段,面试官可能会抛一个开放式问题:“你在项目里怎么用多线程的?”这个问题的回答策略非常关键。千万不要只丢一句“我用了线程池”。我推荐一个“三段式”回答结构:

  1. 业务场景:说一个你真实用过多线程的场景,比如批量数据同步、异步消息推送、多线程报表导出等。就算没有真实项目,也要选一个业务场景描述清楚,说明为什么必须多线程,单线程为什么不行。

  2. 技术方案:说明你用什么工具解决,比如线程池的参数怎么配、队列选什么、拒绝策略怎么定、为什么选ConcurrentHashMap而不是Hashtable、为什么用CountDownLatch等待子线程结果。把你前面的八股知识全部串进去。

  3. 遇到的问题:说一个你踩过的坑,比如线程池队列积压导致OOM、ThreadLocal没有remove导致内存泄漏、死锁导致的接口超时。然后说排查过程——jstack、监控、日志,怎么定位和解决。最后补充一句“如果重来,我会怎么设计”。这个“问题 + 复盘”是最打动面试官的。

8.3 线上线程问题排查:jstack是基本功

如果面试官问你“线上CPU飙高如何排查”,这已经不是单纯的八股了,而是考察实战能力。标准的排查路径是:

  • 用top命令找到CPU占用最高的Java进程pid;
  • 用top -Hp <pid>找到该进程内CPU占用最高的线程tid;
  • 用printf '%x\n' <tid>把十进制的tid转成十六进制;
  • 用jstack <pid> > stack.log导出线程快照,在文件里搜索十六进制线程号,定位到具体代码行。

这个流程在实际工作中非常常用。我遇到过不止一次线上线程池用满、任务积压的情况,靠的就是jstack看哪些线程卡在什么方法上。如果所有工作线程都阻塞在同一个锁上,那基本能判断是锁竞争或者死锁问题;如果大量线程都在执行同一个耗时方法,则要考虑是不是数据库慢查询或者外部接口超时。

另外jstack还能辅助诊断线程池饥饿:如果线程池参数配置不合理,核心线程都在执行一个阻塞型任务,其他依赖同一个池的任务就会一直排队,体现在WAITING状态。这种问题光看代码不容易发现,但jstack的线程状态分布能一目了然。

8.4 动态线程池与参数调优的工程实践

最后分享一个我在项目里实测有效的做法:把线程池参数配置化,支持运行时调整。具体是在配置中心里放corePoolSize、maximumPoolSize、queueCapacity这些参数,同时在应用中启动一个定时任务,周期性读取配置变更,然后调用ThreadPoolExecutor.setCorePoolSize()和setMaximumPoolSize()动态调整。我有一次线上压测发现核心线程数设少了,任务排队严重,就在配置中心把core从8调到16,几秒内线程池就扩容了,完全不用重启应用。

这里有个小技巧:更新线程池参数时,先改maximumPoolSize再改corePoolSize。因为如果先缩小core且当前线程数大于新的core,ThreadPoolExecutor会中断多余的空闲线程,如果这时maximum还没有调小,理论上线程数还在允许范围内,不会误杀。顺序反过来的话,可能触发某些版本里的不必要线程回收。虽然现代JDK改进了很多,但养成这个习惯能避免很多奇怪问题。

动态线程池的核心意义是:并发场景下没有一劳永逸的参数,业务流量是波动的,参数必须跟着流量走。面试时如果能把这个方案讲给面试官听,并说清楚为什么这么做、如何监控、如何评估是否需要调整,基本就从一个“背八股的人”变成了“会解决问题的人”。


写在最后的一点经验

多线程这块的知识点非常多,但往深了看,核心就是一模型、两手段、三工具:一模型是JMM内存模型,两手段是synchronized和Lock两类锁,三工具是线程池、并发容器和同步器。把这几个东西串成一条线去理解,比零散背题有效得多。面试官真正想看到的,不是你记住了多少API,而是你能不能把一个并发场景拆解出问题、选对工具、说出原理、复现排错。建议你在准备时,每个知识点都追问自己一句“为什么”,然后去翻源码确认。照着这个思路刷牛客面经里那些Java多线程的帖子,你会发现很多问题其实是在反复考同一个底层逻辑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询