Java并发编程核心指南:锁机制、线程池与JUC工具实战解析

发布时间:2026/9/10 6:38:24
Java并发编程核心指南:锁机制、线程池与JUC工具实战解析 Java并发编程应该是很多后端开发又爱又恨的一个模块。爱的是面试基本必考背熟几个知识点就能应付不少题目恨的是到了线上环境遇到性能问题、卡顿、死锁才发现自己当初背的那些八股根本不够用。这篇文章的目标很直接按面试和实战两条线把并发编程的核心概念、锁机制、JUC工具、线程池这些高频考点用最快的方式串一遍。看完之后你能回答清楚“synchronized锁升级是怎么回事”“线程池参数到底怎么定”“ConcurrentHashMap为什么快”这类问题并且知道在真实项目里遇到类似问题该从哪里下手。我不是来给你复述文档的只讲我实际用过、踩过坑、也在面试里反复被问过的东西。你把这篇文章当成一个整理好的知识地图走一遍之后再回去看那些两千页的大部头心里会有底很多。1. 并发基础先把“线程”这个地基打牢1.1 创建线程的三种姿势你真搞懂了吗聊Java并发第一个绕不开的就是线程怎么创建。教科书会告诉你三种方式继承Thread、实现Runnable、实现Callable。很多工作两三年的同学也只会背这三种但你要问他“三种方式有什么区别”“实际项目中到底用哪种”他反而说不清楚。// 方式一继承Thread class MyThread extends Thread { Override public void run() { System.out.println(线程执行中); } } new MyThread().start(); // 方式二实现Runnable class MyTask implements Runnable { Override public void run() { System.out.println(线程执行中); } } new Thread(new MyTask()).start(); // 方式三实现Callable FutureTask class MyCallable implements CallableString { Override public String call() throws Exception { return 线程执行结果; } } FutureTaskString futureTask new FutureTask(new MyCallable()); new Thread(futureTask).start(); String result futureTask.get();先说结论实际项目中继承Thread这种方式基本没人用因为Java是单继承你继承了Thread就没法继承其他类了扩展性太差。实现Runnable接口是早期最推荐的方式把任务逻辑和线程执行解耦。而Callable本质上就是Runnable的增强版它的call方法可以返回结果还可以抛出受检异常。Callable最终要通过FutureTask来包装运行。这里有个容易被忽略的细节FutureTask既实现了Runnable接口也实现了Future接口所以它能被Thread直接启动也能提交给线程池执行。调用futureTask.get()会阻塞当前线程直到任务执行完成并返回结果。我见过不少面试者在说到这一块时支支吾吾原因是他平时只写业务代码从来没在项目里真正用过Callable。1.2 线程状态切换面试官最爱画图考你线程创建之后就开始进入各种状态流转。Java里Thread.State定义了六个状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这里最容易出问题的是RUNNABLE它把操作系统层面的“运行中”和“就绪”合并了用大白话说就是“要么正在跑要么随时能被调度跑”。这是JVM层面的封装面试时要说清楚这一点别一上来就背操作系统的五状态模型。BLOCKED和WAITING看起来很相似但触发条件完全不同。BLOCKED是因为没拿到监视器锁也就是synchronized的锁被其他线程占用了所以卡在锁池等待WAITING是主动调用了wait、join、park这些方法。TIMED_WAITING则是带超时时间的等待比如sleep(1000)、wait(5000)之后自动苏醒。我自己在面试里喜欢问一个场景题有两个线程线程A持有锁线程B在等这把锁这时线程A调用wait()释放了锁线程B会变成什么状态答案是B依然处于BLOCKED状态因为它还在竞争锁但A会进入WAITING需要被notify唤醒后才能重新获取锁。很多人分不清等待队列和锁池的区别这个点值得好好记一下。说简单点一个线程必须先从锁池里抢到锁才能进入synchronized代码块而wait的目的是主动让出锁跑到等待队列里休息。两拨线程的歇脚地不一样。1.3 中断机制优雅停线程比暴力杀线程重要Thread.stop()这个方法早就被标记为废弃了因为它会在任意位置强制终止线程导致共享资源处于不一致状态比如数据库连接写了一半、文件写到一半突然断了。现在标准做法是用interrupt机制靠线程自己响应中断信号来安全退出。Thread worker new Thread(() - { while (!Thread.currentThread().isInterrupted()) { try { Thread.sleep(100); } catch (InterruptedException e) { // 中断信号被消费了需要重新设置标志位 Thread.currentThread().interrupt(); break; } } }); worker.start(); // 一段时间后发起中断 worker.interrupt();这里有个经典坑sleep、wait这些阻塞方法被中断时会抛出InterruptedException然后会清除中断标志位。也就是说你在catch块里如果直接break退出线程确实停止了但调用方在业务代码里调用isInterrupted()时拿到的是false没法感知到“这个线程是被中断才退出的”。所以我上面在catch里又调了一次interrupt()把中断状态重新设置回去这是一种比较规范的写法。2. 深入synchronized锁升级与对象头2.1 加锁的三种形式到底锁的是什么synchronized是Java并发里使用频率最高的锁但很多人只是会用不知道它底层锁的是什么。我们知道它有三种使用位置修饰实例方法、修饰静态方法、修饰代码块。前两种的语义是JVM帮你指定好的第三种是你手动指定锁对象。修饰实例方法锁的是当前实例对象this修饰静态方法锁的是当前类的Class对象修饰同步代码块锁的是括号里指定的对象锁是一个对象这句话怎么理解Java中每个对象都有一个对象头Mark Word锁状态就记录在这个对象头里。当多个线程竞争同一个对象的监视器锁时操作系统层面会有一个Monitor对象来管理这些竞争。synchronized就是通过这个Monitor来实现线程的阻塞和唤醒的。这里我建议你记住一个等价关系synchronized锁定的是Java对象不是代码片段。所以用synchronized保护代码时最关键的是所有线程要“锁定同一个对象”。我见过有人在实例方法上加了synchronized然后业务代码里通过new创建了两个对象分别执行锁完全没有互斥效果这就是典型的锁对象没选对。2.2 锁升级为什么synchronized不再是“重量级锁”早期JDK 5时代synchronized确实是个性能黑洞每次加锁都要通过操作系统互斥量实现线程会从用户态切到内核态开销很大。但从JDK 6开始HotSpot做了大量优化引入了锁升级机制也就是无锁、偏向锁、轻量级锁、重量级锁这条链路。偏向锁的核心思想是大多数场景下锁不存在竞争只有一个线程反复进同一段代码。那么就让这把锁“偏向”第一个获取它的线程后续这个线程再进入时不需要做任何同步操作。如果遇到另一个线程竞争才会撤销偏向并升级为轻量级锁。轻量级锁用CAS自旋去抢锁大量短临界区的场景下自旋等待比重挂起线程划算得多。自旋次数超限或者在线程数较多的场景下锁才会升级为重量级锁此时才会有真正的线程阻塞和唤醒。这段链路其实对应了不同并发强度下的优化策略你把它看成“JVM在帮你做自适应调度”就行。值得注意的是JDK 15开始偏向锁已经被废弃并且默认关闭了因为现代应用的线程调度和锁竞争模式已经变了偏向锁本身的维护成本反而成为负担。面试时你可以先说JDK 8环境下的锁升级机制然后补充一句“JDK 15以后偏向锁废弃了”这就很加分。2.3 可重入、锁消除与锁粗化synchronized是可重入的。同一线程已经持有锁之后再次进入由同一把锁保护的代码块时可以直接进入而不会把自己阻塞死。底层实现是监视器锁里维护了一个计数器每次进入加1每次退出减1减到0才会真正释放锁。这个设计的必要性很直观如果你的代码里方法A调用了方法B两个方法都用了同一把锁而锁不可重入那就会自己把自己锁死。除此之外JIT编译器还会做锁消除和锁粗化。锁消除是指JVM通过逃逸分析发现某个锁对象只会被当前线程访问根本不存在竞争就会直接去掉同步块。锁粗化则是反过来如果JVM发现短时间内反复对同一个对象加锁解锁比如循环里逐行执行加锁操作就会把锁的范围扩大到整个循环外面减少重复加锁解锁的开销。这些优化是JVM自动做的不需要你写代码干涉但理解它们能帮你解释很多性能问题。我之前调过一段循环里加synchronized的代码锁粗化之后吞吐量直接翻倍。你脑子里要有一个概念synchronized的性能已经不差差的往往是你对锁粒度的设计。3. volatile与JMM搞懂可见性和指令重排3.1 JMM到底规定了什么Java内存模型JMM定义了多线程程序在共享内存系统中的读写行为规范。它规定每个线程有一个工作内存里面缓存了变量的副本线程对变量的所有操作都发生在这个工作内存里然后再同步回主内存。问题就出在这个“同步回主内存”的时机上。线程A修改了一个变量数据还留在自己的工作内存里没有刷回主内存线程B去主内存读的时候读到的还是旧值这就产生了可见性问题。JMM通过几条Happens-Before规则来约束编译器、JIT和CPU不能做出破坏内存可见性的优化其中几条核心规则你最好能背下来程序顺序规则一个线程内写前面的代码对后面的代码可见锁规则解锁操作对后续加锁操作可见volatile规则volatile写对后续volatile读可见传递性如果A对B可见、B对C可见那么A对C也可见这里不要把它当成玄学它就是一套“哪些操作之间必须保证可见性”的清单。凡是不在规则内的读写顺序运行时都可能被指令重排优化这也是并发Bug最隐蔽的来源之一。3.2 volatile能干什么、不能干什么volatile关键字有两个核心语义保证可见性、禁止指令重排。先说可见性。被volatile修饰的变量写操作会立即刷新到主内存读操作会直接从主内存取值不会用工作内存里的缓存副本。所以一个线程改了值另一个线程能马上看见。但它不能保证原子性。经典的counter问题即使counter是volatile多线程环境下结果依然可能小于预期。因为counter不是一个原子操作它包含“读、加、写”三步volatile只保证每一步操作的可见性不保证这三步不会被其他线程插队。解决办法要么用AtomicInteger要么用synchronized要么用LongAdder看场景选。禁用指令重排这个特性很多人只能说出“单例模式下防止半初始化”这一个案例其实在无锁编程里更常用。比如双缓冲队列的切换、状态标志位的发布都要靠volatile来确保对象引用更新和内部字段初始化之间不会乱序。你记一个结论只要一个变量被多个线程共享并且它的写入不依赖当前值也没有与其他变量组成复合操作那么用volatile几乎不会错。3.3 DCL单例最经典的volatile应用场景双重检查锁DCL是面试高频题代码一共十几行难点在理解为什么加了synchronized还不够、还要加volatile。public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }第一层if判断是为了避免每次都进同步块第二层if判断是加锁之后重新检查防止多个线程同时完成第一次判断后依次创建多个实例。这个逻辑好理解。关键在于new Singleton()这行代码实际上包含三个步骤开辟内存空间、调用构造方法初始化对象、把引用赋值给instance。由于CPU指令重排第三步可能先于第二步执行。这时候如果线程A正在执行构造方法前期的赋值线程B进入方法看到instance不为null直接返回了未被完整初始化的实例业务上就出问题了。volatile的禁止指令重排在这里正好锁住了顺序引用赋值必须发生在对象初始化完成之后。所以DCL单例里volatile不是可选的是必须的。我自己见过有人把volatile去掉理由是本项目的并发量不大这属于没想清楚风险到底出在哪里并发量再小也不能容忍错误实例被使用。4. JUC并发工具CountDownLatch、CyclicBarrier、Semaphore4.1 三个工具类的核心区别JUC包里最常用的三个同步工具类功能上各有侧重面试里经常会要求对比。我直接用一张表说清楚工具类核心机制是否可复用典型场景CountDownLatch计数器递减到0后放行等待线程不可复用用完即弃主线程等待多个子任务完成CyclicBarrier等待N个线程全部到达屏障点后同时放行可循环使用多线程分阶段计算每阶段同步一次Semaphore维护N个许可证线程获取到才能执行可复用限流、控制并发连接数CountDownLatch的直观理解是一个倒计时的门闩。你创建它的时候设置一个计数比如5然后启动5个子线程并行执行任务每个子线程完成任务后调用countDown()把计数减1主线程调用await()等待等计数归零门闩打开主线程继续执行。CyclicBarrier则更像一堵“集合墙”N个线程各自跑自己的部分跑完到墙边等待直到N个线程全部到齐才一起冲破墙进入下一阶段。所以它能循环使用很适合多轮计算任务。Semaphore跟上面两个不太一样它不关心几个线程协作只关心同时能放行几个线程。初始传入的permits数值就是并发上限。4.2 使用场景和容易踩的坑实际项目里这三个工具的使用频次我个人排序是Semaphore CountDownLatch CyclicBarrier。Semaphore最常见的场景就是做接口限流你希望某个核心接口最多同时有10个线程在处理超过的必须等就可以用Semaphore(10)。它比线程池限制并发的方式更灵活因为任务不一定非要绑定在线程上。CountDownLatch很好用但有一个坑很隐蔽如果你在子线程里跑的是受线程池管理的任务而线程池的核心线程数小于任务数任务会排队一旦主线程await超时或者任务异常中断countDown没有被执行主线程可能永远阻塞下去。所以使用CountDownLatch时最好加上超时时间也就是await(10, TimeUnit.SECONDS)这种形式同时把countDown放在finally里。CyclicBarrier比较容易出问题的是“屏障破损”如果某一线程在等待过程中被中断或超时会导致整个屏障被打破其他线程会抛出BrokenBarrierException。代码里要显式处理这个异常否则线程会意外结束。5. ConcurrentHashMap并发容器的代表作5.1 从分段锁到CASsynchronizedJava里提到并发集合ConcurrentHashMap一定是主角。JDK 7时代的ConcurrentHashMap采用了分段锁设计内部维护一个Segment数组每把锁管一部分桶每个Segment管若干个HashEntry桶。不同线程能同时操作不同的Segment并发度等于Segment数组长度默认16。但这种设计的锁粒度还是偏大而且Segment本身继承ReentrantLock属于遗留产物定位有点尴尬。JDK 8彻底重写了ConcurrentHashMap放弃了分段锁改用CAS synchronized。锁粒度从Segment级别细化到每个哈希桶也就是链表或红黑树的头节点。线程执行写入时如果目标桶为空就通过CAS直接放进去如果桶不为空就对桶头节点加synchronized锁住这一整条链表或红黑树。不同桶之间天然互不干扰并发度大幅提升。为什么JDK 8敢用synchronized而不是ReentrantLock因为锁升级机制已经让无竞争或者低竞争下的synchronized开销足够小并且JDK团队本身在JVM层面做了大量优化。我们自己在代码里也应该遵循同样的思路能用内置锁解决的低竞争场景就别为了炫技去引入复杂锁。5.2 put流程、扩容与计数原理ConcurrentHashMap的put操作值得好好梳理。第一步先对key的hashCode做扰动计算得到最终的hash值第二步根据hash定位到某个桶如果桶是空的就CAS插入成功则结束失败则说明有竞争第三步如果桶非空检查桶头节点是不是正在扩容如果是就帮忙去扩容而不是傻等第四步synchronized锁住头节点之后判断是链表还是红黑树分别进行插入或覆盖。扩容机制是另一个加分点。JDK 8的扩容不是一次性完成的而是多线程协助完成的。每一个线程在put时发现自己所在的桶被标记为扩容状态会进来帮忙把一部分桶的数据迁移到新数组迁移完再继续自己的put操作。这种设计把扩容压力分摊到多个线程上避免了Stop The World式的全量迁移。计数方面ConcurrentHashMap用了一个baseCount和Cell数组的结构。线程写入时先通过CAS累加baseCountCAS竞争失败就改投Cell数组把计数分散到不同Cell上最后累加baseCount和所有Cell的值得到当前大小。因为计数不是原子的所以size()返回的是一个近似值这在并发容器里是可以接受的不要指望它像ArrayList.size()一样精确。5.3 ConcurrentHashMap到底解决了什么问题很多人分不清Hashtable、Collections.synchronizedMap和ConcurrentHashMap的区别。简单说前两者都是在方法级别加锁锁粒度是整张Map并发能力非常弱。ConcurrentHashMap通过精细的锁设计把竞争降到最低。它适合高并发读写场景比如本地缓存、会话上下文存储、配置动态刷新。但要明确一点ConcurrentHashMap只保证单个操作的线程安全。如果你的业务逻辑需要进行“先检查、再修改”的复合操作比如“存在则自增、不存在则放入”ConcurrentHashMap自身的putIfAbsent、compute等原子方法可以帮上忙但如果你拆成两步来写依然会有并发问题。这里最容易犯的错误是拿get的结果去做业务判断再回头put过程中数据早就被别人改了。6. ReentrantLock、AQS与读写锁锁的高级玩法6.1 ReentrantLock对比synchronized的优势在哪ReentrantLock作为JDK 5引入的外置锁能力上基本覆盖synchronized并提供了几个synchronized没有的功能支持公平锁、支持响应中断、支持尝试获取锁并指定超时时间、能通过Condition实现精确唤醒。看一个带超时获取锁的经典写法ReentrantLock lock new ReentrantLock(); try { if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // 临界区代码 } finally { lock.unlock(); } } else { // 没拿到锁的处理逻辑 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); }tryLock给了一个非常实用的能力拿不到锁时可以走另外的业务逻辑或者补充日志而不是傻等。synchronized没法做到这一点线程一旦进入锁等待就只能被阻塞没有超时机制。对于线上服务来说“有超时的获取锁”有时比“一定拿到锁”更重要能有效避免线程无限期挂死。公平锁跟非公平锁的取舍也是常见考点。公平锁的意思是先来后到等待时间最长的线程优先获取锁非公平锁允许新来的线程直接抢。synchronized是非公平的ReentrantLock默认也是非公平的但可以传true开启公平模式。为什么默认反而用非公平因为非公平锁的吞吐量更高新线程抢锁成功会减少线程切换缺点是可能造成饿死。我在实际项目里很少主动开公平锁除非业务上对严格排队有明确要求。6.2 AQS几乎所有JUC锁的基石AbstractQueuedSynchronizerAQS是JUC包的地基。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock全部构建在它之上。理解了AQS就等于理解了这半壁江山。AQS核心有三个组件一个volatile int类型的state用来表示同步状态一个CLH变体的双向等待队列用来存放拿不到锁的线程以及通过CAS修改state的方法。它对同步器的实现采用了模板方法模式子类只需要重写tryAcquire、tryRelease等钩子方法具体排队、阻塞、唤醒的骨架逻辑已经定好。ReentrantLock的可重入性就是通过state值实现的。每加锁一次state加1每解锁一次state减1。state为0表示锁空闲。Semaphore则是把state当作剩余许可证数量获取时CAS做减操作释放时CAS做加操作。你看同一个基础设施赋予不同的语义就能实现完全不同的同步工具。这就是AQS设计的精妙之处。6.3 读写锁与缓存场景ReentrantReadWriteLock维护了读锁和写锁两把锁。读锁之间是共享的多个线程可以同时持有写锁是排他的写的时候不能有读读的时候不能有写。这个设计非常贴合“读多写少”的业务场景比如配置缓存、数据字典加载。不过读写锁有一个潜在问题如果读线程特别多写线程可能长时间拿不到锁造成写饥饿。ReentrantReadWriteLock默认是非公平模式写线程在竞争时没有优势。Java 8之后引入的StampedLock则提供了乐观读机制读线程不会阻塞写线程并发表现更好但使用复杂度更高要自己处理版本号校验。对于普通场景我认为ReentrantReadWriteLock已经够用StampedLock适合对性能有极致要求的内部中间件。7. 线程池并发面试的重灾区7.1 七大参数与执行流程对照着记线程池是Java并发面试里最容易被问“崩”的环节没有之一。最常见的问题就是“线程池有哪些参数任务进入线程池后的执行流程是怎样的”我建议你把每个参数和整个流程绑在一起记知其然也知其所以然。一个ThreadPoolExecutor的完整构造是这样的new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 非核心线程空闲存活时间 TimeUnit.SECONDS, // 存活时间单位 new LinkedBlockingQueue(), // 任务队列 Executors.defaultThreadFactory(), // 线程工厂 new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );任务进来后的执行顺序是当前线程数小于corePoolSize时直接创建核心线程执行任务线程数达到corePoolSize后新任务进入阻塞队列排队队列满了之后才会创建非核心线程直到maximumPoolSize连非核心线程也满了就触发拒绝策略。这个顺序很多人背反特别是“先排队后扩容”这一点——线程池不是你想的那样“满了就加线程”它先尝试把任务放到队列里队列满了才扩容。这是为了尽量让核心线程稳定工作减少线程频繁创建销毁。7.2 核心线程数到底怎么定别再抄网上的公式了线程数的计算公式网上流传很多版本我说两个最常被引用的CPU密集型任务核心线程数 CPU核心数 1IO密集型任务核心线程数 CPU核心数 * 2或者CPU核心数 / (1 - 阻塞系数)为什么要这么定CPU密集任务的线程基本一直在跑超过CPU核数的线程只会增加上下文切换的代价所以用N1保证CPU切换时有个替补线程顶上就行。IO密集任务中线程大部分时间在等待IOCPU处于空闲状态可以多起一些线程来利用这些空闲时间。阻塞系数指线程阻塞时间占总运行时间的比例假设阻塞率是80%那么最合适的线程数就是CPU核数 / (1 - 0.8)也就是5倍核数。但我想多说一句这些公式都只是估算起点真实环境必须压测。原因很简单不同中间件的IO等待时间、锁竞争情况、JVM垃圾回收周期都会影响最优线程数。我一般先按公式算一个估值然后通过压测工具逐步调整观察吞吐量和延迟曲线找到拐点。这才是靠谱的做法。7.3 拒绝策略与任务队列选型JDK内置了四种拒绝策略值得一个个看拒绝策略行为适用场景AbortPolicy直接抛出RejectedExecutionException默认策略适合允许失败告警的场景CallerRunsPolicy提交任务的线程自己执行该任务适合不想丢任务的场景但会阻塞提交方DiscardPolicy静默丢弃任务适合允许丢弃的任务DiscardOldestPolicy丢弃队列里最旧的任务再尝试提交适合时效性要求高的场景CallerRunsPolicy是我个人比较推荐的一种因为当线程池饱和时提交任务的线程自己执行相当于一种天然的反压机制不会无声无息地丢任务。代价是提交方线程会被占住但这往往比丢任务更能接受。队列选型同样重要。LinkedBlockingQueue默认是无界队列如果任务积压队列会无限增长最终内存被撑爆这是最危险的隐患。ArrayBlockingQueue是数组实现的公平队列容量固定更可控。SynchronousQueue容量为0不存任务直接交给线程处理适合希望没有排队、要么立即执行要么拒绝的场景。实际项目中我倾向于用有界队列容量根据业务峰值来估算配合合理的拒绝策略让系统在极端流量下也能优雅降级。7.4 线程池里最常见的三个坑第一个坑是用Executors工厂方法创建线程池。Executors.newFixedThreadPool用的是无界LinkedBlockingQueue任务一多内存就爆炸newCachedThreadPool的maximumPoolSize是Integer.MAX_VALUE线程数无限大容易压垮机器。阿里巴巴开发规范里也明确禁止使用Executors创建线程池建议直接用ThreadPoolExecutor显式指定参数。你最好养成传参显式构造的习惯。第二个坑是execute和submit的区别。execute传入Runnable没有返回值无法感知任务异常submit传入Callable或Runnable返回Future能在拿到结果时感知异常。如果只用execute提交任务任务抛出的异常如果没有捕获处理会直接吞在worker线程里日志里基本看不到。更坑的是submit提交返回Future后如果你没有调用future.get()任务内部异常也会被Future吞掉。我排查过一回“接口偶发超时但日志无异常”的问题最后发现就是提交给线程池的任务内部抛了异常没人调用get异常信息根本打印不出来。第三个坑是ThreadLocal与线程池的搭配。线程池里的线程是复用的ThreadLocal保存的数据在线程执行完任务后不会被清除下一个任务复用了这个线程可能会读到上一个任务遗留的数据。解决办法很简单任务执行完后在finally块里调用ThreadLocal.remove()或者用ThreadLocal.withInitial方式管理初始值。面试官问到线程池和ThreadLocal的坑基本就是指这个。8. 并发问题排查实录一个线程池卡死案例8.1 现象接口超时率飙升CPU却不高之前遇到过一个非常典型的并发问题。一个核心查询接口平时几十毫秒返回某天突然超时率飙升到百分之二三十但是看机器的CPU使用率并不高内存也没涨。一开始大家以为是数据库慢查询结果检查了数据库指标一切正常。之后看了线程池的运行指标发现任务队列在持续堆积活跃线程数比平时高很多。这才把方向从数据库转向线程池本身。这里有一条经验并发问题排查不要只盯着CPU和内存线程池队列长度、活跃线程数、拒绝次数这些JVM层面的指标同样关键最好在监控系统里提前配上。8.2 排查手法jstack、线程转储与日志交叉验证遇到线程池阻塞最直接的排查手段就是抓线程转储。执行jstack 命令把当前所有线程的快照打出来然后重点看线程池里那些线程处于什么状态。如果大量线程卡在WAITING或者BLOCKED状态基本可以锁定是锁或者阻塞调用导致的问题。在刚才那个案例里jstack出来发现线程池里的线程全部阻塞在一个第三方SDK的socket读取上也就是说任务把线程池里的线程占满了所有线程都在等一个远端服务的响应而远端服务因为某种原因没有返回数据。线程池排队的任务越来越多接口自然就超时了。线程转储是排查线上并发问题最常用的手段。你不需要看懂每一行但至少能快速识别线程状态、线程名、堆栈位置。建议你在排查问题前先拍一个正常时刻的jstack做对照很多问题没有基线很难判断到底哪里异常。8.3 根因与修复三处改动根治问题定位到问题后做了三处改动。第一给所有调用第三方服务的逻辑加上统一的超时控制通过Future.get(timeout)或者HttpClient连接超时来限制保证任务不会无限期占用线程第二线程池队列改成有界队列容量按峰值估算放不下就走CallerRunsPolicy至少不会积压到OOM第三在任务代码里补上异常兜底保证最终一定会释放线程。这次排查看起来简单但背后涉及线程池参数设计、调用超时保护、监控指标建设几个维度每一环都缺一不可。并发问题往往不是单点引入的而是多个因素叠加后集中爆发。排查时要先看现象、再看线程状态、最后看代码逻辑一步一步来不要一上来就怀疑BUG。我个人在实际项目中最大的感受是并发编程不是一个孤立的知识点它和JVM内存、操作系统调度、业务设计全都搅在一起。很多人面试前背熟了synchronized和线程池但一遇到线上问题就无从下手就是因为缺乏把知识串联起来的主线。你抓住“锁对象”“线程状态”“队列饱和度”这三条线去分析大部分并发场景都能快速定位。如果这篇文章能帮你把并发知识串成一张网那目的就达到了。最后再提醒一句看再多总结都不如自己动手写几个多线程demo跑一跑把线程转储打印出来亲眼看一下印象会深得多。