线程控制,这四个字看着简单,真正在线上跑过的人都知道,它往往决定了系统能不能撑住高峰流量。这篇“线程控制 下”继续聊线程控制,不处理线程概念和基础API,直接讲线程生命周期管理、锁的选择、线程间协作、线程池治理,以及线上出问题时怎么顺着线程状态排查根源。里面绝大多数内容,来自我在模拟项目X并发改造中的真实复盘,当时因为线程控制没做好,把一台四核八线程的机器跑到了几乎假死,后来一步步收敛,才把方案整理成现在这套实践思路。适合已经写过线程、但总觉得控制不住线程的开发者,也适合准备做并发优化的团队做参考。
1. 先想清楚:线程控制到底在控制什么
1.1 从一次线上卡顿说起
在模拟项目X里,我们有一个消息聚合服务,每天凌晨会有批处理任务,同一时间会有几百个并发请求进来。最开始没做任何控制,代码里new Thread()到处都是,每个请求都开新线程去处理数据。结果某一天凌晨,系统突然大量超时,CPU使用率冲到95%以上,线程数量从正常的几十个暴涨到几千个,堆内存也跟着被榨干。后来dump线程栈一看,大量线程阻塞在某一个共享资源上,还有一部分线程处于WAITING状态永远没人唤醒。当时我的第一个感受是:线程控制不是一个start/stop的问题,而是一整套“状态-协作-资源”的约束问题。
那次事故让我重新理解了线程控制。你以为你在控制线程,其实线程通常不受你控制。它由操作系统调度,由锁控制访问,由条件变量控制等待,由线程池控制生命周期。代码里能干预的,只是在这些节点上布好规则。如果规则不明确,哪怕每个线程都写对了业务逻辑,组合起来仍然会变成一场混乱。
1.2 控制三个核心维度:状态、协作、资源
很多人理解线程控制,以为就是控制线程的创建和销毁。实际上我后来在实践中总结,真正要控制的是三个维度。第一是状态,线程在不同时间点处于什么状态,由谁负责转换,转换之后数据是否可见;第二是协作,多个线程在同一时间对同一资源或者同一业务流程进行推进,如何分工、等待、唤醒,才不会出现漏通知或者假唤醒;第三是资源,线程本身占据内核资源,线程太多会导致上下文切换成本暴涨,线程太少又无法利用多核,资源控制的核心是让线程数量与任务性质匹配,同时对线程生命周期受控。
这三个维度并不是孤立存在的。状态转换经常由锁和条件变量触发,协作需要线程在合适的状态下停下或前进,资源控制又决定线程数量的上限。你会发现,只要一个维度没控制好,另外两个维度都会被拖累。比如线程池参数设置过大,线程数量失控,锁竞争变得激烈,协作效率反而下降。所以做线程控制,不要只盯着某个API,要从整体约束去设计。
1.3 两个前提先说清:并发模型和语言差异
线程控制在不同语言里的表述不一样。我用得最多的是Java,但C++、Go、Python也都跑过。本文大部分例子会偏Java生态,因为它的线程模型非常典型,线程状态在JVM里有一套可观测的规范,适合解释问题。但很多思路是通用的:比如Go里面的Goroutine虽然不直接对应操作系统线程,却也有调度和锁的概念;C++里的std::thread则需要手动管理生命周期。如果你用的不是Java,也可以把这里的状态机、协作模型、资源控制逻辑映射过去。
还有一点要提前说明:这里的“线程”在我讨论的大部分场景里,可以理解成一条独立执行流。不管它背后是OS线程,还是用户态协程,对“控制”的需求是一样的——谁启动它,谁让它等待,谁唤醒它,谁保证它不会泄漏。语言差异只是实现方式不同,控制思路完全可以复用。
2. 线程生命周期与状态控制实操
2.1 线程状态转换:不是只有运行和停止
JVM线程状态是排查问题的一大利器。线程主要分为:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。NEW是创建还没start;RUNNABLE是已经在执行,也包括操作系统里的就绪态;BLOCKED是等待进入synchronized临界区;WAITING是等待被显式唤醒;TIMED_WAITING是限时等待;TERMINATED是已结束。一开始我容易把BLOCKED和WAITING混在一起,后来才认识到:BLOCKED是竞争锁失败,WAITING是主动让出执行权,排查方向完全不同。
| 线程状态 | 触发场景 | 排查重点 |
|---|---|---|
| NEW | 线程对象已创建,未调用start | 检查是否忘了启动 |
| RUNNABLE | 正在执行,或等待CPU调度 | 可能是CPU密集或存在热点循环 |
| BLOCKED | 进入synchronized锁阻塞 | 重点看锁竞争和锁范围 |
| WAITING | 等待notify或park | 检查是否漏唤醒、死锁 |
| TIMED_WAITING | sleep、限时等待 | 查看等待时间是否过长 |
| TERMINATED | 正常退出或异常结束 | 关注异常是否被吞掉 |
在写控制代码时,我习惯先在注释里写明这个线程“允许在哪些状态间切换,每个状态由谁负责退出”。比如一个消费者线程,状态应该是WAITING等待队列任务,被唤醒后进入RUNNABLE处理消息,处理完再回到WAITING。没有这种状态设计,线程一旦卡在错误状态,只能靠经验猜测。
2.2 停止线程的正确姿势
线程停止是线程控制里最容易犯错的地方。早期Java的Thread.stop()会直接终止线程,释放掉所有锁,可能让共享数据处于残缺状态,现在基本是禁用状态。正确做法是协作式取消:在线程里设置一个中断标记,由线程自己决定在合适的时机退出。具体用Thread.interrupt()发出信号,在线程体内通过判断isInterrupted()或捕获InterruptedException来响应。要特别注意的是,当线程处于sleep/wait等阻塞状态时,interrupt会抛出InterruptedException,此时如果不处理中断标记,线程会继续运行,甚至丢失退出信号。建议在处理前调用Thread.currentThread().interrupt()恢复中断状态。
我见过不少新手代码,捕获InterruptedException后直接吃掉,然后继续跑循环,中断信号就丢了。这就相当于有人按了门铃,你听到了却不开门,对方自然不知道你已经知道。正确的协作式取消,应该让中断成为一个持续有效的信号,而不是一次性的异常。
这里还有一个细节:中断标志在一些方法被调用后会被清除。比如Thread.interrupted()会返回并清除标志,如果不小心在判断条件里用了这个方法,可能导致下一次判断失效。我的建议是,在普通循环场景里用一个volatile的boolean字段做取消标志,在需要等待的场景里用interrupt配合处理,两者结合更稳妥。
2.3 挂起和恢复的替代方案
在老代码里能看到Thread.suspend()和resume(),这两兄弟同样不建议用。因为挂起线程时不会释放锁,如果恢复动作迟迟不来,等于死锁。现代做法是用LockSupport的park/unpark,它按“许可”机制控制线程启停,不依赖锁,挂起时不会占住同步资源。也可以用信号量Semaphore或者阻塞队列做变相挂起。实际控制中,我更推荐用显式的状态标记加上条件等待,这样代码意图清楚,也方便排障。每次挂起和恢复都要考虑超时兜底,避免线程永久挂起。
在模拟项目X里,曾经有人写了一个重试调度线程,处理完一批后主动挂起,等待下一个触发信号。他用的就是suspend,结果触发的代码抛异常没走到resume,那个线程就永久沉睡,任务队列慢慢堆满。后来改成LockSupport.parkNanos加上超时重试,至少保证线程能自己醒过来检查条件,才彻底解决这个问题。
3. 同步与互斥:用锁之前先理解锁
3.1 锁粒度决定吞吐量
“锁”不是越严越好。我在模拟项目X里,最初用一把大锁锁住整个处理流程,正确性没问题,但并发上不来。后来把锁粒度从“锁整个流程”降到“锁共享资源最小范围”,吞吐量提升非常明显。例如在写入内存缓存并更新计数时,只需要在更新那两步加锁,而不是把整个请求处理过程都包进去。线程控制里一个把握原则:锁保护的代码越短,锁等待时间越短,并发能力越强。但锁粒度也不是越小越好,如果拆得太碎,原子性会被破坏,也会让锁数量变多。
判断锁粒度是否合适的办法很简单:看锁内代码是否包含不必要的耗时操作。如果有IO、网络请求、Sleep,这些都应该挪到锁外面。锁内只保留对共享变量的检查、修改,以及必须与其他操作保持原子性的处理。还有一个经验:如果两个共享变量总是同时被修改,一般建议用同一把锁保护,避免出现“先改了A再改B,中间被另一个线程读到不一致状态”的竞态问题。
3.2 锁的三种常见实现选择
Java里最常见的是synchronized和ReentrantLock,再往上还有基于CAS的原子类。synchronized用于临界区比较简单,锁自动释放,不容易漏解锁。但需要限时等待、可中断、多个条件变量时,用ReentrantLock更合适。原子类适合单变量读改写,利用CPU的CAS指令,不加锁也能保证可见性。选择逻辑我一般这样判断:如果只是保护一小段复合操作,优先synchronized;如果需要tryLock超时来避免死锁,用ReentrantLock;如果是简单计数累加,用LongAdder/AtomicLong这类原子变量。锁的选择先求对,再求性能,不要一上来就“高大上”。
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 短临界区、保护共享变量 | synchronized | 简单可靠,自动解锁 |
| 需要超时等待、可中断 | ReentrantLock | tryLock能避免永久阻塞 |
| 简单计数或单变量更新 | AtomicLong/LongAdder | 无锁CAS,吞吐量高 |
| 多个条件等待队列 | ReentrantLock + Condition | 条件控制更清晰 |
我在项目里有一个偏好:非必要不用ReentrantLock。因为synchronized的语义足够表达大多数互斥场景,代码也更短。但涉及“我要限制自己最多等100毫秒拿不到锁就放弃”这样的场景,synchronized做不到,必须用ReentrantLock或Semaphore。所谓“拿不到就走”的策略,对避免服务雪崩很关键。
3.3 死锁形成与控制思路
死锁是线程控制的头号杀手,四个必要条件:互斥、持有并等待、非抢占、循环等待。在工程里,循环等待最常见。两个线程各自持有一把锁,再去获取对方手里的锁,就会卡死。控制死锁最有效的办法是打破循环等待:要么给所有资源编号,线程按固定顺序获取锁;要么用一次性获取所有锁,获取不到就释放已有锁再重试;要么用ReentrantLock的tryLock,加超时。我习惯同时做两层:编码规范上要求统一加锁顺序,运行时用锁超时兜底,并在死锁发生时dump线程栈定位。
死锁不像空指针那样会立刻报错,它表现为整个线程组静默卡住。最头疼的是,出现死锁的代码平时可能跑得好好的,一旦并发压力增大才偶发。所以控制死锁必须在设计期就建立顺序。比如有两把锁A和B,代码里所有人都必须先拿A再拿B,不允许反过来。这条规则要写进代码评审清单,比事后排查靠谱得多。
4. 线程间协作与通信的工程化设计
4.1 等待通知模型:wait/notify的坑
线程间协作最基础的是wait/notify,但它们非常容易踩坑。第一个坑是丢失通知:如果notify发生在wait之前,等待线程会永远睡下去。第二个坑是虚假唤醒,因为操作系统或JVM的原因,wait可能在没有任何通知时返回。因此使用wait有一个铁律:必须放在while循环条件判断里,被唤醒后重新检查条件是否满足。同时,wait/notify必须持有同一把对象锁,且wait会释放锁。实际操作中,我一般优先用JUC包里的Condition,它语义更清晰,多个条件可以分开等待,避免了一个锁上多个等待集合互相打扰的问题。
我见过一个典型的丢失通知案例:一个生产者消费者模型,生产者先执行了notify,但消费者线程还没执行到wait,通知就这么错过了。之后消费者进入wait,再也没人叫它,整个系统卡住。解决方式要么是加状态标志,让wait循环先检查标志;要么直接把场景改成阻塞队列。阻塞队列内部用Lock和Condition实现,已经帮我们处理好了通知时序。
4.2 用队列解耦线程通信
比wait/notify更好维护的是生产者-消费者模型,中间放一个线程安全的队列,生产线程往队列放数据,消费线程从队列取。队列天然解决了等待和唤醒问题,而且可以通过有界队列实现背压:当队列满时,生产者阻塞,不会无限堆积。在模拟项目X里,我把原来的每一请求开线程,改成批量任务投递到有界队列,用固定数量的消费者线程处理,等于就给系统加了“流量闸门”。选择阻塞队列时,无界队列容易撑爆内存,有界队列需要在入队时设置拒绝策略或超时,建议优先用有界队列。
队列还有一个隐藏优点:它让线程控制变成数据流控制。你不再纠结某个线程该暂停还是该唤醒,只需要控制队列的长度和消费速度。消费线程满负荷时,队列自动积压;队列满时,生产者被阻塞,速度自然降下来。整个过程没有显式wait/notify,但线程之间却自动协调。这也是为什么线程池内部都带工作队列的原因。
4.3 线程协调工具:CountDownLatch、CyclicBarrier、Semaphore
除了队列,JUC还提供了一批协作工具。CountDownLatch适用于“等待N个事件完成再继续”,例如等待多个前置任务跑完;它是一次性使用的。CyclicBarrier适用于“N个线程互相等待,各自到达后一起放行”,它可以循环使用。Semaphore更像并发令牌,控制同时访问资源的线程数。这些工具本质上都是线程状态控制的高级封装。使用时要特别注意计数设置:CountDownLatch计数归零后不能再复用,如果还想复用就要换成CyclicBarrier。在线程池场景里,用CountDownLatch等待所有任务完成时,记得把await放在所有submit之后,避免在提交前就误判结束。
我曾经踩过一个不算罕见的问题:用CountDownLatch等待批量任务完成,结果把await写在了for循环提交任务之前,导致计数器还没被扣到零,主线程就提前放行,后面任务还在跑,数据已经当成“完成”处理。后来我把提交逻辑和await顺序理清楚,并且增加了超时等待,才算稳定。协作工具最容易出的问题就是“使用者对语义理解偏差”,所以用之前一定要先分清是一次性的门闩,还是可复用的屏障。
4.4 Future与线程结果回收
线程执行完,结果怎么拿回来?直接操作共享变量会有可见性问题,更自然的方式是用Future。把任务交给线程池后,用Future.get()阻塞获取结果;如果超时,要取消任务并处理任务内部的线程中断。在模拟项目X里,我遇到过一种情况:某个子任务不超时,Future.get()就会一直等,导致整个批处理卡住。后来给所有get加了超时,并且用CompletableFuture做异步编排,让结果回收集合到回调里,避免一个个阻塞等待。Future的取消也不是一定会生效,如果任务不响应中断,取消只是给线程发一个信号,真正执行中的任务可能继续跑,这点要预热在方案里。
Future.get的经典问题是“看似异步,实则同步”。你把任务提交到线程池,看起来是异步执行,但如果你紧接着对每个Future逐个get,那又变回了串行等待。更好的做法是把多个独立任务组合到CompletableFuture上,用thenCombine或allOf聚合结果。这样线程的等待让位给回调编排,控制逻辑也更清晰。
5. 线程资源治理:控制线程数量与线程池
5.1 线程池的“堵车”问题
线程池是线程控制里最重要的资源管理手段。很多人用了线程池,却不知道它的工作流程:提交任务时,如果工作线程数小于核心线程数,创建新线程;如果达到核心线程数,任务进队列;如果队列满了且工作线程数小于最大线程数,继续创建线程;如果所有线程都在忙且队列满,走拒绝策略。这个流程理解不透,就会出现任务满天排队、线程数飙高的“堵车”现象。现实中最常见的问题是:核心线程很大、最大线程更大、队列还是无界的,结果任务全部堆积在队列,线程数始终不增长,响应却越来越慢。这是典型的队列和线程参数不匹配。
线程池的运作有点像银行柜台。核心柜台固定开几个,来的人多时先排队。如果排队区满了,再开临时柜台。如果临时柜台也满了,就只能拒绝新客户。很多人把排队区设成无限大,结果临时柜台永远用不上,客户全部堵在排队区。系统看起来还有余力,实际任务却迟迟处理不完。正确的做法是让队列有界,并且通过拒绝策略或者降级保护来应对冲击。
5.2 线程池参数怎么定
线程池参数没有一个万能公式,但有一些参考方法。CPU密集型任务,线程数接近CPU核数,太多线程只会增加上下文切换;IO密集型任务,线程数可以大于核数,常见做法是核数乘以一个系数,例如2到4,具体要压测。队列容量要看任务堆积容忍度:希望快速拒绝而不是延迟处理,就把队列调小;希望削峰填谷,可以用稍大的有界队列。拒绝策略上,CallerRunsPolicy能让提交线程自己执行任务,适合降低吞吐换取不丢任务;DiscardOldestPolicy则适合允许丢弃旧任务的场景。我建议每组参数都基于压测数据调整,不要直接抄网上的固定值。
我记得有一次给一个IO密集的报表生成接口调整线程池,核心线程从8调到24,吞吐量确实上去了,但数据库连接池很快被打满,反而出现新的等待。后来才意识到,线程池参数不能只看线程本身,还要看下游资源能承受多少并发。线程数乘以每个线程的IO并发量,不能超过下游连接池上限。线程控制从来不只是控制线程,更是控制它背后占用的全部资源。
5.3 优雅停机与线程本地变量
线程池停机是线程控制里经常被忽略的一环。shutdown()不会强制终止正在执行的任务,只是不再接收新任务;要等待任务真正结束,需要配合awaitTermination。如果超时还未结束,就需要考虑shutdownNow(),它会返回未执行的任务列表,同时给正在执行的任务发中断。优雅停机的目标是:不要因为服务下线,把正在处理的请求直接切断,导致数据不一致。另外,线程池里的线程如果是复用的,ThreadLocal里可能残留上一次任务的数据,必须在使用结束后清理。我在模拟项目X里就在finally里调用remove(),发现跨任务串数据的问题立刻消失。
停机流程是很多人写完后永远不测的部分,直到发布时遇到“重启服务后任务重复执行”才发现问题。我的习惯是,在测试环境专门用一个接口触发停机,检查是否还有线程在跑、队列里还有多少任务、是否能在给定时间内退出。只有做过一次完整的优雅停机演练,你才敢在凌晨发布新版本。线程控制要覆盖的,不只是运行期,还包括结束期。
6. 常见问题与排查技巧实录
6.1 死锁排查:从日志到线程转储
线上出现卡顿,第一件事是拿到线程转储。Java里jstack是常用工具,它会打出每个线程的状态、持有锁和等待的锁。排查死锁时,重点找WAITING/BLOCKED线程并聚焦在等待关系上;多个线程互相持有对方需要的锁,就有死锁嫌疑。除了jstack,也可以用jconsole和Java Flight Recorder。经验是第一现场很重要:服务还在卡着的时候dump才有意义,等它恢复过来,线索可能已经消失。在模拟项目X里,我们是在问题出现后10秒内连着dump了三次,才定位到两个线程在锁顺序上交叉等待。
连着dump三次的原因是要排除偶然情况:第一次看到疑似卡住,第二次看是否还卡在同一位置,第三次确认线程状态是否变化。如果三次都在同一个点,基本可以断定是永久阻塞。如果线程状态在切换,可能只是慢,不是死锁。通过比较多次dump,能区分“卡死”和“性能退化”,排查方向完全不同。
6.2 线程泄漏和资源耗尽的特征
线程泄漏不像内存泄漏那样容易用GC日志看出来,但特征也很明显:线程数持续上涨,CPU上下文切换走高,内存里线程栈对象占空间越来越大。常见的来源是:每次请求都创建线程而忘记回收;线程池被多次创建没有复用;Future或异步任务抛异常后线程没有正确释放。控制资源的一个重要手段是对线程池设置监控指标,比如活动线程数、队列大小、拒绝次数。如果一个服务的线程池长期高水位,说明你的并发模型需要调整,该限流就限流,不要把线程资源当成无限可用。
我处理过一个比较隐蔽的泄漏:业务代码里用了线程池,但没有把线程池定义为静态资源,而是每次进入方法都new一个,用完又不shutdown。结果线程池里的线程全部存活,线程数缓慢增长,最终系统OOM。排查的时候看内存dump,线程对象占据了大量堆空间,很难一眼看出来。后来统一管理线程池实例,才从根上解决。线程池必须“复用”,这是一个资源管理原则,不是性能优化原则。
6.3 高并发下的线程控制清单
我梳理了一份检查清单,供团队在改动线程控制代码前对照。第一,线程生命周期是否可控:所有线程都能在业务结束时退出,没有循环等待或永久WAITING。第二,锁范围是否最小化,锁顺序是否固定,是否设置了超时。第三,协作是否依赖条件变量,是否避免了丢失通知和虚假唤醒。第四,线程池参数是否经过压测,队列是否有界,停机流程是否具备。第五,监控和日志是否能看到线程状态,出现异常是否能快速dump分析。这五条看着简单,每一条都能在事故复盘里找到对应案例。
这份清单不是写给别人看的,是自己真正每次上线前都要过一遍的。尤其是“线程生命周期是否可控”这一条,我会盯着代码里每一个循环和等待点,确认所有线程都有明确的退出条件。很多线上问题不是设计不出来,而是没有在收尾时确认线程真的能退出。你可以把清单做成表格放在文档里,每次变更后逐项打勾。
写到这里,我想起模拟项目X里那次事故复盘会上的结论:线程控制不是先把并发做上去再说,而是先把失控的底线画出来。如果你也在调线程,建议从最小化的受控改造开始,而不是一上来就上各种复杂的并发工具。把线程状态、锁、协作、资源四个维度分别画清楚,很多莫名其妙的问题会自愈。最后再分享一个小技巧:不管用什么语言,在线程代码的关键路径上打印足够详细的状态变化日志,会让你在后续排查时省下至少一半的时间。线程控制的下半场,拼的是规范和控制,而不是花哨的工具。