ARTICLE DETAIL

资讯详情

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

Java多线程面试3天冲刺:从锁机制到线程池与线上排查

Java多线程面试3天冲刺:从锁机制到线程池与线上排查 Java多线程面试题几乎每年都是后端岗位的高频区。如果你正打算在8月准备Java面试多线程这块不用追求把100道题全背完更值得做的是把线程基础、锁、JUC、线程池、场景题和线上排查串成一条线。这篇文章按3天节奏来组织适合准备Java后端面试的应届生、初级工程师也适合想系统梳理并发知识的人。3天不夸张关键是每天的范围要控制住别第一天就钻进源码细节里出不来。很多人准备多线程时有个误区先背synchronized和volatile的区别再背线程池参数最后看两篇源码解析。背完感觉记住了一到面试官问“你的项目里哪里用到了多线程遇到问题怎么排查”就答不上来。所以这篇文章不会只罗列面试题而是按“基础 - 原理 - 工具 - 场景 - 排查”顺序拆每个部分都告诉你复习到什么程度、怎么验证以及面试时怎么组织语言。1. 多线程面试到底考什么值得花3天重新过一遍吗1.1 面试官不是只想要你背结论多线程是Java面试里一类比较“泛”的题目。它不像Redis、MySQL那样有明确边界也不像算法题一样有标准答案。面试官问多线程通常不是为了让你背出定义而是想通过一系列追问判断你有没有真正写过多线程代码有没有在并发环境下遇到问题有没有排查和治理的经验。常见的情况是面试官先问“创建线程有几种方式”你答出4种他会继续问“那线程池里的线程是怎么创建的”你答出ThreadPoolExecutor他又追问“核心线程数怎么设置”你说按CPU核数设置他又问“IO密集和CPU密集有什么区别”。这一连串追问并不是要为难你而是要看你的知识是点状的还是链状的。所以复习多线程最重要的不是背题而是把每个考点串起来。比如线程状态和锁有关系锁和阻塞队列有关系阻塞队列和线程池有关系线程池和线上性能问题有关系。串起来之后面试官无论从哪个点切入你都能接住。1.2 真实考点是下面四层我把多线程面试内容拆成四层第一层是基础线程创建方式、生命周期、JMM、volatile、synchronized、sleep与wait、线程中断。第二层是锁与同步机制ReentrantLock、公平锁、非公平锁、CAS、AQS、锁升级、死锁。第三层是JUC工具与容器ThreadLocal、ConcurrentHashMap、CopyOnWriteArrayList、阻塞队列、CountDownLatch、Semaphore、CyclicBarrier、Atomic类、CompletableFuture。第四层是实战能力线程池参数与调优、多线程调用外部接口、批量任务拆分、Spring Boot请求多线程问题、线上线程栈分析、OOM和死锁排查。这四层对应面试题的难度。前两层是基础题大部分面试都会问第三层是进阶题看你有没有系统用过第四层是拉开差距的地方应届生如果能把项目里的并发场景说清楚会明显加分。1.3 3天时间怎么分配才合理3天不是把每天排满12小时。我更建议每天留出6到8小时上午学新内容下午动手跑代码晚上把当天能答的题口头自测一遍。第1天覆盖基础、JMM和锁第2天覆盖JUC工具、并发容器和线程池第3天集中做场景题、项目串联和线上排查。每个部分单独拿出来都不算难难的是把它们串成一条线。所以计划要有但别卡太死。2. 第1天上午先打底线程创建、状态流转和JMM2.1 创建线程的几种方式别只说四种网上最常见的答案是四种继承Thread、实现Runnable、实现Callable、使用线程池。这个答案本身没错但面试时如果只答到这里面试官会觉得你只是背过。更好的回答方式是先给结论再说明本质。Java里真正“创建线程”的操作只有一个就是new Thread()之后调用start()底层会调用本地方法创建操作系统线程。Runnable、Callable只是把“要执行的任务”抽象出来线程池也只是对线程生命周期的复用。所以那些所谓“创建方式”本质是“任务提交方式”。基础代码平时还是要写一写。// 方式1继承Thread不推荐因为Java是单继承 class MyThread extends Thread { Override public void run() { System.out.println(Thread.currentThread().getName()); } } // 方式2实现Runnable更推荐 new Thread(() - System.out.println(runnable), test-thread).start(); // 方式3实现Callable配合FutureTask获取结果 FutureTaskInteger task new FutureTask(() - 1 2); new Thread(task).start(); Integer result task.get();这里要注意的是Callable和Runnable最大的区别是Callable有返回值能抛受检异常。面试时如果提到FutureTask最好能继续说一句FutureTask.get()是阻塞方法调用时会等待任务执行完成所以不要在循环里频繁调用get()否则会阻塞主线程。2.2 线程状态切换和sleep/wait的本质区别线程状态是Java多线程面试的必问题。我建议直接记住六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这里最容易被问的是RUNNABLE状态Java里的RUNNABLE其实包含了操作系统层面的“运行中”和“就绪”两个状态不要把它理解成只有CPU正在执行。sleep和wait的区别也是高频。可以从三个维度答所属关系sleep是Thread的静态方法wait是Object的方法。锁释放sleep不释放锁wait释放锁。唤醒方式sleep到时间自动醒wait需要notify/notifyAll或者带超时时间自动醒。面试时如果只答这三条已经算过关。但如果能补充一句“wait之所以设计在Object上是因为它依赖对象的监视器锁需要先拿到synchronized锁才能调用sleep是线程自己的行为不需要持有对象锁”会明显显得理解更深。还有一个容易混的概念是线程中断。interrupt()不是立刻把线程停下来而是给线程打一个中断标记。具体怎么响应由线程内部代码决定。如果线程正在sleep或wait会抛出InterruptedException。所以写多线程代码时不要用destroy()之类的方法停止线程那不是正常方式正常做法是用一个volatile标志位或者通过interrupt()协作式中断。2.3 JMM、可见性、有序性、原子性怎么串起来JMM是Java内存模型。很多人一听这个就发怵其实面试常考的就是三个问题什么是可见性、什么是有序性、什么是原子性。用一个很常见的例子说明两个线程同时对一个int变量做i最后结果可能小于预期。因为i不是原子操作它分成“读取、加1、写回”三步多个线程交错执行时会丢失更新。这就是原子性问题。再看另一个例子一个线程改了变量另一个线程一直读不到最新值。因为变量可能被缓存在线程自己的工作内存中没有及时刷新到主内存。这就是可见性问题。volatile关键字可以保证可见性它告诉JVM这个变量每次都从主内存读取写入后也刷回主内存但它不能保证复合操作的原子性。有序性则和指令重排有关。编译器、CPU为了性能可能会调整指令执行顺序。单线程下不影响结果多线程下可能出问题。volatile还有一个作用是禁止指令重排所以双重检查锁单例里要用volatile修饰instance。回答时建议把这三点放在一个场景里说更容易让面试官觉得你理解了。比如“一个计数器用普通int多线程累加会丢更新加volatile后能保证读取可见性但i还是丢更新要保证原子性得用AtomicInteger或synchronized。”这个回答串起了可见性和原子性比单纯背定义好很多。3. 第1天下午synchronized、volatile和锁到底怎么复习3.1 synchronized的锁升级过程synchronized是Java面试第一梯队考点。现在的回答已经不能只说“它给代码块加锁”了至少要知道锁升级路径无锁 - 偏向锁 - 轻量级锁 - 重量级锁。偏向锁是同一个线程反复进入同步块时减少CAS操作的锁。一旦出现其他线程竞争会升级为轻量级锁。轻量级锁通过CAS尝试获取锁如果失败会自旋一会儿自旋超过阈值或竞争太激烈就会升级成重量级锁也就是依赖操作系统互斥量的锁线程会进入阻塞。至于JDK 15之后引入了虚拟线程以及偏向锁的一些变化面试时不用背太细。只要把升级过程和“为什么会有升级设计”说清楚就行。设计逻辑很简单为了平衡性能。多线程竞争不激烈时用轻量手段竞争激烈时再切换到重量级避免过度自旋消耗CPU。3.2 ReentrantLock和synchronized的取舍ReentrantLock和synchronized的对比也是必问。可以从这几个维度回答ReentrantLock是JUC包下的API层面锁synchronized是JVM层面内置锁。ReentrantLock支持公平锁synchronized默认非公平。ReentrantLock支持中断响应lockInterruptibly()。ReentrantLock支持超时获取锁tryLock(timeout)。ReentrantLock支持多个条件队列Condition可以让线程精确唤醒。锁的释放方式不同ReentrantLock需要手动unlocksynchronized自动释放。补充一点ReentrantLock的可重入性意思是同一个线程可以多次获取同一把锁。synchronized也是可重入的。面试时提到可重入最好顺带解释一句“防止同一个线程再次进入同步代码块时把自己锁死”。什么时候用ReentrantLock需要公平锁、超时等待、可中断或精确唤醒时。如果你的业务里只要简单的互斥synchronized足够了。3.3 CAS、AQS和LockSupport的关系CAS是Compare And Swap比较并交换。它通过比较内存当前值和预期值是否一致一致才更新整个过程是硬件级的原子操作。乐观锁思想的一种实现。AtomicInteger的incrementAndGet底层就是CAS。CAS有个典型问题ABA问题。变量从A变成B又变回ACAS会认为没变过。解决方法可以用带版本号的AtomicStampedReference。这个知识点不算冷门面试被问到的概率不低至少要能说出ABA是什么。AQS是AbstractQueuedSynchronizer。JUC里很多工具如ReentrantLock、Semaphore、CountDownLatch底层都是AQS。它的核心是维护一个volatile int state以及一个FIFO等待队列。线程抢锁失败就进入队列排队释放锁时唤醒队首线程。面试不一定要懂每个细节但提到AQS时能说出“CLH队列、state状态、独占和共享模式”就算过关。LockSupport是更底层的线程阻塞工具park()和unpark(thread)可以挂起和唤醒线程。它和wait/notify的区别在于LockSupport不需要先获取对象锁unpark可以被先调用。理解LockSupport后再看AQS的阻塞唤醒逻辑会顺很多。4. 第2天JUC工具和并发容器不能只背名字4.1 ThreadLocal内存泄漏问题ThreadLocal的作用是让每个线程持有自己的变量副本。典型场景是SimpleDateFormat、数据库连接、用户上下文。面试如果只问“ThreadLocal是什么”已经太少见了现在更常问“ThreadLocal为什么会导致内存泄漏”。ThreadLocal的实现是每个Thread内部有一个ThreadLocalMapKey是ThreadLocal对象Value是业务数据。Key是弱引用当外部ThreadLocal对象没有强引用时Key可能被回收但Value还存在如果线程长时间存活且不调用removeValue就泄漏了。尤其是线程池里的线程是长期复用的泄漏风险更大。回答时不要说“所以ThreadLocal不能用”正确理解应该是用完主动remove。最佳实践是在finally块中调用threadLocal.remove()。面试时如果能说清“为什么线程池环境下更容易泄漏”和“如何避免”比单纯背弱引用概念更有亮点。4.2 ConcurrentHashMap和HashMap、Hashtable的对比这个对比几乎是必问。至少要覆盖HashMap线程不安全多线程put可能导致数据覆盖JDK 7甚至可能出现死循环。Hashtable线程安全但所有方法都用synchronized锁整张表并发度低。ConcurrentHashMap并发度高JDK 7用分段锁JDK 8改用synchronized锁哈希桶头节点加CAS锁粒度更细。JDK 8里ConcurrentHashMap在扩容和计数上做了很多优化比如transfer协助迁移、CounterCell分散计数。面试不要求背到那么深但至少要知道它比Hashtable高效在哪里。如果面试官追问“ConcurrentHashMap的size()是怎么算的”你可以说“通过baseCount和CounterCell累加在竞争激烈时用分区计数减少CAS冲突”。4.3 CountDownLatch、Semaphore、CyclicBarrier各解决什么问题这三个工具很多人混在一起。要区分清楚。CountDownLatch是倒计时门闩。一个或多个线程等待其他线程完成指定数量任务后再继续。比如主线程等5个子线程都执行完毕再汇总结果。它是一次性的计数归零后不能再复用。CyclicBarrier是循环栅栏。多个线程互相等待都在到达栅栏后放行。可以循环使用。它适合“多线程分阶段计算每阶段结束后对齐一次”的场景。Semaphore是信号量。控制同时访问某个资源的线程数量。比如一个接口最多允许10个线程同时调用外部接口用Semaphore限流。面试时每个工具最好能配一个业务场景。只背“CountDownLatch用于等待多个任务完成”太单薄如果能说“我用CountDownLatch把一批用户数据分批查询完成后再统一做汇总和导出”会更有说服力。4.4 阻塞队列和Future/CompletableFuture阻塞队列是线程池的重要组成部分。常见的有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、DelayQueue等。重点理解两个操作put/take是阻塞的offer/poll可以带超时。线程池里的workQueue用来缓存暂时无法执行的任务。Future代表异步执行结果get()会阻塞等待结果。JDK 8之后推荐用CompletableFuture做异步编排它可以串行执行thenApply、并行执行thenCombine、异常处理exceptionally等。如果项目里用了异步任务面试时可以把CompletableFuture作为亮点讲。一个常见的示例是CompletableFuture.supplyAsync(() - queryUser()) .thenApply(user - queryOrder(user.getId())) .thenAccept(order - System.out.println(order));这里要注意的是supplyAsync默认使用ForkJoinPool.commonPool()如果任务里有IO阻塞建议传入自定义线程池避免公共线程池被占满。这又是一个能体现工程经验的细节。5. 第3天线程池和项目场景题是拉分点5.1 线程池七参数和拒绝策略线程池是Java后端面试的重头戏。建议直接背熟ThreadPoolExecutor的七个参数corePoolSize核心线程数。maximumPoolSize最大线程数。keepAliveTime非核心线程空闲存活时间。unit时间单位。workQueue任务队列。threadFactory线程工厂可以自定义线程名。handler拒绝策略。执行顺序也要能说清楚核心线程先执行任务核心线程满了任务进队列队列满了创建非核心线程线程数达到maximumPoolSize执行拒绝策略。拒绝策略有四种。AbortPolicy抛异常CallerRunsPolicy由提交任务的线程自己执行DiscardPolicy直接丢弃DiscardOldestPolicy丢弃队列中最老的任务。实际开发中CallerRunsPolicy相对温和但也可能导致提交任务的线程被拖慢所以要看业务是否允许。5.2 核心线程数和队列容量怎么设置这个问题没有标准统一答案面试官主要看你有没有依据。一般可以分两类说CPU密集型任务核心线程数建议接近CPU核数比如N1避免过多线程争抢CPU。IO密集型任务核心线程数可以适当放大参考公式是CPU核数乘以1 平均等待时间/平均工作时间因为线程大部分时间在等待IO。重点不在于公式精确而在于说明线程池参数要结合任务类型、队列容量、服务可用资源和对延迟的容忍度来评估不要拍脑袋。比如队列设太小容易被拒绝队列设太大任务积压会导致延迟升高还可能OOM。如果线上接口峰值不稳定宁可队列小一点让部分任务失败重试也不要把请求无限堆积在内存里。示例配置ExecutorService pool new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(200), new CustomThreadFactory(batch-task), new ThreadPoolExecutor.CallerRunsPolicy() );这个例子只表示一类配置思路。实际核心线程数、最大线程数、队列容量要按接口压测结果和机器配置来调。5.3 submit和execute的差别线程池提交任务有两种常见方法。execute(Runnable)没有返回值提交异常会直接抛出submit(Callable/Runnable)返回Future可以获取结果但任务内的异常会被封装在Future.get()里抛出。如果通过submit提交任务又不主动get()异常可能被吞掉线上问题排查时会很难受。所以一个经验是提交任务时明确预期。不需要结果的用execute或者submit后主动处理异常需要结果的用submit并且一定要处理Future.get()抛出的ExecutionException和InterruptedException。同时要注意Future.get()会阻塞如果大量任务同时get()存在线程阻塞风险。5.4 项目里用多线程调用外部接口怎么回答才有亮点“多线程调用外部接口”是热词里很常见的问题也是项目场景题。面试官问这个通常想考察你有没有踩过以下坑并发上去了但外部接口QPS支撑不住反而超时。线程池参数乱设导致内存或线程数暴涨。调用外部接口没有设置超时线程一直被IO阻塞。没有失败重试机制一批任务里有一个失败影响整体结果。可以这样组织回答“我在项目里用线程池批量调用外部接口。首先会根据外部接口的压测数据估算并发上限比如对方最多支持20个并发我就把核心线程数设为10到20然后给每个请求设置连接超时和读取超时避免线程长时间挂住同时用Future收集结果并对失败任务做重试重试次数限制在2次以内最后把线程池的队列和拒绝策略单独配置满员时根据业务决定是快速失败还是由调用线程执行。”这个回答里有并发评估、超时、重试、线程池参数、失败处理足够覆盖大部分追问。面试官如果再问“你如何监控线程池”可以说关注activeCount、queue.size、completedTaskCount和最大线程数是否触顶配合日志和监控大盘告警。6. 高频场景题交替打印、多线程累加和Spring请求并发6.1 两个线程交替打印数字考察的是什么交替打印原题大概是线程A打印1、3、5线程B打印2、4、6交替输出1到10。很多人一听是线程通讯第一反应是wait/notify其实这个题目考察的是“怎么控制线程执行顺序”。最简单粗暴的是用两个信号量或synchronized 标志位。如果用ReentrantLock的Condition可以精确唤醒。但要记住手写代码不是目的关键要解释为什么可以用Condition以及park/unpark、volatile标志位各自适合什么场景。面试时如果遇到场景题一定要先问清楚限制条件。比如“两个线程交替打印”和“三个线程交替打印”难度不一样。“可以用锁吗”“不能用锁只用线程池呢”“打印结果必须是严格顺序吗”这些都要先确认。能主动确认边界本身就是加分行为。6.2 多线程累加为什么结果不对怎么改成对的经典题目两个线程同时对count执行10000次结果明显小于20000。原因是count不是原子操作。解决方式有三种用synchronized给累加方法加锁。用AtomicInteger底层CAS。用LongAdder高并发下性能更好适合写多读少。面试时建议把三种方式都答出来并说明区别。AtomicInteger在低并发下简单好用LongAdder在竞争激烈时通过分段控制减少CAS冲突。最后可以主动提一句“如果只是代码演示AtomicInteger够了生产环境还要结合业务选型”。6.3 Spring Boot 请求是多线程吗这个问题是热词里出现的。很多初学者会有困惑Controller是单例Bean那同时来多个请求是排队处理还是并发处理答案是在默认的Servlet容器下比如内嵌Tomcat每个HTTP请求由独立的线程处理。Tomcat会维护一个线程池接受TCP连接后从线程池里取一个线程来处理请求。所以Controller虽然是单例对象但多个请求是并行进入Controller方法的。这带来一个关键问题Controller里如果有共享的可变状态比如一个普通HashMap或SimpleDateFormat字段多线程同时访问就有线程安全问题。所以Spring MVC的推荐做法是Controller无状态有状态信息尽量放在方法局部变量、请求参数或ThreadLocal中并且ThreadLocal要记得清理。回答这个问题时如果能延伸到“为什么Spring默认Controller是单例”以及“无状态Bean为什么更适合多线程”效果会好很多。7. 线上排查和避坑这些坑面试也会问7.1 先看日志、线程栈和资源占用面试官经常问“线上线程池出问题你怎么排查”。这个问题很能区分能力。基本路径可以这样先看现象是接口变慢、CPU飙升、内存飙升还是任务失败率升高。再看日志Exception堆栈、任务提交时间、失败频率、超时时间。再看线程池指标activeCount、queue.size、corePoolSize、maximumPoolSize、completedTaskCount。最后看线程栈用jps找到进程ID用jstack导出线程快照看线程处于RUNNABLE、BLOCKED、WAITING哪种状态。JVM自带的工具其实就够用。jps -l jstack pid thread_dump.txt导出后重点搜索“java.lang.Thread.State”附近的内容。如果大量线程BLOCKED在同一个锁上大概率是锁竞争如果大量线程WAITING在某个条件上可能是线程池等待队列或者异步任务等待如果CPU高但堆栈显示在GC线程可能不是并发代码问题而是内存分配压力。7.2 ThreadLocal不清理导致的OOM热词里提到过OutOfMemoryError。多线程环境下一个常见的OOM来源就是线程池里的ThreadLocal使用后没清理。原因是线程池线程存活时间长ThreadLocalMap里的Value一直被强引用无法回收。长期积累后内存持续增长。排查时需要动态观察老年代和堆内存走势同时查看线程栈里是否有ThreadLocalMap相关内容。这类问题在面试时可以这样回答“我会优先检查用ThreadLocal的代码有没有在finally中remove尤其是线程池场景。没有清理的话即使一次只存几十KB几千个线程积累起来也可能触发OOM。”7.3 环境问题也会卡住面试准备热词里还有“源发行版 17 需要目标发行版 17”这类编译警告它本身不算多线程题但如果你准备面试时连代码都跑不起来会很影响效率。常见的环境问题包括JDK版本和Maven编译版本不一致、Lombok和JDK版本不兼容、IDE缓存导致代码识别异常。建议准备面试环境时统一JDK、Maven、Spring Boot版本。可以用一个简单的多线程Demo工程做验证把线程池、ThreadLocal、CompletableFuture、CountDownLatch都放进去。能跑通之后再背诵概念效果更好。遇到编译问题先看JDK版本和Lombok版本再看Maven的compiler配置别一上来就怀疑代码逻辑。8. 3天冲刺计划和自我验证清单8.1 三天时间怎么分配可以按下面的安排来执行。第1天基础与锁上午线程创建、线程状态、JMM、可见性/有序性/原子性。下午synchronized、volatile、ReentrantLock、CAS、AQS。晚上手写一个交替打印、一个多线程累加并口头复述锁升级过程。第2天JUC与线程池上午ThreadLocal、ConcurrentHashMap、阻塞队列、CountDownLatch、Semaphore、CyclicBarrier、Atomic类。下午线程池七参数、执行流程、拒绝策略、CompletableFuture。晚上用线程池写一个批量任务Demo输出执行耗时和结果。第3天场景与排查上午回答Spring Boot请求多线程问题、多线程调用外部接口、死锁场景题。下午用jps、jstack做一次线程快照分析模拟一次死锁或线程堆积。晚上把每个题目按“概念、原理、场景、坑”四段口头自测。这个计划不求覆盖所有冷门题但求把高频主线过一遍。如果你时间更紧至少保证第1天和第2天内容完整第3天以场景题和口头表达为主。8.2 每天结束前的自测标准复习效果不能靠“看过”来判断要能口头答出来。建议每天结束时闭上眼睛随机抽几个问题线程池执行任务的过程是什么volatile和synchronized的区别是什么ThreadLocal为什么可能内存泄漏ReentrantLock和synchronized怎么选多个线程同时修改同一个变量怎么保证正确性线上线程池满了你从哪里看指标如果能不翻资料答出核心逻辑并且能用项目里的例子补充就说明当天合格。如果只能答出一两个关键词第二天早上先复习薄弱点再推进。8.3 最后一天晚上做什么最后一天晚上不建议再学新东西。保持题感和表达状态更重要。你可以做三件事一是把每个考点整理成一页笔记比如用表格写“锁方式、获取方式、是否公平、失败表现、适用场景”。二是找几个常见场景题模拟面试官追问比如一个线程池八股题故意追问“队列满了怎么办拒绝策略了怎么办任务可以重试吗”。三是放松心态。多线程面试不是看谁背得多而是看谁能在实际问题里把原理用起来。能连续回答“为什么”比单个答案正确更有价值。如果面试中遇到没准备过的并发题不要急。先拆题干把变量、并发点、共享资源、操作原子性这四件事说清楚再给出方案。大部分并发面试题都可以从“共享了什么哪些操作不是原子的如何加锁或减少共享”这三个角度切入。踩过几次之后我的体会是多线程复习真正重要的不是你背了多少题而是你能不能把一次并发事故从日志、线程栈到代码逻辑串起来。3天把主线打通至少能保证面试时遇到多线程问题不慌。把上面这些细节逐个验证一遍比盲目看10篇源码解析更有用。
返回列表