线程间的友谊小船,说翻就翻。这句玩笑话,现在成了我们工位上最真实的写照——多线程协作本身是为了把活儿干得更快,可一旦共享资源没管好、锁的时序没理顺、生命周期交接没谈拢,死锁、竞态、数据错乱这些翻车现场说来就来。干这行越久越明白,线程之间能不能和平共处,靠的不是运气,而是对几个底层原语的把握:互斥怎么上、等待怎么等、线程池怎么配、出了问题怎么定位。这篇博文从线程与进程的关系开始,把线程安全、锁与协作、线程池参数与阻塞队列选择、死锁排查、Java 21 虚拟线程、以及 Akka、Qt、Android 等不同框架下的线程模型整条线全部捋一遍,文中代码和排查命令都是可以直接复制到项目里用的。适合主写 Java、但日常也会碰其他语言并发场景的开发者,看完全文建议对照你自己的线程池配置和加锁逻辑重新过一遍。
1. 先弄明白:线程间的友谊建立在哪
1.1 线程与进程:分清家庭与成员
很多人一开始就把进程和线程搞混,导致后面看问题全歪了。简单说,进程是资源分配的基本单位,线程是 CPU 调度的基本单位。一个进程崩溃了不会直接影响其他进程,但一个线程抛了未捕获异常,整个进程都可能跟着崩掉,友谊小船瞬间两翻。
我用一个生活类比:进程像一栋公寓楼,每栋楼有独立的墙和管线(内存空间、文件句柄),线程是楼里的住户,共享水电和公共区域(堆内存、静态变量),但每户人都有自己的房间(虚拟机栈)和私人物品(寄存器状态、程序计数器)。住户之间可以串门,但门牌号得记清楚,走错房间就是非法内存访问。
在 Java 里,进程与线程的边界尤其明显。Runtime.getRuntime().availableProcessors()拿到的是机器逻辑核数,这个值通常用来算线程池核心线程数。每个线程默认栈大小在 64 位 JVM 上约 1MB,所以理论上 JVM 能创建几千个线程,但几千个线程同时竞争 CPU 时,光上下文切换就能把性能拖垮。这也是为什么后面讲线程池时强调“复用”比“创建”重要。
1.2 线程安全:友谊的底线
线程安全本质上就一个问题:多个线程同时访问了共享可变状态。三个底层原因拆开看:
第一,原子性。count++在字节码层面是“读、改、写”三步,两个线程同时执行就可能在“读”完之后都基于旧值做加法,结果少加了一次。第二,可见性。每个线程有自己的工作缓存,一个线程改了共享变量,另一个线程可能读到的还是缓存里的旧值,就像两个人同时编辑同一个 Excel 文件,一个改完保存了,另一个看的还是打开时的版本。第三,有序性。编译器和 CPU 可能会对指令进行重排序,在多线程下这种优化会导致匪夷所思的时序问题。
解决思路就两条线:volatile解决可见性和有序性,但解决不了原子性;synchronized、Lock以及AtomicInteger这类机制解决原子性,同时也能把事情限制在临界区内执行。判断一段代码是否线程安全,最靠谱的口诀是:这行代码到底动没动共享数据。没动共享数据,不用加锁;动了共享数据,就得想清楚并发访问时会不会出乱子。
1.3 线程的状态:友谊发展的六个阶段
线程生命周期是排查一切并发问题的底层地图。Java 线程状态分为六个:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED,对应关系如下表:
| 状态 | 进入方式 | 说明 |
|---|---|---|
| NEW | new Thread() | 创建后未启动 |
| RUNNABLE | start() | 运行中或等待 CPU 时间片,包含就绪和运行 |
| BLOCKED | synchronized 竞争锁失败 | 等待监视器锁,阻塞在同步块外 |
| WAITING | wait()、join()、park() | 无限期等待其他线程唤醒 |
| TIMED_WAITING | sleep()、wait(timeout)、join(timeout) | 有超时时间的等待 |
| TERMINATED | run() 正常返回或异常退出 | 终止 |
一个特别容易踩的坑是:RUNNABLE其实同时包含了“正在 CPU 上执行”和“排队等 CPU 时间片”两种状态,很多人看 jstack 发现线程是RUNNABLE就以为它在疯狂计算,其实它可能只是等待调度。反过来,BLOCKED是阻塞在synchronized锁上,而Lock接口的锁等待会显示为WAITING(park),这个区别在死锁排查时至关重要,后面会重点讲。
2. 让友谊小船不翻的三大基本功
2.1 互斥:synchronized 与 Lock 的正确姿势
互斥是线程协作的地基。Java 里最基础的是synchronized,它有三种用法:同步实例方法锁的是当前对象 this,同步静态方法锁的是 Class 对象,同步代码块锁的是括号里的对象。锁对象选不对,等于没锁——我见过最经典的坑是把锁加在String常量上,结果 JVM 里同一个字面量被多个完全不相关的逻辑共享,一个线程锁了大半个应用。
Lock接口的代表是ReentrantLock,区别在于它支持超时获取锁、可中断获取锁、多个条件队列(Condition)。做一个简单对比:
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 锁获取 | 简单直接 | tryLock(timeout) 避免死等 |
| 锁释放 | 方法结束自动释放 | 必须手动 unlock,finally 里释放 |
| 可中断 | 不支持 wait 时响应 interrupt | lockInterruptibly() 可中断 |
| 条件队列 | 只有一个 wait set | 可创建多个 Condition |
| 性能 | JDK 6 后优化,差距很小 | 类似 |
我的选择标准很简单:默认synchronized,代码更干净;需要“拿不到锁就放弃”或者“多个条件队列”时再上ReentrantLock。另外提醒一句,ReentrantLock的unlock()一定要放在finally里,否则方法中途抛异常锁就永远不释放,这比死锁还难排查。
2.2 协作:wait/notify 与 Condition 的标准套路
互斥解决了“同一时间只有一个人能动手”的问题,但线程之间还需要通信——你做完了一步,告诉我该我了。经典的wait/notify协作模式是生产者消费者模型,标准写法如下:
synchronized (sharedQueue) { while (sharedQueue.isEmpty()) { // 必须用 while 而不是 if sharedQueue.wait(); } Object item = sharedQueue.poll(); sharedQueue.notifyAll(); }为什么必须用while而不是if?因为存在“虚假唤醒”问题,线程可能在没有任何 notify 的情况下被唤醒,集合里有多个消费者时也会出现一个唤醒线程发现队列又被别人取空了的情况。用while重新检查条件,不满足就继续等,这是写进 Java 并发编程规范里的硬要求。
notify和notifyAll也有讲究:notify只唤醒一个等待线程,效率略高,但如果唤醒的那个线程条件仍不满足又继续等,就可能导致任务停滞;notifyAll唤醒所有线程,让它们重新竞争条件,稳妥但会带来额外上下文切换。我一个很朴素的建议:不能 100% 确定唤醒条件场景时,用notifyAll保平安。Lock体系对应的Condition能解决synchronized只能有一个等待队列的痛点,生产者和消费者各有一个Condition,互不干扰。
2.3 等待所有线程完成:join、CountDownLatch 与 CompletableFuture
日常开发里“主线程等所有子线程干完再合并结果”这个需求太常见了,热搜词里“java线程等待都完成”基本就是这意思。三个工具各有适用场景:
Thread.join()是最原始的方式,主线程调用t1.join(); t2.join();依次等待单个线程结束,适合线程数少且固定、不需要汇总结果的场景。CountDownLatch适合“一个主线程等待 N 个子任务都到达某个同步点”,计数归零后所有等待的线程一起放行,但它是“一次性”的,用完不能重置。
实际业务里我最常用的是CompletableFuture:
List<CompletableFuture<Result>> futures = tasks.stream() .map(task -> CompletableFuture.supplyAsync(task::execute, threadPool)) .collect(Collectors.toList()); List<Result> results = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) .thenApply(v -> futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList())) .join();这里allOf(...).join()负责等待全部完成,CompletableFuture::join逐个取结果,单个任务失败也不影响整体流程统一处理异常。这段代码的好处是任务可以并发执行、结果自动汇聚,并且不会像手写wait/notify那样容易写出死锁。唯一要注意的是:supplyAsync默认用ForkJoinPool.commonPool(),它会被很多框架共享,高并发下容易互相排队,务必要像上面代码里那样显式传入自己配置的线程池。
2.4 中断与守护线程:体面退出的艺术
线程关闭是所有协作里最考验素养的部分。早期的Thread.stop()已经被废弃,因为它会直接释放线程持有的所有锁,导致共享数据停留在中间状态,相当于友谊小船直接被炮弹轰沉。正确做法是通过中断机制协作退出。
public void run() { while (!Thread.currentThread().isInterrupted()) { try { // 业务逻辑 TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { // 恢复中断状态,让上层感知 Thread.currentThread().interrupt(); } } }有个特别容易忽略的细节:InterruptedException被捕获时,JVM 会清除线程的中断标志位,所以如果你在 catch 块里直接吞掉异常,循环条件isInterrupted()就永远为 false,线程退也退不干净。正确的做法是捕获后重新调用Thread.currentThread().interrupt()恢复中断标志,或者直接return结束任务。
另外是守护线程。setDaemon(true)标记的线程是“后勤部队”,当 JVM 里只剩下守护线程时进程会直接退出,不会等待它们完成。适合场景是后台心跳、监控采集、缓存预热这类不需要记录落盘的活。守护线程别拿去跑业务核心任务,比如写数据库、发消息,进程一退出就是静默丢失,线上事故往往就是这么来的。
2.5 怎么知道当前跑的是谁
排查并发问题第一步永远是搞清楚“现在这个日志是谁打的”。Thread.currentThread().getName()是最基本的工具,但很多人的线程名是pool-1-thread-3这种自动生成的名字,日志里一眼望去根本分不清是哪个业务。我的习惯是启动线程池和创建线程时,一律通过自定义ThreadFactory命名:
ThreadFactory namedThreadFactory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { return new Thread(r, "order-async-worker-" + seq.getAndIncrement()); } };别小看这个细节。有一次线上排查慢接口,就是因为所有线程池都用默认命名,日志里的线程 ID 和业务完全对不上,绕了一大圈。后来用带业务前缀的线程名,定位问题直接靠 grep 就能缩小范围。后面讲线程池配置时还会再提它。
3. 线程池:从“单打独斗”到“编队管理”
3.1 为什么别裸 new Thread
每次new Thread(...).start()意味着一次完整的系统调用创建内核级线程,任务执行完再销毁,整个过程开销很大。更糟的是,线程数量一旦没有上限,CPU 时间全浪费在线程切换上,每个线程的执行效率反而断崖式下降。
很多人以为“线程池的作用是减少线程创建开销”,这只是最表层。它真正的价值有三个:第一,复用,避免重复创建销毁;第二,限流,通过线程数上限保护系统不被突发流量冲垮;第三,统一管理生命周期,比如优雅关闭、监控活跃线程数。我经常把线程池比作餐厅的后厨:厨师(线程)就那几个,排队的人再多也得一桌一桌炒,不然十几口锅同时开火,厨房连转身的地方都没有。
3.2 ThreadPoolExecutor 七个参数逐个拆
ThreadPoolExecutor有七个核心参数,网上资料多但能真正讲明白执行顺序的少。我先给结论再给解释:
新增任务时的完整流程:先看核心线程有没有空闲,有空闲直接用;没有空闲就尝试把任务放进阻塞队列;队列满了再看当前线程数有没有达到最大线程数,没到就创建非核心线程;到了最大线程数还处理不了,就触发拒绝策略。
这个顺序是最关键的认知——好多人以为线程池是“线程不够就加线程、加到上限才排队”,实际恰恰相反:先排队,队满了才加线程。参数逐个说:
corePoolSize是常驻线程数,核心线程默认不回收(除非设置了allowCoreThreadTimeOut(true));maximumPoolSize是允许的最大线程数;keepAliveTime是非核心线程空闲多久被回收;workQueue是任务队列,决定“排队”的逻辑;threadFactory是线程工厂,用来命名线程;handler是任务拒绝策略。
怎么定corePoolSize没有银弹,但有一个常用估算公式:
- CPU 密集型任务,核心线程数约等于 CPU 核数 + 1,因为 +1 是为了补偿偶尔的缺页中断和暂停;
- IO 密集型任务,核心线程数约等于 CPU 核数 ×(1 + 等待时间 / 计算时间)。比如接口里 80% 时间在等数据库返回、20% 在计算,整机 8 核,那就是 8 ×(1 + 4)= 40。
实际项目里我还会在公式基础上加一个“安全系数”,取公式结果的 50% 作为初始值,然后通过压测不断往上调。因为这公式是理论值,真实的锁竞争、GC、网络波动都会影响结果,一上来拉满反而容易把下游服务击垮。
3.3 阻塞队列怎么选
阻塞队列的选择直接决定线程池行为。常见类型对比如下:
| 队列 | 特性 | 适用场景 | 隐患 |
|---|---|---|---|
| LinkedBlockingQueue | 默认无界链表队列 | 任务量可控 | 队列无限增长,maximumPoolSize 失效 |
| ArrayBlockingQueue | 有界数组队列 | 需要明确限制积压 | 容量过小触发拒绝 |
| SynchronousQueue | 不存任务直接转交 | 希望“来一个立刻处理” | 没空闲线程就立即创建非核心线程 |
| PriorityBlockingQueue | 带优先级排序 | 任务有优先级要求 | 入队出队顺序与等待时间无关 |
| DelayedWorkQueue | 延迟任务专用 | ScheduledThreadPoolExecutor | 容量默认极大 |
一个线上事故的典型链条是:使用无界LinkedBlockingQueue,下游服务变慢后任务在队列里无限堆积,内存里攒了几十万个请求对象,maximumPoolSize和拒绝策略全部形同虚设,最终 OOM。所以我的铁律是:生产环境队列必须设容量上限,哪怕设个很大的数,也要让“队列满”这个信号能触发后续保护逻辑。
3.4 拒绝策略怎么配
当队列满了、线程也加到最大仍然处理不了新任务,handler就登场了。四种内置策略没有绝对好坏,关键看业务容忍度:
AbortPolicy是默认策略,直接抛RejectedExecutionException,任务丢失,调用方感知到异常。CallerRunsPolicy是被拒绝的任务由提交它的线程自己执行,这是一种天然的降级——因为提交线程去执行任务了,就空不出手继续提交新任务,相当于把压力反向传导给调用方,生产环境里这份“不丢任务”的保底最常用。DiscardPolicy静默丢弃,容易造成任务无感知丢失,一般只在日志、统计这种“丢了无所谓”的场合适用。DiscardOldestPolicy把队列里最早的任务丢给新任务让位,适合实时性要求高、旧任务意义不大的场景。
我的配置习惯是核心业务线程池用CallerRunsPolicy,同时配合监控:当触发拒绝策略时记录一个告警计数,因为拒绝策略触发本身就是“系统过载”的信号,不该被吞掉。
3.5 Java 21 虚拟线程:还需要手动调线程池吗
虚拟线程是 Java 21 正式版的重大更新,本质上是由 JVM 调度的轻量级线程,用户态创建销毁,成本极低,数量可以达到百万级别。它的核心设计理念是“一个请求一个线程”,线程阻塞在 IO 等待时会自动让出载体线程,不像平台线程那样傻等。
Spring Boot 3.5 启用虚拟线程非常简单,配置项一行搞定:
spring.threads.virtual.enabled=true配置之后容器处理每个请求时就会使用虚拟线程。但这里必须泼盆冷水:虚拟线程不是线程池的万能替代品。两个典型反例:
第一,CPU 密集型计算场景,线程数超过核数并不会加速,反而增加切换开销;面向 CPU 任务的核心线程数仍需按核数控制。第二,虚拟线程并发量暴涨后,如果业务里大量同步获取数据库连接,数据库连接池会被瞬间打满,之前平台线程受限于线程数量反而“幸免于难”,虚拟线程则把并发压力直接传导给了连接池。所以网上有人开玩笑说:用了虚拟线程,才发现自己系统的瓶颈在连接池、在锁、在下游接口。
我的建议是:新项目、IO 密集型业务可以把虚拟线程当作默认选项,但保留对共享资源(如连接池、外部服务)的限流意识;已有的线程池代码不急着全量替换,先拿一个低风险接口试点,压测对比线程池方案和虚拟线程方案的吞吐与资源占用,数据说话。
4. 友谊翻船现场:死锁与诊断
4.1 死锁:四个条件齐了才翻船
死锁定义为“两个或多个线程互相持有对方需要的资源,且都拒绝释放”,教科书上有四个必要条件:互斥、持有并等待、不可剥夺、循环等待。四者缺一不可,所以理论上破坏任何一个条件就能避免死锁。
最经典的 Java 死锁如下:
class Account { private final Object lock = new Object(); int balance; void transfer(Account target, int amount) { synchronized (this.lock) { // 先拿自己 synchronized (target.lock) { // 再拿目标 this.balance -= amount; target.balance += amount; } } } }当线程 A 执行a.transfer(b, 100),线程 B 执行b.transfer(a, 100)时,A 持有 a 的锁等 b,B 持有 b 的锁等 a,循环等待成型,死锁发生,程序永久卡住。
破坏条件的最简单实战法就是“固定加锁顺序”:约定任何转账操作都先锁 id 小的账户再锁 id 大的账户,这样循环等待被打破。其次可以用tryLock(timeout)在短时间内拿不到第二个锁就释放已有锁后退,虽不优雅但保险。再就是缩小锁的粒度,把转账过程对两个账户的操作拆成两段临界区,虽然引出一致性窗口,但总比死锁强,这需要业务允许。
4.2 现场取证:jstack 完整排查过程
代码写进生产环境前你根本不会意识到死锁离你有多近。真出了问题,第一件事就是抓现场:
# 找到目标 Java 进程 jps -l # 导出线程 dump jstack 12345 > thread_dump.txt # 或者 jcmd 12345 Thread.print > thread_dump.txt打开 dump 文件后先搜关键句:Found one Java-level deadlock,JVM 已经帮我们把死锁环指出来了。正常情况 dump 里会显示类似下面的依赖关系:
"thread-B" waiting to lock 0x000000076b4f5d28 (a Account$lock) "thread-A" waiting to lock 0x000000076b4f5c08 (a Account$lock) "thread-B" locked 0x000000076b4f5c08 "thread-A" locked 0x000000076b4f5d28看到两组locked和waiting互相指向对方时,死锁确凿无疑。注意看每个线程的状态:卡在synchronized上是BLOCKED,卡在ReentrantLock上是WAITING (parking)。这两个状态区分出来,你立刻就知道是哪种锁出了问题。
没有现成 dump 就临时抓也可以,但更好的习惯是提前给 JVM 加上-XX:+HeapDumpOnOutOfMemoryError,并在容器里挂好 jstack 脚本,出事时能 30 秒内出现场材料。
4.3 线程方程组与等待图:看穿互相等待的闭环
“线程方程组”这个热词,我理解就是线程转储里所有waiting和locked关系抽出来后形成一个互相等待的依赖图。每一条“线程 T1 正在等待 T2 持有的锁 L”就是方程里的一个等式,把所有等式列出来,找到闭环,就是死锁环。
实操步骤很简单:打开 dump 后,把所有locked的锁地址(比如0x000000076b4f5d28)和所有waiting to lock的锁地址提取出来。如果线程 A 的locked地址出现在线程 B 的waiting列表里,就画一条 A → B 的箭头;顺着箭头找一圈,能回到起点就说明存在环。这个环节手工做下午茶级别的工作量,但并发多的时候容易看漏,我建议用小脚本把每行waiting to lock与locked对应关系列出来,再照着对。
如果还觉得不够,生产环境可以用 JFR(JDK Flight Recorder)录制一段线程争用数据,jfr view直接展示阻塞关系热点,比人肉翻 dump 高效得多。
4.4 线程监测工具全家桶
这里整理一下我实际用下来口碑最好的几个工具:
| 工具 | 场景 | 亮点 |
|---|---|---|
| jstack / jcmd | 日常堆栈快照 | 最直接,死锁检测自带 |
| JConsole | 本地/测试环境 | 可视化线程状态和锁等待 |
| VisualVM | 本地/测试环境 | 线程时间线、锁视图 |
| Arthas | 生产环境 | 在线反编译、watch、线程栈过滤,无需重启 |
| JFR | 生产环境 | 低开销录制,支持事后分析 |
| Spark UI | 大数据场景 | 每个 Executor 的活跃线程、GC 时间、内存占用一目了然 |
大数据方向特别提一下 Spark:Spark UI 的 Executors 页签能看到每个 Executor 的活跃线程数和 GC 时间,如果 GC 时间占比高且线程数异常(比如大量BLOCKED),说明并行度设置或者持久化级别有问题,优先调整spark.executor.cores和内存配置。至于 QNX 这类嵌入式系统上查看单个线程指令的调试方法,和通用 JVM 差异很大,通常是pdebug挂调试器后按线程 ID 切上下文看 PC 值,这属于小众场景,这里只提一句,真有需要的读者去查对应平台手册即可。
5. 各框架线程协作的现实:消息、UI 与亲和性
5.1 Akka 的 Actor:用消息替代锁
前面讲的所有锁、等待、协作,本质上都在跟共享可变状态搏斗。Akka 的 Actor 模型换了一条路:不共享,就不需要锁。每个 Actor 有自己私有的状态,其他 Actor 不能直接访问,只能向它投递异步消息,Actor 内部串行处理消息队列里的消息。
类比一下:传统多线程编程就像几个室友共用一间乱糟糟的储物间,想拿东西得抢钥匙、排队、防止有人乱翻;Akka 的 Actor 模型更像是每个人有自己专用的信箱和抽屉,你需要什么就写信给对方,对方有空时把东西装回信封放到你信箱里。你永远不需要担心对方正在用某个抽屉时你去抢,因为抽屉根本不在你的权限范围里。
Akka 线程模型的另一点价值在于 Dispatcher 和 Mailbox 的配合:Dispatcher 决定消息到 Actor 时用哪个线程执行,Mailbox 负责排队,开发者不直接管理线程的锁和条件变量。代价是 Actor 间通信有异步延迟、消息协议设计得额外费心、定位跨 Actor 的调用链比定位线程堆栈难。所以选不选 Akka,取决于你的系统是不是天然适合“消息驱动 + 状态隔离”,不适合的硬用反而比直接写锁更别扭。
5.2 UI 线程的铁律:子线程不能直接碰控件
UI 框架里的线程协作全部有一个共同铁律:UI 控件只能在主线程(UI 线程)上操作。这条规则在不同语言里换了个马甲,本质一样。
Android 的典型报错是CalledFromWrongThreadException,你平时在子线程里做个耗时网络请求,拿到结果后想直接更新 TextView,就会看到它。正确姿势是runOnUiThread(() -> textView.setText(...))或者用Handler往主线程消息队列里投递一个 Runnable。Fragment 里开线程尤其要小心:页面退出后线程还在跑,等它回调时去更新已经销毁的 View,轻则空指针,重则内存泄漏。解决办法是在 Fragment 的onDestroyView里清理回调,或者用生命周期感知组件。
Qt 的解决方案是信号槽机制的跨线程队列连接:一个线程发出信号,由接收对象所在线程的事件循环处理槽函数;配合moveToThread可以把一个 QObject 整体搬去工作线程,这是 Qt 里比较正统的“线程内做活、跨线程通知”模式。
易语言的场景也同理:子线程里不能直接操作主线程的窗口控件,需要投递自定义消息回主线程的消息循环,或者用SendMessage让主线程代为触发事件。搞懂了底层逻辑,不管哪种语言,你其实都在处理同一个问题:把子线程的“结果数据”跨线程转交到主线程的事件队列。理解了这一点,换框架只是换 API 而已。
5.3 线程与 CPU 亲和性:为什么“独占一个线程”不是个好主意
热词里“W11 怎么设置一个程序单独占一个线程”指的是 Windows 任务管理器里的“设置相关性”,它对应的是 CPU 亲和性(Processor Affinity)功能,可以把进程绑定到指定 CPU 核上。但要注意:通用操作系统里,你设置的是“进程可以使用的 CPU 核集合”,是离散的选择,而不是真正意义上的“这个进程独占某个核”。系统内核照样会把其他进程调度到那几个核上,除非整个系统只有你一个重负载程序。
从实践角度看,我一般不建议普通业务程序设置亲和性。原因有三:第一,绑定核后 OS 调度灵活性下降,某个核忙到冒烟、旁边核空闲的情况时有发生;第二,现代 CPU 的睿频和热量管理需要跨核动态调度,绑死反而可能拖慢性能;第三,Windows 桌面场景下,把某程序绑到一个逻辑核上常常带来鼠标卡顿、输入延迟,因为中断和系统进程也依赖这些核。
真有低延迟需求(比如实时音频处理、硬化实时系统),正确路径是 QNX 这类实时操作系统里的线程绑定原语(pthread_attr_setaffinity_np),这是另一套体系。普通开发者遇到“程序占满全部核心导致其他程序卡”的问题,先检查是不是线程池开太大或者死循环,而不是想着绑定核心,方向对了才不会白折腾。
6. 常见线程问题速查表与经验清单
6.1 高频问题排查速查表
我把这几年线上和社区里最常遇到的线程问题整理成一张表,方便出问题时快速对照:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 程序卡住,接口不响应 | 死锁 | jstack 搜 deadlock,画等待环 |
| 偶发数据错乱、金额差一 | 竞态条件 | 加锁或原子类,检查共享可变字段 |
| CPU 100% 且不降 | 死循环正占着线程 | jstack 找 RUNNABLE 线程的栈顶 |
| 线程数暴涨 | 无界任务队列堆积 | 监控 workQueue.size(),设容量上限 |
| 数据库连接池耗尽 | 并发请求超过连接上限 | 核对线程池并发度与连接池池大小 |
| 偶发卡顿但 jstack 看不出 | JVM GC 频繁 | JFR 录制,看 GC 耗时与线程阻塞关联 |
| UI 卡死或闪退 | 主线程做耗时任务 | 子线程执行耗时逻辑,消息转交 UI 更新 |
| 日志里线程名全是 pool-x | ThreadFactory 未自定义 | 自定义带业务前缀的线程名 |
6.2 几条实战经验
最后分享几条我做并发开发攒下来的经验,字字都是翻车换来的:
第一,写多线程代码之前,先花十分钟列一张共享资源清单。哪些对象会被多线程读写、谁负责加锁、锁的粒度是什么,都写下来。即使后面有改动,也容易快速判断影响面。
第二,锁的粒度宁小勿大。能用原子类就用原子类,能用读写锁就别用普通锁,能用非阻塞算法就尽量别阻塞。锁范围越大,线程间的相互等待就越频繁。
第三,线上不要临时再想办法抓堆栈。提前在监控系统里接好线程数、等待线程数、队列深度这几个指标,设置好告警阈值。死锁这种问题,等用户发现投诉再去查,往往现场已经被重启清了。
第四,测试阶段别在小并发下验证正确性。很多竞态问题要线程数多、执行频率高才会显形,压测和混沌测试(随机设置锁超时、随机线程中断)比单元测试更能暴露线程问题。
第五,不要一上来就追求“最优雅的方案”。有一种线程管理方案是能看懂最简单的方式先跑通,再逐步优化。上来就上无锁队列、Disruptor、协程,一旦出问题,排查成本对你的团队来说可能完全失控。
6.3 从友谊小船到系统化工程
啰嗦了这么多,脑子里的线收一收。真正把“线程间的友谊小船”开稳,靠的是把并发当成系统性工程:设计阶段排查共享资源,编码阶段用对锁和协作原语,上线前配好线程池和监控,上线后遇到问题用 jstack 和 JFR 快速定位。这套方法论没有一步是可以省掉的,省掉的每一步最后都会变成线上事故。
最后再说一句
做了这些年并发编程,我越来越觉得线程之间的友谊不是靠技巧维系的,而是靠规矩。谁动共享数据前先拿锁,谁等别人前先想清楚有没有循环等待,谁退出前先把状态交代清楚——这些规矩看着基础,但所有线上翻车事故,回头一查基本都是规矩没守住。上面这些工具和方法,都是我被现场教育过之后整理出来的。看这篇文章的各位,与其收藏夹里吃灰,不如现在就打开项目,把你线程池的核心线程数和队列大小都拉出来看一眼,再写一遍 ThreadFactory 自定义线程名。十分钟不到,但回报远超十分钟。