先从一个最反直觉的现象聊起。我给某个服务做压测2个线程跑接口时CPU占用率50%、QPS稳定在3000左右把线程数调到20后CPU还是50%QPS却掉到1800平均RT反而翻了一倍。当时我一度怀疑是监控出了问题后来才明白问题就出在“多线程”这三个字上线程不是越多越好用错了线程性能不是提升是恶化。今天这篇就聊聊 Java 多线程我把这几年踩过的坑、反复验证过的调优思路、以及真实项目里用得上的排查手段都整理成下面这份偏实战向的经验帖。希望能帮一个个踩坑的人少走一点弯路。1. 先弄清楚 Java 多线程到底在并发什么1.1 线程不是越高越好并发背后的“开销模型”很多新手对多线程的理解是“一个任务拆成多个子任务每个子任务一个线程同时跑肯定更快”。这个理解方向对但忽略了两个关键因素线程创建与切换是有开销的共享资源是有竞争瓶颈的。我习惯用一个厨房打比方的说法。你有一间厨房一个厨师一个CPU核心一次只能做一道菜。现在你请来10个厨师理论上能同时做10道菜但厨房只有一个灶台、一把锅铲、一个水池厨师之间要抢灶台、排队等锅铲还有一堆厨师在旁边互相让路这个“让路”的开销就是上下文切换。真正干活的CPU时间被这些等待和让路吃掉了最终出菜速度不一定比一个厨师快。Java里的线程本质上是对操作系统线程的封装每个线程都有自己的程序计数器、虚拟机栈和本地方法栈。线程切换时JVM和操作系统要保存和恢复这些运行状态这是一笔真实的开销。我实测过的一个小实验1万个任务分成100组跑单线程总耗时1.2秒开50个线程跑总耗时0.9秒再往上开到300个线程总耗时反而变成1.8秒。问题不在任务本身而在线程调度、锁竞争和内存同步带来的额外负担。理解了这一点再看很多“并发优化没什么效果”的案例基本都能找到原因要么线程数设置脱离了硬件资源要么任务之间频繁争抢共享变量要么干脆是IO等待和CPU计算混在一起没有区分对待。1.2 区分CPU密集型和IO密集型任务在设计任何多线程方案之前我建议你先对任务做一次分类。分类标准很简单任务执行过程中主要时间花在CPU计算上还是花在等待外部响应上。CPU密集型任务的典型特征是计算逻辑复杂、循环多、没有外部调用。这类任务榨干的是CPU核心的算力线程数设到核心数附近就够了开多了只会增加切换开销。IO密集型任务则不一样比如调用第三方接口、读写数据库、上传下载文件线程大部分时间都在阻塞等待IO返回CPU其实是空闲的。这时候线程可以开多一些让等待的线程挂起把CPU让给其他线程去干活。判断某个任务的类型最笨也最有效的方法就是压测。先单线程跑一遍把“并发数”这个变量固定住观察CPU利用率再逐步往上加线程数记录吞吐量曲线。CPU利用率长期低于40%说明线程大概率在等待可以继续加CPU利用率逼近90%以上再加线程只会变慢就别继续了。区分任务类型这件事直接决定了后续所有线程池参数的设置方向。我在下面讲线程池调优时还会再回到这个分类上。2. 线程创建方式与生命周期别再只会 new Thread2.1 四种创建方式对比与选型Java里创建线程的方式看起来有好几种但本质上都绕不开Thread类。我按代码形式把常见的写法分成下面四种第一种继承Thread类重写run方法Thread thread new Thread() { Override public void run() { System.out.println(任务执行中); } }; thread.start();第二种实现Runnable接口作为任务传给ThreadRunnable task () - System.out.println(任务执行中); Thread thread new Thread(task); thread.start();第三种实现Callable接口配合FutureTask使用CallableString callable () - 调用结果; FutureTaskString futureTask new FutureTask(callable); Thread thread new Thread(futureTask); thread.start(); String result futureTask.get();第四种通过线程池提交这是我最推荐的做法ExecutorService pool Executors.newFixedThreadPool(4); FutureString future pool.submit(() - 调用结果); String result future.get();对比一下这四种方式继承Thread的写法把“线程”和“任务”耦合在了一个类里不利于复用实现Runnable接口把任务和线程分开了更灵活但拿不到返回值Callable解决了返回值的问题但是单个使用的话还要自己维护FutureTask并不方便线程池则把线程的创建、复用、销毁都托管了起来业务侧只需要提交任务即可。从我个人经验来说除非是为了写Demo演示基本API否则现实项目里我不会直接new Thread。原因很简单每次创建线程都要经历“创建-运行-销毁”的过程高频任务场景下线程频繁创建销毁内存和CPU开销不容小觑而且代码里到处都是线程线程的生命周期完全失控出了问题连基本的监控都没有。2.2 线程状态机与常用方法Java线程有六个状态理解状态机是排查线程问题的基本功。六个状态分别是NEW新建、RUNNABLE可运行、BLOCKED阻塞、WAITING等待、TIMED_WAITING定时等待、TERMINATED终止。启动一个线程之后它先处于NEW状态调用start方法后进入RUNNABLERUNNABLE并不代表“正在运行”而是“可以运行”的状态具体什么时候真正在CPU上跑由操作系统调度器决定。线程在等待进入synchronized代码块或方法时是BLOCKED状态调用了wait、join或park时进入WAITING调用了sleep、wait带超时时间、join带超时时间或parkNanos时进入TIMED_WAITING。运行完或者异常退出后就到了TERMINATED。我经常被问到的一个问题是sleep和wait有什么区别。从状态上看sleep进入TIMED_WAITINGwait既可能进入WAITING也可能进入TIMED_WAITING从锁的关系上看sleep不释放任何锁wait调用后会释放当前持有的锁让出Monitor对象。这个区别在实际代码里会引发一些诡异的假死问题如果在持有锁的代码块里调用sleep其他线程就会一直卡在BLOCKED整个并发就瘫痪了。另一个值得牢记的细节是start和run的区别。start才是真正启动一个新线程由新线程异步执行run里的逻辑直接调用run只是当前线程同步执行了一遍方法体等于没有用线程。我把这个知识点叫“start和run的十米跳台”start跳下去了run只是站在跳台上不走。很多初学或者转语言过来的同事在这上面翻过车。2.3 优雅停止线程interrupt机制与协作式取消JDK早期版本提供过stop和suspend方法后来被标记为废弃原因是它们太粗暴stop会直接终止线程导致该释放的锁没释放资源清理做不到位甚至可能让数据状态变得不一致。我见过一台机器上跑着的线程被stop掉后连带数据库连接池出现一批悬挂连接整个应用卡了十几分钟才恢复。现代Java推荐的停止方式是基于中断机制的协作式取消。核心思路是调用线程的interrupt方法给目标线程设置一个中断标志目标线程在自己的业务代码里判断这个标志主动选择什么时候退出、如何退出。public class Worker implements Runnable { private volatile boolean running true; Override public void run() { while (running !Thread.currentThread().isInterrupted()) { try { // 模拟业务逻辑 Thread.sleep(100); } catch (InterruptedException e) { // 恢复中断标志位 Thread.currentThread().interrupt(); // 可以记录日志后退出循环 break; } } } public void stop() { running false; } }一个很容易被忽略的细节是InterruptedException被捕获后线程的中断标志会被清除。如果此时不清除而继续运行外层判断isInterrupted就永远感知不到中断请求了任务就停不下来。所以捕获到InterruptedException后要么重新调用interrupt恢复标志要么直接退出二选一但不要什么都不做。3. 并发安全的底层原理synchronized、volatile 与 Lock 怎么选3.1 并发的三大隐患原子性、可见性、有序性写并发代码之前我建议先把这三个隐患刻在脑子里。它们是所有讨论线程安全问题的地基。原子性指的是一组操作要么全部执行完要么全部不执行不能被中断。典型例子就是i自增操作底层要经过“读取-修改-写回”三个步骤两个线程同时操作同一个变量就可能出现其中一个线程的修改被另一个覆盖的情况。可见性指的是一个线程对共享变量的修改另一个线程能不能立刻看到。Java内存模型JMM规定每个线程都有自己的工作内存线程对变量的读写都是基于工作内存副本进行的工作内存和主内存之间什么时候同步没有硬性保证。一个线程改了变量另一个线程可能还在读旧值。有序性指的是代码编译执行时不一定严格按书写顺序来。JVM和CPU为了优化性能会对指令进行重排序只要重排序不影响单线程执行语义即可。但在多线程环境下重排序可能导致一个线程看到的执行顺序和源码顺序不一致从而产生问题。举个例子说明可见性。假设两个线程共享一个boolean标志public class VisibilityDemo { static boolean flag true; public static void main(String[] args) throws Exception { Thread t1 new Thread(() - { while (flag) { // 空循环 } System.out.println(线程t1退出); }); t1.start(); Thread.sleep(1000); flag false; System.out.println(主线程修改flag为false); } }这段代码在不加volatile的情况下t1很可能一直死循环跑下去因为t1工作内存里的flag副本没有被及时更新。加上了volatile修饰flag之后t1就能感知到主线程的修改顺利退出循环。3.2 synchronized 的升级机制与使用边界synchronized是Java内置的关键字用于实现方法级别或代码块级别的锁。它保证同一时刻只有一个线程能进入临界区同时也实现了跨线程的可见性线程释放锁之前对共享变量的修改对后续获得锁的线程是可见的。JDK 6之后synchronized经历了锁升级机制性能得到了很大改善。锁的升级路径是无锁→偏向锁→轻量级锁→重量级锁。偏向锁适用于只有一个线程反复进入同步块的场景它会记录持有锁的线程ID避免频繁竞争一旦出现第二个线程竞争偏向锁就会撤销并升级为轻量级锁通过自旋等待获取锁自旋失败的线程会被挂起锁升级为重量级锁依赖操作系统互斥量来实现。在实际项目中synchronized仍然是很多共享资源保护的首选方案原因有三语法简单不需要手动释放锁JVM自带锁升级优化大多数场景下性能不一定比Lock差代码上下文清晰不容易出现锁忘记释放的问题。它最适用的场景是临界区逻辑简单、操作时间短、竞争不剧烈的共享数据保护。3.3 volatile 的适用场景与陷阱volatile关键字的作用是保证共享变量的可见性和有序性禁止指令重排序但不保证原子性。我的理解是它适合做“状态标志”和“发布安全的引用”不适合做“计数累加”。最常见的错误是拿volatile修饰计数器然后多个线程执行count操作。因为count本身不是原子操作即使加了volatile两个线程仍然可能同时读到同一个旧值各加一次最终只加了1。要把计数累加做对用synchronized、AtomicLong或LongAdder二选一或者三选一视竞争程度而定。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; } }这个实例中volatile防止的是对象创建的指令重排序new Singleton()在底层要分成“分配内存空间-初始化对象-把引用指向内存”三步如果第二步和第三步被重排序另一个线程可能拿到一个还没有完成初始化的半成品对象。加volatile之后禁止了这个重排序双重检查锁单例才能安全使用。3.4 ReentrantLock、读写锁与ConditionLock是一个接口Java并发包里最常用的实现是ReentrantLock。和synchronized相比它有四个独有能力支持尝试获取锁tryLock支持超时获取锁支持公平锁支持多个等待队列条件Condition。公平锁的意思是按线程请求锁的顺序分配锁先到先得默认构造的是非公平锁允许线程“插队”。非公平锁的优势是吞吐量更高因为在锁释放瞬间唤醒等待线程期间新请求的线程可能直接拿到锁减少了唤醒开销但某些场景下必须使用公平锁防止线程饥饿比如交易系统的资源分配逻辑必须保证请求顺序不被权重偏斜影响。使用ReentrantLock时释放锁一定要放在finally块里防止代码异常导致锁泄漏ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }Condition允许在一个锁上定义多个等待条件最经典的用法是生产者-消费者队列一个作用在“不满”的条件上唤醒生产者一个作用在“不空”的条件上唤醒消费者比单用synchronizedwait/notifyAll要精细得多能避免无谓的唤醒和竞争。我在工具选型上的经验规则是默认用synchronized只有需要尝试获取、超时获取、中断响应、公平锁或精细条件控制时才换ReentrantLock。读写锁ReadWriteLock则适用于“读多写少”的场景比如缓存加载、配置读取读与读之间不互斥能明显提升并发读吞吐。4. 线程池参数调优与工程化实践从“会写”到“会用”4.1 ThreadPoolExecutor 核心参数逐个拆解线程池是Java多线程工程化最重要的一环。我强烈建议不要使用Executors工具类提供的便捷方法而是直接用ThreadPoolExecutor构造器创建这样才能把每个参数的含义吃透。ThreadPoolExecutor pool new ThreadPoolExecutor( 4, // corePoolSize 核心线程数 8, // maximumPoolSize 最大线程数 60L, TimeUnit.SECONDS, // keepAliveTime 空闲存活时间 new LinkedBlockingQueue(1000), // workQueue 有界任务队列 new ThreadFactoryBuilder() // 线程工厂自定义线程名 .setNameFormat(order-export-%d) .build(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );corePoolSize是线程池常驻线程数量初始可能不是全部启动而是来一个任务创建一个线程直到达到该数值。maximumPoolSize是线程数上限当任务队列满了且线程数低于上限时才会继续创建新线程。keepAliveTime是超出corePoolSize部分的线程空闲多久后被回收。workQueue是任务等待队列有界还是无界非常关键无界队列会导致maximumPoolSize形同虚设。threadFactory用于定制线程名、是否为守护线程等信息方便日后排查线程资源问题。handler是任务被拒绝时的处理策略。线程池处理任务的完整流程是这样的提交任务时如果核心线程数未满创建核心线程并执行如果核心线程已满将任务放入队列如果队列已满在最大线程数范围内尝试创建新线程如果线程数已经到了上限触发拒绝策略。很多线上故障的根源就是流程中某一环节的参数设置不合理比如核心线程数设得过大、队列无界、拒绝策略不明确。4.2 Executors 工具类的三个坑Executors提供的newFixedThreadPool、newSingleThreadExecutor内部用的是无界LinkedBlockingQueue任务无限堆积可能撑爆内存。newCachedThreadPool用的是SynchronousQueue线程数没有上限任务量大时会创建海量线程直接拖垮系统。newScheduledThreadPool内部最大线程数是Integer.MAX_VALUE也存在线程无限增长的风险。这三个坑我在线上都见过对应的故障案例所以我现在基本不用这些快捷方法。我还专门给线程工厂设置过自定义名称。默认的线程名都是pool-1-thread-1这种格式一旦线上出现问题抓线程栈时根本分不清每个线程是干嘛的。改成order-export-1、http-api-2这种语义化名称后排障效率会高很多jstack或Java Flight Recorder采集的线程栈一眼就能定位业务模块。4.3 核心线程数配置的计算思路核心线程数的设置没有万能公式但有一个实用的起点。对于CPU密集型任务理论参考值是CPU核心数1因为多一个线程能在某个线程因页面缺页、等待内存等短暂停顿时顶上去增加吞吐。对于IO密集型任务更常用的参考公式是2倍CPU核心数或者更精细一点的 N×(1IO等待时长/CPU计算时长)。我以一个典型订单导出场景为例算一笔账机器是4核CPU任务内部要查数据库、组装文件、上传对象存储平均每个任务的计算耗时约50毫秒IO等待耗时约200毫秒。那么N×(1200/50)4×520初始线程数可以设20再配合有界队列压测验证后微调。线程数不是一次性设了就不改的。我的习惯是先按公式给一个初值再在预发环境做多组对比压测分别记录不同线程数下的QPS、RT和CPU利用率最终选一个“吞吐量达标且RT不过高”的组合。线上运行后还要持续观察监控曲线尤其是在大促或高峰时间段看队列积压和活跃线程数是否逼近水位线随时准备调整为更大规格。4.4 拒绝策略的选型与任务降级逻辑线程池饱和之后拒绝策略决定了提交方和线程池之间的交互方式。自带四种策略如下策略行为适用场景AbortPolicy直接抛RejectedExecutionException要求任务不能丢失报警让开发介入CallerRunsPolicy由提交任务的线程自己执行降低提交速率能起到天然限流效果DiscardPolicy静默丢弃任务允许丢弃日志中记录丢弃数DiscardOldestPolicy丢弃队列头部的旧任务重试提交新任务适合对实时性要求高、可放弃旧任务的场景我个人的默认选择是CallerRunsPolicy。原因很简单它会阻塞住任务提交方的线程让上游感知到下游处理能力饱和从而自动放慢生产速度而且任务本身不会丢只是在下游消费线程里执行。配合线程池监控里的队列积压指标这种方案很适合大多数后端服务。如果要更精细化地做降级可以考虑在拒绝策略里塞一个消息队列做缓冲把超出处理能力的任务先暂存起来等系统空闲后异步补偿处理这个方案在数据同步、定时任务补跑场景中很实用。4.5 线程池监控与优雅关闭线程池在实际运行中不能当黑盒看待。ThreadPoolExecutor本身提供getPoolSize、getActiveCount、getQueue().size、getCompletedTaskCount等指标可以通过定时任务定期打印或者暴露成监控接口接入告警平台。我一般关注四个指标活跃线程数是否长期等于最大线程数、队列大小是否持续增长、拒绝任务是否开始出现、completedTaskCount的增长速度是否异常放缓。关闭线程池时shutdown和shutdownNow是有明显区别的。shutdown会停止接收新任务但已经在队列中的任务会继续执行完shutdownNow会中断所有正在执行的任务并返回尚未执行的任务列表。应用停机时我一般先调用shutdown然后调用awaitTermination等待一段时间让执行中的任务有时间安全收尾超过等待时间后再调用shutdownNow强制结束再配合jstack抓取现场用于事后分析。5. 多线程性能排查与高频避坑清单5.1 从指标入手定位线程池问题排查多线程性能问题我第一步看的是CPU利用率和线程状态分布而不是直接看代码。用jstack抓线程栈后观察线程分布大量线程处于RUNNABLE状态且CPU吃满可能是CPU密集型任务过多大量线程处于WAITING或BLOCKED状态可能是锁竞争或同步等待大量线程处于TIMED_WAITING状态但活跃度低则可能是任务本身IO阻塞或者线程池空闲。一个典型问题的排查过程是这样某服务高峰期接口响应变慢CPU利用率只有30%但线程池活跃数接近上限。抓了jstack后发现大多数线程卡在了数据库查询方法上再配合数据库慢SQL日志定位到是一条关联查询缺索引导致的。线程池本身没有问题问题出在下游依赖的耗时上。所以在排查多线程问题时不要只盯着线程池本身要顺着线程栈往业务代码和外部依赖的方向追。5.2 死锁的模拟、检测与规避死锁产生需要四个条件互斥、持有并等待、不可剥夺、循环等待。实际代码里最常见的死锁场景是多个线程以不同顺序去获取多把锁。比如线程A持有锁1去等锁2线程B持有锁2去等锁1两个线程就永远等下去。我用一个简单的代码结构模拟过死锁Object lockA new Object(); Object lockB new Object(); // 线程1 synchronized (lockA) { Thread.sleep(100); synchronized (lockB) { // 业务代码 } } // 线程2 synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { // 业务代码 } }排查死锁最简单的办法是jstackJVM会自动检测死锁环并在线程栈末尾打印Found one Java-level deadlock信息直接标出哪两个线程、哪两个锁、互相持有什么锁。要预防死锁我建议遵循三条实践尽量使用tryLock获取锁并设置超时时间拿不到锁就不继续等所有需要多把锁的代码都保证以相同的全局顺序获取锁尽量减少锁的粒度能用一种锁就用一种锁不要嵌套多个同步块。5.3 ThreadLocal 在线程池中的内存泄漏隐患ThreadLocal是一种线程内共享变量的方案每个线程都持有变量副本。它本身并不算多线程协作工具但在线程池场景下隐藏着一个经典的内存泄漏问题。线程池里的线程是复用的如果在线程里设置过ThreadLocal值却没有在任务结束时调用remove那么线程在下一次执行任务时仍然持有上一次的变量引用。如果这个变量是自定义的上下文对象就可能导致数据错乱更严重的是如果ThreadLocal的键是强引用且线程不被销毁关联对象就永远不会被回收形成内存泄漏。我遇到过一次线上故障一个批量任务处理线程池里埋了ThreadLocal存用户上下文结果任务B执行时读到了任务A残留的用户信息把数据写到了错误的用户名下排查过程非常痛苦。现在的习惯是凡是在线程池里使用ThreadLocal必须在finally块里removeThreadLocalContext contextHolder new ThreadLocal(); try { contextHolder.set(context); // 业务处理 } finally { contextHolder.remove(); }另外尽量不在核心业务的ThreadLocal里存放大的对象引用如果只是传递轻量的标识字段用InheritableThreadLocal或直接用方法参数传递会更安全。5.4 高频坑位速查表坑点现象解决建议线程数远超CPU核心数CPU利用率不高但RT不稳定按任务类型重新计算线程数无界任务队列内存持续增长直到OOM改用有界队列配拒绝策略直接new Thread且不管理线程数失控、关闭困难统一收敛到线程池锁范围过大吞吐量骤降缩小同步块必要时拆锁忘记释放Lock线上死锁、任务堆积finally里unlockvolatile做计数器最终值少于预期用AtomicLong或LongAdderThreadLocal未移除数据错乱、内存泄漏finally块中remove频繁创建销毁线程池大量线程对象垃圾复用核心线程池不要每个请求都新建从实际使用经验来说多线程项目排障排到最后绝大多数问题都不是出在API怎么调用上而是出在线程池参数、锁粒度和资源复用策略上。项目初期多花一点时间把任务类型分析清楚、线程池参数压测到位、线程命名规范做好后面线上踩坑的概率会小很多。最后再分享一个小技巧给线程池设置队列大小的时候不要只按业务量峰值估算还要考虑“任务积压后内存能扛多久”。假设每个任务平均占2KB内存队列容量1万就是20MB左右加上线程数和临时对象内存占用还在可控范围如果设置成10万就奔着200MB去了一旦下游服务抖动队列积压很容易把堆内存打满。把队列容量、拒绝策略和监控告警这三件事一起设计才算把线程池用明白了。