ARTICLE DETAIL

资讯详情

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

Java多线程面试核心考点与底层原理深度解析

Java多线程面试核心考点与底层原理深度解析 干了这么多年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和RunningJava里没有单独的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重排a1; b2可能先执行b2再执行a1这在单线程内不影响结果但在多线程下就会出幺蛾子。面试时最好结合JMMJava内存模型来讲。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与原子类无锁编程的大杀器CASCompare 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 AQSJUC锁的基石AQSAbstractQueuedSynchronizer算是并发包里最核心的类了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最大线程数。keepAliveTimeunit非核心线程空闲存活时间。workQueue任务队列核心线程满了之后任务先进队列。threadFactory线程工厂设置线程名、是否daemon等。handler拒绝策略。执行流程是重中之重很多人背错顺序。正确的顺序是提交任务后如果当前线程数小于corePoolSize创建核心线程执行任务。如果线程数达到corePoolSize任务进入workQueue排队。如果workQueue已满且当前线程数小于maximumPoolSize创建临时非核心线程执行任务。如果线程数已经达到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会内存泄漏吗在线程池场景下如果线程没有removeThreadLocal里的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是信号量用来控制同时访问某个资源的线程数也就是限流。它内部也是AQSstate表示可用许可证数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内部有一个ThreadLocalMapkey是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 死锁的四个必要条件与排查手段手撕题之前通常会先问理论死锁产生的四个必要条件教科书上写得很清楚互斥条件资源只能被一个线程独占使用。持有并等待一个线程持有至少一个资源同时等待获取其他线程持有的资源。不可剥夺资源只能由持有者主动释放不能被其他线程强行抢占。循环等待存在一个线程与线程之间的循环等待链比如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锁怎么串成一条线去答面试到了后半段面试官可能会抛一个开放式问题“你在项目里怎么用多线程的”这个问题的回答策略非常关键。千万不要只丢一句“我用了线程池”。我推荐一个“三段式”回答结构业务场景说一个你真实用过多线程的场景比如批量数据同步、异步消息推送、多线程报表导出等。就算没有真实项目也要选一个业务场景描述清楚说明为什么必须多线程单线程为什么不行。技术方案说明你用什么工具解决比如线程池的参数怎么配、队列选什么、拒绝策略怎么定、为什么选ConcurrentHashMap而不是Hashtable、为什么用CountDownLatch等待子线程结果。把你前面的八股知识全部串进去。遇到的问题说一个你踩过的坑比如线程池队列积压导致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且当前线程数大于新的coreThreadPoolExecutor会中断多余的空闲线程如果这时maximum还没有调小理论上线程数还在允许范围内不会误杀。顺序反过来的话可能触发某些版本里的不必要线程回收。虽然现代JDK改进了很多但养成这个习惯能避免很多奇怪问题。动态线程池的核心意义是并发场景下没有一劳永逸的参数业务流量是波动的参数必须跟着流量走。面试时如果能把这个方案讲给面试官听并说清楚为什么这么做、如何监控、如何评估是否需要调整基本就从一个“背八股的人”变成了“会解决问题的人”。写在最后的一点经验多线程这块的知识点非常多但往深了看核心就是一模型、两手段、三工具一模型是JMM内存模型两手段是synchronized和Lock两类锁三工具是线程池、并发容器和同步器。把这几个东西串成一条线去理解比零散背题有效得多。面试官真正想看到的不是你记住了多少API而是你能不能把一个并发场景拆解出问题、选对工具、说出原理、复现排错。建议你在准备时每个知识点都追问自己一句“为什么”然后去翻源码确认。照着这个思路刷牛客面经里那些Java多线程的帖子你会发现很多问题其实是在反复考同一个底层逻辑。
返回列表