☰
Java线程六状态与进程线程区别:实战jstack排查
2026/10/20 17:23:26 网站建设 项目流程

上周排查一个线上服务变慢的问题,我盯着线程快照看了半天,发现三个业务线程全部停在BLOCKED状态,而旁边的一个循环任务线程占满了 CPU。那次排查让我又一次体会到:Java 进程与线程的区别、线程状态这套东西,平时看像八股文,真正出问题的时候,它就是定位故障的地图。

如果你刚学完 Java 基础,准备碰并发编程;或者你写过几个多线程程序,但总说不清WAITING和TIMED_WAITING到底差在哪;又或者你面试前想把这几个基础点一次理顺——这篇文章值得看完。我会先用一个直觉模型讲清进程和线程的本质区别,再逐个拆 Java 线程的六种状态,最后聊一点我看线程快照和排查线上问题的实操经验。

1. 先建立直觉:进程和线程到底在争什么

1.1 一句话说清:进程是车间,线程是车间里的工人

我见过不少解释,最后还是觉得“车间和工人”这个类比最贴切。一个进程就像一个独立的车间:车间有自己的围墙(独立地址空间)、有自己的机器设备(文件描述符、内存、信号处理器),车间与车间之间互相看不见,交流要通过外部管道或共享中转站。

线程则是车间里的工人。工人干活要用电、用工具、用原材料,但工人之间共用同一套车间设施。车间把资源划给工人用,工人负责真正把活儿干出来。操作系统分配资源时,最小单位是进程;操作系统调度执行时,最小单位是线程。这句话是全部区别的总纲,最好刻在脑子里。

1.2 一个单词看懂资源与调度的分工

我们把视角切到操作系统底层。创建进程时,内核要给它分配独立的地址空间、页表、文件表、信号处理结构,代价很重;而创建线程时,内核只是在已有进程内部再分一条执行流,线程共享进程的地址空间和大部分资源,只需要独立的程序计数器、寄存器集合和栈。

所以“进程是资源分配的基本单位,线程是 CPU 调度的基本单位”这句话不是考试术语,而是两者的定位差异。资源归进程管,CPU 归线程花。一台机器可以同时跑多个进程,也可以在一个进程里跑几十上百个线程;进程负责圈地,线程负责冲锋。

1.3 一张表看完核心差异

对比维度进程线程
资源拥有独立地址空间、独立资源共享进程资源,仅栈和程序计数器独立
调度单位进程作为资源容器线程被操作系统直接调度
切换成本需要切换页表、刷新 TLB,开销大只需切换寄存器上下文,开销小
通信方式管道、消息队列、共享内存、Socket 等共享内存,配合 wait/notify 等直接通信
健壮性一个进程崩溃不影响其他进程一个线程崩溃可能导致整个进程退出
适用场景强隔离、多实例、独立部署高并发、IO 密集、需要频繁共享数据

这张表后面每一项我都会展开讲,因为只看表不理解背后的机制,真到排查问题时会犯低级错误。

2. 逐项拆解六个核心区别

2.1 资源拥有:独立厨房与合租厨房

进程的独立是彻底的。进程 A 里修改了一个全局变量的值,进程 B 完全感知不到,因为它们运行在各自的虚拟地址空间里。这也意味着进程间不能直接拿到对方的指针去读写,必须走 IPC 机制。

线程的“共享”则是致命的便利和风险并存。同一个进程内的所有线程共享堆和方法区,一个线程 new 出来的对象,另一个线程可以直接持有引用去访问。每个线程私有的只有虚拟机栈、本地方法栈和程序计数器。栈管方法调用,程序计数器管下一条指令地址,这两样不共享是理所当然的——每个线程正在执行的方法路径本来就不一样。

实操中容易被忽略的是:虽然堆是共享的,但线程栈上的局部变量是线程私有的;只有堆里的对象才是多线程共享的。很多人写并发代码时,把一个局部变量塞进 lambda 里给另一个线程用,以为安全,结果变量被多个线程同时改,这就是没分清“共享区”和“私有区”导致的。

2.2 切换成本:为什么线程切换更轻

线程切换要保存的东西:当前线程的寄存器、程序计数器、栈指针,然后加载下一个线程的这些上下文。因为线程共享同一个进程的地址空间,页表不用换,TLB 不用失效,CPU 缓存大部分还能继续命中。

进程切换就复杂了。内核要切页表,页表一换,TLB 基本作废,CPU 的指令缓存和数据缓存命中率也会明显下降。再加上虚拟地址空间的切换,整体开销比线程切换高出一个数量级甚至更多。

但这并不代表线程切换就“不要钱”。线程一多,上下文切换的总开销同样可观。你在线程池里把线程数调到 CPU 核数的几十倍,大量时间都会耗在切换上,而不是干活上。这也是为什么 IO 密集和 CPU 密集场景的线程数设置策略完全不同的原因。

2.3 通信方式:管道工与对讲机

进程之间通信,要么用管道、消息队列、信号,要么用共享内存加信号量,要么走 Socket。这些机制要么涉及内核态拷贝,要么需要序列化反序列化,写起来繁琐,性能也有折损。我把这叫作“两个人不在一个屋,只能靠对讲机喊话”。

线程之间通信就简单很多,因为大家本来就在一个地址空间里。共享一个对象,配合wait()、notify()、notifyAll()或者 Lock 体系就能完成协作。代价是共享意味着竞争,你需要同步机制来保证内存可见性和原子性,否则轻则数据错乱,重则死锁。

需要警惕的是:共享内存这把双刃剑。进程间通信虽然慢,但边界清晰,不容易出“幽灵数据”;线程通信快,可一旦同步写错,问题极难定位,因为你根本不知道是哪个线程在什么时候改了那个字段。

2.4 创建开销:fork 与 new Thread 的成本差异

类 Unix 系统创建进程用fork(),底层要复制父进程的页表、文件描述符表、信号处理器等一堆东西,开销很大。Java 里每次new Thread()并start(),在 HotSpot 虚拟机中是 1:1 映射到操作系统原生线程,同样要触发内核去创建线程结构。

但两者级别不同:创建进程要复制资源,创建线程只是从已有进程的资源池里分一份执行上下文,成本低得多。因此需要轻量级并发时,多线程几乎是唯一选择。不过 Java 的线程创建成本仍然不算低,而且线程本身要占用大约 1MB 左右的栈空间,成千上万条线程会带来明显的内存压力和调度压力。这也是线程池和虚拟线程出现的原因。

2.5 隔离与崩溃:砖墙与通铺

进程的隔离性表现在:进程 A 崩了,进程 B 毫发无伤。因为两者地址空间隔离,内核只回收 A 的资源。所以在需要高隔离的场景(比如同时跑互不信任的任务),多进程更好。

线程的隔离性就差得多。同一个进程里,线程之间没有地址空间保护,一个线程因为OutOfMemoryError或者其他未捕获异常崩溃,进程整体可能直接退出,殃及所有线程。Java 里虽然用未捕获异常处理器可以兜底,但像StackOverflowError、OutOfMemoryError这类问题,往往不是一个线程能抗住的。

这个区别决定了架构选型:需要稳定边界的模块部署成独立服务(进程隔离),需要高吞吐和大量协作的内部组件用多线程。

2.6 场景取舍:多进程还是多线程

如果任务是 CPU 密集、逻辑相对独立、中断风险高,多进程隔离更安心;如果任务是 IO 密集、需要频繁共享状态、延迟敏感,多线程更合适。实际工程中两者往往混用:外层用多进程做故障隔离和水平扩展,内层用多线程做并发处理。

3. Java 线程的六种状态全解

3.1 全貌总览:JVM 视角的六状态

Java 线程状态定义在Thread.State枚举里,共六个:

状态含义进入方式离开方式
NEW线程对象已创建,但还没调用 start()new Thread()调用 start()
RUNNABLE正在执行或等待 CPU 时间片start() 后、获得锁后、被唤醒后被调度、进入等待/阻塞、终止
BLOCKED等待进入 synchronized 监视器锁竞争 synchronized 锁失败获得锁
WAITING无限期等待被唤醒wait()、join()、LockSupport.park()被 notify/notifyAll、join 线程终止、unpark
TIMED_WAITING有明确超时时间的等待sleep(ms)、wait(ms)、join(ms)、parkNanos超时、被唤醒
TERMINATEDrun() 正常返回或抛出未捕获异常run() 结束无

这六个状态不是照搬操作系统的线程状态,而是 JVM 为了统一描述线程生命周期做的抽象。HotSpot 里 Java 线程和操作系统原生线程一一对应,JVM 把 OS 层的“运行中”和“可运行”合并成了RUNNABLE,把各种等待细分成WAITING和TIMED_WAITING,把等synchronized监视器锁的状态单独抽成BLOCKED。理解了这一点,你再看线程快照时,就能明白每个状态背后的操作系统行为是什么。

3.2 NEW 与 RUNNABLE:最容易读懂的两种状态

NEW很好理解:Thread t = new Thread(),此时对象在堆里,但底层操作系统线程还没创建。只有调用start(),JVM 才会真正创建原生线程,并让线程进入可运行状态。start()对一个线程只能调用一次,第二次会抛出IllegalThreadStateException,这是新手很容易踩的坑。

RUNNABLE最容易引起误会。字面意思是“在跑”,实际上它表示“万事俱备,只欠 CPU”。线程可能正在执行,也可能排在就绪队列里等时间片。所以看到线程快照里一堆RUNNABLE,不等于 CPU 真的很忙。也可能线程正卡在某个非内存操作上,比如阻塞式 Socket 读,但 JVM 依然把它标记为RUNNABLE,因为它没有进入 Java 层面的等待或阻塞状态。

3.3 BLOCKED:锁门口的排队

BLOCKED只对应一种情况:线程想进入synchronized代码块,但锁被别人持有,于是只能等在监视器锁的入口区。等持锁线程释放锁,JVM 会从等待队列里挑一个线程唤醒,它拿到锁后进入RUNNABLE。

这里有个高频误解:很多人以为用ReentrantLock.lock()抢不到锁时线程也是BLOCKED,其实不是。ReentrantLock底层用AbstractQueuedSynchronizer,抢不到锁时线程会调用LockSupport.park(),线程状态是WAITING。所以你说“有线程 BLOCKED,一定是 synchronized 竞争”,这句话才准确。

3.4 WAITING 与 TIMED_WAITING:等待的两种姿势

WAITING是无限期等待,必须有人来唤醒。进入方式有三个主流入口:Object.wait()(先持有监视器锁)、Thread.join()(等另一个线程结束)、LockSupport.park()(AQS 体系的基础)。

TIMED_WAITING则带一个超时时间,时间一到自动恢复竞争执行资格。常见入口包括Thread.sleep(ms)、wait(timeout)、join(timeout)、LockSupport.parkNanos()、LockSupport.parkUntil()。

WAITING和TIMED_WAITING的叙事差别很重要:WAITING是你把一个线程弄丢了,没人notify它就是永久等待;TIMED_WAITING是给等待上了个闹钟,超时后自己醒来重新抢锁。排查线程池任务卡死时,如果线程长期停在WAITING,优先查谁该唤醒它;如果长期在TIMED_WAITING但任务没进展,优先查是不是每次醒来又进去了一个更长的 sleep。

3.5 TERMINATED:走到尽头

线程的run()方法正常返回,或者执行中抛出未捕获异常,线程体结束,状态变成TERMINATED。之后这个线程对象虽然还能调用方法,但不可能再回到RUNNABLE。如果确实需要重新执行,只能再new一个线程。

实践中一个坑是:如果run()里用了无限循环,而循环条件写错,线程会一直停在RUNNABLE或等待状态,永远不TERMINATED。这种线程不是“退不出来”,而是“根本没想着退”。写循环退出条件时,多想想interrupt()这个信号有没有被正确处理。

4. 状态转换图谱与实战观察

4.1 谁能迁向谁:转换规则清单

不要死记六状态名,记转换关系:

  • NEW→RUNNABLE:唯一入口是start()。
  • RUNNABLE→BLOCKED:进入synchronized块时锁被占用。
  • RUNNABLE→WAITING:调用wait()、join()、LockSupport.park()。
  • RUNNABLE→TIMED_WAITING:调用sleep()、wait(timeout)、join(timeout)、parkNanos()。
  • BLOCKED→RUNNABLE:拿到监视器锁。
  • WAITING→RUNNABLE:被notify/notifyAll、join 的线程结束、被unpark。
  • TIMED_WAITING→RUNNABLE:超时到点,或被唤醒 /unpark。
  • RUNNABLE、WAITING、BLOCKED、TIMED_WAITING→TERMINATED:run()执行完毕或抛出异常退出。

一个细节:wait()被唤醒后不会立刻回到RUNNABLE。它会先去重新竞争监视器锁,如果锁还被别人拿着,它先进入BLOCKED,拿到锁后才RUNNABLE。很多人画状态图时把WAITING直接指向RUNNABLE,这没错,但少了一条“竞争锁失败先 BLOCKED”的路径,导致看线程快照时困惑:明明 notify 了,线程怎么还在 BLOCKED?

4.2 用代码看六状态

看书不如跑代码。下面这段代码可以让你直观看到状态流转,建议自己敲一遍:

public class ThreadStateDemo { public static void main(String[] args) throws InterruptedException { // 演示 NEW、RUNNABLE、TIMED_WAITING、TERMINATED Thread worker = new Thread(() -> { synchronized (ThreadStateDemo.class) { try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "worker"); System.out.println("1. 创建后:" + worker.getState()); // NEW worker.start(); System.out.println("2. start 后立刻看:" + worker.getState()); // 大概率 RUNNABLE Thread.sleep(100); System.out.println("3. 睡 100ms 后:" + worker.getState()); // TIMED_WAITING (sleeping) // 演示 BLOCKED:主线程先占锁,再让另一个线程去抢同一把锁 synchronized (ThreadStateDemo.class) { Thread blockedThread = new Thread(() -> { synchronized (ThreadStateDemo.class) { // 不做事,只是假装要拿锁 } }, "blocked-thread"); blockedThread.start(); Thread.sleep(200); System.out.println("4. 锁被占用时新线程:" + blockedThread.getState()); // BLOCKED } worker.join(); System.out.println("5. worker 结束后:" + worker.getState()); // TERMINATED } }

运行后输出类似:

1. 创建后:NEW 2. start 后立刻看:RUNNABLE 3. 睡 100ms 后:TIMED_WAITING (sleeping) 4. 锁被占用时新线程:BLOCKED 5. worker 结束后:TERMINATED

想看WAITING,可以用join()模拟:

Thread target = new Thread(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); Thread waiter = new Thread(() -> { try { target.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); target.start(); waiter.start(); Thread.sleep(200); System.out.println(waiter.getState()); // WAITING,因为它在等 target 结束

4.3 jstack 实战:线上线程快照怎么读

状态不只是理论玩具,排查线上问题第一个动作就是抓线程快照。命令只有一行:

jstack <pid>

更推荐用jcmd <pid> Thread.print,输出格式基本一致。抓出来的快照会显示每个线程的栈和状态,例如:

"worker" #11 prio=5 os_prio=0 tid=0x00007f3d3c03d800 nid=0x2a4b waiting on condition [0x00007f3d2c1fd000] java.lang.Thread.State: TIMED_WAITING (sleeping) at java.lang.Thread.sleep(Native Method) at com.example.ThreadStateDemo.lambda$main$0(ThreadStateDemo.java:9)

我的排查套路是:每隔 3 到 5 秒抓一份快照,连续抓三份,然后对比同一线程的状态是否在变化。

  • 线程状态在RUNNABLE、TIMED_WAITING之间切换,说明线程还在正常干活,只是可能有点忙。
  • 线程一直钉在WAITING不动,怀疑是等待唤醒没等到。
  • 线程一直钉在BLOCKED不动,几乎可以断定是锁竞争激烈或死锁。
  • 一堆RUNNABLE但线程栈停在SocketInputStream.read这类调用上,说明线程名义上可运行,实际被网络 IO 卡住。

抓完快照再配合top -Hp <pid>看线程 CPU 占用,能快速区分是 CPU 忙还是线程空等。这套动作比看任何理论都管用,因为你是在用真实世界的状态分布反过来验证你对六状态的理解。

5. 面试和实战里最常踩的五个坑

5.1 wait 和 sleep 不是一回事

这两个方法太容易混了。sleep()是Thread的静态方法,目的是让线程睡一会儿,它不释放任何锁,只是让出 CPU。wait()是Object的实例方法,它必须配合synchronized使用,目的是让线程释放监视器锁并进入等待队列。

有一个隐藏细节:调用wait()前没持有锁,会直接抛IllegalMonitorStateException。这不是运气问题,而是 JVM 在强制保证“先拿锁再放弃锁”的语义。面试官经常会追问:sleep期间持有锁,wait期间释放锁,为什么?因为wait的设计初衷是“等一个条件满足”,如果还霸着锁,其他线程就进不来改条件,这是个死局。

5.2 RUNNABLE 不代表真的在跑

排查问题时最容易看走眼的就是RUNNABLE。一个线程在 Java 层面被标记为RUNNABLE,可能是因为它正等 CPU 时间片,也可能是因为它在执行一段很耗时的 Java 代码,还可能是因为它阻塞在 Socket 的read()调用上——JVM 无法区分这种系统调用阻塞,干脆统一标成RUNNABLE。

所以看到快照里大量RUNNABLE,先别急着下结论说“CPU 已经被打满”。去看看线程栈停在哪里,如果栈顶是java.net.SocketInputStream.socketRead0或FileDispatcher.read0这种 Native 方法,真实情况很可能是线程在等待外部数据。判定 CPU 是否密集,最终要结合top的 CPU 数字,而不是只看状态名。

5.3 BLOCKED 不等于盲目等待锁

前面提过,BLOCKED在 Java 语义里只针对synchronized监视器锁。ReentrantLock、Semaphore、CountDownLatch这些并发工具类,内部用的是 AQS,等待锁时线程进入的是WAITING或TIMED_WAITING,栈上能看到LockSupport.park。

这意味着你用 jstack 统计BLOCKED线程数量来评估锁竞争,会漏掉大量 AQS 锁的等待线程。正确的做法是看线程栈上是否有AbstractQueuedSynchronizer相关的park调用,很多排查工具在jstack输出里用java.lang.Thread.State: WAITING (parking)来表示这种状态。

5.4 start() 只能调用一次,stop() 尽量别碰

一个线程对象调用两次start()会抛IllegalThreadStateException。原因是线程生命周期有严格方向性,NEW一旦离开就无法回退。如果业务上确实需要重新执行同一条“任务逻辑”,就创建新的线程对象,不要把Thread实例复用到另一个任务上。

Thread.stop()则是另一个坑。它虽然能强制杀死线程,但不会给你任何机会释放锁、清理资源,可能导致共享对象停留在不一致状态,随后引爆其他线程。现在官方已经标记为过期方法,强烈不建议使用。想让线程停下来,正确做法是协作式中断:设置一个标志位,或者调用interrupt(),让线程自己检查状态并退出。

5.5 线程池里的状态怎么对应六状态

线程池里的线程本质也是普通线程,六状态完全适用,但你不会直接从线程池里拿到Thread对象来查状态。你看到的是线程池的快照,里面展示的是池级别的指标:核心线程数、活跃线程数、队列积压数、拒绝任务数。

实操中常见的状态现象有两类。一类是线程池里所有线程长期停在WAITING,这看起来是“没人干活”,实际上是线程池在等待任务入队,属于正常闲置;另一类是线程池线程长期停在RUNNABLE或BLOCKED,这时要怀疑任务里有死循环或者锁竞争。判断线程池是否“假死”,不能只看活跃线程数,要结合任务队列和状态分布一起看。

6. 我的一点实操体会

这几年的经验让我有个习惯:写并发代码之前,先在白纸上把这几个线程的状态转换画一遍,标出每个等待的进入条件和唤醒条件。画不清楚,就说明还没想明白。这个步骤看起来老土,但确实帮我躲过了很多死锁和信号丢失的坑。

排查线上问题时,我的第一动作永远是抓三份间隔几秒的线程快照。状态如果固着不动,基本就是死锁或者 wait 没人通知;状态如果来回跳,反而是相对健康的表现。再配合一次top -Hp看线程 CPU,整个过程十分钟内就能定位到具体线程和大致原因。

最后分享一个小技巧:给每个业务线程设置有意义的名字,别用pool-1-thread-1这种默认名。线程快照里名字就是定位确认的第一个线索。再给线程统一注册UncaughtExceptionHandler,线程异常退出时会留下第一现场信息。这两件事加起来成本极低,但能让你在故障时少花一个小时。

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

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

立即咨询