拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

线程与并发排查手册:从线程池配置到死锁中断与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、实现Callable<V>。三者中,继承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-1
handler拒绝策略默认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 Android:Fragment 里开启线程的正确姿势

“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堆积。这个动作只要一分钟,但能提前拦住大量线上事故。线程这个东西,平时看着安静,一出问题就是连锁反应;笔记记得越细,踩坑时越稳。

返回列表