进程和线程这个话题,几乎所有后端开发都绕不开。面试要问,线上排查要用,调 JVM、写并发代码更要理解它们。很多人张嘴就能背出“进程是资源分配的最小单位,线程是 CPU 调度的最小单位”,但一旦落到实际场景,比如为什么 kill -9 杀不死某个进程,为什么线程池的核心线程数这么难定,为什么两个线程同时改一个变量数据就乱了,立马就懵。这篇文章我就从实际开发角度,把进程和线程的区别、联系讲透,顺便把这些年踩过的坑和排查思路一起带上。我不会上来就念教科书定义,而是用“公司”和“员工”这类类比,加上真实案例,把这些概念串起来。适合准备面试的人、刚接触多线程编程的初学者,以及被线上并发问题折磨过的同学。
1. 先别背定义:搞清楚进程和线程到底是个啥
1.1 进程:一个装满资源的“盒子”
进程是操作系统进行资源分配的最小单位。每个进程都有自己独立的地址空间、文件描述符、环境变量、信号处理器,还有独立的进程 ID。也就是说,一个进程从操作系统手里申请到的资源,都是这个进程私有的,别的进程不能直接访问。
用一个更生活化的类比来解释。进程就像一家独立的公司,有自己的注册资金(内存空间)、办公场地(地址空间)、规章制度(系统资源)。公司之间互相独立,一家公司倒闭了,正常情况下不会影响到另一家公司,最多是让周围的供应商亏点钱。进程也这样:一个进程崩溃了,操作系统负责回收它的资源,不会让其他进程跟着一起崩。
这种隔离性是好事,因为系统稳定性有保障。但代价也很明显:创建进程的开销大,进程间协作的成本也高。你不可能为了做一个简简单单的并发任务,就开几十个进程,那样光是资源分配和回收就够你喝一壶的。
1.2 线程:公司里的“员工流水线”
线程是 CPU 调度的最小单位,它的本质是进程内部的一条执行路径。一个进程至少有一个线程,也就是主线程。你可以把一个进程看作一个容器,线程就是在这个容器里跑的“执行体”。
继续用公司来类比。一家公司(进程)里有很多员工(线程),员工之间共享公司的会议室、打印机、数据资料(堆内存、全局变量、打开的文件),但每个员工有自己独立的工作台(栈空间、寄存器值),互不干扰。新招一个员工(创建线程)的流程很简单,只需要准备一张桌子就行了,不需要重新注册公司、租办公室。
所以线程的创建开销远小于进程,线程间的切换也比进程间切换快得多。但代价是,员工共享了会议室资源之后就会有冲突:两个人同时要用打印机,就会打起来。放到代码里就是线程安全问题。
1.3 一张表看明白进程和线程的区别
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 资源拥有 | 独立地址空间、堆、文件描述符 | 共享所属进程的地址空间、堆、全局变量 |
| 切换开销 | 大,需要切换页表、刷新 TLB | 小,只需保存和恢复线程上下文 |
| 通信方式 | 需要通过 IPC:管道、消息队列、共享内存、Socket 等 | 直接读写共享内存,但需要加锁同步 |
| 健壮性 | 进程之间互相隔离,一个崩溃不影响其他 | 一个线程崩溃可能导致整个进程退出 |
| 创建销毁开销 | 大,要分配大量私有资源 | 小,线程栈通常只有几 MB |
| 调度单位 | 进程不是 CPU 直接调度的单位 | 线程是 CPU 直接调度的单位 |
表格里有一个特别关键的区分:进程是资源分配的基本单位,线程是 CPU 调度的基本单位。这句话看起来简单,但很多人没真正理解。资源分配说的是“系统给谁分了多少内存、多少文件句柄”,CPU 调度说的是“CPU 这一纳秒到底跑谁的代码”。
1.4 它们的联系:谁也离不开谁
进程和线程从来不是对立关系,而是包含关系。线程必然属于某个进程,进程至少要有一个线程来执行代码。你启动一个 Java 程序,操作系统先创建一个进程,然后 JVM 再在这个进程里创建各种各样的线程,比如主线程、GC 线程、JIT 编译线程。
线程和进程的联系最直观的体现就是“共享”。线程能共享进程的代码段、数据段、堆、打开的文件等资源。这是线程的优势,同时也是线程安全问题的根源。你可以理解为:进程负责“圈地”,线程负责“在圈好的土地上干活”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 深度拆解:为什么切换开销差这么多,通信方式也不一样
2.1 进程切换 vs 线程切换:搬家 vs 换工位
进程切换的开销为什么大?因为要安全地隔离两个进程,操作系统必须把当前进程的“全套家当”保存下来,再加载下一个进程的“全套家当”。这包括程序计数器、所有通用寄存器、栈指针、浮点寄存器,还要更新页表基址寄存器(比如 x86 上的 CR3),随之而来的就是 TLB(快表)大量失效。TLB 一失效,后续每次内存访问都要重新查页表,等于到了一个陌生的新小区,每栋楼都得重新找一遍,性能损耗非常大。
线程切换就要轻松得多。同一进程内的线程共享地址空间,线程切换只需要切换栈指针、程序计数器、寄存器等执行上下文,页表不用换,TLB 大部分还是有效的。这就好比员工换了个工位继续干活,桌子和资料都在同一个办公室,你只需要拿着自己的本子换一张桌子就行。
但这里有个细节容易被忽略:多核 CPU 场景下,线程跨核调度也会造成缓存缺失。如果线程被调度到另一个 CPU 核心上,之前在这个核心 L1/L2 缓存里的热数据可能全部失效。这也是为什么一些高并发系统会做 CPU 亲和性绑定,让关键线程固定在某个核心上跑。
注意:线程切换开销小,是相对进程切换来说的。如果线程数量成千上万,切换频率极高,累加起来也很可观。所以线程并不是越多越好。
2.2 共享内存的代价:线程安全问题从哪来
线程共享堆内存和全局变量,这是它的优势,但也是一切并发 bug 的源头。我举一个最经典的例子:
java复制public class Counter {
private int count = 0;
public void add() {
count++;
}
}
两个线程同时执行 count++,表面上看是一行代码,但在 JVM 底层其实是三个步骤:读 count 到寄存器、寄存器加 1、把新值写回内存。两个线程“同时”执行时可能产生交错:都读到旧值 100,都加 1 得到 101,然后都写回 101,正确的 102 永远没有出现过。这就是竞态条件。
解决思路无非几条。第一是加锁,用 synchronized 或者 ReentrantLock 把读改写包成原子操作;第二是用原子类,比如 AtomicInteger,底层是 CAS 乐观锁;第三是 ThreadLocal,给每个线程一份变量副本,牺牲内存换隔离;第四是设计上就不可变,比如用 final 字段,发布之后不允许修改。
这里我需要纠正一个常见误区:volatile 并不能解决所有线程安全问题。它只保证可见性和有序性,但不保证原子性。count++ 不是原子操作,光加 volatile 照样出错。只有在你写一个状态标志位,比如 boolean running = true,且不依赖旧值做复合操作的时候,volatile 才够用。
Java 里哪些类天生线程安全?ConcurrentHashMap、CopyOnWriteArrayList、StringBuffer、Vector、Hashtable 这些都属于线程安全的集合类,但实现方式不一样。Vector 和 Hashtable 是粗暴地给方法加 synchronized,并发度很低;ConcurrentHashMap 是分段锁或 CAS,性能好很多。选型的时候别看着“线程安全”就直接用,得看并发场景。
2.3 进程通信(IPC):进程间为什么不能直接共享变量
因为进程的地址空间是隔离的,A 进程根本没有办法直接读写 B 进程的内存。想绕过这个隔离,就必须通过操作系统提供的中转机制,也就是 IPC(Inter-Process Communication)。
常见的 IPC 方式有六种:
- 管道(pipe):半双工通信,常用于父子进程之间,本质是内核里的一段缓冲区。
- 命名管道(FIFO):有名字,无亲缘关系的进程也能用它通信。
- 消息队列:内核维护的消息链表,按类型读取,适合小块数据。
- 共享内存:把同一块物理内存映射到多个进程的虚拟地址空间,读写效率最高。
- 信号量:用于同步而非数据传递,常配合共享内存使用。
- Socket:跨网络通信,也能用于本机跨进程通信,最通用但开销最大。
这些机制每一个都有各自的适用场景。比如 Redis 的 RDB 持久化、Nginx 的 worker 进程间通信,都会用到不同的 IPC 方式。但核心思想都是“借道内核”,因为只有内核才有权限在多个进程之间搬运数据。
有意思的是,线程通信其实也是走共享内存,但不需要内核中转。因为线程的地址空间本来就是同一个进程的,天然就能看到同一份数据。但也正因为少了内核这层“审查”,线程之间沟通必须自己加锁、自己保证同步。你可以理解为:进程通信像两个公司通过供应商(内核)传货,正规但慢;线程通信像同一家公司的两个员工直接递纸条,快但容易搞乱。
2.4 守护线程:那种“干完就走”的特殊线程
讲线程的时候,必须提一下守护线程,因为很多第一次接触的人分不清普通线程和守护线程的区别。Java 里线程分两类:用户线程和守护线程。守护线程是在后台提供通用服务的线程,比如 JVM 的 GC 线程、JIT 编译线程,还有你在项目里写的日志清理线程、心跳检测线程。
设置守护线程很简单:
java复制Thread daemonThread = new Thread(() -> {
while (true) {
// 做一些后台清理工作
System.out.println("守护线程执行中");
}
});
daemonThread.setDaemon(true);
daemonThread.start();
守护线程有一个非常关键的特性:当进程中所有用户线程都执行完毕后,JVM 会直接退出,不会等守护线程跑完。这就像公司所有正式员工都下班了,保洁阿姨也会跟着断电锁门走人,不会让阿姨一个人在公司磨磨蹭蹭。
这个特性是双刃剑。好处是主程序退出时不会被后台线程拦住,坏处是如果守护线程正在写数据,进程突然退出,数据可能丢。我见过有人在守护线程里做消息发送、状态上报和日志写入,然后程序退出,数据就无影无踪了。所以判断是否要设置成守护线程,核心标准是:这个线程的工作是否允许在进程退出时被强行中断。敏感操作不要放守护线程里,宁可让主线程等一等。
3. 工程实践:线程池的“三大件”和参数调优实战
3.1 为什么生产环境不裸用线程:线程池的由来
之前我见过一些新人写的代码,一碰到异步任务就 new Thread().start(),看起来简单直接,但在生产环境会出大问题。线程的创建和销毁是有成本的:创建要分配栈空间、注册到线程表,销毁要释放资源。如果任务是高频率提交的,频繁创建销毁线程,就会白白消耗大量 CPU。
更麻烦的是,裸线程的数量不可控。一个稍微有点用户量的系统,如果每个请求都 new Thread,线程数很快涨到几千。线程一多,CPU 光忙着切换上下文了,根本干不了正事,最后直接拖垮整个进程。
线程池就是为了解决这两个问题:一是复用线程,降低创建销毁开销;二是控制并发上限,防止线程无限膨胀。它的本质是“生产者-消费者模型”:生产者把任务丢进队列,线程池里的工作线程从队列里取任务执行。
Python 里还有进程池的概念,比如 multiprocessing.Pool。因为 Python 的 GIL 导致多线程无法真正并行利用多核,所以 CPU 密集型任务会考虑用进程池替代线程池。这也是进程和线程联系中一个典型取舍:处理器核多想并行,就得上多进程;IO 密集需要轻量并发,就多线程。
3.2 线程池核心参数与任务执行流程
Java 的 ThreadPoolExecutor 是理解线程池的最佳模板,参数如下:
| 参数名 | 作用 |
|---|---|
| corePoolSize | 核心线程数:即使空闲也会保留的线程数量 |
| maximumPoolSize | 最大线程数:线程池最多能创建的线程数量 |
| workQueue | 阻塞队列:存放待执行任务的队列 |
| threadFactory | 线程工厂:创建线程的工厂类 |
| handler | 拒绝策略:队列满且线程数达上限时怎么处理新任务 |
任务提交后的执行流程一定要记清楚,很多人栽在这里。当调用 execute() 提交一个任务时:
- 如果当前线程数小于 corePoolSize,直接新建线程执行任务,哪怕有线程空闲也优先新建。
- 如果当前线程数已大于等于 corePoolSize,就把任务放进 workQueue 阻塞队列。
- 如果 workQueue 已经满了,且当前线程数小于 maximumPoolSize,就新建非核心线程来执行任务。
- 如果 workQueue 满了,且线程数已经到 maximumPoolSize,就执行拒绝策略。
注意第 1 步很反直觉:线程池满了才用队列,也就是“先加人手,再排队”。所以核心线程数的意义是“保持活跃的底线”,最大线程数是“能加人的上限”,队列是“人手不够时的缓冲区”。
拒绝策略有四种。AbortPolicy 是默认策略,直接抛 RejectedExecutionException;CallerRunsPolicy 会在提交任务的线程里直接运行任务;DiscardPolicy 悄悄丢弃;DiscardOldestPolicy 丢掉等待最久的任务再加新任务。
3.3 线程池的 submit 和 execute 别混着用
Java 线程池提交任务有两种方式:execute(Runnable command) 和 submit(Runnable task) 或 submit(Callable<T> task)。
execute 返回值是 void,只负责把任务丢进线程池,任务里的异常会直接抛到调用线程;如果调用线程没有 catch,可能导致线程挂掉。submit 返回一个 Future,可以获取任务执行结果,如果任务内部抛了异常,Future.get() 会抛出 ExecutionException,这样你能明确感知任务失败。
一个很常见的坑:很多人觉得用 submit 更高级,就全员 submit,但如果不关心结果还从来不 get,异常会被吞掉。Java 官方文档里明确提示过,如果提交的任务通过 submit 执行,异常不会直接打印,而是存在 Future 里,你 get 才能拿到。所以不关心结果时用 execute 更直观,出现异常至少能在日志里看到;需要拿结果或者需要在主线程感知任务失败的,用 submit。
3.4 阻塞队列怎么选:ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue
线程池的 workQueue 选择非常关键。很多人图省事用 Executors.newFixedThreadPool,它的底层是 LinkedBlockingQueue,默认是无界的。无界队列意味着任务永远不会触发创建 maxPoolSize 线程的逻辑,也永远不会触发拒绝策略,因为队列永远装得下。任务堆积太多时,内存被吃满,系统直接 OOM,而这个锅往往要扣在“线程池配置不当”上面。
| 队列 | 是否有界 | 特点 | 适用场景 |
|---|---|---|---|
| ArrayBlockingQueue | 有界 | 底层数组,FIFO,生产和消费共用一把锁 | 适合需要严格限制等待任务数量的场景 |
| LinkedBlockingQueue | 默认无界 | 链式结构,生产和消费锁分离,吞吐量更高 | 适合任务量可控、不希望触发拒绝策略的场景 |
| SynchronousQueue | 不存任务 | 提交的任务直接交给线程,没有缓冲队列 | 适合要求任务立即执行、低延迟的场景 |
| PriorityBlockingQueue | 无界 | 按优先级出队,元素需实现 Comparable | 适合任务有优先级差异的场景 |
如果设置了有界队列,比如 ArrayBlockingQueue(100),当任务提交速度远大于处理速度时,队列会迅速满,然后触发创建更多线程。所以有界队列 + 合理 maxPoolSize 才是保证系统不被打爆的标配。
经验之谈:除非你非常清楚任务量和处理速度的关系,否则不要用无界队列。无界队列配上 maxPoolSize 就像“限高杆没装好,车都能过,但停车场迟早挤爆”。
3.5 核心线程数到底该设多少
这是被问得最多的问题。先说结论:没有万能公式,但有实用的估算方法,最终要以压测为准。
如果是 CPU 密集型任务,比如计算、压缩、加解密,线程数一般设为 CPU 核数 + 1。为什么加 1?因为线程偶尔会发生页缺失或调用阻塞 API,多一个线程可以填补 CPU 空闲的间隙,让 CPU 尽量满载。
如果是 IO 密集型任务,比如查询数据库、调远程接口、读写文件,线程 IO 等待时间占比很高,CPU 大部分时间是空闲的。这时候可以多开线程,一个粗粒度经验值是 CPU 核数 × 2。更精确一点可以用公式:
code复制线程数 = CPU 核数 × (1 + 线程等待时间 / 线程计算时间)
比如 CPU 核数是 8,线程计算时间是 20ms,等待 IO 的时间是 80ms,那么线程数 ≈ 8 × (1 + 80/20) = 40 个。
但这个公式只给你一个起点。真正上线前一定要做压测,观察 CPU 使用率、请求 RT、TPS、线程池队列长度。如果 CPU 没打满,队列却一直在积压,说明线程数不够或者核心线程数设置太低;如果 CPU 已经飘到 90% 以上,RT 还在增长,那就是线程开多了,上下文切换吃掉了性能。
Python 的多线程受 GIL 限制,纯 CPU 任务线程再多也没有用;Java 多线程能利用多核,但也不能无限扩。动手之前先看一眼 Runtime.getRuntime().availableProcessors(),别自己糊弄自己。
4. 排查实录:那些“杀不死”“卡死”“找不到”的进程线程坑
4.1 kill -9 为什么杀不死进程
很多新手遇到卡死的进程,第一反应就是 kill -9,但有时候会发现进程纹丝不动。这不是 kill 命令不行,而是进程处于特殊状态,根本不接收普通信号。
最常见的是 D 状态,也就是不可中断睡眠状态。这种状态通常发生在进程等待磁盘 IO、等待网络文件系统响应的时候。因为内核不能贸然打断 IO 操作,否则数据会损坏,所以进程只能“睡死”在那里,任何信号来了都不响应。遇到 D 状态的进程,kill -9 没用,只能等底层 IO 超时,或者干脆重启系统。
还有一个常见情况是僵尸进程 Z 状态。僵尸进程本身已经终止了,内核中只留下一个进程表项等父进程回收。kill -9 不能杀僵尸,因为“尸体”已经死了,你只能杀掉它的父进程,让 init 进程接管并回收它。用 ps aux 查看 STAT 列是 Z 的进程,基本上就是僵尸。
另外有一种进程在 Linux 里叫内核线程,比如 kswapd,负责内存回收。这类进程是内核创建的,不占用用户态资源,你 kill 它们不但杀不掉,还可能引发更严重的系统问题。
排查步骤:先用
ps -eo pid,stat,cmd | grep 卡住的进程名看状态,再决定策略。状态是 S/R 直接 kill,状态是 D 就等,状态是 Z 就找父进程。
4.2 终端进程启动失败:conpty 这个报错怎么回事
在 Windows 上写代码的人,偶尔会碰到一个很诡异的错误:“终端进程启动失败: 启动期间发生本机异常(无法启动 conpty)。已移除 winpty”。这其实是 VSCode 或者 Windows Terminal 在启动终端时的后端进程 conpty.exe 出了幺蛾子。
ConPTY 是 Windows 提供的伪终端机制,很多现代终端工具都依赖它。出现这个报错通常是因为 Windows 的终端服务组件异常,或者某个旧的 winpty 版本与当前系统冲突。处理方案一般围绕三个方向:
第一,打开任务管理器,把 Windows Terminal 以及 VSCode 的终端宿主进程全部结束,然后重新打开终端。第二,升级 Windows 系统补丁,很多 conpty 相关 bug 在 Windows 更新里修复了。第三,如果 VSCode 里仍然报错,可以修改 settings.json,把默认终端配置文件换成 cmd 或者 PowerShell,或者重置终端集成缓存。
最麻烦的情况是 conpty 被安全软件当成可疑进程拦截了,那就要在安全软件里加白名单。总的来说,这个报错不深奥,核心思路就是“先清理终端宿主进程,再逐步排查系统组件”。
4.3 Java 线程安全问题和线程死锁的排查步骤
Java 并发出问题的表现很多,最常见的就是数据被改乱、死锁、线程卡死。排查线程问题,第一件事是抓线程快照。
JDK 自带 jstack 命令,用法很简单:
bash复制jstack <PID> > thread_dump.txt
拿到 dump 文件后,重点搜 Found one Java-level deadlock,如果搜到了就说明死锁。死锁产生的四个必要条件,缺一不可:
- 互斥:资源被一个线程占用。
- 持有并等待:线程持有资源,同时还想申请别的资源。
- 不可剥夺:别的线程不能强行抢走资源。
- 循环等待:多个线程形成环形等待关系。
实际工程里死锁往往不容易一眼看出来,因为业务复杂、锁很多。用 jstack 能直接定位到具体的锁对象和线程栈,看到类似“Thread A 持有 monitor1 等待 monitor2,Thread B 持有 monitor2 等待 monitor1”的输出,就可以确定是循环等待问题。
如果你嫌 jstack 麻烦,不想频繁打印 dump,可以试试 Arthas。它是一个 Java 诊断工具,启动后能直接附着到运行中的 Java 进程上,用 thread 命令查看线程状态,用 thread -b 直接定位死锁和阻塞线程。
不过 Arthas 也有自己的坑。网上经常有人问“Arthas 启动时无法获取 jps 进程”,这大概率是 Java 版本太高或者 JDK 模块化限制了 attach 权限。解决办法是在启动命令里加上 -Djdk.attach.allowAttachSelf=true,或者用管理员权限启动 Arthas。
4.4 常用进程线程查看命令速查
排查问题的第一步永远是“看状态”,下面这些命令建议背下来。
Linux 下:
bash复制# 查看进程
ps -ef
ps aux
# 查看进程下的所有线程(Linux 线程本质上是轻量级进程)
ps -eLf | grep <进程名>
# 实时查看 CPU 和内存占用
top
# 查看某个进程下的线程实时状态
top -H -p <PID>
# 查看 CPU 核数
nproc
lscpu
Windows 下:
bash复制# 查看进程列表
tasklist
# 按进程名查 PID
tasklist | findstr java
# 强制结束进程
taskkill /F /PID <PID>
# 查看进程详细信息
wmic process where name="java.exe" get processid,commandline
有人会遇到“电脑任务管理器里没有开始进程”的情况,大概率是两个原因:一是 explorer.exe 没响应导致任务管理器 UI 刷新异常,可以重启 explorer;二是当前用户权限不够,某些系统进程或内核进程不会展示给普通用户。用管理员身份运行任务管理器一般能解决。
另外,Windows 里有个进程叫 rundll32.exe,经常被用户怀疑是病毒。它的正式身份是 Windows 用来加载 DLL 中导出函数的宿主进程,也就是“跑 DLL 用的进程”,系统本身会常驻多个。判断是否安全的关键看路径:正常的 rundll32 在 C:\Windows\System32\rundll32.exe,如果这个进程出现在 Temp、Users 或者奇怪目录下,那就需要警惕了。
4.5 C# 里中止线程的正确姿势
C# 老版本的 Thread.Abort() 和 Thread.Suspend() 已经明确过时了,微软官方都不推荐直接终止线程。原因是强行中止线程,线程可能正持有锁或者在写数据,直接掰断会留下一个不一致的状态,甚至造成死锁。
现代 C# 的做法是使用取消标记(CancellationToken)。主线程发起取消信号,工作线程在合适的时机响应并优雅退出:
csharp复制var cts = new CancellationTokenSource();
var task = Task.Run(() =>
{
while (true)
{
cts.Token.ThrowIfCancellationRequested();
// 执行业务逻辑
Thread.Sleep(100);
}
}, cts.Token);
// 需要停止时
cts.Cancel();
核心思想是“请求取消,而不是强制杀死”。这样工作线程可以处理完当前步骤、释放锁和其他资源,再安全退出。这也是进程线程管理里一条通用原则:强制终止永远是最后手段,优雅关闭才是第一选择。
5. 典型场景实战:压测、桌面开发与运维监控
5.1 Jmeter 模拟登录后 5 个线程并发查询接口
用 Jmeter 做接口压测,最容易踩的坑是“登录态不隔离”。比如线程组设了 5 个线程,但这 5 个线程如果共用同一个 token 去查接口,测出来的数据不具备代表性,因为服务端可能做过用户维度的缓存或限流。
正确做法分几步。第一步,加一个 HTTP 请求,模拟登录接口,拿到返回的身份令牌,用 JSON 提取器或者正则表达式提取器把它取出来。第二步,把 token 写进 CSV 文件,或者用 Jmeter 的 ${__setProperty()} 设置成全局变量。第三步,在线程组里配置 5 个线程,循环若干次,每个线程在发起查询请求时从 CSV 或变量中读取自己独立的 token,放到 HTTP Header Manager 的 Authorization 字段里。
另外,Jmeter 里线程组的“调度配置”默认是同时启动,这会导致瞬间打满服务器的连接池。如果你只想做轻度验证而不是压测,可以把 Ramp-Up Period 设为 5 秒,让 5 个线程在 5 秒内平滑启动,更贴近真实用户行为。
5.2 Qt 中将函数放到子线程里执行
Qt 桌面开发的并发有个大忌:不能在子线程里直接操作 GUI 控件。很多人第一次写多线程界面程序,在子线程里调用 ui->label->setText(),结果要么界面无响应,要么程序直接崩溃。
Qt 推荐两种方式。第一种是继承 QThread,重写 run() 函数,在 run 里执行耗时逻辑。这种方式简单直观,但要注意 QThread 对象本身最好留在主线程,只有 run 里的内容在子线程跑。第二种是更推荐的模式:创建一个普通 QObject 工作对象,调用 moveToThread 把它移动到子线程,然后通过信号槽触发任务执行,结果也通过信号传回主线程。
我自己用得比较多的是第二种。写一个 Worker 类,成员函数做耗时操作,执行完成后 emit 信号;主线程 connect 这个信号,在槽函数里更新界面。这样线程的启动、停止、资源管理都在掌控之内,而且避免了“在子线程操作 UI”的定时炸弹。
5.3 用守护线程和进程守护保障线上服务稳定
“进程守护”这个词,运维同学都不陌生。一个常见的线上需求是:监控某个前台进程,一旦它挂了就自动拉起。系统原生方案是 systemd,在 service 配置里加上:
ini复制[Service]
Restart=always
这样进程意外退出后,systemd 会立刻重启它。很多运维面板也内置了类似功能,原理差不多:一个守护进程每过几秒检查目标进程是否存在,不存在就用预设命令拉起。
而在 Java 应用内部,“守护线程”也可以做类似的事。比如启动一个低优先级的监控线程,定期把服务健康状况写入一个本地文件,一旦主线程挂了,监控线程也会跟着进程退出。这个过程不需要额外写停止逻辑,因为 JVM 自己会在所有用户线程退出后终止。
注意,守护线程适合做“可有可无”的辅助工作。如果你想让它真的保障主服务的稳定运行,还是有状态的进程守护方案更靠谱,因为进程级守护能管理资源、记录崩溃现场、设置自愈策略,线程级守护只能做内部状态兜底。
5.4 从“当前线程名”到特定领域的线程配置
排查并发问题时,最基础也最容易被忽视的操作是打印当前线程名。Java 里一行代码就够:
java复制Thread.currentThread().getName()
很多日志框架的 pattern 里也内置了 %thread 占位符,打印出来的日志自带线程名。这一招非常有用:线上日志混在一起时,你靠线程名能快速判断是哪些线程在跑任务、哪些线程阻塞了、哪些线程执行了异常分支。没有线程名的日志,排查并发问题就像摸黑走路。
特定领域的线程配置也很有意思。比如围棋 AI 引擎 Katago,支持设置搜索线程数(numSearchThreads)。围棋 AI 的搜索是典型的 CPU 密集型任务,线程数设置过高会导致频繁上下文切换,低配机器反而更慢。经验值是设为物理核心数或略低,让每个搜索线程尽量独占一个核心。这正好印证了前面那句话:线程不是越多越好,关键看让 CPU 怎么“干活”。
最后分享一点个人体会。我在实际排查中,明显感觉“进程和线程的区别”不是一道背完就扔的面试题,而是一整套定位问题的思考框架。遇到系统卡住,先判断问题是进程级的还是线程级的:进程级的问题看内存、文件句柄、进程状态;线程级的问题看线程栈、锁竞争、队列积压。选错了排查方向,可能在日志里翻半天也找不到根因。还有一个我踩过多次坑后总结出来的小技巧:所有线程池参数都先打印出来贴在项目文档里,线上问题复盘时,第一件事永远是对比“配置值”和“实际运行值”。很多并发问题根本不是代码逻辑错了,而是配置参数跟生产环境完全不匹配。先看数字,再调参数,这个顺序不能反。
