☰
再谈线程:从并发基础到线程池与线上排查实战
2026/10/11 15:09:10 网站建设 项目流程

1. 为什么“再谈线程”:先回到最基础的问题

1.1 很多并发问题,其实不是框架问题,而是线程问题

这两年做项目评审,我发现一个挺普遍的现象:很多人张口就是“我用线程池”“我上了消息队列”“我加了分布式锁”,真正问到“线程切换到底发生了什么”“你的锁保护的是哪一块内存”“为什么这里要用两个条件变量”,反而答不上来。框架把并发细节包得越来越严实,可一旦线上出问题,CPU 飙高、接口超时、数据错乱,最后刨根问底还是得回到线程本事上。这就是我想“再谈线程”的原因——不管上层工具怎么变化,线程依然是并发编程绕不开的根基。

这篇文章不是从零教你怎么new Thread()。我默认你已经写过一些多线程代码,但可能对背后的机制还有模糊地带。我会从进程与线程的边界、线程调度的成本、内存可见性、同步工具的选型、线程池参数的计算,一直谈到线上问题的排查套路。适合正在写业务系统、想真正把并发搞明白的开发同学,也适合准备深入理解语言并发模型的人。

线程这个东西,说简单也简单,就是一条独立的执行路径;说复杂也复杂,它牵扯到操作系统调度、CPU 缓存、内存模型、锁的公平性、队列的背压……每一个点摊开都够写好几篇。这篇文章我希望帮你把这些点串起来,形成一张完整的图,而不是碎片化地记结论。

1.2 从进程到线程:一次操作系统的视角切换

很多人对线程的理解停留在“进程里跑着多个线程”,这个说法没错,但不够本质。从操作系统的视角看,进程是资源分配的单位,线程是调度的单位。什么意思?进程手里握着的是内存地址空间、文件描述符、打开的设备这些“资产”;线程才是真正被 CPU 拉去执行代码的那个实体。同一个进程里的多个线程,共享进程的地址空间和绝大部分资源,但各自拥有独立的执行上下文。

这带来两个直接后果。第一,线程之间通信成本极低,因为它们本来就在同一片内存里,不需要走进程间通信那套管道、消息队列、共享内存的机制;但反过来,正因为共享,线程之间才需要同步,否则你改我也改,数据就乱了。第二,线程创建和切换的开销比进程小得多,因为不需要复制地址空间、不需要重新建立文件表,但这不意味着线程创建就是免费的。

我见过不少刚入门的人,把“线程便宜”理解成“线程可以随便 new”。实际上一万次线程创建和一万次线程池复用,性能差距可以到几十倍。线程创建要申请内核栈、初始化线程控制块、建立调度实体,每一样都不是零成本。这也是后面要讲线程池的伏笔。

1.3 线程到底贵在哪:上下文切换的成本账

聊到线程开销,绕不开上下文切换。我做过一次不太严谨的本地测试:在普通 Linux 机器上,一个线程从running状态切走再切回来,大概要消耗几微秒。听起来不多,但如果你的服务一秒要处理几万请求,每个请求又触发多次线程切换,这笔账就非常可观。

为什么切换这么贵?因为 CPU 要“收摊”再“开张”。当前线程正在用寄存器、程序计数器、栈指针,这些都得保存到内核态的内存里;接着要切换到另一个线程,把它的上下文从内存恢复到寄存器。这中间还要经过用户态到内核态的边界,清流水线、刷新 TLB(快表),缓存命中率也会掉。所以线程数不是越多越好,超过核数太多,大量时间都在做无用切换。

一个很典型的案例:某次帮朋友看一个压测上不去的服务,配置不差,16 核机器,业务也不复杂,但吞吐就是卡在 3000 QPS。一看线程数,业务线程池开了 400,再加各种异步回调线程,高峰期活跃线程数到 500 多。16 核机器,平均每个核要轮转 30 多个线程,大量时间耗在切换上。后来把线程池压到 60,再加合理的队列缓冲,吞吐反而翻了一倍。这个案例让我深刻意识到:线程的“贵”,比大多数人直觉上认为的还要贵。

2. 线程的核心机制:调度、栈与可见性

2.1 线程的状态迁移:别只会背那五个状态

Java 里一个线程有NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED六种状态,很多面试题都在考这个。但实际排查问题的时候,光会背名字没用,你得能从状态反推代码在干什么。

比如BLOCKED和WAITING的区别,我见过很多人答不准。BLOCKED是线程在等一把被占用的监视器锁(synchronized 的锁),它的特征是“被动等待”某个持锁线程释放;WAITING通常是调了wait()、join()、LockSupport.park(),在等一个明确的信号,特征是自己主动让出了 CPU。两者线程 dump 里的栈信息也完全不一样,一个停在锁对象上,一个停在park或者wait方法里。

线程状态看多了之后,你会形成一种直觉:如果线上大量线程卡在BLOCKED,多半是锁竞争太激烈,要么锁粒度太大,要么持锁线程在做耗时操作;如果大量线程在WAITING,可能是线程池队列满了、消费速度跟不上,也可能是在等一个永远不会到的通知——后者就是典型的逻辑 bug 了。状态本身不是重点,重点是状态背后的“原因链”。

2.2 栈、寄存器与线程私有数据

每个线程最核心的私有资产,是它的调用栈。栈上装的是局部变量、方法参数、返回地址,这些天生就是线程私有的,不需要加锁。这也是为什么局部变量永远线程安全——只要它没有被逃逸出去。理解这一点,很多并发设计会豁然开朗:能用局部变量就别用实例字段,能传参就别去读共享状态。

但栈也有它的麻烦。栈空间不是无限的,默认线程栈大小通常在 1 MB 到 8 MB 之间(不同系统、不同语言不一样)。如果你的线程里写了一个很深的递归,或者往栈上塞了大对象,就会StackOverflowError。反过来,如果你开非常多线程,光是栈空间就可能吃掉大量内存。算一笔账:1000 个线程,每个默认 1 MB 栈,光栈区就占 1 GB 虚拟内存,再加上线程控制块和内核栈,内存压力非常大。

还有一个容易忽略的点是线程局部存储(Thread Local Storage)。它看起来像全局变量,但每个线程都有自己的副本,常用的ThreadLocal就是基于这个机制。用ThreadLocal的坑在于生命周期:线程池里的线程是复用的,线程不销毁,ThreadLocal里的值就不会被 GC 回收。如果你往里面塞了带大对象的引用,又忘了remove(),相当于在内存里埋了一个慢慢泄漏的定时炸弹。这个我后面在问题排查部分会再展开。

2.3 内存可见性与指令重排:volatile到底在干什么

线程的基础机制里,最反直觉的就是内存可见性。很多写了好几年并发代码的人,也容易掉进一个坑:以为加了锁就万事大吉,却说不清锁到底保证了几件事。

这里必须展开讲。现代 CPU 是多核架构,每个核心有自己的缓存。线程 A 改了变量x,这个改动可能还躺在 A 所在核心的缓存里,没有同步到主内存;线程 B 从自己的缓存里读x,读到的还是旧值。这不是玄学,是 CPU 为了性能做的延时写回策略。锁和volatile的作用之一,就是强制让这些缓存同步——准确地说,是插入内存屏障,保证改动对其他线程可见。

volatile只保证可见性和有序性,不保证原子性。i++这种操作,即使i是volatile的,在多线程下依然会丢更新。因为i++本质是三个动作:读、加一、写回,volatile管不住这个复合操作的中间状态。很多人在这里栽跟头,以为是 JDK 的 bug,其实是没理解可见性和原子性的区别。

指令重排则更隐蔽。编译器和 CPU 为了优化流水线,会调整没有数据依赖的指令顺序。单线程下看不出来,多线程下就可能导致意外结果。经典的例子是双重检查锁单例:如果不用volatile修饰instance,某个线程可能看到半初始化的对象——因为“分配内存”和“执行构造函数”两条指令被重排了。这个问题在 Java 里靠volatile解决,在 C++ 里靠std::atomic和内存序解决,在 Go 里则要小心sync包的使用方式。本质都一样:在多线程世界里,代码的执行顺序不能靠直觉判断。

3. 线程协作与同步:锁只是手段,不是目的

3.1 同步的本质:从“互斥”到“内存屏障”

很多人把同步简单理解为“互斥”:同一时刻只能有一个线程进入临界区。这个理解没错,但不完整。锁真正做的事有两件:互斥 + 内存屏障。

互斥保证的是“操作不交叉”,内存屏障保证的是“进入临界区前,能看到前一个持锁线程写入的所有东西”。如果锁只有互斥没有屏障,那线程 A 在锁里写的变量,线程 B 拿到锁之后可能还是读不到最新值。所以 JDK 的synchronized、ReentrantLock在底层释放锁的时候,都会插入StoreStore、LoadLoad这些屏障,目的就是让临界区内的修改对其他线程可见。

理解了这一点,你对锁就会有更清醒的认识:加锁不是给某段代码“贴标签”,而是给这段代码涉及的共享内存建立一套“发布-订阅”的规则。锁内的读写,都要假设其他线程会同时参与。这也是为什么很多并发 bug 出在“你以为锁保护了数据,其实没有”的场景——比如 HashMap 的某个操作在锁外,另一个线程却同时改它,那崩溃就不奇怪了。

3.2 互斥锁/读写锁/条件变量/信号量怎么选

同步工具五花八门,选错了后面全是坑。我做项目时通常按这个思路选:

  • 只是保护一段短临界区,用互斥锁(synchronized或ReentrantLock)。如果锁竞争不激烈,两者差别不大;竞争激烈且有超时、可中断需求,选ReentrantLock。
  • 读多写少,用读写锁(ReadWriteLock、StampedLock)。但要注意,读写锁在读多写多的场景反而更慢,因为写线程会被大量读线程饿死或者频繁来回切换。JDK 的StampedLock还提供了乐观读,适合写极少、读极多的缓存场景。
  • 需要等待某个条件成立再继续,用条件变量(Condition),配合await()和signal()。这里最容易写错的是signal()可能只唤醒一个线程,而那个线程等待的条件恰恰没满足,导致“假唤醒”问题。所以等待条件必须放在循环里检查,这是一个几乎每个初学者都会踩的坑。
  • 信号量适合控制并发访问的资源数量,比如连接池、限流器。Semaphore的许可证数量就是并发上限,acquire 拿不到就阻塞等待。

我不建议“一把锁走天下”。曾见过一个服务,所有共享数据都用一把大锁保护,代码是简单了,但并发能力直线下降。锁的粒度要做成“可论证的最小”:先定位真正的共享数据,看看哪些操作必须原子,再决定锁范围。很多时候锁内只有几行代码,但为了图省事把整个方法锁住,性能就差在那些不必要的等待上。

3.3 原子操作与无锁编程:CAS的适用边界

原子操作和锁解决的是同一类问题,但思路完全不同。锁是让线程排队,原子操作是让操作在硬件层面不可分割,最典型的就是 CAS(Compare And Swap,比较并交换):先读值,比较是否等于期望值,如果是就更新成新值,整个过程原子完成。

CAS 的好处是线程不需要阻塞,适合短操作。JDK 的AtomicInteger、AtomicLong就是基于 CAS 实现的,ConcurrentHashMap在不用锁时也大量用 CAS。但 CAS 有三个明显的坑:

一是 ABA 问题:线程读取值为 A,中间其他线程改了又改回 A,CAS 会误以为没变过。解决思路是加版本号,JDK 的AtomicStampedReference就是干这个的。

二是自旋开销:如果竞争非常激烈,CAS 一直失败,线程就在那空转,白白烧 CPU。CAS 只在低冲突场景下是高效的,高冲突场景反而比锁更差。

三是只能保护单个变量:CAS 保护的是一个字段,没法直接保护一组字段的一致性。你想同时更新两个字段还要求原子性,要么用AtomicReference包成对象,要么还是老老实实加锁。

所以无锁编程并不是银弹。我的经验是:短操作、低竞争、用 CAS;长操作、复杂不变量、用锁。强行无锁反而会让代码难懂、难维护。网上那些“无锁队列性能碾压有锁队列”的 benchmark,往往只测了低竞争场景,真实业务里通常没那么理想化。

3.4 死锁:四个必要条件与现场排查

死锁是线程协作里的最经典的灾难。四个必要条件:互斥、持有并等待、不可剥夺、循环等待。代码里最常见的成因是嵌套锁顺序不一致:线程 A 持锁 L1 等 L2,线程 B 持锁 L2 等 L1,两个谁也不让,就死锁了。

排查死锁最好的工具是线程 dump。在 JDK 环境下,jstack打出来的 dump 里如果有 “Found one Java-level deadlock” 字样,会直接标出死锁的线程和锁对象,非常直观。生产环境用jcmd Thread.print或者压测时快照,基本能立刻定位。

还有一个更隐蔽的场景:不是典型死锁,而是“活锁”。线程没有被阻塞,但一直在做无用功,互相让步,永远没有进展。活锁的排查比死锁难得多,因为它不表现为卡死,表现为 CPU 高但业务不动。我遇到过一次,两个线程都检测到冲突,然后都主动让出资源,结果循环往复。后来在代码里加了随机退避才解决。这类问题,靠看代码比靠看 dump 更有效,也说明多线程出问题时不能只依赖工具,还得对代码逻辑有整体把握。

4. 线程池:参数不是随便填的

4.1 为什么要用线程池:创建线程的隐性成本

线程池的本质是复用线程,避免频繁创建销毁的开销。但线程池的意义不只是“复用”两个字,它还天然提供了任务队列,可以实现流量缓冲、排队、拒绝策略,这些在突发流量下非常关键。

如果不考虑这些,直接每个请求new Thread().start(),你会面对三个问题:一是线程创建销毁开销大;二是线程数不可控,可能瞬间创建几万个线程把内存打爆;三是缺少背压机制,系统扛不住时不会优雅降级,而是直接 OOM。线程池把线程数量钉死在一个范围里,任务来了先排队,排不下再按策略处理。这就是生产环境必须用线程池的根本原因。

4.2 核心参数背后的计算逻辑

线程池有几个核心参数,很多人只是照抄默认值或者网上搜一个配置来用。这里我分享一下我自己确定参数的思路:

核心线程数和最大线程数的关系,我倾向于把核心线程数设为“稳定负载下的并发数”,最大线程数设为“峰值容忍并发数”。稳定负载怎么估算?简单版公式是:并发线程数 = CPU 核心数 * (期望CPU利用率) * (1 + 等待时间/计算时间)。如果你的任务是 IO 密集型,等待时间远大于计算时间,线程数可以超过核数很多;如果是 CPU 密集型,线程数接近核数即可,多了反而因为切换变慢。

举个例子:一个 8 核机器,任务的平均计算时间是 20ms,IO 等待时间 80ms,期望 CPU 利用率 80%,那么合理线程数大约8 * 0.8 * (1 + 80/20) ≈ 32。这只是初始值,真实上线前要用压测校正。我通常先定一个保守值,压测看 CPU 利用率和队列入队速率,再逐步调整。

最大线程数不是越大越好。之前说过,线程超过核数之后,切换成本会快速上升。如果队列是无限队列,最大线程数甚至永远不会触发。很多框架默认就是无限队列,你要真以为设置了最大线程数就有保护,那就错了——队列无限长,任务只会积压,不会触发拒绝策略。

4.3 队列策略与拒绝策略:线程池参数调整实战

队列选型和拒绝策略,是线程池参数里最考功力的地方。直接上结论:有界队列要好于无界队列,因为无界队列在流量突发时会让内存无限增长;队列大小一般控制在能容忍的排队延迟内。如果单个任务处理时间 100ms,队列长 100,理论上等待上限 10 秒。你要考虑这 10 秒用户能不能接受。

拒绝策略我平时按业务场景选,比较常用的是这几个:

  • AbortPolicy(默认):直接抛异常。适合不允许丢任务的场景,但异常得有人接住,否则可能影响主流程。
  • CallerRunsPolicy:任务回到提交线程执行。我刚入行时觉得这个策略很“蠢”:线程池满,反而让提交方自己干?后来想明白了,这是一种背压,提交方自己执行就会变慢,从而少提交任务,系统自动限流。这个策略在真实业务里其实很好用。
  • DiscardPolicy和DiscardOldestPolicy:适合可丢的任务,比如日志上报、打点统计,丢了不影响主流程。

我踩过一次坑是线程池 + 超时控制的组合。业务方提交一个任务后调用Future.get(timeout)等待,结果线程池拒绝策略是AbortPolicy,任务一旦被拒,Future立即以异常结束,这本身没问题;但任务没执行成功,业务方没做好兜底,导致数据状态不一致。所以线程池改动一定要梳理好“任务被拒之后,系统怎么闭环”,而不是让异常裸奔。

5. 常见问题与排查技巧实录

5.1 线程安全问题的三类典型症状

线上多线程问题,我归结下来基本就三类症状:

第一类是结果不正确,表现为计数器不对、库存扣错、金额对不上。这种基本是复合操作没加锁,或者锁的粒度不对。排查时优先看共享变量,再分析有没有“读-改-写”这种复合操作。

第二类是线程卡死,表现为接口无响应、进程不退出。用jstack看线程状态,基本能区分是互斥锁竞争,还是条件等待没人唤醒,还是死锁。卡死类问题最怕乱重启,因为现场丢了,问题可能再难复现。

第三类是性能问题,表现为 CPU 高、吞吐低。这类最复杂,可能是线程数设错、假共享、锁竞争太激烈、频繁上下文切换。我一般先看活跃线程数和 CPU 核数的比例,再看锁的等待统计,最后才考虑缓存优化这种细粒度问题。

排查顺序我建议:先看线程状态,再看 GC 日志,然后看锁统计,最后才深入到代码逻辑。很多时候问题不在某一个线程,而在它们之间的协作模式。

5.2 假共享:一个被忽视的性能杀手

假共享是很冷门但确实存在的性能陷阱。多个线程分别操作不同变量,但这些变量恰好落在同一个 CPU 缓存行里(通常 64 字节),某个线程修改其中一个变量,导致整个缓存行失效,其他线程的变量也不得不重新从内存加载。因为缓存行是缓存同步的最小单位,不同线程明明没有共享数据,却被“假装共享”拖累了性能。

经典的解决方案是缓存行填充(padding),在变量前后填充一些无意义的字节,让变量独占一个缓存行。不过现代 JDK 里,某些并发容器内部已经做了类似优化,@Contended注解在 JDK 8 之后也可以控制填充。我自己实践下来,假共享在极端高并发的计数器场景影响最明显,普通业务代码里很少成为瓶颈。所以不要为了“防假共享”去过度设计代码,先把业务并发逻辑理清楚再说。

5.3 排查工具与定位思路

最后分享一套我常用的排查思路。假设线上出现并发问题,我一般按下面的路径走:

  • 先拿线程 dump。jstack <pid>或者jcmd <pid> Thread.print,看线程大量停在什么状态、什么代码行。这一步能排除 70% 的问题,尤其是死锁和锁竞争。
  • 再看 CPU。top -Hp <pid>找到哪个线程在烧 CPU,转成十六进制线程号,配合 dump 定位到对应的 Java 线程。这个组合拳几乎能找到所有的热点循环和无锁自旋问题。
  • 第三步看 GC。GC 频繁也会表现为线程卡顿、吞吐下降,因为垃圾回收时的 STW 会暂停所有业务线程。如果并发问题伴随着内存持续增长,先处理 GC 可能更有效。
  • 最后才是加日志、做压测复现。并发问题很多是概率性的,日志打全了才好定位。但逻辑错误靠日志分不开的话,就得靠对代码的理解了——这也是为什么我一直强调线程知识的系统性,而不只是会调 API。

排查过程里,我最喜欢用的一个技巧是“和线程同频率思考”:你在看一个问题的时候,把自己当成那个卡住的线程,它现在正在等什么?它是被谁占用了资源?它有没有可能永远等下去?这种代入式的分析,比死记硬背排查流程管用得多。

6. 一些项目里的“再谈”心得

这篇文章从线程的本质一直写到线程池和排查,算是我对“再谈线程”这个主题的完整回答。回头看这些年踩过的坑,我想把最有价值的几条经验总结在这里,不是要凑总结,而是真心觉得这些比任何框架知识都值得记下来。

第一,线程的标识不只是状态,更是资源。每个线程都在消耗栈、内核对象和调度时间,设计并发系统时先把“最多能有多少个线程”这个问题想清楚,比选什么锁重要得多。

第二,同步工具选型,要按同步的真实需求来,不是按“哪个看着高级”来。能用原子操作解决的就不上锁,能用局部变量解决的就不动共享状态,能用有界队列解决的就别依赖无界队列。

第三,线程问题最好的排查方式是预防。加锁之前先问自己一句:这段代码真的需要锁吗?保护的数据范围到底是多少?线程池参数设完有没有想过拒绝之后怎么办?多问几句,线上事故常常就能避免。

很多年前我第一次用线程池时,傻乎乎地设了 200 个线程就觉得优雅得很,结果上线后被压测数据狠狠教育了一顿。后来才慢慢意识到,多线程编程真正难的不是把线程开起来,而是搞清楚线程之间的协作边界和性能代价。希望这篇“再谈”能让你少走一些我当年走过的弯路。如果你在自己的项目里也遇到过有意思的线程问题,欢迎按上面的思路去复盘,相信你会收获比我更多的细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询