Java线程状态转换与内存模型:从原理到jstack实操

发布时间:2026/9/24 22:24:20
Java线程状态转换与内存模型:从原理到jstack实操 很多Java开发者学到这里都会有一种“好像懂了但一深究就露馅”的感觉。线程状态转换和Java内存模型其实属于并发编程的“底层地基”面试必考排查线上问题也绕不开。但市面上讲这块的资料要么是枯燥的官方文档翻译腔要么是考试八股文里高度压缩的结论很难真正让人建立画面感。这篇文章我结合自己这两年的实操经验和踩坑记录把这两个硬骨头拆开揉碎讲清楚。不搞玄学不堆术语尽量用生活化的类比加上可运行的代码示例把“状态机怎么走”“内存模型到底在管什么事”这两件事说到位。如果你正在准备Java面试或者写并发代码时总感觉心里没底又或者看jstack日志时一脸茫然这篇文章就是给你准备的。1. 线程状态转换六种状态其实是一个“人的一生”先讲线程状态因为这是最直观、最容易建立画面感的部分。1.1 六种状态先记牢别把操作系统状态混进来Java里的线程状态定义在Thread.State这个枚举里总共只有六种状态名称含义触发典型动作NEW新建还没调用start()new Thread()RUNNABLE可运行等待CPU调度或正在运行start()之后BLOCKED阻塞等待获取监视器锁竞争synchronized失败WAITING无限期等待wait()/join()/park()TIMED_WAITING限时等待sleep(ms)/wait(timeout)TERMINATED终止正常结束或异常退出run()方法返回这里第一个容易踩的坑就是Java的RUNNABLE对应到操作系统层面其实是“就绪运行”两个子状态的合体。操作系统的线程状态有ready就绪、running运行、blocked阻塞等但JVM在设计时做了简化只要线程不阻塞在等待锁或等待通知上统一归为RUNNABLE。所以在jstack里看到大量RUNNABLE状态的线程并不代表它们都在疯狂消费CPU有的可能是在等网络IO、等磁盘IO这时CPU利用率并不高。1.2 核心转换路径一张图记住所有走向网上关于线程状态转换的图很多画得密密麻麻的。我自己梳理出一个“人的一生”版类比你品一下NEW相当于一个婴儿刚出生还躺在摇篮里。它知道自己将来要干嘛目标运行run()但还没真正开始行动。调用start()的那一刻就是婴儿“下地走路”。从此进入RUNNABLE在操场上排队等CPU这个“家长”点名。家长喊到谁谁就真正跑几步操作系统层面的running。如果跑的过程中想休息一下调sleep(ms)就进入TIMED_WAITING—— “我睡10分钟闹钟响了再回来排队”。如果遇到了需要别人配合的事情比如等另一个线程执行完join()或者等别人唤醒wait()会进入WAITING—— “我无限期等着直到有人叫我”。wait()有个带超时参数的版本那就是TIMED_WAITING限时等待。如果两个线程都想进同一个synchronized代码块锁被别人占着抢不到的线程进入BLOCKED—— “在门口蹲着等锁释放了立刻冲进去”。run()方法执行完毕或者抛出未捕获异常生命周期结束进入TERMINATED。关键转换路径我用文字给你画出来NEW --start()-- RUNNABLE --- RUNNABLE被调度/时间片用完 RUNNABLE --synchronized竞争失败-- BLOCKED BLOCKED --拿到锁-- RUNNABLE RUNNABLE --wait()/join()/park()-- WAITING WAITING --notify()/notifyAll()/线程终止-- RUNNABLE RUNNABLE --sleep(ms)/wait(timeout)/join(ms)/parkNanos()-- TIMED_WAITING TIMED_WAITING --时间到/被唤醒-- RUNNABLE RUNNABLE --run()执行完毕-- TERMINATED注意一个误区BLOCKED不能直接变WAITING反过来也一样。两个状态之间不能互转必须经过RUNNABLE。这就好比你在门口等锁BLOCKED不可能直接跳到“无限期等人通知”WAITING总得先回到排队等待CPU调度的状态再执行wait()才行。面试时会有人把这两个搞混其实区分标准很简单BLOCKED是在等一把“别人占着的锁”而WAITING是在等一个“别人主动发出的通知”。注意sleep()不会释放锁wait()会释放锁。这条在面试里几乎是必问的背下来不如想明白——sleep只是让自己睡一会儿手里攥着的东西当然不放wait的语义是“我暂时没条件继续干活先把资源让给别人”所以它必须释放锁否则其他线程进不来永远没人来唤醒它。1.3 实战验证用代码让线程游走各个状态光背概念没用我建议你亲手写一段代码让线程在六种状态之间切换然后打印出来验证。下面这段我经常在培训时用的示例public class ThreadStateDemo { public static void main(String[] args) throws Exception { // 状态1: NEW Thread t1 new Thread(() - { while (true) { // 空转让线程保持RUNNABLE } }); System.out.println(刚new完: t1.getState()); // 状态2: RUNNABLE t1.start(); System.out.println(start后: t1.getState()); // 状态3: BLOCKED等锁 Object lock new Object(); Thread t2 new Thread(() - { synchronized (lock) { try { Thread.sleep(10000); // 持有锁10秒 } catch (InterruptedException e) { e.printStackTrace(); } } }); t2.start(); Thread.sleep(200); // 确保t2拿到锁 Thread t3 new Thread(() - { synchronized (lock) { // 永远等不到锁 } }); t3.start(); Thread.sleep(200); System.out.println(t3等锁中: t3.getState()); // 状态4: WAITING Thread t4 new Thread(() - { synchronized (lock) { try { lock.wait(); // 无限期等待 } catch (InterruptedException e) { e.printStackTrace(); } } }); t4.start(); Thread.sleep(200); System.out.println(t4 wait中: t4.getState()); // 状态5: TIMED_WAITING Thread t5 new Thread(() - { try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } }); t5.start(); Thread.sleep(200); System.out.println(t5 sleep中: t5.getState()); // 状态6: TERMINATED Thread t6 new Thread(() - {}); t6.start(); Thread.sleep(200); System.out.println(t6结束: t6.getState()); System.exit(0); } }运行这段代码你会看到类似这样的输出刚new完: NEW start后: RUNNABLE t3等锁中: BLOCKED t4 wait中: WAITING t5 sleep中: TIMED_WAITING t6结束: TERMINATED这里有个小细节t2持有锁期间t3的状态一定是BLOCKED但t4的状态是WAITING而不是BLOCKED因为t4是拿到锁之后主动调用wait()进入的它已经过了“等锁”这一关现在是“等通知”状态。这两者的区别在定位线上问题看jstack时非常关键——如果大量线程BLOCKED说明锁竞争激烈如果大量线程WAITING说明可能在等某个公共资源或条件通知需要单独排查。1.4wait和sleep的纠葛五连问这块面试官特别爱连环追问我帮大家把最常见的五个问题整理成对比表对比项Thread.sleep(ms)Object.wait()谁的方法Thread的静态方法Object的成员方法是否释放锁不释放释放调用前提无必须持有该对象的监视器锁自动唤醒时间到了自动恢复必须靠notify()/notifyAll()或带超时参数使用场景模拟耗时、限速线程间协作、生产者消费者为什么wait()必须放在synchronized块里因为要保证“检查条件→进入等待→释放锁”这三步是一个不可分割的原子操作。如果不加锁可能会出现两个线程同时检查到条件不满足、同时wait()然后互相等死的情况。wait()的底层会把当前线程挂起并释放锁这个动作在JVM层面是原子的你不需要担心“释放锁”和“挂起”之间会插入其他操作。另外一个常见面试点是notify()和notifyAll()的区别notify()只随机唤醒一个等待线程notifyAll()唤醒所有等待线程。如果用notify()被唤醒的线程重新竞争锁但如果是多条件等待风险很大——比如两个线程分别等“库存充足”和“价格上涨”你只唤醒一个可能唤醒的是条件不满足的那个它就会继续wait()而真正该唤醒的没被唤醒造成线程“假死”。所以实践中推荐优先用notifyAll()让所有线程都起来重新判断条件这样才能避开信号丢失问题。2. 从硬件到JMMJava内存模型的本质是“统筹CPU和内存”聊完线程状态我们进入第二个硬骨头Java内存模型Java Memory ModelJMM。很多初学者一听到“内存模型”就觉得这是JVM内存区域划分堆、栈、方法区那些其实不是一回事。JMM关心的不是“数据放在哪”而是**“多线程读写共享变量时数据的可见性和有序性怎么保证”**。2.1 为什么会有JMMCPU缓存带来的“并行陷阱”为了理解JMM的由来我们先看看现代计算机的存储架构。CPU的运算速度比内存快好几个数量级如果每次读写都直接和内存打交道CPU就得干等着。所以CPU内部有几层高速缓存L1、L2、L3把频繁访问的数据先缓存起来。单核时代这没问题但多核时代问题来了两个CPU核心同时读了一个变量x 0到各自的缓存。核心1执行x 1只改了核心1的缓存还没同步回主内存。核心2读到的x还是0。这就是缓存一致性问题。为了解决它硬件层面引入了“缓存一致性协议”如MESI协议核心之间通过消息传递同步缓存状态。但即便如此协议只能保证“最终一致”不能保证你在任意读取瞬间拿到最新值。Java程序是跑在操作系统和JVM之上的跨平台是Java的立身之本。不同CPU架构的缓存模型、不同操作系统的线程调度方式都不一样如果Java直接依赖硬件特性那同样的代码在Windows上和Linux上行为不一致开发者的噩梦就来了。JMM就是一套抽象规范它屏蔽了底层硬件的差异统一规定“线程怎么访问共享变量、什么时候必须刷新到主内存、什么时候必须从主内存重新读取”。2.2 主内存和工作内存JMM的“双内存”抽象JMM规定主内存所有线程共享的存储区域可以类比成物理内存本身。所有共享变量实例字段、静态字段、数组元素都存储在主内存。工作内存每个线程私有的存储区域可以类比成CPU缓存和寄存器。线程对变量的读写都必须在工作内存中进行不能直接操作主内存。线程A修改一个共享变量的完整流程是先在工作内存中改再把改动刷新到主内存线程B读取时先到主内存拿到最新值再拷贝到自己的工作内存中操作。这个模型的“形象版本”就是主内存是公司群里的公告板工作内存是你手机里的缓存页面。你在自己手机上改了内容必须同步到公告板别人刷新后才能看到最新版如果你总是看自己本地的旧缓存那你永远不知道别人已经把公告板改过了。2.3 三大特性原子性、可见性、有序性JMM核心要解决的问题可以归纳为三大特性面试考点基本全在这原子性一个操作或者多个操作要么全部执行且不被任何因素打断要么全部不执行。经典的例子是i这行代码在字节码层面其实是三步读取 i 的值、计算 i1、写回 i。任何一步被其他线程打断都会产生脏数据。synchronized、Lock、AtomicInteger等工具真正解决的是原子性问题——保证临界区代码块要么不执行要么彻底执行完。可见性一个线程修改了共享变量其他线程能立即看到这个改动。volatile关键字解决的就是可见性。它通过内存屏障强制对 volatile 变量的写操作立即刷新到主内存读操作每次从主内存重新加载。有序性编译器、CPU为了优化性能可能会对指令重排序。在单线程环境下重排序不影响最终结果但在多线程下重排序可能导致诡异的结果。volatile和synchronized都能在一定程度上禁止重排序保证有序性基于后者还衍生出了happens-before原则。注意很多人以为volatile可以保证原子性这是天大的误解。volatile只能保证可见性和有序性不能替代锁。经典的volatile int count在多线程下做count照样会丢数据因为“读改写”三步不是原子的。2.4 重排序与as-if-serial到底“乱序”到什么程度重排序这个概念比较抽象我举个例子。看下面这段代码int a 1; // 语句1 int b 2; // 语句2 int c a b; // 语句3理论上CPU可以先执行语句3再执行语句1和2吗不行因为语句3依赖前面两个变量的值。但在不改变单线程程序执行结果的前提下CPU和编译器可以对没有依赖关系的语句乱排。例如语句1和语句2先执行哪个都行最终a1, b2不变。JMM的as-if-serial语义不管怎么重排序单线程程序的执行结果不能被改变。这也是编译器、处理器重排序的“底线”。但是这条底线只保护单线程不保护多线程。多线程环境下一个线程对变量的写操作在另一个线程看来可能是乱序的。经典的重排序问题就是“双检锁单例”public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); } } } return instance; } }这个写法早期是有严重问题的。instance new Singleton()在JVM里不是原子操作大概分三步分配内存、调用构造方法初始化、将引用指向该内存。编译器可能重排序成“先分配内存、先将引用指向内存、再调用构造方法”。此时另一个线程进来发现instance ! null直接返回了一个还没初始化完成的对象程序就炸了。解决办法就是给instance加上volatile禁止这三步重排序。现在面试如果让你写单例一定要用 volatile 双检锁或者直接上枚举单例干脆利落。2.5 happens-before原则JMM给程序员的“免死金牌”JMM设计者当然知道重排序的存在也知道程序员不可能在每个变量上都小心翼翼地加锁。所以它定义了一套happens-before先行发生原则如果两个操作满足happens-before关系那么前一个操作的结果对后一个操作可见且前一个操作的执行顺序排在后一个之前。哪怕它们被重排序了在逻辑上顺序不会变。常用的规则如下规则名称具体内容生活化类比程序次序规则一个线程内按代码顺序前面的操作 happens-before 后面的操作同一件事的步骤之间天然有序锁规则解锁操作 happens-before 后面对同一个锁的加锁操作前一个人从房间出来后一个人才能进去volatile变量规则对一个volatile变量的写 happens-before 后面对该变量的读广播更新了公告之后来看的人一定看到新内容传递性如果A happens-before BB happens-before C则A happens-before CA先于BB先于C则A必然先于C线程启动规则start()方法 happens-before 该线程的每一个动作运动员发令枪响选手才开始跑线程终止规则线程中的所有操作 happens-before 其他线程检测到该线程终止员工干完活老板才能验收中断规则对线程的interrupt()调用 happens-before 被中断线程检测到中断事件你喊“停”对方才能感知“停”对象终结规则对象初始化完成 happens-beforefinalize()方法开始先建好房子才能做验收销毁实际写代码时你不需要逐条背诵规则去推导每个场景。只要记住一句话能用锁就用锁能用volatile就用volatile别自己搞“聪明”的并发优化。happens-before这套理论更多是帮你在排查诡异问题时建立“这件事按理说应该能同步”的判断框架。3. 实操排查线上线程状态与JMM问题的分析思路理论终归要落地。这一节我分享几个在实际项目里遇到过的案例帮你看完能对应到自己踩过的坑上。3.1 案例一jstack定位死锁与线程饥饿有一次线上服务突然响应变慢我上去打了份jstack日志。看到两个线程互相持有对方需要的锁thread-1 - waiting to lock 0x00000000d5c5f908 (a java.lang.Object) - waiting on 0x00000000d5c5f9b8 (a java.lang.Object) thread-2 - waiting to lock 0x00000000d5c5f9b8 (a java.lang.Object) - waiting on 0x00000000d5c5f908 (a java.lang.Object)这就是教科书级别的死锁线程1拿着锁A想拿锁B线程2拿着锁B想拿锁A。两个线程都在BLOCKED状态等锁谁也等不到。用jstack看它们卡在waiting to lock那行非常明显。排查方法很简单先看是否有deadlock字样JVM自带的线程转储分析通常会在末尾提示“Found one Java-level deadlock”然后往下看锁的持有关系找出两个线程互相等待的锁。根因通常是代码里多把锁的获取顺序不一致比如线程1先锁A再锁B线程2先锁B再锁A。解决方式是把锁的获取顺序统一或者用tryLock加上超时。3.2 案例二wait()后没有notify导致的线程挂死还有一个经典场景生产者消费者的队列中消费者执行了queue.wait()等待数据结果生产者线程抛异常挂了notify()永远没发出来所有消费者线程一直卡在WAITING。表现就是系统吞吐量骤降但 CPU 利用率并不高。从这个案例你能学到两点wait()应该总是放进while循环里而不是用if。原因就是前面提到的线程被唤醒后需要重新检查条件是否满足否则可能因为“被唤醒但条件不满足”而继续执行错误逻辑。这就是经典的“虚假唤醒”spurious wakeup问题JDK官方文档里明确建议用while。notify()可能丢失信号。如果等待线程还没进入wait()生产者就先调用了notify()那这个通知就丢了。避免方式是把“判断条件”和“调用wait/notify”都放到同一个synchronized块里确保原子性。3.3 案例三volatile误用导致计数错误我见过一个同事为了性能把计数器的字段标记成了volatile然后多个线程直接执行count结果每次运行结果都不一样。他来找我排查我跟他说这不是volatile的锅是原子性的问题。volatile保证的是“读和写”的可见性但count是读改写三步中间任何线程插队都会丢更新。改成AtomicInteger后问题解决——它内部的 CAS 操作保证了“比较-交换”的原子性。如果读多写少还想保证绝对一致也可以用synchronized或LongAdder高并发计数场景效率更高。3.4 案例四内存可见性导致的死循环一个经典的可见性案例是static boolean flag true; public static void main(String[] args) throws Exception { new Thread(() - { while (flag) { // 空转 } }).start(); Thread.sleep(100); flag false; // 期望子线程退出循环 }在JMM规范下这个程序不保证会停下来。因为子线程可能在它的工作内存里一直缓存着flagtrue主线程修改后的值没有刷新进去。把flag加上volatile修饰后子线程每次循环都会读到主内存的新值程序才能正常退出。这正是volatile在“状态标志”场景下的最经典用法。4. 面试高频追问从线程状态到JMM的答题顺序很多同学面试时一紧张就乱被问到“线程状态有哪些”就开始背书。我建议你按照下面的思路回答既显得有体系又能带节奏先给全景Java线程有六种状态从NEW到最后TERMINATED中间通过start()、锁竞争、wait/notify、sleep等操作切换。再讲关键转换重点挑RUNNABLE → BLOCKED锁竞争、RUNNABLE → WAITINGwait/join、TIMED_WAITINGsleep这三条路径展开顺带提一下sleep和wait的区别。切换到JMM时用一句话过渡“刚才聊的是线程生命周期接下来我讲讲线程之间协作时JMM是如何保证共享变量不出错的。”然后从硬件缓存讲起一步步推出主内存/工作内存、三大特性、happens-before。面试官如果追问“volatile和synchronized的区别”你只需要记住表格里那三行核心volatile解决可见性和有序性synchronized解决原子性和可见性。再补一句“volatile不会阻塞线程synchronized会让线程进入BLOCKED状态”基本就能送分题满分。面试官如果追问“什么是内存屏障”可以从“volatile的写操作会在前面插入StoreStore屏障在后面插入StoreLoad屏障”这种层面简单回答并且强调“这是JVM在x86等常见平台的实际实现Java层面的规范是不保证具体屏障类型的只保证语义”。这表示你既懂规范又懂实现很加分。这里特别提醒如果你能画一下线程状态转换图尤其是RUNNABLE作为“中心枢纽”的那张图基本就赢了半个面试。不必画得多精美关键在于标注清楚“谁触发谁”以及BLOCKED和WAITING的进入和退出条件。5. 学习路径与实用工具把这块知识焊死在脑子里到这一步理论上你已经掌握了线程状态和JMM的核心知识。但要真正“焊死”在长期记忆里光读这篇文章不够我建议你走完接下来的实操路线。5.1 推荐三步走第一步手写一遍状态转换Demo。把1.3节里的代码跑起来手动改几处参数比如把sleep(10000)改成不同数值或者用notifyAll()替换notify()观察行为差异。每改一次打印一次状态建立真实的“代码→状态”反射。第二步用jstack分析自己写的并发Bug。故意写一个死锁程序然后用jstack抓线程转储找出“Found one Java-level deadlock”的提示对照自己的代码理解锁的持有和等待关系。这一步做完你再看线上日志时会更有底气。第三步阅读《Java并发编程的艺术》相关章节。这本书对JMM和happens-before讲得很系统和本文的类比理解搭配起来一个偏直观、一个偏严谨正好互补。看完之后你可以试试自己写个小总结看看能不能把“重排序”讲给身边人听——能讲明白才是真懂。5.2 排查问题常用命令速查工具/命令用途jstack pid查看线程状态和堆栈定位死锁、等待jconsole图形化监控线程状态、内存使用jvisualvm可视化线程分析自动检测死锁java -XX:PrintFlagsFinal查看JVM默认配置排查内存参数-Xlog:gc*打印GC日志配合内存模型分析对象分配5.3 准备面试的背诵锚点如果你想快速应付面试我建议脑子里只记这几句话面试时展开来说“Java线程状态 six核心是 RUNNABLE 做中枢其他状态都要通过它转换。”“BLOCKED是等锁WAITING是等通知TIMED_WAITING是限时等待。”“sleep不释放锁wait释放锁。”“JMM通过主内存/工作内存抽象解决可见性通过happens-before保证有序性通过synchronized/Lock保证原子性。”volatile是轻量级的同步机制只保证可见性和有序性不保证原子性。结尾几点个人体会文章最后说点我在实际开发中的体感。线程状态和JMM这两块知识不是背下来就能用的。你真正需要建立的是“模型感”——写并发代码时脑子里能浮现出“这个线程现在在哪条状态路径上”“这个变量从工作内存刷新到主内存要经过什么步骤”。有了模型感很多问题其实不用靠猜顺着状态转换路径和happens-before规则捋一遍基本就能定位到问题所在。有一个很实用的小技巧排查并发问题时先不看代码逻辑直接打jstack看线程卡在什么状态再反向推代码。比如大量BLOCKED就去找锁竞争点大量WAITING就去找wait/notify配对这个方法在线上救了我很多次。如果这篇文章能帮你把线程状态转换和JMM从“八股文”变成“有画面感的工具知识”那我觉得这两年的并发问题没白踩。后面你如果再深入可以去看看AbstractQueuedSynchronizerAQS的实现它把线程状态转换用在了更底层的同步器设计上理解了AQS你对这两块知识的理解又会拔高一截。