ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java线程状态详解:从进程线程原理到jstack实战排查

Java线程状态详解:从进程线程原理到jstack实战排查

1. 先搞清楚:进程和线程到底差在哪

写这篇文章的起因,是最近又看到有人在群里讨论一个老生常谈的面试题:Java进程与线程的区别,以及线程状态怎么变化的。说实话,这个问题看着基础,但大部分人能背出答案,真到了线上排查问题时却容易翻车——比如jstack抓出来的线程状态和脑子里那张状态转换图对不上,遇到BLOCKED和WAITING分不清哪个是锁竞争、哪个是等待条件。我见过不少有几年经验的同学卡在这个地方,所以想系统梳理一篇,把底层原理、状态机、实战排查串在一起讲透。

先说结论:进程是操作系统分配资源的基本单位,线程是CPU调度的基本单位。这个定义几乎在所有教材里都有,但要真正理解它,得从资源隔离、上下文切换成本和通信协作方式三个角度去看,否则只是一句背下来的口诀。

1.1 资源维度:进程是"独立公司",线程是"公司里的员工"

每个进程都拥有独立的虚拟地址空间,包括代码段、数据段、堆、栈、文件描述符表、信号处理器等。换句话讲,进程和进程之间是"物理隔离"的——A进程往自己的地址空间写数据,B进程完全感知不到,也不该感知到。这就好比两家公司,财务报表、办公场地、员工档案都是各管各的,互相之间没有直接通路。

而线程是进程内部的一条执行路径,同一个进程里的多个线程共享该进程的堆内存、方法区、文件描述符等资源,各自只保有一份独立的程序计数器、虚拟机栈和本地方法栈。如果你用公司的类比,进程是一家公司,线程就是公司里的员工:员工们共享办公场地、会议室、项目文档(进程资源),但每个人的工作台、笔记本(栈和计数器)是私有的。

这个差异带来的直接后果是:进程之间天然隔离,一个进程崩溃不会拖垮别的进程;线程共享同一进程空间,一个线程把堆内存写坏,整个进程大概率直接退出。很多服务端程序选择多进程架构而不是多线程,核心考量之一就是隔离性——某个模块出问题不能影响主业务,代价则是跨进程通信复杂得多。

1.2 调度维度:为什么说线程上下文切换开销更小

CPU调度线程时,需要保存当前线程的执行上下文(寄存器、程序计数器、栈指针等),然后加载下一个线程的上下文。这个操作叫上下文切换。如果是进程切换,除了上面这些,还必须切换页表、刷新TLB(快表),因为每个进程的地址空间是独立的,CPU要重新建立虚拟地址到物理地址的映射关系。线程切换时地址空间不用变,天然省掉了这一大块开销。

用搬家类比最好理解:进程切换等于从一套房子搬到另一套房子,家具、水电、门锁全部要重弄一遍;线程切换等于在同一套房子里从一个房间走到另一个房间,只是换个椅子坐下而已。

这也就是为什么单机高并发场景普遍选"多线程 + 少量进程"而不是"大量进程"的原因。实际数字上,进程切换的代价通常是线程切换的几倍到几十倍不等,虽然现代CPU做了不少优化(比如PCID技术减少TLB刷新代价),但量级差距依然存在。

1.3 通信维度:共享内存的效率与代价

进程之间通信必须借助操作系统提供的IPC机制,例如管道、消息队列、共享内存、信号量、Socket等。这些方式要么涉及数据拷贝,要么涉及用户态和内核态的切换,编码复杂度和性能损耗都比较高。线程就简单了,同进程内的线程天然共享堆内存,直接读写共享变量就行,不需要操作系统介入。

但共享也意味着竞争。多个线程同时读写同一个变量时,必须加锁或用原子操作保证一致性,否则会出现数据竞争、可见性问题。到这里,你就明白为什么并发编程的核心矛盾是"共享与同步":共享内存带来了高效的协作基础,同步机制又引入了阻塞、死锁等新问题。

放到Java里,我们通常说的"启动一个Java程序"其实是启动了一个JVM进程。这个进程内部包含多个线程,比如执行main方法的main线程、后台的GC线程、JIT编译线程、信号处理线程等。所以严格来说,你写一个Main函数跑起来,操作系统里多的是一个进程,进程里自动拉起了若干Java线程。

2. Java线程的创建与底层调度模型

理解了进程和线程的资源与调度差异后,接下来看看Java里线程是怎么创建和运行的。这部分内容虽然基础,但很多人会忽略底层模型,导致对"为什么线程状态和操作系统状态不完全一致"产生困惑。

2.1 三种创建方式,其实本质都一样

Java创建线程有三种常见写法:继承Thread类重写run方法、实现Runnable接口、实现Callable接口配合FutureTask。我直接说结论:除了需要拿到返回值或抛受检异常时用Callable,其余场景都优先实现Runnable,因为Java单继承,接口方式更灵活,也方便配合线程池使用。

// 方式一:继承Thread class MyThread extends Thread { @Override public void run() { System.out.println("thread running"); } } new MyThread().start(); // 方式二:实现Runnable Runnable task = () -> System.out.println("runnable running"); new Thread(task).start(); // 方式三:Callable + FutureTask FutureTask<Integer> futureTask = new FutureTask<>(() -> 42); new Thread(futureTask).start(); int result = futureTask.get(); // 阻塞获取结果

三种方式最终都是构造一个Thread对象,调用start()后由JVM创建操作系统线程并执行run方法。注意,直接调用run()不会启动新线程,它只是在当前线程里同步执行一遍方法体。很多新手会在这里踩坑,以为new完Thread再调run就并发执行了。

2.2 底层模型:HotSpot里的1:1映射

JVM规范并没有规定Java线程必须怎么映射到操作系统线程,但主流的HotSpot虚拟机采用的是1:1线程模型,即一个Java线程对应一个内核线程。Java的调度完全交给操作系统调度器,这样能最大化利用现代OS对多核CPU的管理能力。

这也带来一个重要推论:Java线程状态是JVM层面的黑话,和操作系统线程状态不是一一对应的。比如OS的线程状态里有Ready(就绪)、Running(运行)、Blocked(阻塞)、Sleeping等,但Java只定义了六种枚举值,把OS里的Ready和Running合并成了RUNNABLE。所以jstack看到RUNNABLE不代表CPU一定在跑它,它可能只是排队等CPU,甚至在做网络IO(对JVM来说,IO等待也算RUNNABLE,这随时有坑)。

HotSpot线程实现:Java线程 <-> 内核线程 (1:1)

另外,JDK 21引入了虚拟线程(Virtual Threads),底层是M:N模型,由JVM自行调度大量轻量级线程,这是后话。但不管是平台线程还是虚拟线程,上面这套进程/线程的基本概念仍然是理解一切并发问题的基础。

2.3 为什么现实中要用线程池而不是频繁new Thread

如果你去查Thread.start的源码,会发现它调用的是本地方法start0,最终创建一个新的操作系统线程。创建线程涉及分配内核资源、初始化栈、准备调度器等,这个代价不小。如果接口每次请求都new一个Thread,高并发下线程数量可能失控,上下文切换开销巨大,甚至直接把内存耗尽。

线程池的核心意义是复用线程,同时控制并发数量。它预先创建少量线程,提交的任务进入阻塞队列,由线程循环取任务执行。配置线程池时七个参数值得一个个抠:核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。拿个最常见的例子,IO密集型任务核心线程数可以设成CPU核数乘以2左右,CPU密集型则建议等于CPU核数,具体还要结合压测调整。

3. 线程六种状态逐个拆解:源码与真实触发场景

讲完创建和调度,进入本文的核心地带:线程状态。Java里线程状态由Thread.State这个枚举定义,一共有六个:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。下面逐个讲透,并给出触发场景和代码验证。

public enum State { NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED; }

3.1 NEW与TERMINATED:生命周期两头

NEW是线程还没调用start()时的状态。这个状态下线程对象已经创建,但尚未关联操作系统线程,什么资源都没分配。调用start()之后线程才会进入RUNNABLE,而且start只能调用一次,重复调用会抛IllegalThreadStateException。

TERMINATED是run方法正常执行完毕或抛出未捕获异常结束后的状态。线程一旦终止,就没有办法重新启动,再调用start也只会抛异常。如果你看到一个长期存活的线程池里的工作线程出现TERMINATED,那通常说明线程池被关闭了,或者线程在执行过程中抛了致命异常。

这两个状态好理解,实际排查时基本不会作为关注重点。

3.2 RUNNABLE:就绪和运行合并的坑

RUNNABLE是Java线程运行时的"通用状态",它同时包含两层含义:一是线程正在被CPU执行;二是线程已经具备运行条件,正在排队等待CPU调度。OS层面的Ready和Running在Java层面被合并成一个RUNNABLE,正因为如此,jstack输出里出现大量RUNNABLE线程,不代表CPU跑满了。

我举一个最典型的例子:一个Java线程正在执行一次Socket读操作,等待网络数据到达。在OS看来,这个线程处于等待队列,属于不可运行状态;但在JVM的线程状态监控里,Socket IO等待通常仍然显示为RUNNABLE。所以排查高CPU问题时,如果发现RUNNABLE比例很高,需要先确认是CPU密集型计算,还是线程都在做IO导致"看起来像RUNNABLE"。办法是把线程dump配合线程栈一起看,栈顶停在底层native方法上,往往就是IO等待。

3.3 BLOCKED:等待monitor lock

BLOCKED状态的完整含义是:线程在等待进入synchronized同步块或同步方法时,monitor锁被其他线程持有,于是阻塞在这个锁的入口。注意,BLOCKED只跟synchronized锁有关,其他锁机制(比如ReentrantLock)产生的等待不会进入BLOCKED,而是进入WAITING。

监控线程状态时,如果发现大量线程BLOCKED在同一个锁对象上,说明发生了严重的锁竞争。比如某订单系统的核心方法用synchronized做互斥,但持锁线程操作数据库很慢,其他请求全部堵在锁外,线程dump里能看到大量"waiting for monitor entry"的栈。这种问题的修复思路一般是缩小锁粒度、改用读写锁或乐观锁机制,而不是无条件堆机器。

3.4 WAITING与TIMED_WAITING:等待条件,非锁竞争

WAITING意思是线程正在等待某个条件成立,并且调用的是无时限等待方法。触发WAITING的场景主要有三个:

  • 调用Object.wait(),等待notify或notifyAll唤醒
  • 调用Thread.join(),等待目标线程终止
  • 调用LockSupport.park(),被移除许可后挂起

TIMED_WAITING则是有时限的等待版本。常见触发场景:

  • Thread.sleep(millis)
  • Object.wait(timeout)
  • Thread.join(millis)
  • LockSupport.parkNanos / parkUntil

这两个状态经常被混淆。从线程dump的关键词能区分:如果栈里显示"java.lang.Thread.sleep",是TIMED_WAITING;如果显示"waiting on <0x...> (a java.lang.Object)",是WAITING或BLOCKED。记住这条经验:BLOCKED一定是等在synchronized的门口,WAITING通常是在锁内部主动让出条件,两者性质完全不同。BLOCKED属于"想进进不去",WAITING属于"进去之后暂时无事可干"。

3.5 用一个小Demo观察状态流转

光说理论不够,写一个示例代码直观验证一下。下面的程序在main线程里启动一个新线程,然后依次打印它在不同操作下的状态。

public class StateDemo { public static void main(String[] args) throws Exception { Object lock = new Object(); Thread t = new Thread(() -> { synchronized (lock) { try { System.out.println("child: wait start"); lock.wait(1000); // 进入TIMED_WAITING System.out.println("child: wait end"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); System.out.println("1. new: " + t.getState()); // NEW t.start(); Thread.sleep(100); // 让子线程抢到锁 System.out.println("2. waiting: " + t.getState()); // TIMED_WAITING Thread t2 = new Thread(() -> { synchronized (lock) { System.out.println("t2 got lock"); } }); t2.start(); Thread.sleep(100); System.out.println("3. blocked: " + t2.getState()); // BLOCKED,锁被t持有 t.join(); System.out.println("4. terminated: " + t.getState()); // TERMINATED } }

实际输出大致是:

1. new: NEW 2. waiting: TIMED_WAITING 3. blocked: BLOCKED 4. terminated: TERMINATED

栈顶状态和代码操作严格对应,大家可以自己跑一遍,对状态的理解会更扎实。

4. 状态转换中的经典坑:sleep、wait、notify与锁的关系

线程状态这部分,面试和实战中出现率最高的坑集中在锁与等待的互动上。搞明白下面几个问题,状态转换的模糊地带基本就澄清了。

4.1 sleep不释放锁,wait释放锁

这是最容易被问吐但也最容易被混淆的点。Thread.sleep是纯粹的"让当前线程睡一会儿",它只让出CPU,不会释放当前线程持有的任何锁。所以如果一个持锁线程执行sleep,其他线程哪怕在等待这个锁,也只能继续阻塞着,等它睡醒并且锁被释放。

Object.wait则不一样,wait的前提是当前线程已经持有了该对象的监视器锁。调用wait后,线程会释放这个锁并加入对象的等待集合,这样其他线程才有机会进入同步块,等被notify唤醒后重新参与锁竞争,继续执行。

换句话讲,sleep是"原地睡觉带着钥匙",wait是"把钥匙放到桌上,睡在休息室等前台喊你"。这个区别直接决定了你会看到什么线程状态:sleep对应TIMED_WAITING,且锁不释放;wait对应WAITING或TIMED_WAITING,锁已被释放。

4.2 BLOCKED和WAITING的判别技巧

线上看到大量BLOCKED,基本可以断定存在激烈的monitor锁竞争。线上看到大量WAITING,则要分情况:可能是某个线程调用了无参wait等待业务信号,也可能是线程池里空闲线程从队列take任务时的正常表现,还可能是有线程长时间park在那里等待某个许可。

补充一个实用技巧:线程池里的核心线程空闲时,会阻塞在workQueue.take()上,jstack里表现为WAITING(parking)。这是正常现象,不代表系统出问题,别看到WAITING就大惊小怪。真正要看的是数据量和比例,如果几千个线程全部WAITING,可能是线程池参数配置不合理——最大线程数设得太大但任务很少,白白占着内存。

4.3 死锁现场:四个条件与jstack里的"Found one Java-level deadlock"

死锁是并发编程最典型的问题,它要求四个条件同时满足:互斥、持有并等待、不可剥夺、循环等待。Java里最常见的死锁是嵌套锁形成循环等待。

举个例子:线程A持有锁1,要拿锁2;线程B持有锁2,要拿锁1。两个线程都进入BLOCKED状态,互相等对方释放锁。这时候jstack会在最下方打出类似"Found one Java-level deadlock"的信息,并列出循环等待链。遇到这种输出,说明死锁实锤,下一步就是看哪两个锁对象,找到相关业务代码,通过调整锁顺序或者加超时获取(如ReentrantLock的tryLock)来避免。

// 死锁简例:A持lock1等lock2,B持lock2等lock1 Object lock1 = new Object(); Object lock2 = new Object(); Thread a = new Thread(() -> { synchronized (lock1) { try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lock2) { System.out.println("A got lock2"); } } }); Thread b = new Thread(() -> { synchronized (lock2) { try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lock1) { System.out.println("B got lock1"); } } }); a.start(); b.start(); // 运行后,线程dump会报告死锁

4.4 wait与notify的老生常谈:为什么必须在synchronized里

Obejct.wait和notify的调用要求当前线程必须持有该对象的monitor锁,否则直接抛IllegalMonitorStateException。原因很简单:wait和notify需要操作对象的等待集合,而对等待集合的访问必须由monitor锁保护,否则会出现多个线程同时wait或notify导致状态错乱。

还有两个容易忽略的细节:notify()只随机唤醒一个等待线程,如果该线程再次wait而其他线程没有被唤醒,可能造成信号丢失甚至假死,所以能用notifyAll()尽量用,除非你能确认只会有一个等待者;wait方法会响应中断,线程被interrupt时会抛出InterruptedException并从等待集合中移除。

现代并发代码其实很少直接操作wait/notify,因为ReentrantLock、CountDownLatch、Semaphore、BlockingQueue这些更高层的工具把wait/notify封装得很完善,细节不容易出错。但底层原理依然值得理解,因为你排查问题时,很多现象本质上还是Monitors模型在起作用。

5. 用jstack看真实线程状态:一次线上排查实录

前面讲的状态转换偏理论,这一节聊聊实际操作。我在某电商公司的支付回调模块排查过一次接口超时,线程dump给到的线索非常典型,可以拿来当教程。

5.1 jstack怎么抓现场

先用jps找到Java进程PID,然后执行jstack <pid>抓线程快照。如果输出太长,可以落盘后再分析,比如jstack <pid> > thread_dump_$(date +%Y%m%d%H%M%S).txt。对于容器环境,需要先进入宿主机找到容器内主进程号,或者用jcmd <pid> Thread.print命令,效果一样的。如果想连续观察状态变化,隔几秒抓几次,对比哪些栈一直持续不变,那基本就是问题出处。

5.2 看懂线程名和关键字段

jstack输出里每个线程块分三部分:线程名与ID、线程状态、完整调用栈。线程名一般可以直接看出用途,比如main是主线程,GC task thread#0是垃圾回收线程,ForkJoinPool.commonPool-worker-1是公共线程池,http-nio-8080-exec-12是Tomcat业务线程。

关键字段在状态行:

"http-nio-8080-exec-12" #24 daemon prio=5 os_prio=31 cpu=8.9ms elapsed=4.25s tid=0x00007fabbc05b000 nid=0x6403 waiting for monitor entry

waiting for monitor entry表示正在等锁,对应BLOCKED;in Object.wait()表示在等待集合里,对应WAITING。

那次我看到的场景是:大量Tomcat线程状态为BLOCKED,栈顶清一色java.lang.Object.wait和synchronized相关方法,等待的锁对象指向同一个数据库连接池接口。查代码发现,问题根源是获取数据库连接时走了一个全局锁,高峰期几百个请求挤在锁外。当时的修复有两个方向:一是换用连接池自带的超时获取和支持更细粒度锁定的实现,二是调整业务逻辑,减少同步块内的工作量。最终我采取了前者,线程BLOCKED数量立刻回归正常。

5.3 从状态比例判断问题类型

根据线程dump的状态分布,可以快速给问题归类。这里整理一份判定表,也是我个人排查时最常用的一张表:

jstack状态分布特征常见原因入手排查方向
大量BLOCKED,栈顶waiting for monitor entry锁竞争严重分析锁对象、缩小锁粒度、查死锁
大量RUNNABLE,栈顶是业务代码或native方法计算密集或等待IO结合CPU使用率区分,栈顶native多为IO等待
大量WAITING,栈顶是park线程池空闲或条件等待看是否业务需要信号唤醒
大量TIMED_WAITING,栈顶是sleep/parkNanos业务主动睡眠等待检查是否有等待时间过长的逻辑
线程数极少且全部TERMINATED应用退出了查JVM是否被关闭

另外关于死锁,jstack输出最后会特别提示:

Found one Java-level deadlock:

如果看到这一行,基本不用再猜,按提示的线程和锁对象去查代码就行。

6. 关于线程状态,最后分享几点我的个人体会

把进程/线程区别和线程状态说完,最后分享一些我自己踩坑后的体会,希望能帮读者少走弯路。

第一,永远不要通过线程状态来反推CPU是否忙碌。RUNNABLE不等于占用CPU,TIMED_WAITING也不等于系统空闲。状态只是一把钥匙,真正要结合线程栈看到的调用点和CPU使用率、IO指标一起判断,才能下结论。排查时我一般先看CPU整体消耗,再抓线程dump看栈顶,最后看状态分布,这个顺序基本不会错。

第二,线程命名是廉价但极有价值的习惯。用new Thread(() -> {}, "my-service-worker")这种写法,或者在线程池的ThreadFactory里统一加前缀。线上排查时,能一眼认出线程归属,可以省掉大量对代码定位的时间。默认的Thread-1、pool-2-thread-1这种名字,出了事你都不知道它是哪个业务模块创建的。

第三,线程状态要配合线程池参数一起看。很多WAITING其实是线程池正常工作时的"空闲等待",很多BLOCKED则是核心线程数不足导致的任务全部排队。排查慢接口时,先看线程池活跃数和队列积压数,比单纯看状态来得更直接。我习惯在监控里同时暴露线程池的getActiveCount()、getQueue().size()和RejectedExecutionHandler的计数,这样从线程状态到应用行为就串起来了。

第四,隔离性是选型的第一原则。能不用跨进程通信就不要跨进程,能用线程就不要开进程;反过来,一旦某个模块对稳定性要求极高,且和主业务没有强共享状态,宁可拆成独立进程也不硬挤在一个JVM里。这个取舍在设计和排查中经常出现,理解了进程和线程在隔离性上的本质区别后,决策会清晰很多。

最后还是那句老话:线程六种状态背下来不难,难的是看到真实dump能快速判断业务方发生了什么。建议有空的时候自己写几个小Demo,故意制造BLOCKED、WAITING、死锁,再抓jstack对照看一遍,一次下来印象就牢固了。这比刷一百道面试题都有用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进