一文读懂Java线程:从生命周期、线程池到死锁排查
2026/9/9 6:38:04 网站建设 项目流程

说到线程,很多人第一个反应是“哦,多线程嘛,new一个Thread然后start不就行了”。但真到线上排查问题、压测调优、处理诡异死锁的时候,你会发现线程这块的水比想象中深得多。这篇是“线程基础概念”系列的第二篇,面向已经写过一些多线程代码、知道怎么创建线程但还没系统梳理过底层逻辑的开发者。如果你完全没接触过线程概念,建议先看系列第一篇补一下基础;如果你已经踩过线程池、锁、并发工具这些坑,这篇文章能帮你把碎片化的经验串成体系。

这篇文章主要解决这几类问题:线程和进程到底怎么区分才准确;线程在JVM里的一生是怎么流转的;同步互斥、线程通信、线程池这些高频工具背后的设计逻辑是什么;死锁到底是怎么形成、又该怎么定位。内容偏实操,所有结论都会落到“代码怎么写、参数怎么配、线上怎么查”上。

整篇文章我会按照从底层概念到上层工具的顺序展开,每一步都会结合我在实际项目中踩过的坑和验证过的方案来讲。你不需要一次读完,完全可以按目录挑着看,但建议至少把第1节和第5节读完,这两个部分是面试和日常开发里最容易被问倒、也最容易出问题的地方。

1. 线程与进程:被问烂了但依然有人答错

1.1 进程和线程到底差在哪

我面过不少候选人,问到“进程和线程的区别”,背得滚瓜烂熟的答案是“进程是资源分配的最小单位,线程是CPU调度的最小单位”。这句话本身没错,但你要让他具体讲讲“资源”是什么,“调度”又意味着什么,很多人就卡住了。

用大白话打个比方:进程就像一家独立的餐厅,有自己独立的厨房、仓库、收银台,员工之间隔着墙,要协作得靠传菜窗口;线程就像这个餐厅里的员工,他们都在这同一个厨房里干活,共用灶台、共用冰箱、共用菜板。这就是最核心的区别——进程拥有独立的地址空间,线程共享所属进程的地址空间。

放到操作系统层面看,进程拥有的“资源”包括:独立的虚拟地址空间(代码段、数据段、堆、栈)、文件描述符表、信号处理器、当前工作目录等。线程则基本不拥有这些,它只有自己独立的栈空间、寄存器现场和程序计数器。所以线程也叫“轻量级进程”,它的创建、切换、销毁成本都比进程低得多。

这里要特别强调一点:很多人以为线程切换就没有成本,这是错的。线程切换同样有开销,只是比进程切换小。进程切换要处理地址空间的切换(涉及TLB失效、页表重新加载),线程切换因为共享地址空间,省掉了这部分开销,但内核态的上下文保存、恢复、调度器调度一样都少不了。

1.2 为什么需要线程而不是只用进程

既然进程也能多开几个来并发,为什么还要线程?核心原因有两个:数据共享和切换成本。

如果两个进程要共享一块数据,就得走进程间通信(IPC),比如管道、消息队列、共享内存、Socket。这些机制要么慢、要么复杂、要么两者都有。而同一个进程内的多个线程天然共享堆内存,读同一个全局变量就是直接读内存,不需要任何中间层。这在“同一份数据、多个执行流处理”的场景下(比如Web服务器的并发请求处理)优势极其明显。

第二个原因是切换成本。我可以在Linux上用time命令做一个粗略对比:创建10000个进程 vs 创建10000个线程,前者的时间开销往往是后者的几十倍。原因就是前面说的地址空间切换。生产环境里,每秒钟要处理成千上万个并发请求的场景,用进程模型肯定扛不住,这也是为什么Nginx这种高并发服务用的是多进程+多线程混合模型,而绝大多数应用服务器用的是多线程模型。

1.3 实操视角:什么时候该上多线程,什么时候该上多进程

不是所有并发场景都适合用线程。从实践角度,我总结了一个简单的判断标准:

  • 需要大量共享数据、频繁通信:选多线程。比如Web应用处理请求、消息队列消费端、批处理任务的并行计算。
  • 需要强隔离、高稳定性:选多进程。比如浏览器渲染每个标签页用独立进程、微服务拆分独立部署,某一个崩溃不影响其他。
  • 计算密集且无共享数据:两者皆可,但多进程在多核CPU上更容易做到真正的并行,还能避免GIL限制(这里特指某些语言)。
  • 需要调用非线程安全的三方库启动多个独立任务:选多进程更安全。

还有一个实操细节:在Linux上,可以用ps -eLf按行输出每个线程的信息,或者用top -H -p <pid>查看指定进程下的所有线程。我自己排查Java应用线程问题时,最常用的命令是jstack <pid>,JVM会输出当前所有线程的转储(thread dump),这是定位死锁、阻塞、CPU飙高问题的基础手段,后面第6节会展开讲。

2. 线程生命周期与状态流转:你写的线程到底经历了什么

2.1 六种状态不是六种“死法”

Java里,Thread.State枚举定义了6种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多新手把这六种状态当成六个独立的阶段,其实它们更像六种运行场景,而且线程的状态是在这些场景之间来回切换的。

  • NEW:线程对象new出来了,但还没调start()
  • RUNNABLE:调了start()之后,线程就进入可运行状态。注意,这里包含了两种子情况:正在CPU上执行,以及等待CPU调度。Java把这两个都归为RUNNABLE,这也是很多人疑惑“为什么我的线程明明没干活,jstack却显示RUNNABLE”的原因——它可能在等时间片。
  • BLOCKED:线程在尝试获取一个被其他线程持有的监视器锁(synchronized)时,进入阻塞状态,直到获取到锁。
  • WAITING:线程在等待另一个线程的通知或特定动作,比如调用了wait()join()LockSupport.park()
  • TIMED_WAITING:带超时时间的等待,比如sleep(1000)wait(1000)join(1000)LockSupport.parkNanos()
  • TERMINATED:线程正常执行完run()方法,或者因为未捕获异常提前退出。

从操作系统层面看,JVM的RUNNABLE状态对应操作系统的运行态和就绪态。这里要注意Java的状态和操作系统状态不是一一对应的,比如OS里的WAITING(睡眠等待)在JVM里可能对应BLOCKED或WAITING,就看等待的资源和方式。

2.2 状态切换的触发条件与代码对照

这部分最容易混淆的是sleep()wait(),我每隔一段时间就会被问到一次。直接说结论:

sleep()是Thread的静态方法,作用是让当前线程暂停执行指定的毫秒数,它不释放任何锁。它让线程进入TIMED_WAITING状态,时间到了自动恢复RUNNABLE。

wait()是Object的实例方法,作用是让当前线程释放已经持有的监视器锁,然后进入等待队列。它必须在synchronized代码块或方法中调用,否则抛出IllegalMonitorStateException。调用wait()后线程进入WAITING或TIMED_WAITING状态,需要其他线程调用notify()notifyAll()来唤醒,或者等到超时时间自动唤醒。

还有一个容易搞混的是yield()。它让当前线程从RUNNABLE退回RUNNABLE,本质是提示调度器“我可以让出CPU”,但调度器完全可以忽略这个提示。yield()不会让线程进入WAITING,也不会释放锁。实际开发中,我基本不会用yield()去实现任何逻辑,因为它不可靠,这是需要明确的一点。

写个简单的代码对照:

public class StateDemo { public static void main(String[] args) throws InterruptedException { Object lock = new Object(); Thread t1 = new Thread(() -> { synchronized (lock) { try { // 持有锁,进入TIMED_WAITING,不释放锁 Thread.sleep(2000); // 释放锁,进入WAITING lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); t1.start(); Thread.sleep(100); System.out.println("线程状态: " + t1.getState()); // 大概率输出 TIMED_WAITING synchronized (lock) { System.out.println("主线程拿到锁"); lock.notify(); // 唤醒t1,但t1要重新竞争锁 } } }

2.3 实操笔记:怎么观测线程状态

说个实际案例。之前排查一个线上服务接口变慢的问题,jstack打出来的线程转储里,有一个业务线程长期处于WAITING状态,栈顶是java.lang.Object.wait(Native Method)。顺着栈往下看,发现它在等一个阻塞队列的take()操作,而生产者的线程数不够,消费者全都在空等。

排查线程问题的通用流程是这样的:

  1. top -H -p <pid>查看进程内所有线程的CPU占用,找到CPU占用最高的线程ID(十进制的PID)。
  2. printf "%x\n" <pid>把线程ID转成十六进制。
  3. jstack <pid>输出线程转储,在转储里搜这个十六进制线程ID,就能定位到对应的Java线程栈。

这个流程我遇到过几十次,几乎算是线程问题排查的“起手式”。平时开发时如果遇到线程卡住、死锁、CPU飙高,第一步一定是抓线程转储,而不是瞎猜代码哪里出了问题。线程转储能看到每个线程当前在哪一行代码上、处于什么状态、持有哪把锁,信息量很大。

另外,线程的名字一定要规范设置。虽然不影响运行逻辑,但对排查问题帮助巨大。我习惯用new Thread(runnable, "order-query-thread-" + i)这种格式命名,线上出了问题,一眼就能看出来是哪个业务模块的线程。

3. 线程同步与互斥:并发安全的第一道防线

3.1 为什么必须同步:i++ 是条假指令

很多人写并发代码时有个思维盲区:觉得“这行代码就一条语句,怎么可能出问题”。经典的i++就是最好的反例。在Java字节码层面,i++对应的是四条指令:从内存读取i到操作数栈、复制一份、加1、写回内存。即使你的CPU是单核,操作系统也随时可能在中途切换线程。更不用说现代CPU还有多级缓存和指令重排,问题只会更复杂。

我举一个实际踩过的坑:当时一个库存扣减功能,在高并发下偶发超卖。代码逻辑很简单,就是if (stock > 0) { stock--; },对着数据库里的字段做的,但因为没有加锁或者使用原子操作,两个请求同时读到stock=1,各自执行了减1,最后库存变成了0,实际上是卖了2件。这种问题在低并发测试时很难复现,一旦上量就暴露。

解决这类问题有多个层次:最简单的方案是加锁保证原子性,更好的是用AtomicInteger这类原子类(底层是CAS),更彻底的是用数据库的行锁或者乐观锁版本号。具体选哪个,要看你的业务场景和性能要求。

3.2 synchronized与Lock:不是替代关系,是场景互补

synchronized是JVM内置的关键字,在较新的JDK版本里引入了偏向锁、轻量级锁、重量级锁的升级路径,大多数场景下性能已经足够好。它的优点是自动释放锁、语法简单、不容易出错;缺点是获取锁的过程不可中断、不能设置超时时间、只有一个条件队列。

Lock(实现类如ReentrantLock)是JDK层面的API,提供了更灵活的控制能力:可中断获取锁(lockInterruptibly())、可超时获取锁(tryLock(timeout, unit))、支持多个条件队列(newCondition()多个条件)、支持公平锁。但它需要手动unlock(),必须在finally里释放,否则锁泄漏会引发严重问题。

我个人的选择标准很简单:代码简单、同步块短、不需要高级功能,优先用synchronized;需要超时控制、可中断、多条件队列的时候才用ReentrantLock。实际项目里,80%的同步需求用synchronized就够了,很多问题出在“过度设计锁”上。

3.3 volatile:可见性神器,但不是万能的

volatile解决的是两个问题:可见性和禁止指令重排序。它的本质是在写入时强制把缓存中的数据刷回主内存,读取时强制从主内存读取,从而保证一个线程的修改对另一个线程是立即可见的。

但要注意,volatile不保证原子性。它无法解决“读-改-写”这类复合操作的并发问题,比如i++、count += 1在volatile下依然会丢更新。所以它适合的场景是:一个线程写、多个线程读的共享标志位,比如停止标记、初始化状态开关;或者作为双重检查锁(DCL)中保证对象发布安全的字段。

有一个我印象深刻的坑:一个缓存组件用volatile Map存储配置,每次更新就整体替换Map引用,这在单写多读场景下没问题。后来有人改成map.put(key, value)来更新单个键,就出现了其他线程读不到新值的情况,排查半天才意识到把volatile的语义用错了——volatile保证的是引用可见性,不是Map内部结构的线程安全。

3.4 同步工具在线上的几个真实教训

总结一下我在同步这个领域踩过和见过的高频坑,每一条都是血泪教训:

  • 锁对象不能是可变字段。比如用private Integer lock = 1;做锁对象,一旦代码里其他地方改了这个字段的值,锁就失效了,两个线程可以同时进入临界区。正确做法是用private final Object lock = new Object();
  • 别锁字符串常量"abc"这种字面量在JVM字符串常量池里是全局唯一的,在不同类、不同包里锁同一个字符串,会莫名其妙地互相阻塞,排查起来像见鬼一样。
  • 锁的粒度尽量小。锁一个方法整个体积可能很优雅,但并发度也锁没了。我见过一个报表服务把所有操作都锁在了一个大方法上,结果QPS上不去,改成按订单号维度加锁或者用分片锁之后,性能提升明显。
  • 使用Lock时必须unlock。哪怕中间抛了异常也要在finally里释放。这个问题太常见了,代码评审时一定要看有没有在finally里释放锁。
  • ThreadLocal这个边角料要记得remove。线程池里的线程是复用的,ThreadLocal的值不会自动清理,可能导致内存泄漏或串数据。每次用完记得remove()

4. 线程通信:从wait/notify到并发工具

4.1 wait/notify的正确姿势,少有人真正讲透

wait()notify()是Java最基础的线程通信机制,但正确使用方式里隐藏了不少细节。最重要的是三个原则:

第一,调用必须在synchronized代码块内。因为wait()释放锁、notify()要通知等待线程,都需要当前线程持有监视器锁,这是语言层面的强制约束。

第二,wait()前一定要用while循环检查条件,而不是if。原因是线程被唤醒后还需要重新检查条件是否成立。比如生产者消费者场景,如果有两个消费者线程同时被唤醒,其中一个消费了队列里唯一的元素,另一个再从队列取就会失败。而且wait()有可能被虚假唤醒(spurious wakeup),即使在正常情况下没有被notify也可能醒来。用while循环包裹wait()是防御性的正确写法。

第三,尽量用notifyAll()而不是notify()。notify()只会唤醒一个等待线程,如果被唤醒的线程因为条件不满足又继续等待,而其他应该被唤醒的线程没有被唤醒,就可能造成死等。notifyAll()会唤醒所有等待线程,让它们自己重新竞争,虽然会多几次无谓的唤醒,但不会出现漏唤醒的问题。

说个实际例子:一个生产者消费者模型里,消费者线程都调用了lock.wait()。如果有两个消费者都在等队列非空,生产者生产了一个元素后调用了notify(),只唤醒了一个消费者,它消费完了元素后发现队列又空了,继续wait。此时另一个消费者还在等,生产者又生产了第二个元素,再次notify,而这次唤醒的可能还是第一个消费者,第二个消费者永远等不到通知。这种“饿死”问题在消费者数量大于1时非常容易出现。

// 正确的消费者模式 synchronized (queue) { while (queue.isEmpty()) { queue.wait(); } Object item = queue.poll(); // 处理item }

4.2 CountDownLatch、CyclicBarrier、Semaphore的实战区别

这三个并发工具经常被混在一起,但它们解决的问题完全不同:

  • CountDownLatch:倒数门闩。一个或多个线程等待其他线程完成指定数量的操作后才能继续。典型场景是并行任务汇总:主线程发起10个子线程分别处理数据,每个子线程完成时调用countDown(),主线程await()等待计数器归零后继续。CountDownLatch只能用一次,不能复用。
  • CyclicBarrier:循环屏障。一组线程互相等待,全部到达屏障点后同时放行。典型场景是并行计算的分阶段同步:每轮计算完成后,所有线程都到齐了才开始下一轮。CyclicBarrier可以重置复用,这在分阶段迭代的算法里非常有用。
  • Semaphore:信号量。控制同时访问某个资源的线程数量。典型场景是限流:一个接口同一时刻只允许10个线程访问,超过的线程必须等待。

我用一个实际案例说明它们的区别。当时做一个数据同步服务,要从三个数据源拉取数据,拉完之后做合并校验,校验之后写入目标库。这里用了CountDownLatch:三个拉取线程完成后主线程才能开始合并。如果还要求“合并后所有线程都要参与下一步”,那就要换成CyclicBarrier了。而Semaphore用在了一个更细的场景——目标库的写入连接数只有5个,用Semaphore(5)控制并发写入。

4.3 线程池里的线程间通信,要换一种思路

在Web应用里,我们基本不会直接new Thread了,而是用线程池。那么线程池里的线程之间怎么通信?答案是用并发容器和Future,而不是传统的wait/notify。

线程池通信的常见模式是任务提交与结果获取:用ExecutorService.submit()提交任务,拿到Future对象,然后调用future.get()阻塞获取结果。多个Future可以用invokeAll()批量提交等待。

如果任务之间有依赖关系,比如A任务完成后B任务才能执行,可以用CompletableFuture。它在Future的基础上支持了任务编排:thenApplythenComposethenCombineallOf这些方法可以组合出相当复杂的异步流水线,而且不需要手写线程池内部通信逻辑。

另外一个容易被忽略的点是:线程池共享的线程,通信时一定要用线程安全的容器。HashMap换成ConcurrentHashMapArrayList在有并发读写时换成CopyOnWriteArrayList,需要先进先出语义的队列要选LinkedBlockingQueueConcurrentLinkedQueuejava.util.concurrent包下的类就是为线程通信设计的标准答案,别自己用synchronized去包装一个非线程安全集合,性能和安全性都不如直接用并发包里的现成实现。

5. 线程池:绕不开的重中之重

5.1 核心参数与工作原理:别再背流程了,要能推演

线程池的七个核心参数:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime(非核心线程空闲存活时间)、unitworkQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。网上有很多文章讲这几个参数,但大多数把工作流程简单概括为“先核心、再队列、再最大线程、再拒绝”,这句话在默认参数下是对的,但有些细节值得展开:

  1. 当提交任务时,如果当前线程数小于corePoolSize,即使有空闲核心线程,线程池也会新建线程来处理任务,而不是复用空闲线程。这是ThreadPoolExecutor.execute()代码里的第一个判断分支,目的是让线程数尽快达到核心线程数。
  2. 如果当前线程数大于等于corePoolSize,任务会先放入workQueue,而不会立刻创建新线程。很多人误解为“核心线程满了就创建非核心线程”,实际上中间还隔着一个队列。
  3. 只有当队列也满了,才会创建非核心线程直到maximumPoolSize
  4. 非核心线程执行完任务后,会在keepAliveTime内等待新任务,超时则销毁,线程数回落到corePoolSize

举个例子:假设corePoolSize=5、maximumPoolSize=10、队列容量=100。那么提交第1到5个任务时创建5个线程;第6到105个任务会进入队列等待;当队列满时,第106个任务到来才会创建第6个线程,以此类推到第10个线程;第110个以后的任务才会触发拒绝策略。

这个推演很重要。我之前遇到一个业务方,核心线程设了50,队列容量设了100000,最大线程设了200,结果压测时接口的延迟越来越离谱。原因就是队列太长,任务全堆积在队列里,线程数永远到不了50的下一层扩展。这种“队列吞掉所有弹性”的配置,在任务突发且没有及时消费的场景下特别容易出问题。

5.2 阻塞队列的选择:不同类型的任务要匹配不同队列

线程池的阻塞队列选择,这个问题几乎每次面试都会被问到,但实际项目中很少有认真选的,大部分人默认用LinkedBlockingQueue。这是个很大的误区。

  • LinkedBlockingQueue:默认容量是Integer.MAX_VALUE,可以认为是无界队列。任务会无限堆积,最大线程数参数形同虚设。适合任务量相对平稳、峰值不高的业务场景。如果用它,一定得手动指定容量,别用无界默认值。
  • ArrayBlockingQueue:有界数组阻塞队列,容量在构造时指定。适合对流量有明确限制、不希望任务无限堆积的场景。
  • SynchronousQueue:同步移交队列,不存储任何元素,每个put必须等待一个take。它强制让线程池立刻创建新线程处理任务。适合任务量很大、每个任务执行时间很短的场景,搭配比较大的maximumPoolSize效果不错。
  • PriorityBlockingQueue:支持优先级的无界阻塞队列,任务可以按优先级执行。适合有明确优先级的任务场景,但要注意它不能和无界队列一样做拒绝策略的背压,因为队列永远不会满。
  • DelayQueue:延迟队列,只有到期的元素才能被取出。适合定时任务、延迟任务的场景。

我自己的经验是:线上环境几乎都应该用有界队列。无界队列的最大问题是没有背压机制,任务只管往里塞,消费能力跟不上时,内存会被耗尽,触发Full GC甚至OOM,而且这些任务都没了。用有界队列加上拒绝策略,至少能在流量超出承载时快速失败或降级,而不是拖垮整个应用。

5.3 submit和execute:不只是返回值不同

ExecutorService.submit()Executor.execute()都能提交任务,但有几个关键区别:

  • 返回值:submit()返回Future,可以获取任务执行结果、取消任务;execute()返回void。
  • 异常处理:execute()提交的任务如果抛出未捕获异常,会直接终止该线程,异常交给线程的UncaughtExceptionHandler处理。submit()提交的任务如果抛异常,不会立刻打印,而是被封装在Future里,只有调用future.get()时才会抛出ExecutionException。所以如果你用submit()但从不调用get(),任务里的异常会被静默吞掉,排查问题时会非常困惑。
  • 内部包装:submit()最终也是通过execute()执行的,只不过把Runnable/Callable包装成了FutureTask

一个真实踩坑案例:一个数据上报模块用线程池提交任务,任务里调用了远程接口,偶尔有超时异常。因为用的是submit()且没人拿返回值,异常全部被吞掉,上报成功率一直不到100%却查不出原因。后来改成在任务内部用try-catch记录日志,才定位到是远程接口超时。

5.4 拒绝策略的适用场景,我之前老选错

线程池满了之后触发拒绝策略,JDK内置了四种:

  • AbortPolicy:直接抛RejectedExecutionException。这是默认策略,适合能接受任务失败的业务,比如非关键日志上报。
  • DiscardPolicy:静默丢弃任务,不抛异常不通知。风险很大,任务丢了完全不知道,基本不推荐。
  • DiscardOldestPolicy:丢弃队列中最老的任务,然后重新提交当前任务。适合允许丢弃旧任务的场景,比如实时性要求高的业务,旧数据没意义了,新数据优先。
  • CallerRunsPolicy:不在线程池中执行,而是由提交任务的线程直接执行。这是一种反馈机制:主线程执行任务时无法继续提交新任务,从而减缓提交速度,实现天然背压。适合对任务丢失零容忍的场景。

我早期做订单同步时用过AbortPolicy,结果高峰期同步任务直接抛异常,一批订单没同步到。后来换成CallerRunsPolicy,虽然提交任务的线程会被占住,但至少没有任务丢失,提交速率也自然被限制了。这里要提醒一句:CallerRunsPolicy可能导致提交任务的线程长时间阻塞在线程池任务执行上,需要评估主线程是否在意这个耗时。

6. 死锁与排查:每个并发程序员都该掌握的技能

6.1 死锁的四个必要条件,把原理讲透

死锁发生必须同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待。任何一个条件不满足,死锁就不会发生。这是判断死锁可能性的理论依据,也指导了实际的规避手段。

  • 互斥:资源同一时间只能被一个线程占用。这是大多数资源的天然属性。
  • 持有并等待:线程已经持有一个资源,又在等待另一个资源。
  • 不可剥夺:线程持有的资源不能被其他线程强行抢走。
  • 循环等待:一组线程之间,形成了环形等待关系,比如A等B持有的锁,B等A持有的锁。

针对这四个必要条件,规避死锁的思路也清晰了:破坏“持有并等待”可以一次性申请所有资源;破坏“不可剥夺”可以设置锁超时;破坏“循环等待”可以规定全局锁的获取顺序。这些都是在编码阶段就能提前做好的设计约束,比出了问题再排查效率高得多。

6.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) { System.out.println("线程1持有lockA"); try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lockB) { System.out.println("线程1获取lockB"); } } }, "demo-thread-1"); Thread t2 = new Thread(() -> { synchronized (lockB) { System.out.println("线程2持有lockB"); try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lockA) { System.out.println("线程2获取lockA"); } } }, "demo-thread-2"); t1.start(); t2.start(); } }

两个线程各自持有对方需要的锁,然后无限期等待,程序就卡死了。这种代码在实际项目里往往不会写得这么直白,而是被封装在多个方法、多个类里,锁的获取顺序和释放位置被掩盖得七七八八,排查起来更难。

6.3 排查死锁的方法论:jstack是第一利器

在Java里,最直接的死锁排查工具就是jstack。运行jstack <pid>,JVM会检测并输出“Found one Java-level deadlock”,然后列出两个线程各自的锁持有者和等待对象。这是JVM自带的死锁检测能力,即使你对代码不熟,看到这个输出也能快速定位是哪两个锁冲突了。

除了jstack,还可以用jconsoleJVisualVM的图形界面查线程状态,更方便但不太适合线上环境。线上更推荐命令方式,第一时间抓现场,然后再慢慢分析。

死锁的预防比排查更重要。我自己的编码规范里有几条硬性要求:

  1. 多个锁必须按统一顺序获取。比如所有代码都先锁A再锁B,就不会有循环等待。
  2. 尽量用tryLock超时代替无期限的锁定ReentrantLock.tryLock(2, TimeUnit.SECONDS)获取不到锁可以做降级处理,而不是傻等。
  3. 缩小锁的粒度和持有时间。锁里只放必须保护的代码,其他都挪出去。
  4. 同步代码里不调用外部方法。调用远程接口、查询数据库这种不可控操作绝不能放在持锁状态下,谁知道它会等多久。

6.4 线上排查线程问题的常用命令汇总

除了jstack,日常排查线程问题还会用到不少命令。我把高频的几个整理成一个速查清单,存下来基本够用:

场景命令说明
查看进程内线程列表及CPU占用top -H -p <pid>定位CPU占用最高的线程
抓Java线程转储jstack <pid>查看线程状态、锁等待、死锁
查看JVM堆内存使用jmap -heap <pid>排查OOM、堆配置不合理
查看线程数统计jstack <pid> | grep 'java.lang.Thread.State' | sort | uniq -c统计各状态线程数量
Linux按进程查线程数ls /proc/<pid>/task | wc -l查看一个进程下的线程总数
Linux全局线程数cat /proc/sys/kernel/threads-max查看系统级线程数上限
Windows强制结束进程taskkill /F /PID <pid>杀进程,慎用
实时查看Java线程情况jcmd <pid> Thread.printjstack的替代方案,JDK8+支持

这里插一句关于“线程数量过多”的问题:我遇到过一次生产事故,一个应用线程数一路涨到上万,最后OOM。原因是业务代码里用new Thread()处理短任务,没走线程池,而且任务里有阻塞调用,线程创建速度大于释放速度。排查思路就是用jstack导出线程转储,发现大量同类线程卡在同一个第三方SDK的等待上,进而定位到问题代码。

7. 常见问题排查速查表:踩过的坑直接对照

日常问题排查有很多重复场景,下面是我这些年整理的高频问题速查,直接按表格对照找方案。

现象可能原因排查手段
线程状态为BLOCKED,大量堆积锁竞争激烈,临界区执行时间过长jstack查看锁栈,优化临界区代码
线程状态为WAITING,队列空转消费线程在等待,但生产任务不足检查提交线程的数量、任务源是否阻塞
CPU飙高,线程一直在RUNNABLE死循环、频繁GC、正则回溯top -H -p定位线程,对应到代码行
接口偶发超时,但平均延迟不高线程池队列积压监控队列深度,压测线程池参数
线程池里的线程数没有达到maximumPoolSize队列没有满,任务全在队列里排队确认队列容量配置是否符合预期
submit提交后任务无结果、异常不报异常被Future吞掉检查submit返回的Future是否调用了get()
服务周期性OOM线程创建过快、内存泄漏jmap导出堆转储,分析老年代增长
同一线程反复使用ThreadLocal值串了线程池复用线程,ThreadLocal未清理检查代码中是否有ThreadLocal.remove()
app线程数和CPU核数不匹配线程池配置不合理按任务类型重新评估corePoolSize

这张表不是全量,但覆盖了我实际工作中超过80%的线程问题。把它收藏起来,遇到类似现象先对号入座,通常能省下大量瞎猜的时间。

拿tomcat来说,很多Java Web服务用的是Tomcat容器,tomcat7及之后的版本默认使用Java线程池来处理请求。如果发现Tomcat线程数异常增长,要重点看一下maxThreadsacceptCount配置,以及后端调用的响应时间。Tomcat线程池本身的配置和JVM的线程池参数是两套体系,别混在一起调。

还有一个细节值得提:在Windows上遇到线程卡死,虽然没有jstack,但可以用jstack连远程JVM(需要开JMX),或者用jcmd <pid> Thread.print,在JDK 8及以上版本基本等效。实在不行,重启是最后的手段,但重启之前一定要尽量抓现场,不然问题只能靠猜。

8. 一些实操心得,写完这篇文章后的沉淀

写到这里,关于线程的基础概念和实操路径基本梳理完了。最后分享几个我在实战中沉淀下来的体会。

第一个体会是:线程池的参数不是配一次就完事的,要在压测和线上运行中持续调整。我负责过的一个核心交易系统,线程池参数从最初凭经验配置,到后来基于压测数据反复调优,QPS和延迟都有明显改善。参数配置没有标准答案,只有基于真实流量特征的持续观察和调整。

第二个体会是:排查线程问题,千万不要凭直觉改代码。很多次我以为找到了问题原因,结果改完上线问题依旧。正确的做法是先抓现场数据——线程转储、堆转储、GC日志、监控指标,基于数据做分析。线程转储里的每一行栈信息都不是多余的,它准确地告诉你代码执行到了哪个位置。

第三个体会是:好的命名和规范的代码结构,本身就是排查问题的基础设施。线程命名规范、日志里带上线程ID、在关键临界区打点记录耗时,这些看似简单的动作,在线上的价值远高于任何复杂的监控工具。出了问题,日志里清晰的时间线和线程信息能帮你快速还原现场。

最后再分享一个小技巧:写并发代码时,先画一张简单的状态图或者锁依赖图,把线程的状态流转和锁的获取顺序画出来,再动手写代码。大多数死锁和竞态问题,在图上一眼就能看出来,这比写完再去排查要高效得多。线程这块的知识,光看不练很快就会忘,真正的理解都来自踩坑和解决坑的过程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询