
1. 别再死背概念了先看进程和线程到底在抢什么我这些年面试过不少人也带过不少新人问起进程和线程的区别十有八九能背出那句话——进程是资源分配的最小单位线程是CPU调度的最小单位。这句话本身没错但它就像驾照考试里的移库口诀背得滚瓜烂熟真把车开到路上一样抓瞎。因为紧接着我再问两个问题大部分人就卡住了第一为什么两个线程同时对一个变量做加一操作会出乱子而两个进程各干各的就不会互相污染第二为什么一个进程崩溃了操作系统不会把它旁边的进程拖下水但一个线程抛异常却经常把整个进程直接带走这两个问题才是理解进程和线程的分水岭。答案归结起来就一句话进程之间是隔离的进程内的线程是共享的。网上有个流传很广的类比说进程是学校食堂线程是食堂里打饭的窗口。每个窗口都有一套自己的锅碗瓢盆栈但共用后厨、共用食材、共用收银台。两个食堂就是两栋独立的楼A楼缺盐不能去B楼拿。这个类比能帮小白建立初步印象但它有个致命盲区它没说明白共享意味着什么。线程之间共享地址空间不是员工之间客气地借用调料而是所有员工在同一个物理空间里干活一个人拿错铲子可以把整锅菜翻掉一个线程写了个野指针能把另一个线程正在用的堆内存直接踩烂。所以更准确的说法是进程是操作系统给一套资源打包后的王国线程是王国里的执行者。进程本身不跑代码它只是一个容器——里面有独立的地址空间、打开的文件描述符、信号处理器、环境变量。真正在CPU上执行指令的是线程。一个进程至少要有一个主线程主线程挂了进程也就没了。顺着这个思路很多热词里的坑就能对上了。比如进程等待wait这在Unix/Linux里是个非常具体的系统调用父进程用fork创建子进程后子进程退出时会先变成僵尸进程父进程必须调用wait或者waitpid去收尸子进程的状态才能彻底清掉。而线程没有这个流程一个线程退出后它的用户态资源由运行时自动回收内核线程对象是池化的也不存在僵尸状态。所以你看同样是等一个执行体结束进程和线程的处理方式完全不同——这就是这两类东西本质差异在日常操作里的体现。2. 从操作系统视角看进程的私有领地与线程的公共设施2.1 地址空间隔离进程之间的防火墙每个进程都有自己独立的虚拟地址空间这是理解进程隔离性的核心。现代操作系统里进程看到的地址都是虚拟地址CPU把虚拟地址翻译成物理地址要靠页表而每个进程都有自己的一套页表。进程A地址空间里的0x1000位置和进程B地址空间里的0x1000位置实际指向的物理内存完全是两回事。这就带来了一连串好处一个进程的代码里随便写个空指针解引用操作系统会触发段错误但这个错误只会杀掉当前进程别的进程毫发无损。浏览器用多进程模型每个标签页一个进程就是这个原因——某个标签页崩溃关掉就好不至于让整个浏览器的其他标签页一起陪葬。Chrome能稳到今天这个程度多进程隔离功不可没。进程隔离还带出一个经典机制fork的写时复制COW。fork创建子进程时并不是把父进程的全部内存复制一份那太贵了。操作系统只是让子进程和父进程共享同一份物理页并把页表标记为只读。只要双方都不写就一直共享一旦有一方要改某个页才触发缺页异常内核单独复制一份给它。这套机制让复制一个进程的成本从拷贝全部内存降到了只拷贝几KB页表所以nginx这类服务器才能靠fork轻松派生出大量worker进程。代价也很明显进程之间现代通信不能靠直接读写对方地址因为地址空间根本看不见。这才有了后面要说的IPC。2.2 同一进程里线程到底是拼车还是拼房如果把进程比作一套合租公寓一个进程里的多个线程就是合租的室友。它们共享的公共区域非常广堆内存和全局/静态变量所有线程都能读写打开的文件描述符、socket句柄一个线程打开的文件另一个线程直接用信号处理器、当前工作目录、用户ID和组ID整个进程级别的状态进程IDPID同一进程的线程共享一个PID。但每个线程也有自己的私人物品线程栈这是每个线程独占的一块内存所以函数里的局部变量天然是线程私有的寄存器状态和程序计数器PC记录这个执行流跑到哪条指令了线程局部存储TLS显式标注为thread-local的变量每个线程一份。这个区分在实际编程里有极强的指导意义。局部变量怎么用都不需要加锁因为它在栈上全局变量只要会被多个线程写就必须考虑同步。Java里有个很容易被忽视的点叫守护线程daemon thread守护线程是用来做后台辅助工作的比如JVM内部的GC线程就是守护线程它的生命周期跟着进程走当所有非守护线程都结束时JVM不管守护线程有没有跑完直接退出。热词里java编写守护线程问的就是这个——如果你的后台任务不能被进程退出打断就要声明成非守护线程方法就是在创建Thread时setDaemon(false)默认就是false很多人反而踩坑在把不该设成守护的线程设成了守护。2.3 上下文切换线程切换真的便宜吗为什么要区分进程和线程传统教科书给出的答案是线程切换比进程切换便宜。这个结论的底层逻辑是这样的。CPU要切换执行流必须先把当前执行流的现场寄存器、程序计数器、栈指针等保存下来再把下一个执行流的现场恢复进去。如果切换的是两个进程内核还需要切换页表——这就麻烦了页表一换CPU的TLB快表基本全失效下一次访问内存都要重新去页表里翻译这个开销在某些工作负载下是实打实的。而切换同一个进程的两个线程地址空间没变页表不用换TLB大多还能命中要保存恢复的就是寄存器和栈指针轻量得多。但这里必须泼一盆冷水线程切换便宜是相对而言的不是绝对值。在真实生产里线程上下文切换照样不便宜——每次切换都涉及陷入内核态、调度器运行、寄存器保存恢复如果线程还带着巨大的热缓存L1/L2 cache切走再切回来缓存全凉了。所以热词里那句线程切换时会泄漏吗我猜问的是线程反复切换/反复创建会不会造成内存泄漏。答案是切换本身不泄漏但反复创建销毁线程会泄漏。因为每次new一个线程操作系统都要给它分配栈空间Java默认栈1MB虚拟内存上更可观高频创建销毁会产生大量内存碎片和内核线程对象堆积这才是真正的泄漏。解决思路就是后面要讲的线程池。顺带提一下调度策略。热词里有异类线程调度策略和freertos切不了线程这类词。Linux主流的CFS完全公平调度器按虚拟运行时间给每个线程分配CPU时间片讲究的是公平而FreeRTOS这类嵌入式实时系统每个任务本质上就是线程有固定优先级高优先级任务永远抢占低优先级任务切换的节奏完全由优先级决定。切不了线程十有八九是优先级配置问题——比如某个任务优先级太高且永不阻塞低优先级任务就饿死了这在RTOS里是经典踩坑现场。3. 通信、同步与死锁理论到工程现场的必修课3.1 进程通信IPC为什么绕不开这道坎进程之间地址空间隔离但这个隔离同时也带来一个麻烦两个进程怎么协作总不能让每个程序都单打独斗。于是操作系统提供了各种进程通信IPC方式热词里进程通信ipc能上热搜说明很多人面试前才临时抱佛脚。常见的IPC机制有这些机制特点典型场景管道pipe单向字节流基于文件描述符命令行ls | grep xxx命名管道FIFO有路径名可用于无关进程服务端与客户端本地通信消息队列有格式的消息内核维护解耦的消息传递共享内存速度最快需要配合同步原语高频大数据量交换信号量用于互斥和同步保护共享资源信号signal异步通知通知进程发生某事件套接字socket跨主机也能用网络通信工程上最常用的是共享内存加信号量组合共享内存负责高速搬运数据信号量负责防止两边同时写。为什么不用管道管道要经过内核复制数据量一大性能明显下降。但共享内存也带来新问题——它本质上是把两个进程的某块地址映射到同一物理内存等于自己打破了隔离所以必须自己处理同步写烂了照样连坐。这就是IPC里的经典取舍高性能要用共享内存但要付出同步的复杂度。3.2 线程同步从互斥锁到原子操作线程共享地址空间所以线程之间最直接的通信方式就是写同一个变量。但共享带来的是竞态条件race condition两个线程同时读改写同一个变量最终结果可能是错的。经典例子是i读i、加1、写回三步操作不是原子的两个线程交错执行可能从211变成213而不是4。解决竞态条件的标准方案是加锁。热词里线程互斥说的就是互斥锁mutex。互斥锁保证同一时刻只有一个线程能进入临界区。锁的底层实现依赖CPU提供的原子指令比如x86的LOCK前缀、ARM的LDREX/STREX或者通用的CAS比较并交换指令。也就是说锁的可靠性最终是CPU硬件保证的不是程序员写了个boolean就能保证的。这也引出一个热词问题AtomicInteger线程安全吗答案是安全但有前提。AtomicInteger内部用CAS实现单次操作如incrementAndGet是线程安全的因为CAS是硬件级的原子操作。但如果你把多个原子操作组合成一个复合流程比如检查-然后-更新中间不额外加锁照样会出问题。原子性只管单步不管流程。这是很多人用并发工具时栽跟头的地方。还有个概念容易被混淆volatile。volatile保证的是可见性——一个线程改了值其他线程能看到最新值但它不保证原子性。两个线程同时执行volatileInt照样会丢更新。所以volatile适合做标志位比如优雅停机的boolean不适合做计数器。3.3 线程死锁从条件到真实排查链路热词里线程死锁赫然在列这个坑几乎每个写并发代码的人都踩过。死锁要成立需要同时满足四个条件互斥资源同时只能被一个线程持有持有并等待线程拿着一个资源同时等另一个资源不可剥夺资源不能被外力抢走循环等待线程A等线程B占的资源线程B等线程A占的资源。操作系统教材里还会要求你用进程资源图可化简来判断死锁是否存在——就是画一张图圆圈代表进程方框代表资源箭头代表请求或分配。如果一个资源分配图经过化简还有不可消除的边说明存在死锁。这个图论方法很严谨但真实生产里没人画图都是用工具直接看线程栈。我自己的排查流程一般是这样的先确认系统是否卡死或某业务线程池是否耗尽如果是Java进程用jstack PID thread_dump.txt抓一份线程快照在快照里搜deadlock关键字HotSpot会直接标出检测到的死锁如果没有明确标注就看哪些线程的状态是BLOCKED且waiting on同一个monitor顺着monitor的持有链找出两个线程各自持有对方的锁这就是死锁现场。说个我遇到过的真实案例。两个服务之间有互相调用的回调线程A先拿了锁L1再调远程服务远程服务回调又回来需要锁L2线程B正好相反先拿L2再等L1。平时两个请求错开没事一旦并发量上来两边同时持锁等待整个调用链全部堵死。最后的修复方案很简单统一加锁顺序所有人都按相同的全局顺序获取锁破坏循环等待条件同时给锁获取加超时Java的lock.tryLock(timeout)超时就释放自己手里的锁重试。从发现问题到找到根因靠的就是一份线程dump比猜谜有效率多了。4. 线程池与进程池生产环境里的资源管理门道4.1 线程池的核心参数与阻塞队列选择前面说了反复创建销毁线程是性能毒瘤线程池就是为了解决这个问题。Java的ThreadPoolExecutor是应用最广的线程池实现它的核心参数配置是一门实打实的工程学问热词里线程池配置线程池的阻塞队列选择都是高频搜索词。ThreadPoolExecutor的核心参数有六个corePoolSize核心线程数默认常驻不回收maximumPoolSize最大线程数keepAliveTime非核心线程的空闲存活时间workQueue任务阻塞队列threadFactory线程工厂用来给线程起名、设优先级RejectedExecutionHandler拒绝策略。刚接触线程池的人最容易踩的坑是以为线程数会从core直接涨到max。其实线程池的扩容逻辑是阶梯式的——当前线程数 corePoolSize来了任务就新建线程执行当前线程数 corePoolSize新任务先塞进阻塞队列不新建线程队列塞满了才新建非核心线程直到maximumPoolSize线程数已经到maximumPoolSize队列也满了触发拒绝策略。也就是说队列在中间扮演了缓冲层的角色。这个顺序决定了阻塞队列选择非常重要LinkedBlockingQueue无界队列队列无限长意味着第3步永远不会发生maximumPoolSize形同虚设。大多数线程池卡死事故就是这么来的——任务堆积几百万内存涨到OOM。这里正好回应热词里idea编译时进程堆大小调整为8000还是报错java.lang.OutOfMemoryError: GC overhead limit exceeded——很多人一遇到GC overhead就疯狂加大堆结果根因是任务队列里堆积了海量任务对象堆加到多少都会被填满。ArrayBlockingQueue有界队列需要显式设定容量配合maxPoolSize才有意义。生产环境我建议必须有界。SynchronousQueue直接交接它不存任务来一个必须立刻被线程接走否则就创建新线程。适合执行时间短、任务量极高、不缓存任务的场景。PriorityBlockingQueue优先级队列任务按优先级出队但要注意它可能让低优先级任务饿死。线程数的估算也有经验公式CPU密集型用CPU核数1因为加一个线程是为了在某个线程发生缺页等短暂阻塞时填补空档IO密集型用CPU核数 * 2或者更高因为IO等待期间线程不占CPU多开线程能提高并发吞吐。但要记住这只是起点真正准确的数据要靠压测。4.2 进程池与Python/GIL的特殊账线程池解决的是线程层面的资源管理但有些场景绕不开进程。最典型的是Python的CPU密集型任务。Python有GIL全局解释器锁同一个进程里多个线程就算跑在多核CPU上同一时刻也只有一个线程能执行Python字节码。所以用Python写一个纯计算密集任务开100个线程不如开4个进程。这就是热词里python 线程嵌套线程自由线程背后真正扎心的问题——Python社区一直在推自由线程free-threaded实现但在那之前老老实实用多进程。Python里用concurrent.futures.ProcessPoolExecutor开进程池任务提交和线程池API一模一样但底层是fork或spawn出多个解释器进程。每个进程有自己的GIL能真正并行利用多核。代价是进程间传递数据要序列化pickle数据传输量一大开销就盖过了多核收益。进程池还有个隐藏优势是隔离。用multiprocessing起的worker如果被某个任务搞崩了Pool会自动重新fork一个worker主进程不会跟着殉葬。这个特性在做不稳定任务的批处理时极其有用——比如从网上抓一堆可能返回畸形数据的资源线程池里一个线程崩了可能整个进程都带崩进程池里顶多挂掉一个worker。热词里摩尔线程s80安装linux驱动这种词看着和线程不搭边但内核驱动的很多工作是跑在内核线程里的。驱动装完之后你会在进程列表里看到大量kworker线程那是内核为驱动分配的工作线程专门用来处理硬件中断、延迟任务这类脏活。这也算线程的一种存在形态——不是用户态业务线程而是内核态的内核线程。4.3 等待所有线程完成后继续多个等的含义与坑热词里java线程等待都完成java线程等待wait进程等待wait扎堆出现说明等这个操作让很多人迷糊。其实不同语境下等的语义完全不同。进程层面前面说过wait()/waitpid()是父进程等子进程结束并回收状态这是POSIX系统调用。线程层面Java里常见的等待方式有Thread.join()当前线程等目标线程执行完CountDownLatch计数器归零则放行适合等N个线程都完成某个阶段CyclicBarrierN个线程互相等待齐了再一起继续Future.get()/CompletableFuture等异步任务的结果。join和CountDownLatch的区别在于join是等某个线程对象终止而CountDownLatch是等某个计数值减到0——线程可能还在继续干别的只要它调用了countDown()就可以放行。所以如果只是等一个标志、一个阶段完成用CountDownLatch更灵活。这个等里最大的坑是无超时的永久等待。Future.get()不带超时参数如果任务里发生了死循环或者远程调用的连接一直不返回这个get会把调用线程永久挂起。生产环境我强烈建议所有get都带timeout比如get(10, TimeUnit.SECONDS)超时后通过cancel(true)打断任务。热词里还有java获取当前线程名这其实是排查并发问题的最基础手段——给每个线程在threadFactory里起一个有意义的名字比如order-save-worker-0然后配合Thread.currentThread().getName()打日志线程dump出来一眼就能定位瓶颈比看pool-1-thread-3这种名字有用一百倍。5. 真实系统里的进程线程问题一次排查实操复盘5.1 System进程CPU占用100%别急着杀进程windows cpu占用率一直100是个常年热搜的问题更让人懵的是任务管理器里显示占CPU的居然是System进程。很多人以为是中毒了四处找杀毒软件。其实System进程是内核和驱动线程的容器。在Windows里内核线程负责内存管理、进程调度、文件系统、硬件中断处理、设备驱动的DPC延迟过程调用都是以System进程的名义显示的。CPU 100%时你该做的不是杀进程而是用Process Explorer这类工具打开System进程的线程列表按CPU占用排序看到底是哪个线程在烧CPU再看这个线程的起始地址或关联的驱动模块。我之前遇到过一台服务器CPU持续100%找了一圈发现是某个老网卡驱动在中断风暴升级驱动后立刻恢复正常。如果贸然去冻结或终止System进程的线程热词里process explorer 冻结线程就是这么来的轻则蓝屏重则系统直接挂掉。进程是系统给的田地线程是田里的庄稼你不可能只烧庄稼不烧田地。5.2 守护进程与会话后台服务的立身之本热词守护进程与会话也是一个高频考点。守护进程daemon是长期运行、脱离终端控制的进程典型的像nginx、redis、各种中间件。守护进程的诞生有一个标准姿势调用setsid()创建一个新的会话。这背后的操作系统机制是会话session是进程组的集合进程组是进程的集合而每个会话可以关联一个控制终端。一个进程调用setsid()之后会发生三件事它成为新会话的首进程它成为新进程组的组长它彻底脱离原来的控制终端。为什么要费这个劲因为普通进程如果父进程退出了而它又没有接管终端很容易被终端挂断信号SIGHUP干掉。守护进程脱离了控制终端就不会因为用户关掉终端而被SIGHUP带走。经典的双fork流程fork一次再fork一次就是为了确保setsid之后这个进程不可能再成为会话首进程从而永远无法自动获取控制终端——这是老Unix程序员传下来的防御性写法。理解这个模型对排查问题很有用。比如你用ssh启动了一个后台任务关掉ssh之后任务就消失多半就是因为没做完整的守护化处理SIGHUP一来进程就没了。反过来热词里守护进程与会话被频繁搜索说明很多人在部署工具时踩过这个坑。5.3 文件锁定、进程意外终止与打不死的小强再讲几个特别贴近日常的进程问题也是搜索热词里出现频率最高的几类。第一类文件被锁定。f盘被另一个进程锁定、未找到baidunetdiskhost进程这类词背后是文件句柄占用问题。Windows上想删除或者格式化某个文件/分区提示被另一个进程锁定最简单的办法是用Process Explorer的查找句柄功能Find - Find Handle or DLL输入文件名直接定位是哪个进程占用了它。如果是像百度网盘、迅雷这类带驻留进程的软件热词里的thunderplatform、baidunetdiskhost都是杀进程往往治标不治本因为后台服务会再拉起来一个最好是去软件设置里关掉自启动和驻留。第二类进程意外终止。热词里mysql1067进程意外终止sql2000服务进程被异常终止这类问题背后其实都有一个共性任何服务进程的非正常退出一定在系统日志或应用日志里留了线索千万不要两眼一抹黑就去重装。Windows上先看事件查看器里的应用程序日志MySQL要看error logSQL Server要看ERRORLOG文件。我处理过一个MySQL服务反复意外退出最后发现是磁盘空间被binlog日志写满服务启动时做崩溃恢复写到一半空间不足又挂了。把binlog清理策略调好问题就没了。这类问题的排查顺序永远是日志 - 资源 - 配置 - 代码而不是先杀了重来。第三类杀了又出现的进程。一直出现com.vortex.helper这个进程杀了一个pid又换了一个——这种打不死的小强进程通常不是普通应用程序而是通过计划任务、服务注册表、启动脚本多重自启动机制挂进来的。排查链路是先看任务计划程序里有没有注册再看服务列表services.msc里有没有对应服务最后检查注册表Run键。三个地方都清理干净进程才能真正消灭。这类经验同样适用于msedgewebview2.exe这类运行时进程——它是Edge浏览器的WebView2运行时组件很多程序用它渲染界面杀掉它是没用的要找到真正调用它的宿主程序在宿主程序里关闭。6. 选型与心得进程还是线程从来不是一道送分题6.1 隔离性优先还是性能优先每次带新人做架构选型他们都会问这里该用进程还是线程。我的回答思路一般是按两个维度权衡。第一维度你有多怕崩溃如果一个组件本身的健壮性不可控比如解析不可信输入、运行第三方插件、执行用户提交的代码那就用进程隔离。插件崩了只是插件进程退出主程序还能继续。IDE就是这么干的——VS Code和IntelliJ都把语言服务器和插件放到独立进程里插件写崩了不会把你正在编辑的代码一起带走。反过来如果组件代码在你掌控之中崩溃概率很低那用线程就够了省去了IPC和序列化的开销。第二维度通信有多密集线程之间共享内存通信基本零成本进程之间通信要走IPC无论管道还是共享内存都存在额外开销和复杂度。如果你有两块模块需要高频协同比如每个请求来回传几千次数据拆成进程会痛苦到怀疑人生。所以我一般给个很朴素的选型原则面向性能优先、协作密切、可控性强的场景用线程面向隔离优先、健壮性差、需要独立部署和故障分隔的场景用进程。大量成熟系统的做法是折中——多进程加多线程的混合模型比如Gunicorn、uWSGI启动多个worker进程每个worker内部再开线程池或协程池。这样既拿到了多核并行和崩溃隔离又保留了线程层面的低延迟通信。6.2 语言和运行时的隐形约束选型还有一个经常被忽略的维度你用的语言和运行时决定了线程到底是什么形态。Java早期的线程模型是1:1映射——一个Java线程对应一个内核线程线程开销就是内核线程的开销所以Java应用普遍重度依赖线程池。Spring Boot默认的Tomcat就是每个请求占一个工作线程一旦某个请求被远程服务堵死线程池会逐渐耗尽其他请求全部排队等线程。JDK 19之后的虚拟线程Project Loom就是为了打破这种1:1模式让几十万个轻量级虚拟线程跑在少量内核线程上但那是另一个话题了。Python在GIL没去掉之前线程面对CPU密集任务就是个摆设Go的goroutine是用户态协程由Go运行时自己调度一个内核线程上能跑成千上万个goroutine和Java线程完全是两回事。同一句话用线程在不同语言里的成本、语义和坑完全不同。热词里有androidfragment开启线程Android更是把主线程UI线程单独拎出来主线程必须快速响应不能跑耗时任务而子线程不能直接更新UI必须通过Handler切回主线程。这类框架级约束说明线程从来不是一个纯粹的操作系统概念它深深嵌在运行时和框架的规则里。Redis的线程IO模型也值得提一句。Redis主线程是单线程的为什么单线程还能撑那么高的QPS因为它的瓶颈在内存和网络而不在CPU单线程模型省去了锁竞争、上下文切换和缓存失效的开销配合IO多路复用epoll一个线程就能处理海量客户端连接。而后来Redis 6引入的IO线程也只是把网络读写的部分放到多个线程命令执行依然在主线程串行。这个案例对线程越多越好是一个很好的反向警示。6.3 我实际用下来的一些经验最后分享几个我踩过无数次坑之后沉淀下来的心得不是什么高深道理但很管用。第一默认无脑用线程池而不是直接new Thread。这不仅仅是为了性能更是为了限制并发上限避免某个突发流量直接把进程的线程数撑爆把CPU和内存一起拖垮。第二每个线程池都要有名字、有队列上限、有拒绝兜底。线程池卡死是线上事故的高发区要么队列无界导致OOM要么拒绝策略不当导致任务悄悄丢掉。更稳妥的做法是核心线程数和最大线程数分开设队列用有界队列拒绝策略用CallerRunsPolicy让提交任务的线程自己执行这个任务至少不会无声无息丢任务。第三凡是等待都要有超时。不管是等待线程完成、等待锁、等待Future结果不带超时的等待都是潜伏的炸弹。一次远程服务慢到超时时间就可能把你整个线程池堵瘫痪。第四排查进程/线程问题先看分布再看单点。CPU高先确定是用户态还是内核态是哪个线程哪个调用栈内存高先看是堆还是非堆是线程栈还是缓存进程退出先看日志看退出码。顺序对了大部分疑难杂症都能在半小时内定位。回到开头那个问题进程和线程的区别确实不是一句资源分配单位vs调度单位能概括的。它们一个是地的边界一个是地上跑的人地之间要修路IPC才能往来地上的人要遵守交规同步机制才能不撞车。把这些底层逻辑真正理解透你在架构选型、问题排查、并发编程的时候心里才会有真正的分寸感。