线程和进程这两个词,面试题里常青树,搜索引擎里常客,实战代码里更是天天见。我入行那几年,被“进程和线程的区别”问过不下十次,自己也背过八股答案,可真到线上排查问题,才发现教科书那几句“进程是资源分配的最小单位,线程是CPU调度的最小单位”根本不够用。后来陆陆续续做过好几轮高并发服务改造、进程守护脚本、线程池调优,把概念踩成经验,才渐渐理解这两个东西到底是什么关系,为什么说线程是进程的“子集”,为什么共享内存的线程要小心死锁,为什么虚拟线程能革了传统线程模型的命。
这篇文章我不打算给你报教科书式定义,也不整“三次握手、四层模型”那种大而全,就围绕“线程和进程的区别和联系”这条主线,把我在项目里实际遇到过的场景、排查过的坑、调整过的参数,全部摊开来讲。既有基础示意,也有实战代码,还有踩坑记录。适合刚接触并发编程的同学建立框架,也适合已经写过几年代码,但被线程池、死锁、IPC 折腾得想翻桌的朋友直接拿去对照。
1. 先建立心智模型:进程是房子,线程是住在屋里的人
1.1 为什么必须把“资源”和“执行”分开理解
讲进程和线程的区别,网上最常见的解释叫“进程是资源分配的最小单位,线程是CPU调度的最小单位”。这句话严格来说没问题,但太干,干到初学者背完就忘。我习惯用一个带生活气的类比:进程是一套房子,线程是住在房子里干活的人。
房子有产权证、有面积、有水电煤独立计量,这些对应进程的资源——地址空间、文件描述符、堆栈、全局变量。每个进程从操作系统拿到的这套“房产”是独立隔离的,进程A的地址空间,进程B默认碰不到,这保证了系统里一个程序崩溃不会把另一个程序的地基也炸了。
线程则是房子里的人。人可以共用客厅、厨房、厕所这些公共区域,也就是同一个进程地址空间里的代码段、数据段、堆。但每个人自己也有独立的“私人物品”——线程栈、寄存器状态、程序计数器。这就是线程上下文,是线程能独立被调度执行的前提。一个人干活干到一半要被换下去,操作系统只需要把这个人身上的随身物品保存好,把另一个人的随身物品装回来,这就是上下文切换。
这套模型的好处是,你能直观解释为什么线程创建比进程快、线程切换比进程切换轻、线程之间通信方便但容易互相踩脚。房子之间打电话要绕物业(IPC),屋子里的人喊一嗓子就行(共享内存),但喊话如果没约定好,容易被隔壁房间的人接错话头,这就是数据竞争。
1.2 地址空间与执行流:一句话区分进程和线程
我再给一个更严谨的版本,适合你写进笔记里:进程是操作系统分配资源(内存、文件、信号量等)的容器,线程是进程内部可独立调度执行的执行流。进程是静态的容器,线程是容器里面真正跑起来的执行流。
写代码的时候这种感觉最明显。你启动一个 Java 程序,操作系统先创建一个进程,从这个进程的 main 主线程开始执行。在 Java 里手动 new Thread(),其实是在创建进程内部的新执行流,而不是新进程。用 jps 看 Java 进程,一个应用就是一个进程 id,哪怕里面开了 200 个线程,也仍然是一个进程。
Linux 下可以用 ps -eLf 看线程视图,你会发现线程的 PID 和主进程 PID 是一样的,但 LWP(轻量级进程)编号不同。这个 LWP 概念就很有意思:Linux 内核里压根没有完全独立的线程数据结构,它把线程实现为“共享地址空间的进程”,叫轻量级进程。所以从内核角度看,你创建 10 个线程,相当于创建了 10 个共享同一套地址空间的“特殊进程”。这也就是为什么很多教材说线程是“轻量级进程”。但我们在用户态应用层编程时,千万不要真把线程当进程处理,否则你会搞混资源边界。
1.3 没有进程的线程,和没有线程的进程
把关系再往深捋一层:线程必须依附于某个进程存在。进程是线程的宿主,线程是进程的执行体。一个进程至少有一个线程,通常叫主线程。主线程如果退出了,整个进程一般也就结束了,因为进程的执行流没了。
反过来,进程可以没有线程吗?严格说,Linux 下进程创建后必须有主线程,因为 exec 之后总要有一条指令开始执行。但有一种“僵尸进程”状态,进程已经结束执行,只留下 PCB 等资源描述信息等父进程回收,这种状态下的进程,从执行流角度已经“没线程了”,但资源占位还在。我当年排查过 Node 服务里的僵尸子进程,ps -ef能看到一堆 defunct,CPU 占用 0,内存只占一点点,却堵住了 shell 脚本的进程回收逻辑,就是因为父进程没有正确 wait 子进程。这恰好是“进程和线程联系”里最容易被忽略的一环:进程的创建与回收,不只是 fork/exec/exit 的问题,还涉及父进程承担回收义务。后面讲守护进程时还会再提到。
2. 资源和调度:为什么线程切换开销小,却容易惹麻烦
2.1 上下文切换的成本对比
进程切换时,操作系统要切换虚拟内存地址空间,这涉及页表切换,TLB(Translation Lookaside Buffer)缓存基本全部失效,下次访问内存要重新走页表翻译。代价非常昂贵。线程切换发生在同一进程内,地址空间没变,TLB 大部分能保留,要切换的只是寄存器、栈指针、程序计数器等线程上下文,开销小一个数量级。这就是为什么高并发场景默认用多线程,而不是多进程。
但记住,我说的是“默认”。极端情况下,如果线程只做 CPU 密集计算且频繁争抢锁,线程切换带来的 cache miss 和内核态进入成本叠加,仍可能让多线程不如单线程。Go 的 GOMAXPROCS 在部分纯计算场景调到 1 反而更快,就是这个道理。所以线程“轻量”,不等于“无脑用就更快”。
2.2 独立还是共享:一张表看清差异
我整理了一份常用对照表,覆盖我平时面试别人时最常考的几个维度:
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 资源拥有 | 独立地址空间、独立文件表、独立信号处理器 | 共享进程地址空间、共享文件表、共享信号处理器 |
| 调度单位 | 进程是资源分配单位,但线程才是被调度的执行体 | 内核或用户态调度器直接调度线程 |
| 创建开销 | fork/exec 开销大,涉及资源复制或写时复制 | pthread_create 开销小,只需分配栈和线程描述符 |
| 切换开销 | 高,页表切换、TLB 失效 | 低,寄存器级上下文切换 |
| 通信方式 | 需管道、Socket、共享内存、消息队列等 IPC | 直接读写共享变量/内存,但需同步机制 |
| 隔离性 | 强,一个进程崩溃不影响其他进程 | 弱,一个线程非法访问会让整个进程崩溃 |
| 生命周期 | 独立生命周期 | 随进程存在,进程没了线程自然消失 |
这张表背后对应着工程选型逻辑:要隔离、要安全,上进程;要并发、要灵活,上线程。后面第 6 章会结合实例展开。
2.3 共享内存的代价:原子性、可见性、有序性
线程之间共享地址空间,带来便利也带来三座大山——原子性、可见性、有序性。原子性指一个操作要么全部发生要么全部不发生,比如 i++ 在 CPU 层面是读改写三步,线程 A 读 i=10,线程 B 同时读 i=10,各自加 1,写回,结果可能是 11 而不是 12。可见性指一个线程修改了共享变量,另一个线程可能因为 CPU 缓存还没来得及看到最新值,这就是 Java 里为什么要加 volatile 的原因之一。有序性指编译器重排、CPU 乱序执行可能让代码实际执行顺序和源码不一致。
把这三座大山对应到进程模型就会发现,进程间共享内存很麻烦,反而天然隔离,不会遇到这种问题。但线程模型为了性能把地址空间共享了,就必须引入锁、原子类、内存屏障这些机制来“补漏”。这就是为什么说进程和线程的联系,不仅是“线程寄生在进程里”,更是“共享资源需要在并发访问时付出额外同步成本”。成本划不划算,要看业务场景。IO 密集任务,线程等待 IO 时可以被切走,用异步或虚拟线程更合适;CPU 密集且数据互相依赖的任务,锁竞争导致线程频繁阻塞,反而可能不如拆成多个无共享数据的进程各自算。
3. 实战一:线程池配置、阻塞队列与线程中断踩坑记录
3.1 ThreadPoolExecutor 的七个参数,我劝你不要背,要算
Java 里最常用的线程池就是 ThreadPoolExecutor,构造函数七个参数:核心线程数 corePoolSize、最大线程数 maximumPoolSize、空闲存活时间 keepAliveTime、时间单位 unit、阻塞队列 workQueue、线程工厂 threadFactory、拒绝策略 handler。
我见过很多人直接new ThreadPoolExecutor(10, 20, 10, TimeUnit.SECONDS, new LinkedBlockingQueue<>())一把梭,核心线程 10,最大 20,无界队列。看起来简单,实际隐藏问题:无界队列意味着当核心线程全忙,后续任务全部堆积在队列里,最大线程数永远不会超过核心线程数,等于 maximumPoolSize 没生效。极端情况下队列堆积几十万任务,内存爆掉。
正确做法是先评估任务类型。如果是 IO 密集任务,比如 HTTP 调用、数据库查询,线程大部分时间在阻塞等待,核心线程数可以设得较高,经验公式大约是 CPU 核数乘以 2。如果是 CPU 密集计算,线程数最好接近 CPU 核数加 1,避免过线程切换浪费 CPU。我这里说的“经验公式”不是银弹,只是让你有个起步值,最终要压测。我当时做订单状态同步服务,8 核机器,IO 密集,先用 16 核心线程起步,压测后调到 24,整体吞吐提升明显,但继续往 32 加就开始下降,因为锁竞争和调度开销盖过了并发收益。
参数配置背后要理解线程池的工作流程:提交任务时,如果当前线程数小于核心线程数,创建新线程执行;否则先放入阻塞队列;队列满了,才尝试创建新线程直到最大线程数;再满,触发拒绝策略。所以你在配置时,核心线程和最大线程之间的差值,是给突发流量预留的“缓冲余量”;阻塞队列的长度,则是让任务“排队”的容量。合理设置是:队列长度根据该任务最长阻塞时间和平均请求速率估算,大约等于速率乘以可接受的最大排队延迟。拒绝策略默认 AbortPolicy 直接抛异常,生产环境我更喜欢 CallerRunsPolicy,超出部分回退给提交任务的线程执行,至少能抑制任务整体积压。
3.2 内置阻塞队列怎么选
ThreadPoolExecutor 常用队列有四种:LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue、PriorityBlockingQueue。前两个好理解,一个有界性可配置,一个固定有界;SynchronousQueue 这个名字有点迷惑,它其实不存储任何元素,每个 put 必须等待一个 take,相当于线程池内部只做“直接交接”,无任务缓存。
我踩过一个坑:某服务用 SynchronousQueue,maximumPoolSize 设了 200,结果任务陡增时直接创建了两百个线程,线程数飙到 200 只是表象,CPU 上下文切换率暴涨,接口 RT 直接翻了 10 倍。后来我换成了带容量的 LinkedBlockingQueue,比如容量 500,maximumPoolSize 设 50,流量波峰时任务先排队,宁可响应延迟增高,也不能让系统瞬间被线程淹没。线程池的意义从来不是“无限并发”,而是“限流削峰”。
还有个常见问题:队列要不要有界、有界容量多大,取决于你是否允许任务丢失。业务中查找“某用户待处理事件”的任务如果丢了会影响最终一致性,就用有界队列配合 CallerRunsPolicy,宁可提交者慢一点,也不让任务直接丢弃。日志上报类任务则相反,可允许丢弃,用 DiscardOldestPolicy 比较合适,优先保住最新日志。
3.3 线程死锁:从“互相等人”到“让线程定时汇报”
死锁是线程和进程共享资源后最容易踩的坑之一。用一个小例子说明:
线程 A 持有了锁 1,等待锁 2;线程 B 持有了锁 2,等待锁 1。两个线程互相等待,谁也不放手,程序假死。如果只是这两个线程还好,jstack 能看到 Found one Java-level deadlock,但现实里死锁往往更隐蔽,往往是三层锁嵌套、循环等待。
治理方案我总结了四条。第一,加锁顺序全局一致,所有代码都按同一顺序获取多个锁,这是从源头避免循环等待。第二,使用 tryLock 带超时,拿不到锁就不死等,Java 里用 Lock 接口的 tryLock(3, TimeUnit.SECONDS),等不到就放弃并记录日志。第三,定期监控,线上用 ThreadMXBean 定时抓线程栈,检测 BLOCKED 状态线程数量和持锁情况,超过阈值就告警。第四,压测时故意制造并发高峰,让死锁提前暴露。
有个小技巧:我习惯给线程池里的线程设置有意义的名字,比如订单线程池、推送线程池。死锁时 jstack 出来一眼就能分清哪个业务点出了问题。如果全叫 pool-1-thread-1,排查起来像在黑暗里摸钥匙。
3.4 线程中断:为什么 InterruptedException 这么难缠
Java 里 Thread.interrupt() 不是“立刻杀死线程”,而是给线程设置一个中断标志位。线程如果正阻塞在 sleep/wait 上,会抛出 InterruptedException;如果正在执行 CPU 计算,不会中断,需要代码里自己判断 Thread.currentThread().isInterrupted()。
很多新手在 catch InterruptedException 之后直接吃掉异常,既不处理也不恢复中断标志,这样做导致的坑是:外层代码再检查中断标志时发现没被标记,中断逻辑失效。我自己的规范是:如果业务代码不准备响应中断,至少在 catch 里调用 Thread.currentThread().interrupt() 恢复标志位;如果是要真正退出线程,则清理资源后 return。线程池里执行任务时也同理,如果任务被强制 shutdownNow(),正在等待的线程会收到中断信号。这时候应该让任务尽快释放锁、关闭文件、返回结果,而不是继续闷头跑完。
4. 实战二:IPC 与守护进程的现场复盘
4.1 进程间通信,到底用哪种
进程间通信的常见方式有管道、FIFO、System V 消息队列、共享内存、信号量、Socket、信号。选择依据一句话:若只是父进程往子进程传数据,管道就够;若多个进程频繁交换结构化数据,共享内存加信号量性能最高;若要跨机器通信,Socket 天然合适。
我最常用的是共享内存配信号量。共享内存速度快,各大消息中间件底层都在用,但任务要自己处理互斥。有一次排查线上两个采集进程偶发读脏数据的问题,就是用 System V 信号量做互斥,读端等待写端释放后进行内存复制,解决了并发读写错乱。如果项目里不用 C/C++,Java 里做 IPC 一般走 Socket 或文件。Java 本身不直接暴露 SysV IPC,要用 JNI 或框架。实际项目里我更多用 Unix Domain Socket,性能比 TCP loopback 高,配置也简单。
4.2 守护进程与会话:为什么 nohup 不是万能的
Linux 守护进程的概念,核心是脱离终端会话。普通进程如果终端退出,会收到 SIGHUP 信号,默认动作是终止进程。nohup 的作用是忽略 SIGHUP,但如果你在 bash 里执行nohup python app.py > app.log 2>&1 &,这个进程仍然可能是会话的首进程,终端退出后 shell 会向会话中的所有进程发送 SIGHUP,虽然 nohup 帮你忽略了,但有些依赖终端的程序还是可能出问题。
真正规范的守护进程要调用 setsid() 创建新会话,使进程完全脱离控制终端,再把工作目录切换到根目录,重定向标准输入输出到 /dev/null 或日志文件。这个逻辑被 toolsets/supervisord/systemd 封装过了。现在生产环境我几乎不用 nohup,直接用 systemd 的 Unit 管理进程,至少日志清理、开机自启、崩溃恢复都省心。不过有一次客户环境没有 systemd,我只好写了个 C 语言小程序封装 daemon() 再调用业务进程,效果也一样。
4.3 进程名修改:prctl 与 /proc 的实战
Linux 默认进程名来自可执行文件路径或 argv[0]。遇到需要区分多个同名进程时,修改进程名很实用。C 里用 prctl(PR_SET_NAME, "worker_1"),Python 里可以使用 setproctitle 库。Java 里只能通过修改 sun.java.command 属性,而且没那么直接。我写过一个小工具,调用 prctl 改进程名,同时通过 /proc/PID/comm 查看改名是否成功。
有个细节:Linux 进程名限制为 15 个字符,超过会被截断。最近几个朋友搜索"linux 修改进程名称 大于15个字符",问怎么绕过。改的是 comm 文件,内核限制就摆在那一层。如果你需要更长描述性名称,建议写到 /proc/PID/cmdline 里,即修改 argv[0] 或者稍微 hack 一下环境变量,很多监控系统优先读 cmdline。我当年给批量任务进程取名,格式固定为worker_orders_2024_0712,16 个字符,comm 显示只有worker_orders_2,排查时发现多个同名被截断进程没法区分,后来统一改成wo_20240712,短而可识别,问题解决。
4.4 进程等待 wait:僵尸进程的悲剧根源
关于“进程等待 wait”,我讲一个真实案例。脚本里通过 subprocess 启动几个子进程,如果父进程没有 wait,子进程结束后就会变僵为僵尸。僵尸进程不占内存,但占用 PID,当 PID 耗尽时系统可能无法创建新进程。
在 C 里,fork 子进程后必须调用 wait/waitpid 回收;在 Python 里,subprocess.Popen 对象如果长期不调用 communicate() 或 wait(),子进程也可能残留。另一个常见做法是采用 SIGCHLD 处理器自动回收子进程,在 handler 里循环 waitpid(-1, &status, WNOHANG),这样父进程就不用一直阻塞等待。shell 脚本里也可以用 wait 命令等待多个后台任务,适合批处理场景。
5. 热点补课:虚拟线程、自由线程和“线程安全”的本质
5.1 虚拟线程到底动摇了什么
Java 21 正式带出了虚拟线程,很多朋友搜索“虚拟线程原理”一头雾水。简单理解:传统线程是一一映射到操作系统线程的,数量一多,OS 线程创建成本和切换成本就高。虚拟线程是 JVM 层面模拟出来的“用户态线程”,也叫协程或纤程。它把上万甚至几十万个轻量任务塞到少数几个 OS 线程(载体线程)上执行,遇到 IO 阻塞时,虚拟线程会被自动让出载体线程,去跑别的虚拟任务,阻塞恢复后再回来。等于线程模型里套了一层调度系统。
为什么以前 Java 做高并发常规要靠线程池?因为 OS 线程贵。而虚拟线程的语义希望让每个任务都有独立线程,不用再为了省线程而费尽心思封装异步回调。虚拟线程的阻塞不再高昂,因为它会释放底层载体线程。所以在典型 IO 密集型 Web 服务中,虚拟线程能让吞吐量提升明显。不过虚拟线程不能无所不能:CPU 密集场景没有收益;加了 synchronized 等 native 代码时可能被载体线程“钉住”,这个问题 JDK 后续版本也在优化。
5.2 “自由线程”到底自由在哪里
“自由线程”这个词近几年在部分编程语言和框架里频繁出现。它指的不是无锁并行,而是“解释器和运行时允许同时执行多个线程”,不再被全局解释锁卡住。典型就是 Python 社区在讨论的自由线程版解释器。
以前 Python 的多线程受 GIL 限制,同一时刻只有一个线程执行 Python 字节码,所以 CPU 密集任务用多进程。自由线程版本的目标是移除或大幅降低 GIL 粒度,允许多个线程并行利用多核。工程上的影响是:现有依赖了大量线程安全假设的 C 扩展代码可能受到影响。很多库 API 的全局锁依赖 GIL 保护数据。所以自由线程不是简单撤掉锁,而是重新设计运行时数据结构的线程隔离机制。这个话题很新,如果你只是写写 Python 脚本,建议暂时观望;如果你是做数据分析框架的维护者,就得开始编译测试 Py 3.13t 等自由线程分支了。
5.3 AtomicInteger 线程安全吗?别把“线程安全”理解得太绝对
网上搜索“atomicinteger线程安全吗”的人,多半是在纠结并发计数递增。AtomicInteger 基于 CAS,在无锁情况下保证单个 getAndIncrement 操作是线程安全的。但注意,“单个操作安全”不等于“业务逻辑安全”。例如:
if (atomic.get() < 100) { atomic.incrementAndGet(); }这两个操作组合并不是原子的。并发请求可能同时读到 99,都进入判断,最终把计数推到 101。要修需要 CAS 自旋或者直接用 synchronized 包住整个逻辑。这就是我说的“不要神化原子类”。
把这个道理推广到进程层面也一样:进程间用共享内存通信,如果每个进程对信号的读改写不是原子的,也会出现类似错乱。所以无论线程还是进程,并发协作的核心都是找出“临界区”并保证“互斥”或“原子性”。这也是进程与线程殊途同归的地方——资源访问共享时,都要解决同步问题。
6. 选型与经验总结:到底什么时候用进程,什么时候用线程
6.1 选型口诀:隔离要进程,并发要线程
我把选型逻辑浓缩成一句口诀:别让线程承担“隔离”职责,别让进程承担“高频数据共享”职责。如果你的业务或模块崩溃会影响主应用,值得把它拆成独立进程,哪怕进程通信困难一些,也要做,因为故障隔离的收益大于通信成本。如果是同一业务的数据并行处理,频繁读写同一批内存对象,那就用线程加锁,而不是先序列化再用进程管道传数据,否则吞吐会垮。
举个我自己项目的例子:一个数据同步服务,主进程负责批量拉取数据,子进程负责清洗入库。子进程崩溃时不能拖垮主进程,所以用多进程模型,进程间通过消息队列传清洗后的批次。但清洗逻辑内部,同一个批次内的多个文件下载任务并发执行,用线程池。进程负责边界隔离,线程负责加速,各司其职。
6.2 踩坑清单:UI 线程、进程无法关闭、后台进程狂占资源
这个部分我当成“实战排雷手册”写给大家。
首先说安卓里 Fragment 开线程的经验。在 fragment 里直接 new Thread 操作耗时任务,如果数据库、网络回调接口没有 Activity 生命周期感知,很容易出现页面销毁后线程还在跑,回调里更新 UI 直接崩。规范做法是用生命周期感知的 ViewModel + 协程,或者 Handler+Looper 回到主线程操作 UI。如果是易语言或其他 GUI 环境,子线程不能直接操作主线程 UI 控件,必须通过消息队列把 UI 操作交给主线程执行。这背后就是线程模型的设计限制。
然后是被无语到极点的“wps进程无法关闭”问题。很多办公软件会常驻后台进程,任务管理器结束进程后又被文件关联唤醒。这种大多是因为安装了后台服务或升级器。你可以在计划任务和注册表里禁用自启动,也可以卸载后手动清理服务项。技术细节不深,但值得提醒一句:如果只是临时要完全退出软件,杀掉主进程后还得结束常驻托盘进程,这种进程名和主程序相似但不完全一样。
最后说说排查“后台进程 CPU 狂飙”。经验是先分清是哪个进程,用 top -Hp PID 看线程级 CPU,再配合 jstack 导出线程栈。比如之前 msmpeng.exe 占 CPU 极高,是微软杀毒在做扫描,我一般临时排除大目录;JVM 进程 CPU 高,多数是 GC 频繁或死循环线程,jstat -gcutil 看 GC 时间,如果 Young GC 每秒几十次,说明堆太小或对象分配太快,调堆大小不如修代码里的循环创建对象问题。
6.3 实操心得:main 线程等待所有任务完成的两种思路
搜索“java线程等待都完成”时,第一反应可能是 Thread.join(),第二可能是 CountDownLatch,第三是 CompletableFuture.allOf。三种我都用过,用场景区分:简单短任务,join 最直观;多个任务并行且要统一结果时,CountDownLatch 可以用,但每减一次 countDown 就要小心异常时是不是少减了,漏减会导致永久等待。
我踩过这个坑:某定时任务里循环提交 100 个任务,其中一个抛异常直接退出,countDown 没执行,主线程 await 永远停在原地,定时任务从此不再执行。修复方式很粗暴:finally 中确保 countDown,但代码丑。后来换成 CompletableFuture.allOf,配合 exceptionally 处理异常,代码简洁多了。这也算“进程等待 wait”思想的线程版:无论在进程还是线程层,都要保证“等待”路径不会被异常截断。
7. 写在最后的几个反直觉结论
学进程和线程,最难的不是记忆,而是接受一些反直觉的结论。
第一个反直觉:线程越多,系统不一定越快。线程是并发的最小单位,但每个线程的切换都有成本。压力上来时盲目扩线程,反而可能让吞吐降低。所以我常用“先压测再调参”的方式验证。
第二个反直觉:线程池参数不是固定值,而是动态值。流量模型变了,核心线程数也该变。我在生产环境做过动态调整,低峰期 8 个核心线程,高峰期动态扩展到 20 个,靠的是监控线程池 activeCount 和队列深度。遇到任务积压持续超过阈值就调大 maximumPoolSize,核心线程数一般不动,避免频繁收缩产生波动。
第三个反直觉:进程隔离和线程共享之间,没有绝对最优,只有场景适配。用 cgroup 控制进程带宽也好,用队列做线程限流也好,都是为了找到系统资源与业务延迟之间的平衡点。你如果问我一开始从哪里下手,我会说从画图开始:把业务拆成“哪几条执行流必须顺序执行”“哪几组可以并发”“哪些数据会被多个执行流访问”,画完这张图,进程还是线程、锁还是 CAS、队列还是信号量,心里基本就有数了。
我自己这些年用下来最深的体会是:面试时能背出概念只是第一步,真正让别人认可你懂,是你能说出“为什么线程切换比进程切换快”“为什么死锁要加锁顺序一致”“为什么线程池队列要用有界队列”。这些“为什么”都藏在资源模型、共享成本和边界隔离的关系里。把这层关系吃透,再去写代码,你自然会在并发编程和进程管理中游刃有余。
如果这篇文章能帮你少踩一个死锁的坑,少排查一次进程假死,我觉得就值了。最后再分享一个小技巧:给你的线程池、进程脚本都起有意义的名字,写进日志。排查问题时,别人在一堆 pool-1-thread 里抓头,你直接 grep 自己的业务线程名,几秒钟定位到问题。这个习惯虽然小,长期下来节省的时间相当可观。