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 FutureTaskInteger 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 lockBLOCKED状态的完整含义是线程在等待进入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里表现为WAITINGparking。这是正常现象不代表系统出问题别看到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等lock2B持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 prio5 os_prio31 cpu8.9ms elapsed4.25s tid0x00007fabbc05b000 nid0x6403 waiting for monitor entrywaiting for monitor entry表示正在等锁对应BLOCKEDin 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不等于占用CPUTIMED_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对照看一遍一次下来印象就牢固了。这比刷一百道面试题都有用。