2026/9/15 3:06:46

Java多线程实战:从线程模型到线程池调优与死锁排查

Java多线程实战:从线程模型到线程池调优与死锁排查 写多线程这块内容的时候我发现很多人的问题不是不会用API而是脑海里对线程没有一个具体的画面感。你问他Thread、Runnable、Callable的区别能背得头头是道一到线上问题排查就抓瞎。这篇文章围绕Java多线程学习整理一套自己的思路从最基础的线程模型一路讲到线程池调优、死锁排查全程用实际项目里能落地的说法来讲不会扯太虚的理论。如果你是那种已经能写Java业务代码但碰到高并发、线程安全、性能调优就心里没底的同学这篇应该能帮你把逻辑理顺。1. 先把线程这件事想明白进程、线程与上下文切换1.1 进程和线程的本质区别很多教科书喜欢用“进程是资源分配的最小单位线程是CPU调度的最小单位”来解释这种话听着很对但对写代码没有直接帮助。我更喜欢换一种说法进程就是一个正在运行的程序实例它独立占有一份内存空间、文件句柄等系统资源线程则是进程内部的执行路径同一个进程里的多个线程共享这块内存和资源。换句话说线程是“在同一个房子里干活的几个人”进程是“房子本身”。房子拆了里面的人不可能继续干活进程挂了所有线程一起完蛋。反过来一个线程崩溃通常不会拖垮整个进程这也是为什么服务端应用敢大量开线程处理请求的原因。1.2 为什么多线程一写就容易懵问题到底出在哪我见过不少同学单线程代码写得非常漂亮一上多线程就各种诡异问题比如数值算错、集合抛出ConcurrentModificationException、程序莫名其妙卡住。放在代码层面原因其实很集中多线程下代码的执行顺序不再固定线程之间又共享了同一个内存区域导致结果依赖极其微妙的时序。这里要引入一个常见的概念叫“可见性”。Java内存模型规定每个线程在工作内存里操作变量而不是直接操作主内存线程之间不共享工作内存。也就是说线程A修改了一个变量的值线程B在某个时间点之前可能完全看不到这个修改。如果多个线程都在修改同一个变量又没有加同步控制那结果可能既不等于A写入的值也不等于B写入的值而是两者的某种“混合”状态。另一个概念是“原子性”。一个int变量的赋值在底层对应一条CPU指令是原子的但i这种操作拆开看是“读取-修改-写回”三步两步之间线程完全可以被切换走这就是并发问题的温床。后来面试里喜欢问的那些synchronized、Lock、volatile、AtomicInteger核心都在解决这三件事可见性、原子性、有序性。还有一点是上下文切换。CPU核数是有限的几十上百个线程不可能同时运行系统要不断把CPU从一个线程切到另一个线程切换前保存当前线程的执行现场切回来再恢复现场。这个开销不是零有人做过粗略统计一次上下文切换大约在几微秒到几十微秒量级看起来不多但高并发下如果频繁切换性能损耗非常明显。这也是为什么后面我们会聊线程池因为反复创建和销毁线程比上下文切换更可怕。2. 创建线程的几种方式别只背结论2.1 从Thread和Runnable说起第一个必须搞清楚的是创建线程本质上只有一种方式就是new Thread()。之所以面试里经常听到“四种方式”其实说的是“如何给线程提供要执行的任务代码”。最粗暴的方式是继承Thread类重写run方法public class MyThread extends Thread { Override public void run() { System.out.println(线程执行中); } } new MyThread().start();这种方式最大的问题是Java是单继承一旦继承了Thread就没法继承其他业务类逻辑上也违背了“把任务和线程分开”的思想。我更推荐立即养成这样的习惯任务逻辑写在实现Runnable的类里线程只是执行任务的容器。Runnable task () - System.out.println(线程执行中); new Thread(task).start();Runnable接口只有一个run方法没有返回值也不能抛出受检异常。这是它最让人难受的地方而Callable恰好补上了这个缺口。2.2 Callable、Future与ExecutorServiceCallable的call方法有返回值还能抛异常配合Future可以拿到异步执行结果。最常见的使用场景是交给线程池去跑而不是直接配合Thread使用ExecutorService executor Executors.newFixedThreadPool(4); FutureInteger future executor.submit(() - { Thread.sleep(1000); return 42; }); Integer result future.get(); // 阻塞等待执行结果 System.out.println(result);future.get()会阻塞当前线程直到任务执行完成。这里有一个常见的坑如果你在主线程里不断调用future.get()等待多个任务完成主线程会被整体卡住和“异步”的初衷背道而驰。正确做法是先把所有任务submit出去收好Future列表再统一get。还有一个细节很多人忽略Future.get()默认阻塞如果想避免长时间等待要用带超时的重载方法future.get(3, TimeUnit.SECONDS)超过时间抛TimeoutException。线上系统里调用外部接口或者执行耗时任务一定要养成设置超时的习惯。2.3 新手常见的线程创建误区我踩过最大的一次坑是直接用Executors静态工厂方法创建线程池代码看着特别简洁ExecutorService executor Executors.newCachedThreadPool();后来线上突然出现大量线程连CPU都快被打满。排查后发现CachedThreadPool的最大线程数是Integer.MAX_VALUE任务的提交速度一旦快于处理速度它就会无限制地创建线程直接把系统资源耗尽。阿里开发规范里明确要求不要使用Executors创建线程池而是通过ThreadPoolExecutor手动指定参数目的就是逼你思考清楚每一个参数的含义。另外很多人以为new Thread()很轻量随手就new。单机测试时确实没事但高并发场景下线程的创建和销毁都要消耗系统资源而且线程数量一旦失控调度开销会迅速吞没业务逻辑本身。这就是线程池存在的意义复用线程限制并发数顺便把线程生命周期管理这个脏活统一收拢。3. 线程安全的核心同步、可见性与原子性3.1 synchronized到底锁住了什么synchronized是Java内置的同步机制本质上是一个“互斥锁”同一时刻只允许一个线程进入临界区。但很多人用了一段时间就会发现有时候锁住的是实例对象有时候锁住的是类对象加错位置照样出问题。public class Counter { private int count 0; public synchronized void increment() { count; } }这里锁的是当前实例this也就是说只有两个线程操作同一个Counter实例时synchronized才会发挥作用。如果两个线程分别new了两个Counter对象各调各的increment锁完全无效count依然是各加各的总结果不符合预期。如果是静态方法锁的是Class对象作用于所有实例。理解了这一点在代码里就不容易把锁加错层级。synchronized还有一个重要特性是“可重入”。同一个线程已经持有一把锁再次请求这把锁时不会被阻塞这在方法互相调用的场景里非常关键。比如一个同步方法调用了本类的另一个同步方法如果没有可重入性程序一进来就死锁了。3.2 Lock与Condition更精细的并发控制synchronized从Java 5之后性能已经不输Lock但有一个短板没法做到“尝试获取锁”“定时获取锁”“可中断获取锁”。ReentrantLock把这些都补齐了。ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 临界区 } finally { lock.unlock(); }注意unlock必须放进finally否则临界区抛出异常时锁永远不会释放轻则性能崩坏重则直接死锁。Condition是Lock的搭档解决了wait/notify的很多痛点。它的wait和signal方法对应Object的wait和notify但可以精确唤醒某一个等待条件上的线程ReentrantLock lock new ReentrantLock(); Condition notEmpty lock.newCondition(); Condition notFull lock.newCondition();经典的生产者消费者模型里用两个Condition能分别控制“队列不空”和“队列不满”消费者只等notEmpty生产者只等notFull互相之间不会误唤醒业务代码也更清晰。3.3 volatile与原子类可见性和复合操作怎么解决volatile是面试里绕不开的话题但很多人误以为用了volatile就能保证线程安全。它的作用是保证变量的读和写直接操作主内存禁止指令重排序也就是解决“可见性”和“有序性”但它不解决“原子性”。最常见的正确用法是状态标记public class Server { private volatile boolean running true; public void stop() { running false; } public void loop() { while (running) { // 业务逻辑 } } }一个线程写running另一个线程读running这种简单读写场景volatile足够。但如果多个线程同时执行running !runningvolatile挡不住因为“读取-修改-写回”不是原子的。复合操作的场景要用原子类最典型的是AtomicIntegerAtomicInteger count new AtomicInteger(0); count.incrementAndGet();AtomicInteger底层依赖CASCompare And Swap指令实现简单理解就是“比较当前值是不是预期的值是的话就更新不是的话就重试”。CAS无锁但并发高的时候会有大量重试和自旋也不算完全免费。用的时候心里清楚就行。4. 线程池实战参数、拒绝策略与调优4.1 线程池参数逐一拆解手动创建线程池时代码长这样ThreadPoolExecutor executor new ThreadPoolExecutor( 2, // corePoolSize 核心线程数 8, // maximumPoolSize 最大线程数 30L, TimeUnit.SECONDS, // 非核心线程空闲存活时间 new ArrayBlockingQueue(100), // 工作队列 Executors.defaultThreadFactory(), // 线程工厂 new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );很多新手只看参数名会晕。我习惯把线程池理解成一个“外包团队”核心线程是正式员工任务多了就招临时工临时工有闲置超时时间keepAliveTime任务提交太快连临时工都忙不过来就去排队workQueue队列也满了再来任务就得按拒绝策略处理。这里最关键的流程是提交任务时线程池会先看核心线程是否满了没满就新建核心线程执行满了就看队列能不能入队能入队就先排队队列也满了才考虑创建非核心线程去执行线程数到达maximumPoolSize且队列也满才触发拒绝策略。注意这个顺序网上很多人会记反误以为核心线程满了就直接创建最大线程。实际上要不要创建临时工优先级排在“队列满不满”之后。4.2 拒绝策略怎么选JDK内置了四种拒绝策略策略行为适用场景AbortPolicy直接抛RejectedExecutionException默认策略不适合用户请求CallerRunsPolicy任务回退给提交者线程执行降低提交速度适合不想丢任务的场景DiscardPolicy静默丢弃任务允许丢任务时可用DiscardOldestPolicy丢弃队列中最旧的任务追求最新数据时可用实际项目里我最常用的是CallerRunsPolicy。它的天然优势是当线程池饱和时任务不会悄悄丢掉而是由提交任务的线程自己执行相当于变相加了一条“流量控制”的回路让调用方亲身体会到系统已经忙不过来了从而自然降低提交速度。4.3 线程池踩坑与线程数估算线程池最隐蔽的坑是任务里异常被吞。用executor.submit()提交任务时如果任务内部抛了异常异常会被封装在Future里除非你调用future.get()否则肉眼根本看不到任何报错。排查这类问题很头疼我建议任务内部自己用try/catch兜底必须抛出的话至少做好日志记录。还有一个经验是不要用无界队列。LinkedBlockingQueue默认不设置容量时是无界队列任务会无限堆积最终可能导致OOM。宁可设置一个有边界的ArrayBlockedQueue触发拒绝策略被大家感知也不要让任务在内存里腐烂。线程数怎么定网上讨论非常多。有一个简单经验公式吻合了我多年的实践CPU密集型任务线程数约等于CPU核数1加减一点没太大区别开太多反而增加上下文切换。IO密集型任务线程数可以明显加大常见的估算方法是 CPU核数 / (1 - 阻塞系数)阻塞系数大约在0.8到0.9之间所以生产环境单机动辄几十上百个线程是有道理的。如果任务类型不明确先按“CPU核数 * 2”起步压测后慢慢调整比一开始拍脑袋定一个大数字靠谱得多。关闭线程池时要注意区分shutdown和shutdownNow。shutdown()是优雅关闭不再接收新任务但会等已经提交的任务执行完shutdownNow()是立即关闭尝试中断正在执行的任务并返回尚未执行的任务列表。线上建议优先用shutdown给任务一个体面的收尾时间除非你明确知道任务卡死了。5. 高频面试追问与死锁排查实录5.1 线程状态流转与JMM高频题面试里问线程状态真正想考察的不只是背出六个状态而是能不能说清楚状态之间怎么迁移。Java线程的状态包括NEW创建未启动、RUNNABLE可运行/正在运行、BLOCKED等待监视器锁、WAITING无限期等待、TIMED_WAITING限期等待、TERMINATED终止。有几个容易被绕晕的细节调用Object.wait()后线程进入WAITING被notify唤醒后不会直接进入RUNNABLE而是先进入BLOCKED因为还要竞争monitor锁。Thread.sleep()不释放锁Object.wait()释放锁这是一个高频考点。sleep是“抱着锁睡觉”wait是“自己先退出来把锁让给别人”。synchronized进入时如果锁被其他线程持有当前线程进入BLOCKED而不是RUNNABLE。JMM面试题我一般建议从主内存和工作内存切入不要上来就背定义。三句话概括volatile解决可见性和有序性synchronized/Lock解决原子性、可见性和有序性final和Happens-Before规则帮忙推断序关系。5.2 死锁如果发生了怎么定位和修复死锁发生的四个条件是互斥、持有并等待、不可剥夺、循环等待。写一个简单的死锁样例如下public class DeadlockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (lockA) { try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lockB) { System.out.println(t1 got lockB); } } }); Thread t2 new Thread(() - { synchronized (lockB) { try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lockA) { System.out.println(t2 got lockA); } } }); t1.start(); t2.start(); } }两个线程各自持有一把锁又都去竞争对方持有的锁程序会永久卡死。线上不会这么直白但是核心模式是一样的。一旦怀疑死锁第一步用jps找到Java进程ID然后执行jstack 把线程快照导出来。在jstack输出里搜“Found one Java-level deadlock”它会把死锁链路和涉及的锁都列出来简直是把答案递到你面前。我曾经在一个凌晨三四点的线上事故里用这招十分钟确认了是两把锁的拿锁顺序写反了。修复死锁最常见的套路是统一锁的获取顺序。两个线程都用同样的顺序去拿lockA再拿lockB就不会形成循环等待。另一个思路是用tryLock带超时if (lockA.tryLock(1, TimeUnit.SECONDS)) { try { if (lockB.tryLock(1, TimeUnit.SECONDS)) { try { // 业务 } finally { lockB.unlock(); } } } finally { lockA.unlock(); } }获取不到锁就放弃并重试从机制上打断死锁的可能性。这种方案在实际项目里很实用尤其是在多个资源需要同时获取的时候。排查线程问题时除了jstack我还常配合jstat和jmap一起看。jstat观察GC和类加载情况jmap把堆dump下来用MAT分析对象引用关系靠三个命令组合定位高并发场景下的线程问题比单纯靠日志猜要快得多。这些年在多线程上踩过的坑回想起来大多集中在几个点上不上锁就直接改共享变量、线程数拍脑袋乱设、任务异常被吞掉、拿锁顺序不一致。如果你想认真把Java多线程啃下来建议不要只盯着API背而是要亲手写几个并发程序去跑一跑故意把程序写成错误版本再用jstack去查现场。线程这个东西光看书是学不会的只有真正把代码运行起来你才会理解什么叫竞态条件、什么叫死锁、为什么volatile在某些场景下确实够用而在另一些场景下必须上锁。把上面的思路理清楚再去刷面试题很多问题其实不用背也能答出来。