我第一次写多线程程序闹过这样的笑话以为线程就是“new 一个对象”那么简单为了赶一个下载任务一口气开了五十个线程结果程序毫无征兆地冻结CPU拉满日志却停在那里一动不动。后来才明白线程是程序运行流程里最基础、也最容易被轻视的一环。这篇文章想用最没有门槛的方式把线程这件事从头讲明白程序怎么变成进程线程在其中是怎么干活的为什么多线程会踩线程安全、死锁这些坑以及出问题了怎么用工具把它捞回来。适合刚学编程不久的人也适合写了好多年业务代码、却始终没系统理解过“程序在操作系统里到底是怎么跑起来”的老同学。1. 程序不是文件而是“跑起来的状态”很多人对“程序”这个词的理解是模糊的一个.exe、一个.py、一个.jar在硬盘上躺着的时候它只是一堆字节你可以叫它“文件”但严格来说它还不是“程序”。真正意义上的程序是这些字节被操作系统加载到内存、分配了资源、开始被 CPU 一条条执行之后才形成的那个“活着的状态”。这个状态在操作系统里有专门的名字叫进程。1.1 从一行命令看程序“开跑”的完整链路先从一个几乎人人都遇到过的报错说起。在 Windows 的 PowerShell 里敲npm install屏幕上蹦出一行红色提示“无法将‘npm’项识别为 cmdlet、函数、脚本文件或可运行程序的名称。”同样的错换到pip、git、mvn、pnpm甚至某些工具软件上只是名字换了格式一模一样。很多人第一反应是“坏了是不是没装好重装吧”。其实这句报错已经把问题说得很直白命令行解释器在它该找文件的地方没找到叫npm的可执行文件。这个查找过程恰恰就是程序运行的第一步。你在终端敲下一条命令shell 要做的是解析命令名 → 在PATH环境变量列出的目录里逐个查找可执行文件 → Windows 还会按PATHEXT扩展名规则去匹配npm.cmd、npm.ps1这类变体 → 找到之后调用操作系统接口让内核创建一个新进程找不到就返回你看到的那句“无法识别”。所以这种报错真正的原因基本就两类一类是这个命令确实没装另一类是装了但安装目录没写进PATH或者当前终端进程的PATH还是旧值。解决办法也简单去安装目录确认可执行文件存在把目录追加到PATH重开终端就好了。理解这一步的额外价值在于很多“命令跑不起来”的问题定位思路都是一样的不要第一时间怀疑系统坏了先去查 PATH查文件是否存在。1.2 进程的内存分区代码、数据、堆、栈当可执行文件被加载进来操作系统不会把字节一股脑扔进内存就完事而是会按区域规划整个进程的虚拟地址空间。这块地皮上通常分成几大块代码段存放指令数据段存放全局变量和静态变量堆用于程序运行时动态分配内存栈则服务函数调用保存局部变量和返回地址。用做饭来类比代码段是菜谱数据段是冰箱里常备的调料和干货堆是你临时去菜市场买的菜栈是你案板上正摊开的半成品。菜谱不变但每次做饭的“临时状态”都堆在案板上。整个程序的执行起点是main函数。CPU 从main的第一条指令开始取指、执行每调用一个函数就在栈上压入一帧栈帧函数返回时弹出。栈是后进先出的所以你能看到调用链一层层叠上去异常时打印的堆栈信息就是把这一帧帧“半成品”摊开给你看。1.3 程序计数器与“单步运行”背后的机制还有一个常被忽略但极其重要的运行时状态程序计数器PC。它保存着下一条要执行的指令的内存地址。CPU 每执行完一条指令就更新一次 PC指向下一条。正是因为 PC 的存在CPU 才知道自己“现在跑到哪一行了”。调试器里的“单步执行”功能本质就是利用这个机制调试器让程序暂停在某个 PC 位置你每次手动下发“继续执行一条指令”的命令再让你看 PC 和寄存器变成了什么。在 Linux 下用 gdb 调试程序时sistep instruction就是单步执行一条机器指令next则是单步执行一行源码。很多新人以为这是“魔法”其实背后的逻辑就是暂停和恢复 PC 而已。而 PC 的重要性在接下来讲线程切换时会再一次放大一个线程“干到哪了”本质上就是它的 PC、寄存器、栈这些“现场”被打包保存在哪里。CPU 把一个线程换下去、把另一个线程换上来做的事情无非是保存当前现场、恢复目标现场、把 PC 指到目标线程该继续执行的位置。2. 线程进程里真正干活的人进程是“活着的程序”是操作系统分配资源的基本单位——代码段、数据段、堆、文件描述符都归属于进程。但进程本身并不会执行真正在 CPU 上跑来跑去的是线程。很多时候我们把“程序在跑”理解成“进程在跑”但更准确的说法是进程是容器线程才是执行者。一个进程至少有一个主线程main方法就是从主线程开始的进程可以往容器里塞更多线程让几条执行路线并行推进。2.1 线程的本质同一个屋檐下的多条执行路线我常用“公司和员工”来打比方。进程是一家公司有办公场地、服务器、财务账本这些公共资源也就是进程的地址空间、堆、全局数据线程是这家公司的员工每个员工有自己的工牌、工位、个人工作台对应线程的 ID、程序计数器、寄存器集合、私有栈但会议室、数据库、文件柜是大家共用的对应线程共享进程的堆和全局数据。多个员工可以同时做不同的事这是并发但共用资源也意味着管理麻烦——两个人同时改一份报表数据就可能对不上。这就是后面线程安全问题的根子。另外要注意公司倒闭所有员工一起失业进程崩溃进程里的所有线程也一起消失。线程本身没有独立的资源归属一切还是靠进程养着。线程之间共享同一份地址空间这是线程比进程“亲”得多的地方。不同进程的内存是隔离的互不可见同一个进程的多个线程却能直接读到同一块堆内存。配合同步机制线程间可以用共享内存沟通这是多线程模型高效的一个关键前提。2.2 线程的组成TCB 管状态私有栈管现场每个线程在操作系统里都有对应的管理结构叫线程控制块TCBThread Control Block也有人叫内核线程结构。TCB 里记录着线程 ID、状态、优先级、寄存器上下文、栈指针、所属进程等信息。可以把它理解成操作系统的“人事档案”这个线程是谁现在处于什么状态上次跑的时候寄存器里是什么值接下来该从哪条指令继续。而线程的“私有存储区”主要指线程自己的栈也包含线程局部存储ThreadLocal这类数据。线程栈里保存的是这个线程的局部变量、函数调用帧线程局部存储则是把一份数据变成“每个线程各有一份”避免了共享冲突。所以“线程控制块和私有存储区的关系”可以这么体会TCB 是档案室里的台账记录着“人现在在哪、干到哪一步了”私有栈是员工手里的草稿纸写着他自己的计算过程和中间结果。线程要被调度运行内核先从 TCB 里找到这个线程的状态和栈指针把寄存器现场恢复出来线程才能接着往下跑。我见过不少初学者把 TCB 和栈当成一个东西其实区分很清楚TCB 是内核管理用的元数据栈是线程实际运行时的内存区域。2.3 语言层的 Thread 与系统线程别让封装欺骗你Java 里有Thread类C# 里有ThreadQt 里有QThreadPython 里有threading。这些语言层面的线程对象底层几乎都是对操作系统线程的封装。以 Java 为例new Thread()只是在 JVM 堆里创建了一个对象还没有真正的操作系统线程只有调用start()之后JVM 才会向操作系统申请创建真正的线程。所以看线程数量的时候你去系统层面看到的通常是操作系统线程数而不是你代码里new了多少个Thread对象。不同语言封装出来的 API 差异很大。比如 Qt 的QThread不仅管理线程执行还封装了事件循环和信号槽机制用起来更像是“把消息循环搬进了一个线程”C# 的Task又多了任务调度层的概念。但只要落到操作系统层终归都是通过pthread_createPOSIX 系统或CreateThreadWindows去创建。换个通俗说法不管语言给线程穿了多漂亮的衣服到内核面前它还是一个 TCB 加一个栈。理解了这一层你在任何语言里看到“线程”都不会慌。3. 进程和线程的分界线一张表看懂聊到线程一定会被问“进程和线程到底有什么区别”。这个问题背概念没有用得从地址空间、调度、通信、故障影响这几个维度和实际需求放一起看才记得住。3.1 六个维度对比进程与线程维度进程线程资源归属资源分配的最小单位独立拥有地址空间、文件描述符CPU 调度的最小单位共享所属进程的地址空间地址空间各进程独立互不可见同进程内线程共享直接访问同一块内存通信方式管道、消息队列、共享内存、Socket 等 IPC 机制共享内存加同步机制效率高但需要加锁创建/切换成本高要分配地址空间、页表、文件描述符低同一进程内切换不需要切换地址空间故障隔离进程崩溃互不影响一个线程崩溃可能拖垮整个进程典型场景多租户隔离、需要高可靠的子系统高并发、IO 密集型、需要共享数据的场景这张表不是让背的是为了建立一种直觉进程和线程的差异几乎都源于同一个根因——地址空间是否独立。地址空间独立所以隔离性好但通信和切换代价大地址空间共享所以通信和切换便宜但容错性差、并发管理难。3.2 线程消息为什么不能跨进程IPC 来兜底网上有个说法“线程消息不能跨进程”第一次听到的人容易困惑线程消息也是数据怎么就不能跨进程其实这句话要拆开看。线程间通信靠的是共享内存加同步原语比如wait/notify、信号量、Lock 的条件变量这些机制的生效范围都限定在同一进程内因为同进程的线程都在同一块虚拟地址空间里天然彼此可见。但两个进程的虚拟地址空间是互相隔离的A 进程的线程想往 B 进程的线程传消息不能直接读对方内存。它必须走操作系统提供的进程间通信IPC通道管道、命名管道、消息队列、共享内存、Unix domain socket、普通网络 Socket 等。你平时在业务代码里用 MQ比如 Kafka、RabbitMQ让两个服务传数据本质上也是 IPC只不过消息跑得更远从“进程内的内存搬运”变成了“跨网络搬运”。所以这句话的更准确理解是线程间基于共享内存的那种高效通信方式过不了进程边界一旦跨进程就要换用完整的 IPC 方案省不掉序列化、拷贝、内核转发这些成本。3.3 为什么高并发场景偏爱线程而不是进程回到实际工程高并发服务普遍选择“多线程 线程池”而不是“多进程”原因就是前文表格里的那两条创建成本和切换成本。创建进程很贵因为要新分配一套虚拟地址空间、页表、文件描述符表创建线程只是在已有进程里再加一个 TCB 和一块栈便宜得多。切换也是同理切换进程要把整个地址空间换掉页表缓存 TLB 直接失效同进程切线程地址空间不变只换寄存器和栈指针。数据共享也差一个量级。多进程要共享数据得引入共享内存并自己做同步否则就靠消息传递每一步都有拷贝开销多线程里两个线程本来就在同一个堆上读一块数据就是读一块数据配合锁或者无锁结构就能协作。所以你会发现主流的高并发模型从老一代 Apache 的“每请求一进程”慢慢演化到 Nginx、Netty、Node.js 那种“一个进程里放大量轻量执行体”底层逻辑都是往“更轻的执行单元”上靠。4. 多线程的“三座大山”安全、互斥、死锁多线程是个好工具但真正用起来绝大多数人被卡在三件事上线程安全、线程互斥、线程死锁。这三件事不是概念题是每天都在线上发生的生产事故。4.1 线程安全count 为什么不是 200000先看一个最经典的例子public class CounterDemo { private static int count 0; public static void main(String[] args) throws InterruptedException { Thread t1 new Thread(() - { for (int i 0; i 100000; i) { count; } }); Thread t2 new Thread(() - { for (int i 0; i 100000; i) { count; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }直觉上结果应该是 200000但实际跑出来经常是一千多、几万偶尔才是 200000。原因在于count根本不是一步操作它在 CPU 层面拆成了“读 count → 加 1 → 写回 count”三步。两个线程可能同时读到 count 5各自加 1 都写成 6于是一次更新被吞掉了。读改写三步不是原子的这正是竞态条件的教科书现场。于是就有了两个高频问题。HashMap线程安全吗答案是不安全。JDK 7 里多线程并发扩容时链表头插法可能形成循环引用get直接死循环JDK 8 改成尾插法修复了死循环但并发 put 覆盖丢数据的问题依然存在。并发场景老老实实用ConcurrentHashMap。AtomicInteger线程安全吗安全它靠的是 CASCompare And Swap底层用 CPU 的cmpxchg指令保证比较并交换是原子操作自旋重试代替加锁阻塞所以适合做计数器。如果自增非常频繁、竞争很激烈LongAdder比AtomicInteger更合适它内部做了分段累加。判断一个类是否线程安全可以看它的共享状态有没有被同步机制保护。无状态类天然安全状态完全不共享比如每个线程只碰自己的ThreadLocal也安全真正危险的是那种“多个线程同时读写同一个可变字段又没有锁”的类。4.2 互斥同步synchronized 到底锁了什么要解决读改写非原子问题最朴素的思路是互斥同一时刻只允许一个线程进入临界区。Java 里最常用的就是synchronized关键字。synchronized可以修饰方法也可以修饰代码块它锁的是对象——每个 Java 对象头里都有一个 Mark Word记录锁状态JVM 拿到这个对象的锁才允许进入临界区用javap -c反编译能看到字节码里的monitorenter和monitorexit。synchronized有两个非常实用的特性可重入也就是同一个线程可以反复获取同一把锁不会把自己锁死自动释放不管方法正常返回还是异常抛出JVM 都会释放锁。这一点和Lock接口形成明显对比ReentrantLock必须手动unlock()通常放在finally里否则锁就永远不释放。volatile是和线程安全经常一起出现的关键字它保证的是可见性和有序性一个线程修改了volatile变量其他线程能立刻看到。但volatile不保证原子性所以前面count的场景加了volatile也没用它帮不了“读改写”三步。理解了原子性、可见性、有序性这三个词线程安全的很多疑问都能自己推导出来。4.3 死锁与排查程序卡死后的自救指南死锁可能是多线程里最让人头疼的问题程序不报错、不崩溃线程状态也活着但任务就是一动不动。看一个标准死锁示例public class DeadLockDemo { static final Object lockA new Object(); static final Object lockB new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (lockA) { try { Thread.sleep(100); } catch (InterruptedException e) { } synchronized (lockB) { System.out.println(t1 got lockB); } } }); Thread t2 new Thread(() - { synchronized (lockB) { try { Thread.sleep(100); } catch (InterruptedException e) { } synchronized (lockA) { System.out.println(t2 got lockA); } } }); t1.start(); t2.start(); } }t1 拿着 lockA 等 lockBt2 拿着 lockB 等 lockA谁都不放手就成了死锁。死锁要同时满足四个条件互斥、持有并等待、不可剥夺、循环等待。打破任意一个就能避免工程上最常用的是打破循环等待——约定一个全局加锁顺序所有线程都先锁 A 再锁 B循环依赖就没了或者用tryLock加超时拿不到锁就放弃重试。排查死锁先jps找到 Java 进程的 PID再jstack PID看到“Found one Java-level deadlock”基本上就实锤了jstack 会直接指出哪两个线程在互相等哪把锁。Linux 下排查非 Java 程序可以用gdb -p PID进去后执行thread apply all bt打印所有线程的堆栈思路完全一样看每个线程阻塞在哪个系统调用或锁上。嵌入式场景看线程当前指令比如 QNX 下用 gdb 切线程再x查看指定地址的指令本质也都是“抓现场、看栈、找等待关系”。我自己的习惯是发现程序卡死先别急着看业务代码先抓线程状态它已经在告诉你它在等什么了。5. 别让线程裸奔线程池与进阶方向理解了线程本身下一步就是如何在工程里“批量管理”线程。真实服务里不会让我写一个任务就new一个线程而是统一交给线程池。5.1 频繁创建线程的隐性成本与线程池核心参数每次创建线程操作系统都要分配内核线程结构、线程栈、向调度器注册实体销毁线程同样要回收资源、做系统调用退避。如果业务是高频短任务线程创建销毁的开销占大头纯粹是浪费。线程池的思路很简单线程创建一次放在池子里反复用任务来了丢进队列空闲线程排队取任务执行。Java 里最常用的是ThreadPoolExecutor它有几个关键参数参数含义我的常用建议corePoolSize常驻线程数量按业务压测决定maximumPoolSize最大线程数量至少大于 corePoolSizekeepAliveTime超出 core 的线程空闲存活时间一般 30~60 秒workQueue任务队列让任务排队等待空闲线程threadFactory线程工厂设置带业务语义的线程名排查问题靠它handler拒绝策略队列满且线程数达到最大时触发任务提交后的流程是线程数小于 corePoolSize直接新建线程线程数超过 corePoolSize任务进队列队列满了、线程数还没到 maximumPoolSize继续新建线程线程数到顶、队列也满走拒绝策略。这个流程不是靠背的出过几次线上问题后自然就熟了。5.2 任务队列和拒绝策略线程池的“蓄水池”与“泄洪闸”阻塞队列的选择直接决定线程池行为。LinkedBlockingQueue默认无界任务可以无限堆积好处是线程数永远不会因为队列满而扩容坏处是任务积压可能导致内存被打爆且你根本不知道“没执行的任务还有多少”。ArrayBlockingQueue有界任务堆积到上限就触发后续机制相当于给系统一个“我扛不住了”的信号更适合对积压敏感的场景。SynchronousQueue不带缓冲任务不排队直接交给线程要求 maximumPoolSize 必须够大否则新任务会因为找不到空闲线程被频繁拒绝。拒绝策略四种最容易忽略的是CallerRunsPolicy队列满了不丢任务而是让“提交任务的那个线程”自己执行任务。这等于把压力反向传导给调用方调用方被拖慢后自然就减少了提交是一种天然的流量控制。我做订单回调的时候踩过无界队列的坑高峰期任务堆积内存直接顶上去GC 频繁改成ArrayBlockingQueue(1000)加CallerRunsPolicy内存稳了任务也没丢。5.3 守护线程、虚拟线程与参数配置实战线程池之外还有几个概念值得一起说清。守护线程daemon thread通过setDaemon(true)设置它的特点是JVM 退出时不等待它结束所有用户线程跑完后守护线程会直接被强制结束。典型的守护线程是 JVM 的 GC 线程。所以不要在守护线程里做“必须落盘、必须通知用户”的活进程一退你的数据就没了。另一个大方向是虚拟线程。JDK 21 正式引入虚拟线程后Spring Boot 3.2 以上可以在配置里开启类似spring.threads.virtual.enabledtrueSpring Boot 3.5 的新项目也默认走这一套。虚拟线程的定位是“海量阻塞 IO 场景的轻量线程”它挂载在少数平台线程上遇到 IO 等待时能把自己卸载下来让平台线程去跑别的虚拟线程所以能支撑几十万甚至上百万并发任务。但它不适合 CPU 密集型任务也不意味着线程安全问题消失加锁、共享状态的处理方式仍然一样。最后聊聊我最常被问的线程池参数怎么配。老经验公式是CPU 密集型线程数设为 CPU 核数 1IO 密集型设为 CPU 核数 × 2。公式能当起点但不能当真理。我现在的做法是先给一个保守配置再用压测去调。比如一个异步回调服务一开始配了 core200、无界队列高峰期任务堆积到堆内存爆炸后面改成 core16、max32、ArrayBlockingQueue(2000)CallerRunsPolicy 队列积压告警超时率反而更低了内存也稳定了。配置线程池没有银弹但有一条通用准则队列必须有界拒绝策略必须明确线程名必须可识别三项缺一不可。说到等待线程完成join()是最简单的办法线程池提交的任务用Future.get()或者CountDownLatch更合适。总之不要用Thread.sleep去硬等那是把不确定性交给时间迟早会翻车。我个人带人学线程很少让他们背概念而是逼他们做三件事写一个并发自增的程序亲眼看到结果不等于 200000写一个死锁用jstack打印线程栈用 jconsole 或 arthas 看一次线程状态从 RUNNABLE 到 BLOCKED 再到 WAITING 的变化。做完这三步线程的“程序运行基本流程”才算真正长在你身上。线程这个东西纸上得来终觉浅它在等你亲手把它跑出错一次。
