线程与并发排查手册:从线程池配置到死锁中断与UI线程实战

发布时间:2026/9/30 16:00:44
线程与并发排查手册:从线程池配置到死锁中断与UI线程实战
做后端开发这几年我发现自己对线程这块知识的掌握始终处在一种“好像会了又好像不会”的中间状态。平时写业务代码new Thread或者直接丢线程池就跑看起来顺风顺水可真到了线上出问题——线程池队列堆满、多个线程互相卡死、某个统计数据莫名其妙错乱——翻书翻文档半天能直接对着用的排查手册几乎没有。这份《黑马笔记线程/自用》就是在反复踩坑的过程里攒出来的定位就是给自己看的个人笔记把线程相关的概念、配置、协作方式和常见坑位全部按自己的理解重写了一遍。整理时还把网上高频搜的问题都收纳了进来比如线程池怎么配置、AtomicInteger到底安不安全、线程中断为什么不生效、UI 线程怎么操作控件这些。如果你也在补线程这块的知识这份笔记或许能让你少走几段弯路。1. 线程与进程先补上最容易被忽略的底层账1.1 进程是资源容器线程是执行单元关于线程和进程的区别教科书的标准答案是“进程是资源分配的最小单位线程是CPU调度的最小单位”。这句话是对的但对初学者来说并不直观。我习惯用公司做类比进程相当于一家公司的办公场地里面有独立的文件柜内存空间、独立的水电账户系统资源场地之间互不干扰线程相当于场地里的工位工位上的人才是真正在干活的主体。同一家公司里的多个工位共享文件柜和水电但每个人手头的工作内容、工作进度是独立的。这种设计带来的直接好处就是成本差异。创建进程要做资源隔离、地址空间复制开销大创建线程只需要为执行路径分配独立的栈空间开销小得多。在 Linux 下fork()创建进程和pthread_create()创建线程两者的性能差距是数量级的。也正因为如此绝大多数高并发应用都选择在线程层面做并发而不是无脑开进程。“线程与进程”这个搜索词下面翻来覆去问的本质都是这一层关系没理顺先搞清楚进程是“场地”线程是“干活的人”后面所有关于并发的问题都好聊了。1.2 线程切换不是免费的一次切换消耗几万个时钟周期很多人在“线程是不是开得越多越好”这个问题上栽过跟头。答案显然是否定的原因在于线程切换本身是有代价的。一次上下文切换要保存当前线程的寄存器现场、程序计数器、栈指针如果换到另一个 CPU 核心上执行还要面对缓存失效后的重建。粗略估算线程切换一次大概要消耗几万到几十万个 CPU 时钟周期换算成时间是几微秒到几十微秒。这意味着什么如果你的机器只有 8 核却开了 200 个线程去跑纯计算任务那大部分 CPU 时间其实都花在“换人上场”这件事上真正干活的占比很低。常见的调优方向是把线程数控制在 CPU 核数附近或者按 IO 等待比例适当放大。近两年热词里的“自由线程”指的则是 Python 3.13 实验性的 free-threading 模式目的是去掉 GIL 对多线程并行计算的限制在 Java 生态里本来就没有 GIL 这种全局锁反而更容易掉进“无脑多开线程”的陷阱。线程数量从来不是越多越好而是够用、不排队、不饥饿。2. 线程创建与生命周期从 new 到 TERMINATED 的完整闭环2.1 三种创建方式、线程名与优先级Java 里创建线程有三种经典姿势继承Thread、实现Runnable、实现CallableV。三者中继承Thread最直观但灵活性最差因为 Java 单继承用完名额就没了实现Runnable是业务代码里最常见的Callable则能返回执行结果配合Future使用。Thread t new Thread(() - { System.out.println(当前线程: Thread.currentThread().getName()); }); t.start();很多人会搜“java 获取当前线程名”答案就是Thread.currentThread().getName()。但这里有个真正的实用点不显式设置线程名的线程默认名称是Thread-0、Thread-1这种毫无辨识度的编号。线上排查问题时你看到的 jstack 输出全是Thread-17根本不知道这是哪个业务逻辑。我自己的习惯是所有业务线程都包一层有意义的名称前缀比如order-pool-1、img-thread-5后患立减。线程优先级setPriority()是另一个容易被误解的 API它只是给调度器一个“建议”操作系统完全可以忽略别指望靠它保证执行顺序。2.2 生命周期六个状态与状态之间的流转Java 线程有六个状态NEW创建未启动、RUNNABLE就绪运行、BLOCKED等待监视器锁、WAITING无限等待、TIMED_WAITING限时等待、TERMINATED终止。画状态图很好画但实际排查时要抓住两个关键判断看到BLOCKED基本可以锁定是 synchronized 锁竞争有人持锁不释放看到WAITING或TIMED_WAITING大面积堆积多半是业务在wait()或join()上等待某个永远不会发生的事件这是最常见的“线程卡死”形态。守护线程也值得单独记一笔。setDaemon(true)要在start()之前调用才生效守护线程的特点是当 JVM 里只剩守护线程时进程会直接退出。典型应用是后台监控、定时清理这类“主人走了我也没有存在意义”的任务。写守护线程时要注意不要在守护线程里做必须落盘的数据清理因为 JVM 退出时守护线程可能被强制终止丢数据是你自己的责任。3. 线程池参数逐项拆解ThreadPoolExecutor 配置不再靠感觉3.1 七个核心参数每个都对应一种线上事故ThreadPoolExecutor的七个参数是面试高频也是线上事故高发区。逐个过一遍参数含义配置不当的后果corePoolSize核心线程数太小则任务排队太大则线程空转占内存maximumPoolSize最大线程数过大会创建大量线程耗尽内存keepAliveTime非核心线程空闲存活时间过长则闲时也占资源unit时间单位配合上面使用workQueue任务队列无界队列会导致任务无限堆积threadFactory线程工厂不设的话线程名全是pool-1-thread-1handler拒绝策略默认AbortPolicy直接抛异常七个参数的执行逻辑可以归成一句话核心线程先跑满了进队列队列满了才开非核心线程非核心也满了触发拒绝策略。很多人会以为“线程数是从 core 直接加到 max 的”其实队列这个中间缓冲层才是决定线程池行为的关键。拒绝策略有四种AbortPolicy抛异常、CallerRunsPolicy让提交任务的线程自己跑、DiscardPolicy静默丢弃、DiscardOldestPolicy丢弃最老任务。生产环境我倾向在自定义RejectedExecutionHandler里做降级处理比如记录日志后把任务放入本地文件或消息队列等补偿机制而不是直接抛异常打死业务。3.2 内置线程池的坑为什么 Executors 被劝退网上劝退Executors工具类不是没有道理。Executors.newFixedThreadPool()用的是无界LinkedBlockingQueue意味着当提交速度远超处理速度时任务会在队列里无限堆积最终内存先爆Executors.newCachedThreadPool()的maximumPoolSize是Integer.MAX_VALUE极端情况下会创建出海量线程把系统资源拖垮newScheduledThreadPool()也常被误用来做定时任务却很少有人关心任务执行时间重叠时的语义。不是说这些工具完全不能用而是用之前必须想清楚你接受不接受无界队列接受不接受理论上无上限的线程数。我自己的实践是一律手动new ThreadPoolExecutor参数写明确命名用 threadFactory 包一层几年下来基本告别了线程池类的事故。3.3 阻塞队列选型与线程数估算线程池的队列选择是一个容易被忽略但影响很大的决策点。LinkedBlockingQueue可以设容量比较通用ArrayBlockingQueue底层数组吞吐和内存表现稳定SynchronousQueue不存任务直接交给线程适合任务量极小的场景PriorityBlockingQueue支持优先级但注意execute提交的任务必须实现Comparable或有对应Comparator定时调度的DelayedWorkQueue则是ScheduledThreadPoolExecutor的内部选择。线程数怎么估算我有一套实践经验纯 CPU 密集场景线程数设CPU核数 1左右IO 密集场景经验公式是CPU核数 * 2起再根据压测上调。更精确的可以用布伦南公式线程数 CPU核数 * (1 等待时间/计算时间)但这个公式需要拿到真实的等待/计算比例一般项目都懒得做这种测量反倒是压测来得最快、最准确。4. 线程安全的三道防线互斥、原子与线程局部4.1 synchronized 的锁升级过程“线程互斥”这个词的底层实现在 JVM 里就是锁。synchronized在 JDK 8 时代经历了偏向锁、轻量级锁、重量级锁的升级路径无竞争时用偏向锁一个线程反复进入时开销极小出现多线程竞争但没有真正阻塞时升级轻量级锁自旋自旋超过阈值才升级重量级锁进入 OS 级的阻塞唤醒。后来的 JDK 逐步废弃了偏向锁但核心思路没变锁尽量在用户态解决实在解决不了才让内核介入。这个设计给我们的启示是过度使用synchronized的代码在低竞争场景下开销其实没那么可怕真正可怕的是高竞争场景下的全局锁之争。ReentrantLock可以看作 synchronized 的增强版可中断、可超时、支持多个条件队列、支持公平锁。如果你只有“加锁解锁”的需求synchronized更省事需要超时获取锁、尝试非阻塞获取或者要精细控制多个条件变量时才值得换ReentrantLock。永远记住锁的范围越小越好锁内别做 IO、别做耗时计算这是互斥编程最大的纪律。4.2 AtomicInteger 为什么安全CAS 的极限与边界“atomicinteger线程安全吗”这个问题答案是单点操作安全复合操作不安全。AtomicInteger内部维护一个volatile变量所有更新走 CAS比较并交换即“内存值等于我预期的值才写入新值否则重试”。因此incrementAndGet()是线程安全的。但如果你做的是if (atomic.get() 10) atomic.incrementAndGet()这种读-判断-写组合就不是安全的因为读到值和写入值之间可能有另一个线程插进来。这就是为什么会有AtomicInteger的updateAndGet()方法把判断和更新打包成一个函数让 CAS 在循环里帮你完成整个复合逻辑。CAS 的另一面是 ABA 问题和自旋空耗 CPU 的问题需要AtomicStampedReference、控制并发度等手段来兜底。日常开发里慎用 CAS它适合的是“单个计数器自增、状态标记切换”这类极简单的场景别拿来硬凹并发数据结构。4.3 线程安全类的边界从 String 到时间格式化“java 类线程安全”是另一个高频问题。Java 类库里String不可变所以天然线程安全ArrayList、HashMap、SimpleDateFormat都不安全Vector、Hashtable、ConcurrentHashMap是安全或加强安全的。这里最容易踩的其实是SimpleDateFormat网上搜“获取当前时间线程安全”基本都会命中它。SimpleDateFormat内部有Calendar共享状态多线程共用同一个实例会算出错乱的时间甚至直接抛异常。JDK 8 以后用DateTimeFormatter它是真正不可变的可以放心做成静态字段。这个案例特别典型因为它告诉我们线程安全不等于类里写了 synchronized而是要看内部状态是否在多线程访问时保持一致。判断一个类是否线程安全最简单的办法就是看它有没有可变的共享内部字段有就危险。5. 线程协作join、CountDownLatch 与 Future 的取舍5.1 主线程等所有子线程完成的三种姿势“java 线程等待都完成”这类需求太常见了主流程拆成 N 个并行任务全部完成后再汇总。实现姿势大致有三档初级Thread.join()。join()的本质是让调用线程进入WAITING直到目标线程终止。好理解缺点是一个个join是串行等待任务执行时间错开时效率不占优。中级CountDownLatch。计数器初始值等于任务数每个任务完成时countDown()主线程await()。它比join灵活因为等待的是“信号”不一定关联某个线程对象。高级CompletableFuture.allOf()。并行提交、链式回调、异常传播都封装好了适合现代代码风格。我个人推荐在业务代码里直接用CompletableFuture它把ExecutorService、Future、回调整合在一起可读性好得多。但要注意allOf()对异常的处理比较隐蔽要配合exceptionally()或handle()把异常显式捞出来不然错误会被吞得干干净净。5.2 wait/notify 与“线程方程组”并发协作练习题里有一类常被调侃成“线程方程组”三个线程按顺序交替打印 A、B、C或是奇偶线程交替打印数字。这类问题的本质是解一组约束条件——每个线程什么时候可以执行取决于前一个线程是否已发出信号。用wait/notifyAll能写但非常容易出问题wait()一定要在循环里调防止虚假唤醒、一定要先拿到锁、notify和notifyAll的选择也要慎重。老实说这类题的练习价值在于理解“状态条件信号”三要素而不是真的在生产里写这种代码。生产环境有太多现成的更优工具Semaphore控制闸门、CyclicBarrier等待多线程都到达某个汇合点、Exchanger交换数据都比手搓wait/notify可靠。手写等待通知逻辑每写一行都要问自己一句这个条件变量谁在改、谁在等、唤醒后会不会误触发想不清楚就别写。5.3 阻塞队列协作的高级武器从BlockingQueue的角度看线程协作其实是把同步问题转化为队列问题。生产者往put()消费者take()队列空时消费者自动阻塞队列满时生产者自动阻塞配对关系天然成立。这个模型比wait/notify好维护得多也是线程池内部工作的基础。选择队列时的关键考量在 3.3 里说过一部分这里补充一个点有界队列 满时降级是生产代码最稳的组合宁可让任务丢到补偿机制里也不要让无界队列把内存吃光。队列泄压的方向本身就是架构设计的一部分。6. 中断与死锁两个高频翻车点的完整排查链路6.1 interrupt 不是“杀死线程”而是一个礼貌的请柬“java 线程中断”这个搜索词后面藏着大量对中断机制的误解。thread.interrupt()并不会把线程杀掉它只是给目标线程设置一个中断标志位。目标线程能不能感知取决于它在干什么如果正在sleep()、wait()、join()会立刻抛出InterruptedException并清除标志位如果正在跑普通计算标志位会一直挂着但代码不检查就一直执行下去如果正好在LockSupport.park()会从阻塞中返回。所以正确的中断处理姿势是sleep/wait捕获到InterruptedException后要么恢复中断标志Thread.currentThread().interrupt()要么退出任务千万别吞掉异常继续往下跑。同时注意isInterrupted()和静态方法interrupted()的区别后者读取后会把标志位清零是给当前线程自省用的用错了就是“为什么我中断了两次还不生效”的惨案。中断机制设计的本意是让协作方通过标志位感知“该停了”而不是强制叫停。6.2 死锁的成因与 jstack 定位全过程死锁的形成条件很简单两个线程各持有一把锁又都在等对方手里的锁。生产环境最常见的诱因是嵌套锁的顺序不一致比如线程 A 先拿 lock1 再拿 lock2线程 B 先拿 lock2 再拿 lock1两边在某个时刻必然互相卡住。定位死锁的标准流程是先jps找到目标进程 PID然后jstack pid导出线程快照。如果存在死锁jstack 末尾会直接输出Found one Java-level deadlock的提示并给出每个线程当前持有的锁和等待的锁。我踩过的一次真实死锁正是两个服务各自管理订单和库存状态加锁顺序正好相反平时流量小看不出来大促流量一上来就超时告警。解决方式是把全局加锁顺序统一成“先订单后库存”或者用ReentrantLock的tryLock(timeout)让获取不到锁的线程自动放弃而不是无限等待。加锁顺序一致是预防死锁的黄金规则。7. UI 线程操作子线程改控件的规矩和三种主流实现7.1 AndroidFragment 里开启线程的正确姿势“android fragment 开启线程”涉及的是移动端并发的一个经典问题UI 控件不是线程安全的只能在主线程UI 线程操作。在 Fragment 里你要做耗时操作比如拉接口、读本地数据库正确的做法是放到后台线程拿到结果后再切回主线程更新控件。常用的切换手段有三种getActivity().runOnUiThread(() - {...})简单直接Activity 还在就安全Handler(Looper.getMainLooper()).post(...)更底层适合在非 Activity 环境下使用协程withContext(Dispatchers.Main)配合LifecycleScope天然处理页面销毁后的取消问题。这里有个常见坑Fragment 在后台或已 detach 时getActivity()可能为空或者 Activity 已 destroyed此时直接更新 UI 会抛异常或泄漏。所以后台线程回调回来时必须先判断isAdded()/isDetached()。在安卓里还有一个反向约束主线程不能做网络请求否则直接NetworkOnMainThreadException。因此绝大多数场景就是“后台线程干重活主线程只负责更新”这个分界线必须刻在脑子里。7.2 Qt 与易语言跨线程 UI 更新的两种生态做法安卓之外的 UI 并发问题原理一样。Qt 里QThread不能直接操作主线程的控件标准做法是通过信号与槽跨线程通信工作线程发射信号主线程的槽函数接收后更新控件类型为AutoConnection时会自动进入队列连接。易语言 Windows 窗口程序里子线程操作主线程窗口控件经典方案是调用SendMessage/PostMessage发送自定义消息到窗口句柄窗口的过程函数在收到消息后由主线程完成控件更新。别看语言差别大核心逻辑完全一致子线程只发消息不碰控件控件的创建线程UI 线程才有资格改控件。哪怕是在浏览器页面里Web Worker不能直接操作 DOM 也是同一个道理。理解了这条总规律再看任何框架的线程与 UI 规矩都是一通百通。7.3 谁创建谁更新跨线程 UI 操作的总原则如果只用一句话总结这一节就是“谁创建谁更新”。这个原则的价值在于不用去背每个框架的 API 差异只需要问一句“这个控件是哪个线程创建的”你就知道更新动作必须回到那个线程执行。任何把控件引用直接塞给工作线程去改的代码都是定时炸弹不是立刻崩就是偶闪崩。排查这类问题也很简单看到异常栈里的CalledFromWrongThreadException安卓、Qt 的 “Cannot create children for a parent in a different thread” 这类报错第一反应就是“我是不是在这个控件的非创建线程里动了它”。8. 线程监测与调优自用笔记里最后沉淀的经验8.1 线上线程状态快速判断工具链排查线程问题最常用的工具是jstack但它是一次性快照适合看“现在卡在哪”。想看趋势、内存和 GC 联动可以配jstat、jvisualvm或 JMC。定位 CPU 飙高的线程有个标准手法先用top -Hp pid找到占用 CPU 最高的线程 ID转成十六进制再到jstack输出里搜这个十六进制就能定位到是哪段代码在疯狂占 CPU。这套流程我用了很多年几乎没失手过。在嵌入式系统里比如 QNX也有类似pidin的命令按线程 ID 查看单一线程的调用栈与寄存器现场原理和 Linux 的/proc/pid/task/tid/stat是相通的核心都是“按线程 ID 去取该线程的执行现场”。大数据场景里Spark 的 Executor 是独立 JVM线上排查线程问题可以打开 Spark UI 的 Executors 页面直接下载线程 Dump再配合jstat看 Executor 的堆外内存与 GC 状况。某个 Executor 的线程异常飙升通常关联到数据倾斜或单分区内死循环这时候光看线程栈还不够要结合 stage 的统计指标一起看。8.2 Akka 线程模型带给我的启发Akka的线程模型值得单独记一笔。在 Actor 模型里线程不是和 Actor 绑定的而是通过 Dispatcher 调度共享线程池Actor 之间靠邮箱消息队列通信消息处理必须不阻塞。这个模型给了我一个很重要的启发线程是宝贵的共享资源应该被统一调度而不是每个业务单元各占一个。很多人写多线程代码的问题恰恰在于每个功能块都自己 new 一个线程资源无法统筹。如果你能用线程池 队列 回调这套组合拳来组织并发其实已经具备了 Actor 模型的雏形。还有一个偏系统层面的技巧在 Windows 11 的任务管理器里进程详情页右键可以设置 CPU 相关性本质上就是让一个进程绑定到某个线程/核心上执行这和 Linux 的taskset、Java 的ThreadMXBean配合绑核优化是同一类手段。一般应用用不上但在延迟敏感的场景里绑核能减少上下文切换带来的抖动。8.3 关于线程数、监控与“够用就好”的一点体会把笔记写到这里回顾所有踩过的坑最深刻的一条体会是绝大多数的线程问题都不是靠“更高级的并发技巧”解决的而是靠更稳定的配置 更清晰的模型 更及时的监控。线程池参数写完要加注释说明为什么是这个值线程名必须能一眼看出业务归属线上要有线程数、队列深度的指标监控报警比优化重要先让问题可见再谈调优。只要把这几条做到位哪怕技术栈用的是最朴素的ThreadPoolExecutor synchronized也不会出大乱子。最后分享一个我自己的小习惯每次写完并发相关的代码都会主动用jstack打一份线程快照看一眼确认线程数符合预期、没有奇怪的WAITING堆积。这个动作只要一分钟但能提前拦住大量线上事故。线程这个东西平时看着安静一出问题就是连锁反应笔记记得越细踩坑时越稳。