接手过一次凌晨两点的告警之后,你就知道“进程和线程”这两个词远没有课本里写得那么轻巧。那是一个Java服务CPU飙到300%的夜晚,我ssh上去先ps看进程,再top -H盯线程,最后jstack一把梭,才发现是一个线程池的队列无界堆积把系统拖垮了。整个过程里,你脑子里如果没有一张“进程是什么、线程是什么、它们怎么协作、怎么被杀”的清晰地图,排障就完全是在碰运气。
这篇内容不是教材第14章的复读,而是把进程与线程拆成实际工作中你一定会遇到的那些问题:线程池的submit和execute为什么不能乱用,阻塞队列怎么选,kill -9为什么有杀不死的进程,进程间通信到底该用管道还是共享内存,以及Linux、Windows、JVM三套环境里怎么快速定位。无论你是刚学完操作系统的学生,还是工作几年但一直靠搜索引擎救火的开发,这篇都值得你花半小时坐下来好好过一遍。
1. 从“第14章”说起:为什么学了进程与线程,上线还是出问题
1.1 教科书里的定义和现实场景的脱节
大学教材翻到进程与线程那一章,核心内容大概是这么几句:进程是资源分配的最小单位,线程是CPU调度的最小单位;进程拥有独立的地址空间,线程共享进程的地址空间;进程切换开销大,线程切换开销小。背下来考试没问题,但一旦你真正进入线上环境,会发现这套理论根本没告诉你:线程池的队列满了会发生什么、进程为什么杀不掉、两个进程怎么高效地传数据。
理论与现实的脱节在于,教材讲的是“概念模型”,线上遇到的都是“边界情况”。比如“进程拥有独立地址空间”这句话,在教科书里是一张虚拟内存布局图,但到了容器环境里,你会发现top看到的进程数、CPU占用率都可能是宿主机的视角,和普通虚拟机完全不同。再比如“线程共享进程地址空间”这句话,直接导致了线程安全问题的根源——共享是福也是祸,一份变量被多个线程同时改,数据就乱了。
所以这篇文章的定位,不是替你重新讲一遍操作系统的课程,而是把“第14章”里那些术语,翻译成你在命令行里敲过的命令、在IDE里报过的错、在监控面板里盯过的曲线。
1.2 用一次实测理解进程和线程的关系
我习惯用一个小实验来解释两者的关系:写一个最简单的C程序,分别创建4个进程和4个线程,看系统里发生了什么。
创建进程的方式是fork,创建线程用pthread_create。fork之后,子进程会获得父进程的一份完整拷贝(实际是写时复制),它有自己独立的PID、独立的地址空间、独立的文件描述符表。而pthread_create创建的新线程,和主线程在同一个进程里,共享同一个PID、同一份堆内存、同一组打开的文件,每个线程只有自己的栈和寄存器上下文。
从开销上看,创建线程比创建进程轻量得多——进程创建要复制页表(虽然现代系统用写时复制优化),线程创建只需要分配栈空间和线程控制块。这种差异在批量创建的场景下会非常明显。但反过来,进程的隔离性远胜线程:进程A崩了,进程B毫发无损;线程A崩了,整个进程可能一起死。
你可以用这样一组命令来观察:
bash复制# 查看进程和线程的关系
ps -eLf | head -20
# 输出里同一PID的行就是同一个进程里的多个线程
# LWP列(轻量级进程)就是线程ID
这就是为什么Chrome浏览器要设计成多进程架构:每个标签页一个进程,就算某个页面崩溃或吃满内存,也不会拖垮整个浏览器。而一个多线程的渲染引擎,虽然省资源,但一个线程的致命错误可能让所有页面一起白屏。性能与稳定性的取舍,从进程和线程的底层差异就已经注定了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 进程的本质与生命周期,以及“杀不死”背后的真相
2.1 PCB、地址空间、文件描述符,进程到底装了什么
把进程理解成一个“容器”是最贴切的。容器里装的是程序运行所需的一切资源:代码段、数据段、堆、栈、打开的文件列表、环境变量、信号处理器,以及最重要的一个维护者——进程控制块(PCB)。
PCB是操作系统管理进程的核心数据结构,在Linux里对应task_struct结构体。它记录了这个进程的PID、状态(运行、睡眠、停止、僵尸)、上下文切换时需要保存的寄存器值、内存管理的相关指针、打开的文件描述符表、信号处理函数、资源使用统计等。可以说,PCB就是进程的“身份证加账本”。
为什么理解PCB很重要?因为很多运维操作的本质就是操作PCB。比如kill命令发送信号,信号会记录到目标进程的PCB里,进程在合适的时机处理。如果进程处于某种内核态不可中断的状态,信号就根本递达不了——这就是后文要说的“杀不死”的原因之一。
文件描述符表也值得多说一句。每个进程默认有0(标准输入)、1(标准输出)、2(标准错误)三个文件描述符。当你在终端里启动一个程序,其实是从shell继承了一套文件描述符。这也是为什么nohup命令能让你退出终端后程序继续跑——nohup的本质是把标准输入重定向到/dev/null,把标准输出和标准错误重定向到文件,并让进程忽略SIGHUP信号。
2.2 前台、后台、守护进程,以及ssh断开后进程的归宿
在Linux里,进程可以分成前台进程、后台进程和守护进程。前台进程直接占着终端,你一关终端它就收到SIGHUP挂断信号,默认动作是终止。后台进程用&启动,虽然不占终端,但终端关闭时同样可能收到SIGHUP。真正的“打不死的小强”是守护进程,它通过setsid脱离会话、脱离控制终端,成了“孤儿”后被init(PID 1)收养,从此独立于终端而存在。
这个机制直接解释了一个经典问题:客户端与Linux系统断开连接后,之前执行的进程还继续运行吗?
答案分两种情况。如果你直接在ssh会话里执行./start.sh,断开连接时进程会收到SIGHUP,大概率被终止。如果你用nohup ./start.sh &启动,进程就会忽略SIGHUP继续运行。如果你用的是tmux或screen,相当于在服务器上开了一个持久化会话,进程挂在会话里,断不断ssh都不影响。
画个几分钟实测一下:
bash复制# 用一个耗时的任务做测试
nohup bash -c 'echo start; sleep 60; echo end' > /tmp/test.log 2>&1 &
# 断开ssh重连后
cat /tmp/test.log
结果是日志里“end”正常出现,说明进程在ssh断开后存活了。这个知识点在部署脚本、定时任务、远程执行场景里几乎天天用到,但很多人直到被“进程神秘消失”坑过一次才真正记住。
2.3 kill -9杀不死的进程:D状态、Z状态、内核线程
“kill -9杀不死进程”是运维群里反复出现的话题,也是最容易误判的问题。先把结论放在前面:kill -9不是万能钥匙,至少有三种情况它杀不死进程。
第一种,D状态(不可中断睡眠)。进程正在内核态等待某种IO完成,比如NFS网络文件系统挂载点卡住、磁盘IO长时间无响应。这个状态下进程不响应任何信号,包括SIGKILL,只能等内核IO操作返回,或者重启机器。你可以用ps aux查看进程状态列,如果显示D就是这种情况。
第二种,Z状态(僵尸进程)。进程已经退出,但父进程没有调用wait/waitpid回收它的PCB,所以它在进程表里残留为一个“僵尸”。僵尸进程已经不再占用内存和CPU,但你既kill不掉它(它不是活着的),也没法让它“死得更透”——唯一的办法是让父进程结束或处理掉父进程,让init进程收养并回收。如果你的系统僵尸进程特别多,说明某个父进程在长期运行且没有正确回收子进程,这本身就是代码缺陷。
第三种,内核线程。注意看ps输出里那些带中括号的进程,比如[kswapd0]、[kworker]、[kthreadd],它们是内核创建的线程,运行在内核态,负责内存回收、内核工作队列等任务。普通用户无权结束它们,你用root执行kill -9虽然不会报错,但它们根本不会退出。
所以遇到“杀不死的进程”,正确的排查姿势是:
bash复制# 查看进程状态
ps -p PID -o pid,state,stat,comm
# D=不可中断睡眠,Z=僵尸
# 如果是D状态,检查是不是nfs挂载、io wait
# 如果是Z状态,找到父进程
ps -o ppid= -p PID
还有一类情况不是“杀不死”,而是“杀错对象”。在容器环境里,你在宿主机上看到了一个进程,但它是其他容器里的,PID命名空间隔离让你kill了也没反应或者kill错了。这个坑在Kubernetes排查里特别常见,先认准进程的namespace和cgroup归属再动手。
3. 线程池:每一个细节都可能搞崩你的服务
3.1 线程池的本质与“先排队还是先扩容”的执行顺序
线程池是Java并发编程里最核心的工具,也是网上问题最多的区域之一。它的本质是复用线程,削峰填谷,让任务在队列里排队,避免无限制创建线程导致系统资源耗尽。
但线程池的执行顺序,很多人理解是反的。正确的是:核心线程先干活,核心线程满了以后,任务不是去创建新线程,而是先进阻塞队列;只有当队列也满了,才会创建非核心线程;如果线程数已经达到最大值、队列也满了,再来的任务就触发拒绝策略。
这个顺序极其关键,因为它决定了你配置的queue容量对系统行为的影响。比如你用了一个无界队列LinkedBlockingQueue(默认Integer.MAX_VALUE),那么核心线程之外的线程永远不会创建,最大线程数参数形同虚设,任务会无限堆积在队列里,最终可能OOM。而且这时候线程数永远等于核心线程数,你的机器CPU可能根本没满,但内存已经被队列堆满了。
写一段代码验证一下执行顺序:
java复制ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // 核心线程数
4, // 最大线程数
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2), // 有界队列,容量2
new ThreadPoolExecutor.CallerRunsPolicy()
);
for (int i = 0; i < 6; i++) {
final int taskId = i;
pool.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
});
}
观察输出你会发现,前2个任务由核心线程执行,第3、4个任务进了队列,等到队列满了,第5个任务才触发非核心线程创建。如果你把队列容量改成Integer.MAX_VALUE,那么永远看不到非核心线程的创建。
3.2 阻塞队列怎么选:五种队列的适用场景
线程池的性能和稳定性,一半取决于阻塞队列选型。我把常见的几种队列的适用场景整理成一张表:
| 队列类型 | 是否有界 | 特性 | 适用场景 |
|---|---|---|---|
| ArrayBlockingQueue | 有界 | 数组实现,必须指定容量 | 任务确定、需要控制内存的场景 |
| LinkedBlockingQueue | 可选 | 链表实现,默认无界 | 任务平缓、数量可控的场景,但无界有OOM风险 |
| SynchronousQueue | 无容量 | 不存储任务,直接交给线程 | 内部处理极快、希望任务不积压的场景 |
| PriorityBlockingQueue | 无界 | 支持优先级排序 | 有优先级需求的任务队列 |
| DelayedWorkQueue | 无界 | 延迟执行,ScheduledThreadPoolExecutor内置 | 定时、延迟任务 |
实际项目中我见过的翻车现场,绝大多数是LinkedBlockingQueue无界队列造成的。任务一多,队列膨胀,系统内存被吃光,然后整台机器开始频繁FullGC,最后OOM崩溃。如果你对业务量没有绝对把握,建议用有界队列ArrayBlockingQueue,明确设置容量上限,配合一个合理的拒绝策略,把风险控制在前端。
SynchronousQueue容易误解,它不是一个“容量为0的队列”,而是“不排队、直接交接”。任何一个任务提交过来,都必须有一个空闲线程立即接手,否则提交线程就会阻塞。所以SynchronousQueue通常和很大的最大线程数配合使用,适合任务耗时极短、且对延迟敏感的场景。
3.3 线程池的submit和execute:异常去向决定成败
这是另一个高频问题点。execute(Runnable)和submit(Runnable/Callable)都能往线程池里交任务,但它们对异常的处理方式完全不同。
execute方法提交的任务,如果run方法里抛了异常,异常会被线程池内部捕获并交给UncaughtExceptionHandler。默认情况下,异常直接打印到控制台而后被吞掉,主线程完全感知不到。如果你的任务是往数据库里写数据失败了、调用外部接口失败了,用execute提交,除非你在任务内部自己处理,否则错误就是无声的。线上服务“看起来一切正常,但数据没写进去”,很多时候就是这么来的。
submit方法返回一个Future对象,任务内的异常会封装成ExecutionException,你只需要future.get()就能拿到并处理。差别看起来不大,但对线上稳定性来说天壤之别。用execute,任务挂了无感知;用submit + get,至少能在日志里留下完整的失败链路。
java复制// execute方式,异常可能静默丢失
executor.execute(() -> {
throw new RuntimeException("这个异常可能看不见");
});
// submit方式,可以捕获到异常
Future<?> future = executor.submit(() -> {
throw new RuntimeException("这个异常可以被捕获");
});
try {
future.get(5, TimeUnit.SECONDS);
} catch (Exception e) {
System.out.println("捕获到任务异常: " + e.getCause());
}
我的建议是,核心任务、关键链路都用submit + get(一定要带超时),把异常的感知权握在自己手里。如果不想每个任务都写try/catch,也可以重写线程池的afterExecute方法,统一捕获Runnable的异常。
3.4 核心线程数、最大线程数怎么配,以及动态变更的尺度
线程池参数到底怎么配,网上公式一堆,最实用的判断逻辑是:计算密集型任务,核心线程数设CPU核数+1左右;IO密集型任务,可以放宽到CPU核数 * (1 + 等待时间/计算时间)。因为IO密集型任务的大量时间在等网络、等磁盘,线程数的上限可以更高。
但记住,这两个参数不是配完就永久不变的。ThreadPoolExecutor提供了setCorePoolSize和setMaximumPoolSize方法,可以动态调整。配合监控数据——比如当前任务积压量、线程活跃度、队列使用率——你可以在业务高峰期调大核心线程数,闲时调小,在保持吞吐的同时避免资源浪费。
另外一个容易踩坑的点是Java 19之后虚拟线程(Virtual Threads)带来的变化。虚拟线程在大量短生命周期IO任务上几乎碾压传统平台线程,共享同一个载体线程。但要注意,虚拟线程也不是万能药,遇到CPU密集计算、synchronized块内部有阻塞时,可能反而因为调度开销让性能下降。在你的项目引入虚拟线程之前,先在压测环境用真实业务流量跑一轮,别直接上生产。
4. 线程安全与死锁:代码里最常见的两类噩梦
4.1 竞态条件的本质:原子性、可见性、有序性
说线程安全,绕不开并发编程的“三大特性”:原子性、可见性、有序性。理解了这三个词,基本就能看懂所有线程安全问题的根源。
原子性指的是操作不可分割。比如i++,看起来是一行代码,实际是“读取i的值、计算i+1、写回i”三步。两个线程同时执行i++,可能发生线程A读到1、线程B也读到1,然后各自写回2,最后i的值只有2而不是3。这就是经典的竞态条件。解决办法是加锁,或者用AtomicInteger这类带CAS(比较并交换)操作的原子类。
可见性指的是多核CPU下缓存不一致的问题。线程A修改了一个变量,但修改可能还留在CPU缓存里没刷回主内存,线程B在其他核心上读到的是旧值。volatile关键字就是解决这个问题的:它保证变量的修改对其它线程立即可见。但volatile不保证原子性,所以“volatile int i; i++”依然是线程不安全的。
有序性指的是编译器、CPU可能为了优化而重新排列指令顺序。在单线程里,重排不影响结果,但在多线程下,重排可能导致另一个线程看到“奇怪”的顺序。加锁或者volatile都能禁止重排。
实际排查的时候,你不需要把这三个特性背得滚瓜烂熟,但看到并发问题,要能判断出是哪一类:数据错乱了大概率原子性问题,一直读到旧值大概率可见性问题,初始化等诡异时序大概率有序性问题。排查工具的用法是一样的,jstack抓线程快照,看是否有多个线程同时卡在同一段代码上。
4.2 synchronized、Lock、volatile怎么选
Java里解决线程安全,最常用的是synchronized、ReentrantLock、volatile三件套。选型逻辑其实很直接。
synchronized是内置锁,简单、自动释放、可重入,适合绝大多数同步场景。缺点是等待不可中断、不支持超时、读写锁需要额外手段。
ReentrantLock是java.util.concurrent.locks包里的显式锁,提供lock()、unlock()手动控制,支持tryLock(timeout)、lockInterruptibly(可中断等待)、多个Condition条件变量。它的灵活性强,但要记得在finally里unlock,否则锁泄漏会让线程卡死。适合需要超时控制、或无法用synchronized表达复杂等待条件的场景。
volatile只解决可见性和有序性,不解决原子性,最典型的应用是状态标志位。比如一个线程控制另一个线程停止运行:
java复制volatile boolean running = true;
// 线程A
while (running) {
// do work
}
// 线程B
running = false;
如果running不加volatile,线程A可能永远看不到线程B的修改。加了volatile,保证修改立刻对线程A可见,循环正常退出。
如果你拿不准用什么,按这个逻辑去选:简单同步用synchronized;需要超时、可中断、多个条件判断用Lock;只是多个线程读写一个标志/开关状态用volatile;计数器累加用AtomicXxx。
4.3 死锁的四个必要条件,以及一次真实的死锁排查
死锁是并发程序里最让人头疼的问题。形成死锁必须同时满足四个条件:互斥(资源一次只能被一个线程占用)、持有并等待(线程持有着一个资源,同时在等别的资源)、不可剥夺(资源不能被强制拿走)、循环等待(线程们形成一个等待环)。四个条件缺一不可,所以打破其中任何一个,就能解除死锁。
实际业务里最常见的死锁原因就是锁顺序不一致。比如两个账户互相转账:A转B,先锁A再锁B;B转A,先锁B再锁A。并发情况下,线程1锁了A在等B,线程2锁了B在等A,就此卡死。
有一次我排查线上死锁,业务反馈“某几个接口偶尔卡住几十秒然后超时”。第一反应是抓线程栈:jstack
解决方式也不复杂,把锁的顺序统一成“先锁ID小的账户”就打破了循环等待。另外一个更稳的办法是用tryLock带超时,拿不到锁就放弃或重试,不会无限等待:
java复制boolean lock1 = lockA.tryLock(10, TimeUnit.SECONDS);
boolean lock2 = lockB.tryLock(10, TimeUnit.SECONDS);
if (lock1 && lock2) {
// 转账逻辑
} else {
// 回滚,稍后重试
}
4.4 死锁的常见误判:CPU飙高其实不一定是死锁
说一个容易误判的点:很多同学以为死锁会让CPU飙高,其实恰恰相反。死锁是线程全部阻塞等待,CPU使用率反而可能很低。CPU飙高通常是死循环、频繁GC、或者大量线程在做大量计算。
判断是不是死锁,最直接的证据是线程状态。死锁线程的堆栈里一定是BLOCKED状态,等待的monitor是别的线程持有的。而CPU飙高时,你用top -H -p PID抓到占用最高的线程PID,转成十六进制,到jstack里搜nid,看到线程状态通常是RUNNABLE。
整个排查链路是这样的:
bash复制# 1. 看进程CPU
top -p PID
# 2. 看进程内哪个线程最费CPU
top -H -p PID
# 3. 把线程PID转十六进制
printf "%x\n" 12345
# 4. 抓线程栈
jstack PID > stack.log
# 5. 搜十六进制nid,定位线程
grep -A 30 "nid=0x3039" stack.log
这套方法也是面试官最爱考的线上问题排查思路,只要实际演练过一次,就能真正体会到进程、线程、线程栈是三层东西,而不是一个抽象概念。
5. 进程间通信(IPC):选型逻辑与应用场景
5.1 为什么进程间通信比线程通信难得多
多线程之间通信简单,因为共享一份内存,写一个变量另一个线程就看到了。但进程之间地址空间隔离,一个进程的变量修改,对另一个进程完全不可见。你要跨进程传数据,就必须让数据经过内核,或者借助操作系统的共享机制。这就是IPC(Inter-Process Communication)的由来。
这个“难”是有意义的——隔离就是为了安全。进程崩溃、越界、写坏内存,不会影响别的进程。代价就是通信开销大,每一句话都要“绕道”内核传递。所以选IPC方案,本质上是在“效率”和“隔离/安全”之间做权衡。
5.2 管道、消息队列、共享内存、信号量,以及Socket这个万金油
Linux下IPC的主要手段有管道、消息队列、共享内存、信号量、信号和Socket。
管道是最古老的IPC方式,常见的ps aux | grep java就是匿名管道。它的特点是单向传输、父子进程或兄弟进程间使用,数据字节流式读取。命名管道(FIFO)解决了无关进程间通信的问题,用mkfifo创建后,任何进程都能读写。
消息队列比管道更结构化,消息有类型、有优先级,可以多个进程读写同一队列。System V消息队列接口是msgget/msgsnd/msgrcv,POSIX消息队列是mq_open。适合传递中小体量、有类型的结构化消息。
共享内存是速度最快的IPC,因为它直接映射一块物理内存到多个进程的地址空间,进程读写这块内存就像读写本地内存一样,无需经过内核拷贝。代价是自己要做同步——通常配合信号量使用,保证同一时间只有一个进程在写。
信号量(Semaphore)本身不是用来传数据的,它是用来做同步和互斥的。共享内存加信号量是历史上高并发服务常用的组合拳。
信号(Signal)是最简单的IPC,kill命令就是发信号。但它只适合发送控制信息,比如SIGTERM让进程退出、SIGUSR1触发配置重载,不适合传大数据。
Socket是“万金油”,既支持本机进程通信(unix domain socket),也支持跨机器的网络通信。微服务架构、数据库连接、消息队列,底层全是Socket。本机场景下,unix domain socket比TCP loopback效率更高,因为它不经过完整的网络协议栈。
5.3 IPC选型一张表,以及你真正该记住的决策路径
| 通信方式 | 数据大小 | 传输速度 | 是否需要同步 | 典型场景 |
|---|---|---|---|---|
| 管道 | 小 | 中 | 阻塞式读写 | 命令管道、父子进程简单通信 |
| 消息队列 | 小到中 | 中 | 系统自带 | 结构化消息、低频数据 |
| 共享内存 | 大 | 最快 | 需要信号量 | 高性能数据交换、图像帧处理 |
| 信号量 | 不传数据 | - | 本身就是同步工具 | 多进程互斥访问共享资源 |
| 信号 | 极小 | 快 | 无需 | 进程控制、通知 |
| Socket | 任意 | 中到高 | 可阻塞可非阻塞 | 网络通信、本机高可用通信 |
选型时真正的决策路径很直接:跨机器通信,没得选,Socket;本机且追求极限性能,考虑共享内存+信号量;简单可靠、数据量不大,管道或消息队列;只是想通知某个进程“重新加载配置”或“退出”,用信号。
行业里现在很多框架的IPC选型也遵循同样的逻辑。比如Redis Cluster的节点通信用的是TCP Socket,因为它要跨机器;Nginx多进程worker之间用共享内存和信号量来维护全局状态;Docker容器和宿主机之间的通信,则大量依赖socket文件来实现高效的本地通信。
6. 进程与线程的实战排查工具箱
6.1 Linux下的一整套命令流:ps、top、jstack怎么配合
进程和线程的排查,最终都会落到命令行上。下面是我实测过无数遍、最顺手的一套命令流。
查看进程整体情况:
bash复制# 全格式显示进程
ps -ef
# 按CPU排序查看,找到最吃CPU的进程
ps aux --sort=-%cpu | head -10
# 按内存排序
ps aux --sort=-%mem | head -10
确认目标进程后,查看它内部的线程:
bash复制# 实时监控进程状态
top -p PID
# 查看进程内所有线程的CPU占用
top -H -p PID
# 一锤子输出线程列表
ps -T -p PID
# 以树状结构查看线程关系
pstree -p PID
# 查看进程的线程数上限与当前线程数
cat /proc/PID/status | grep Threads
这套命令流配合起来,能回答绝大部分“哪个线程在捣乱”的问题。另外,/proc/PID目录是一个宝藏,task/目录下每个子目录就是一个线程,fd/目录能看到这个进程打开了哪些文件,environ/能看到启动它的环境变量。
6.2 Windows下的进程管理:tasklist、taskkill和PowerShell
Windows环境虽然没有Linux的/proc那么直观,但也有完整的一套工具。对应ps和kill,是tasklist和taskkill。
bat复制:: 列出所有进程
tasklist
:: 按映像名称筛选
tasklist /FI "IMAGENAME eq java.exe"
:: 查看进程关联的服务或窗口标题
tasklist /V
:: 强制结束进程
taskkill /F /PID 1234
:: 按进程名结束,注意是任务名,不是PID
taskkill /F /IM java.exe
PowerShell的体验更好一些:
powershell复制# 查看进程
Get-Process
# 按CPU占用排序
Get-Process | Sort-Object CPU -Descending | Select-Object -First 10
# 结束进程
Stop-Process -Id 1234 -Force
# 查看进程的所有线程(含线程ID和CPU)
Get-Process -Id 1234 | Select-Object -ExpandProperty Threads
有一个常见的Windows问题:任务管理器进程列表空白。这个大概率不是什么大事,通常是explorer.exe出了问题。试着在任务管理器里“运行新任务”,输入explorer.exe重启一下资源管理器就好了。如果还不行,检查一下是不是被安全软件或者注入钩子干扰,用干净启动模式(msconfig)排查。
6.3 JVM专属:jps、jstack、arthas的配合与那些“获取不到”的坑
Java进程的排查,光靠ps和top不够,因为Java进程内部的线程、堆内存、GC全在JVM的管辖范围内,你需要JVM自带的工具。
jps是最基础的命令,列出本机所有Java进程的PID和主类名。但jps有一个容易踩的坑:在容器环境里,或者当JVM以某些特殊方式启动时,jps可能看不到目标进程。典型报错是“jps 增量注解进程已禁用。部分重新编译的编译结果可能不准确”,这是IDE的增量编译进程,不是异常,不用管它。真正需要担心的是jps列不出你部署的Java服务——这通常在容器里遇到,因为jps依赖/tmp下的hsperfdata目录,而容器可能清理了它,或宿主机的临时目录与容器不一致。解决方法是直接用ps -ef或pgrep -f java找出PID,然后jstack直接指定。
jstack
bash复制java -jar arthas-boot.jar 12345
arthas的thread -n 3命令能直接打印CPU占用最高的三个线程,dashboard命令能一眼看到JVM内存、GC、线程、类加载的全貌。它在线上排查中的价值比jstack更好用,特别是它能在线打开方法耗时、甚至反编译类,而不需要重启服务。
另外关于热搜词提到的“java线程安全问题”,除了代码层面的synchronized、Lock之外,还有一个排查利器是jcmd或者arthas的watch命令。你可以实时监控一个方法的入参和返回值,看并发调用下数据是不是错乱了。这比闷头读代码快得多。
6.4 VS Code的“终端进程已终止,退出代码: -1”到底是什么
最后说一个非常高频、但很多新手一头雾水的报错:VS Code里运行程序,终端突然显示“终端进程已终止,退出代码: -1”。
这不是VS Code本身的bug,而是你启动的进程非正常退出了。退出代码-1在Windows语义下通常表示进程被强制终止,或者程序崩溃导致进程被系统回收。常见原因有三类。
第一类是程序段错误或非法内存访问(segmentation fault),尤其多见于C/C++程序。第二类是程序主动调用了pthread_exit或进程内的线程abort导致整个进程退出。第三类是程序触发OOM被系统kill——当你启动的Java或Node进程耗尽了内存,Windows会直接终止那个进程,退出代码反映出来就是-1。
排查方法其实就三步:先看终端输出的程序日志,有没有堆栈或错误信息;然后加日志看程序执行到哪一步才退出;如果是启动即退出,检查是否缺依赖库、环境变量、端口被占用。JVM项目的话,再加上-XX:+HeapDumpOnOutOfMemoryError参数,等OutOfMemory时dump出堆文件用MAT分析,基本上都能定位到根因。
多提一句,VS Code里另一个常见退出代码是0(正常退出)和1(通常表示运行时异常),-1和它们的区别在于,0和1是程序自己返回的,而-1更多是外部因素(比如系统或父进程主动kill)导致的非正常终止。理解到这个层面,你就不会被这个红彤彤的报错吓住了。
最后再分享一个我自己反复踩过的坑
进程和线程的知识点,你单看每一个都能看懂,但在实际环境中它们永远交织在一起。就拿线程池OOM来说,表象是内存溢出,根因是队列无界导致任务堆积,中间还牵扯到最大线程数配置、拒绝策略、任务提交方式(submit还是execute),以及监控日志是否覆盖到位。这一串链条,任何一个环节没想清楚,都可能在凌晨三点让你焦头烂额。
如果只让我总结一句经验的话:在上线前花一个小时,把你的业务进程画一张“进程-线程-队列-通信”的拓扑图,标清楚哪里共享、哪里排队、哪里加锁、哪里跨进程,就足以避开线上大多数并发问题。 纸上画得越清楚,线上睡得越安稳。
