写这个标题的时候,我其实是有点兴奋的。搞了十几年Linux服务端,见过太多“会用线程”但“不懂线程”的同事:new一个Thread出来、start、join,出了问题就蒙圈。尤其是面试聊到“线程的本质是什么”这种问题,很多人会卡壳——不是因为他们不聪明,而是因为他们学的是Java、Go那一套语言级线程模型,根本没往Linux内核那一层钻过。
这篇文章就是干这个用的:把Linux线程的底层原理彻底撕开,从clone()系统调用到task_struct,从线程组到调度实体,再从同步原语的本质讲到线程池的配置逻辑。内容会有点硬,但我会尽量用大白话和实操场景把每个概念讲透。不管你是写业务代码的Java工程师,还是搞嵌入式C开发的老兵,或者是正在准备Linux面试的求职者,这篇文章都值得你花半小时细读一遍。
1. 先搞清一件事:Linux里的线程到底是什么
1.1 线程不是独立存在的“东西”
初学操作系统的时候,教科书告诉我们:进程是资源分配的最小单位,线程是CPU调度的最小单位。这个说法没错,但它误导了一大批人——很多人以为线程是操作系统里一个和进程平级的概念,就像盘子里的一颗糖和一把糖的区别。真相是:在Linux内核眼里,根本没有线程这个概念,只有task_struct。
什么意思?就是你用pthread_create()创建一个线程,内核里做的事儿其实是又创建了一个task_struct,也就是又创建了一个“进程”。只不过这个新进程和创建它的那个旧进程共享了地址空间、打开的文件表、信号处理函数等一堆资源。Linux把这种共享资源的进程叫做“线程”,学术点叫“轻量级进程”(LWP,Light Weight Process)。
所以当你用ps -eLf看系统进程时,你会看到每个线程都有一行记录,NLWP那一列显示的就是这个进程里有多少个线程。这里我建议你动手敲一下:
ps -eLf | head -20跑个Java服务再敲一次,你会看到一堆java开头的行,PID不同但PPID相同,LWP各不相同。这就是Linux线程最直观的底层形态:一群共享地址空间的task。
1.2 创建线程背后是clone()系统调用
那这个“共享资源的进程”是怎么创建出来的?靠的是clone()系统调用。clone()是fork()的超集,它最大的特点是允许你通过flags参数精确控制父子进程之间共享什么、不共享什么。
pthread_create()在glibc内部其实就是调用了clone(),传了一堆标志位:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID, ...)这串标志位每一个都对应一项资源共享决策。我挑几个关键的说说:
CLONE_VM:共享内存地址空间。这是线程和进程最核心的区别所在。CLONE_FILES:共享打开的文件描述符表。所以一个线程close()了某个fd,其他线程也能感知到。CLONE_SIGHAND:共享信号处理函数表。CLONE_THREAD:加入同一个线程组,也就是tgid相同,对外表现为同一个进程。CLONE_SETTLS:设置线程本地存储,每个线程要有自己的errno就靠它。
理解了这一层,你就彻底明白为什么线程切换比进程切换“轻”了:因为地址空间共享,切换时不需要切换页表、刷新TLB。当然现在内核用MMU缓存优化了一部分,但原理上仍然成立。
1.3 进程、线程、线程组的关系
内核里每个task_struct都有两个ID:pid和tgid。有意思的是,命令行里那个PID,在内核里其实是tgid(线程组ID)。线程组组长自己的pid等于tgid,其他线程的pid各不相同。
画个朴素的对应关系:
进程(用户视角的PID) = 内核的 tgid 线程(用户视角的TID) = 内核的 pid(也就是LWP)你在/proc/<pid>/status里能看到这样两行:
Tgid: 12345 Pid: 12345如果是某个线程的/proc/<tid>/status:
Tgid: 12345 Pid: 23456所以以后排查问题,别把TID和PID搞混。top -H能看到线程级CPU占用,/proc/<pid>/task/目录下列出的就是所有线程ID。掌握了这层映射关系,系统排查你会少走很多弯路。
2. 线程的调度、栈与生命周期:内核到底在干什么
2.1 调度器眼里没有“线程”只有“可运行实体”
Linux的CFS调度器(完全公平调度器)从进程模型抽象出sched_entity(调度实体),它对task_struct做了一层封装。调度器不关心你到底是进程还是线程,它只关心这个实体“需要跑多久、优先级如何、上次什么时候跑的”。
每个线程有自己的task_struct,所以线程是独立的调度单位,这点和很多RTOS里的“进程内线程由进程自己调度”完全不一样。Linux的线程一旦创建,就平等地参与CPU调度。多核场景下,线程天然可以并行跑在不同CPU上;单核场景下,靠时间片轮转模拟并发。
这里要插入一个很多人忽略的点:线程优先级。Linux线程的优先级走的是nice值和实时优先级(SCHED_FIFO/SCHED_RR)。动态优先级 =nice值映射 + 交互性奖励/惩罚。线程的nice值可以单独设置,但说实话,日常开发里我自己很少直接调线程优先级,除非是音频、工业控制这类强实时场景。调错了优先级导致低优先级线程饿死,是非常隐蔽且难查的问题。
2.2 每个线程的栈和TLS:线程为什么能各干各的
既然线程共享地址空间,那它怎么保证自己的局部变量、函数调用不跟别的线程打架?答案就是:每个线程一个独立的栈。
pthread_create()时glibc会通过mmap()分配一块线程栈(默认8MB),同时在线程栈的某个固定偏移位置放线程控制块(TCB)。x86-64架构下,%fs段寄存器指向TLS区域,访问线程局部变量就通过它。你随手写的__thread int a,编译后访问方式就是:
mov %fs:偏移量, %eax所以你看,所谓“线程安全“的根基其实在这些底层机制上。为什么errno是线程安全的?就是因为每个线程有自己独立的TLS,errno被定义为__thread变量。为什么strtok()老版本不安全?因为它是静态变量。如果你面试被问到“TLS怎么实现”,把%fs和TCB这套讲出来,那已经是加分项了。
线程栈不像进程栈那样可以动态增长到RLIMIT_STACK的极限,mmap出来的栈区域在结尾有一个guard page(保护页),一旦线程栈溢出触到这个页,内核会发SIGSEGV。实践中我见过很多“莫名崩溃”的bug,gdb里bt一下发现栈顶在奇怪的地址,十有八九是线程栈开小了。启动时用ulimit -s可以影响默认栈大小,但代码里最好用pthread_attr_setstacksize()显式指定。
2.3 线程退出:谁回收线程的资源
线程退出有几种方式:函数return、pthread_exit()、被pthread_cancel()取消、进程退出带崩所有线程。最后一种最暴力也最常见:主线程return了,整个进程就退出,所有线程直接殉葬。
资源回收这块有个老生常谈的坑:detached线程。pthread_detach()或者创建时传PTHREAD_CREATE_DETACHED,线程退出后内核会自动回收它的资源。但如果一个线程既不是detached,又没人pthread_join()它,退出后它的task_struct和栈就一直留着,这就是“僵尸线程”。内核里有个release_task()的路径,不join就没人调它,内存一点点泄漏,最后ps里能看到一堆<defunct>或者内存诡异增长。
我也见过不少“线程池线程退不干净”的问题:线程在while(!stop)循环里被pthread_cond_wait卡住,但stop标志位不是volatile也不是原子变量,编译优化后永远读不到新值,线程根本没法优雅退出。这个问题我后面会单独展开。
3. 线程同步的底层本质:锁不是什么玄学
3.1 原子操作、内存屏障与互斥锁的原理
很多写业务的人一提线程安全就是“加锁”,但不知道锁的底层是啥。其实锁的底子就是三条:原子指令、内存屏障、等待队列。
互斥锁pthread_mutex_t最朴素实现是基于futex(Fast Userspace Mutex)的。futex这个词值得你记住。它的设计思路非常巧妙:线程在用户态先用原子指令(比如cmpxchg)尝试拿锁,拿不到就通过futex系统调用去内核的等待队列睡觉。
线程A拿到锁:原子操作把锁状态从0改成1,搞定。 线程B拿锁失败:用户态原子操作失败,调用futex(FUTEX_WAIT)进内核睡眠。 线程A释放锁:原子操作把锁状态改回0,调用futex(FUTEX_WAKE)唤醒一个等待者。这套机制的好处是:锁没竞争的时候,全程用户态,零系统调用;有竞争才进内核。所以别一提锁就觉得必然慢,无竞争锁的开销其实很低。但一旦竞争激烈,线程频繁进出内核睡眠唤醒,上下文切换成本就会飙升。之前有朋友给我看一个Java服务压测数据,吞吐起不来,用perf一看,大量时间花在futex系统调用上——这就说明锁竞争已经到了必须优化的程度。
3.2 互斥锁、读写锁、自旋锁的适用边界
锁有好几种,我列个表说清楚区别:
| 锁类型 | 底层机制 | 适用场景 | 坑点 |
|---|---|---|---|
| 互斥锁(mutex) | futex睡眠/唤醒 | 临界区较长、阻塞IO | 不能在信号处理函数里用 |
| 读写锁(rwlock) | 读计数+写独占 | 读多写少、写频率低 | 写线程可能饥饿 |
| 自旋锁(spinlock) | 忙等while(atomic) | 临界区极短、不可睡眠 | 临界区里有IO会拖死CPU |
| 顺序锁(seqlock) | 计数器+重试 | 读多写少且写优先 | 读者可能反复重试 |
自旋锁这个我多说两句。它的开销模型是:拿不到锁就原地转圈。单核机器上转圈毫无意义(持有锁的线程根本没机会跑),多核机器上如果临界区里有系统调用、IO、调度,那更是灾难。内核里自旋锁的debug选项CONFIG_DEBUG_SPINLOCK能在持锁睡眠时打印警告,我在驱动开发时靠它抓到过不少bug。用户态写代码用到pthread_spinlock_t的场景很少,做嵌入式或者写内核模块时会遇到,记住“恶锁短用”四个字。
3.3 条件变量:为什么必须配mutex使用
pthread_cond_wait(&cond, &mutex)在教科书里被描述为“原子地释放mutex并睡眠,被唤醒后重新获取mutex”。这个设计的核心是防止唤醒丢失(lost wakeup)。
我拆解一下标准用法:
// 消费者 pthread_mutex_lock(&mtx); while (!ready) { pthread_cond_wait(&cond, &mtx); // 原子操作:释放锁+睡眠 } // 拿到锁了,状态ready pthread_mutex_unlock(&mtx); // 生产者 pthread_mutex_lock(&mtx); ready = 1; pthread_cond_signal(&cond); pthread_mutex_unlock(&mtx);为什么while而不是if?因为可能存在虚假唤醒(spurious wakeup)——条件不满足但线程还是醒过来了。而且多个消费者被唤醒时,一个拿到锁改了状态,其他人醒来看状态又不对,必须再睡。while循环是必须的,别图省事写if。
还有一点:pthread_cond_signal()只唤醒一个线程,pthread_cond_broadcast()唤醒全部。你的业务里如果多个线程都等在同一个条件变量上,而一次信号只满足一个消费者的需求,那就用signal;如果一次信号所有消费者都能干活,那就用broadcast。这个选错了,轻则性能下降,重则死锁。
4. 人人都用线程池,但不是人人懂线程池
4.1 为什么需要线程池:线程创建开销到底有多大
很多人觉得线程创建是“便宜”的。是,比起fork一个进程然后exec执行新程序,线程创建确实轻不少——不用复制页表、不用复制文件描述符表。但轻不等于免费。一次pthread_create()要经历:clone()系统调用、内核分配task_struct、设置调度实体、分配线程栈(8MB虚拟内存)和TCB、初始化TLS。一套流程下来,微秒级开销是跑不掉的。
更重要的是,如果每个请求都来一个线程,高并发下线程数一多,线程切换本身就会变成巨大的开销:两个线程来回切,每次都要保存恢复寄存器、栈指针、指令指针,还要处理缓存局部性失效。这种“线程颠簸”我见过太多次了:线程数一涨,吞吐量不升反降,CPU全耗在schedule()里了。
线程池的本质就是复用线程,摊薄创建开销,限制并发度。它要解决的核心矛盾是:线程太少浪费CPU,线程太多浪费切换。
4.2 一个线程池的核心参数怎么定
线程池的经典参数无非是核心线程数、最大线程数、队列容量、拒绝策略。很多文章直接给公式“CPU密集型=N+1,IO密集型=2N+1”,但我不建议无脑套。核心问题是你得先搞清楚你的任务特征:
- CPU密集型任务:算术计算、加解密、视频编码,线程数建议为核心数附近。每个线程吃饱一个核就够,多了只会抢。
- IO密集型任务:网络读写、磁盘IO、数据库访问,线程可以多开,因为大部分时间在等IO。线程数可以按
核心数 * (1 + IO等待时间/CPU计算时间)估算。 - 混合型任务:用两个池子分别处理。
线程池里的队列怎么选也很关键:有界队列还是无界队列?无界队列看起来方便(不会拒绝任务),但真到高峰期内存会涨到不可收拾。我踩过这个坑:一个数据处理服务用了无界队列,上游突发流量把内存顶爆,OOM killed之后任务全丢。后来改成有界队列加拒绝策略,宁可丢弃一部分任务也不能让进程死掉——当然你要根据业务决定丢弃策略是丢弃最老的任务还是最新任务。
线程池监控这个事情我必须提一嘴:没有监控的线程池就是黑盒。至少记录这些指标:活跃线程数、排队任务数、任务执行耗时分布(P99/P999)、拒绝任务数。出问题的时候这些指标能立刻告诉你瓶颈在哪。
4.3 从虚拟机角度理解Java线程池与Linux底层
有Java背景的读者,这里我插一句:Java的ThreadPoolExecutor在标准实现里,每个java.lang.Thread最终都对应一个OS线程(就是我们前面说的LWP)。你用Executors.newFixedThreadPool(4),底层就是调pthread_create()创建4个真实线程。所谓“平台线程”就是操作系统线程。
Java 21之后引入的虚拟线程(Virtual Threads)则完全是另一套玩法:它不再是一对一映射到内核线程,而是由JVM自己调度,一个Carrier线程上可以跑成千上万个虚拟线程。虚拟线程的挂起和恢复是用户态操作,不经过内核调度——本质上它更像协程,和Linux下的ucontext/boost.context一脉相承。所以当你看到“Java虚拟线程性能炸裂”这类文章时,它的底层逻辑其实是:减少操作系统线程切换和创建销毁成本。这恰恰说明搞懂Linux原生线程模型有多重要——没有对照,你就理解不了虚拟线程到底优化了什么。
5. 线程死锁与实战排查:不能只会背四个必要条件
5.1 死锁不只是“互相等锁”,还有这些隐蔽场景
教科书四个必要条件(互斥、持有并等待、不可剥夺、循环等待)我就不背了,说几个实战中容易忽略的:
第一个是锁顺序倒置。这个最经典,A线程持有锁1等锁2,B线程持有锁2等锁1,直接翻车。解决办法是全局给锁排个序:所有线程都先拿编号小的锁,再拿编号大的锁。
第二个是条件变量引起的“假死锁”。某线程在条件变量上等,生产者在另一个锁上signal,但因为signal和修改状态之间没配对,导致signal先发了,等待者还没进wait,醒来后直接睡着,再也没人唤醒。解决方式就是前面说的:在持有互斥锁的前提下修改状态和发signal,让wait和状态检查互相串行化。
第三个容易被忽视的是递归锁的反面。非递归锁被同一线程重复加锁,直接死锁(或触发EDEADLK)。很多人为了省事把互斥锁设成递归锁,但递归锁的代价是慢、可能掩盖设计缺陷。我建议默认用非递归锁,设计上保证一个函数要么只拿一次锁,要么把锁拆到独立的内部函数里。
第四个是跨语言/跨模块锁交互。比如Java代码里synchronized块内部调了一个JNI方法,JNI里又拿了C层的pthread_mutex;反过来另一个线程先拿C层的锁再想进Java的synchronized——这种交叉死锁用gdb都很难定位,因为你看不到Java锁的持有关系。碰到这种场景,我建议在架构上就避免跨层持锁。
5.2 实操:如何用gdb和pstack定位死锁
定位死锁我有一套固定的操作流程,现场救过不少次:
第一步,top -H -p <pid>看看哪个线程的CPU状态异常。死锁线程通常是Sleeping状态,但注意:CPU不涨不等于线程活着。
第二步,用gdb attach <pid>,然后执行:
thread apply all bt这一步会把所有线程的堆栈打出来。死锁的现场通常长这样:线程A停在__lll_lock_wait,线程B也停在__lll_lock_wait,往上翻栈就能看到它们各自身处什么函数、拿着什么锁。配合info threads和frame命令切到对应栈帧看局部变量,基本就能还原锁获取顺序。
第三步,如果觉得gdb太重量级,pstack <pid>也能出所有线程的栈,输出格式对运维同学更友好。缺点是信息量少一点,但快速定位绰绰有余。
第四步,抓一把/proc/<pid>/task/*/stack,这个文件在root权限下能直接看到内核栈。如果是内核态死锁,cat /proc/<pid>/stack直接给出内核调用路径,非常管用。
我早年踩过一个非常隐蔽的坑:信号和锁的互踩。主线程持锁时收到SIGALRM,信号处理函数里又去拿同一把锁——直接死锁,而且gdb很难找到根因,因为信号处理函数的栈帧是在“被中断的代码”之上叠的。从那以后我的信号处理函数里永远只做一件事:写一个volatile sig_atomic_t标志位,让主循环自己处理。这是Linux下写信号安全代码的铁律。
5.3 死锁预防三板斧:锁顺序、超时、锁粒度
预防死锁,我的经验是三个方向同时做:
第一,建立全局锁顺序规范。公司项目里可以做一个静态检查脚本,扫描源码中多锁获取的嵌套顺序,不一致直接CI报错。
第二,用带超时的锁。pthread_mutex_timedlock()允许你设置最长等待时间,拿不到锁就返回ETIMEDOUT,代码可以记日志、做降级、主动退出。Java里对应的就是tryLock(timeout, unit)。超时不是解决问题的根本办法,但它能把死锁从“永久卡死”变成“可观测、可恢复”,这个差距在生产环境里是致命的。
第三,缩小锁粒度。把大锁换成细粒度锁,或者用读写锁、无锁数据结构(如RCU、seqlock、无锁队列)来降低锁覆盖范围。但提醒一句:无锁编程远比看起来难,ABA问题、内存序问题都能让人查到头秃。除非你对代码有绝对的掌控力,否则先保证正确性,再考虑性能。
6. 线程相关性能优化与内核视图
6.1 用perf看线程开销,别凭感觉优化
聊到性能优化,我最烦的就是“感觉线程太多所以调线程数”。优化要有数据,而Linux自带的perf就是最好的工具。
perf top可以直接看到热点函数;如果是多线程应用,perf record -p <pid> -g抓一段采样,再用perf report --sort=pid,comm看每个线程的CPU占用分布。我曾经用这套工具定位过一个“神秘变慢”的服务:表面上某个线程CPU不高,但perf显示大量时间在sched_yield上,说明线程疯狂让出CPU,真实原因是spinlock竞争太激烈。
还有strace -f -p <pid>可以看到系统调用级别的线程行为。哪个线程在频繁futex、哪个在频繁epoll_wait,一目了然,而且strace -f会自动跟踪所有线程,这是定位线程级性能问题的一把好手。
6.2 线程局部存储的陷阱与最佳实践
前面讲了TLS是通过%fs访问的,这里提两个实践中的坑:
坑一:__thread变量不能用于非平凡类型,比如构造函数、析构函数复杂的C++对象,__thread只支持POD类型。线程退出时不会调用析构函数,资源就泄漏了。C++里要用thread_local关键字,它能正确处理带析构函数的对象。
坑二:线程池场景下TLS状态残留。假设你的线程池线程在处理请求时设置了某个TLS标志(比如事务ID),处理完没清掉,下一个请求进来读到的还是上一个请求的事务ID——这种问题是典型的“幽灵数据”bug。我建议在每次任务开始或结束时显式重置TLS状态,别依赖“反正线程应该没状态”这种假设。
6.3 多核环境下的CPU亲和性设置
pthread_setaffinity_np()可以绑定线程到指定CPU核心。什么时候用?我个人经验是:绝大多数场景不要用,让内核调度器自己平衡。但有两种情况值得用:
一种是延迟敏感型线程,比如网络收包线程、音频线程,绑定核后可以避免线程在核间迁移带来的cache miss和TLB刷新,延迟更稳定。
另一种是高吞吐型线程组,分开绑定到不同NUMA节点,避免远端内存访问的额外延迟。特别是大内存服务,NUMA影响能到20%-30%。
设置亲和性有个细节:CPU_SET()宏的正确用法是先CPU_ZERO()再CPU_SET(),每次设置前必须清零。忘了清零,亲和性掩码会带上旧数据,线程可能永远跑在错误的核上。这个bug非常隐蔽,CPU占用和延迟都异常,但查半天查不出原因。
7. Linux线程面试考点与底层原理对照
7.1 高频面试题:从原理层面给出“教科书没有的答案”
Linux线程相关的面试题,网上有标准答案,但如果你想突出深度,照着底层原理答会明显不一样。我列几个经典问题,给出“加餐”角度:
Q:线程和进程有什么区别?
基础答案是“资源占用、切换开销、通信方式”。加餐角度是:Linux里线程就是共享地址空间的进程,它们拥有相同的tgid,但pid不同;线程间通信成本低是因为共享内存天然存在,不需要IPC机制。
Q:创建1000个线程和创建100个线程,差距在哪?
除了内存占用之外,关键是调度器的运行队列长度。CFS每个CPU有自己的运行队列,线程越多,每个线程分到的CPU时间片越碎,上下文切换越频繁。实测同一个任务,线程数从50涨到500,总耗时可能翻倍。多线程不是越多越好,调优的唯一依据是实际观察CPU使用率。
Q:什么时候用多进程,什么时候用多线程?
我的判断标准是:需要强隔离(一处崩溃不影响其他模块)用进程;需要高频数据共享、低延迟协作用线程。历史上有过“Google Chrome多进程”和“Nginx多进程”这类案例,而数据库、中间件内核大量用线程。这个问题的深层逻辑是:可靠性与效率的权衡。
Q:如何让线程安全的单例?
除了双检锁加volatile那套,还可以讲pthread_once(底层是用futex实现的一次性初始化)或者C++11的call_once。面试官如果追问“内存屏障在单例模式里起什么作用”,能答出volatile防止指令重排导致“未完成构造的对象被其他线程看到”,就说明你真懂并发不仅限于锁。
7.2 从线程角度看Linux内核源码的学习路径
对想深入内核的读者,我给一条比较“笨”但非常有效的路径:
先读kernel/fork.c,重点看copy_process()函数,它能让你明白fork()/clone()/vfork()三个系统调用的本质差异是什么。再看kernel/sched/core.c里的__schedule(),理解调度器主路径。然后读kernel/futex.c,理解futex生命周期和等待队列的关系。最后看kernel/exit.c里do_exit()和release_task(),搞懂线程退出的完整路径。
这条路径读下来,你对“线程”的认识就不再是API层面的了,而是能想象出每条代码调用链背后内核里发生了什么。排错的时候脑子里有画面,分析问题完全是另一个境界。
还有个小技巧是直接用bpftrace探针看线程生命周期:
bpftrace -e 'kprobe:copy_process { printf("new task: %d\n", arg0); }'内核态创建的每个task_struct都会被打印出来,你可以直观看到线程创建的真实瞬间。这比瞎猜强多了。
7.3 线程安全的代码习惯:让Bug从源头消失
写了十几年并发代码,我有几条铁律,分享给各位:
第一条,能不共享就别共享。无共享消息传递(actor模型、Channel、队列)比任何锁都安全。单线程事件循环配合协程能搞定很多并发需求,为什么要硬上多线程?
第二条,锁的粒度越小越好,但别小到破坏业务原子性。比如转账操作,检查余额和扣款必须在同一把锁内,拆分才是真正的坑。
第三条,公开API层面尽量暴露线程安全的语义。比如接口文档注明“线程安全”“非线程安全”“每实例一把锁”还是“全局一把锁”,让调用方心里有数。
第四条,多写测试、多用Sanitizer。-fsanitize=thread是TSan(ThreadSanitizer),能检测数据竞争;-fsanitize=address能找出线程栈溢出和越界。CI里跑一遍TSan能拦住80%的“偶现”并发bug。说实话,很多“线上诡异问题”拿到TSan下面跑一遍,立刻暴露。
8. 写在最后的一些杂谈
Linux线程这事,说到底是理解内核怎么看待并发的问题。无论你是做后端业务、中间件、嵌入式还是内核开发,底层原理这层窗户纸捅破了,后面的应用、排查、调优都有了坐标系。
我个人在实际排查中最大的体会是:出问题的时候,先画一张线程状态图——每条线程当前处于什么状态(运行、睡眠、等锁、等IO)、持有哪些资源、阻塞在哪一级调用链上。这张图画完,60%的问题你已经能猜到答案了。工具只是验证你的猜测。
最后送你一个小技巧:排查线程问题前,先确认三件事——线程数是否超出预期、线程栈大小是否被改过、是否有线程卡在IO上“看似死锁实则正常等待”。这三个我都栽过,提前排查能省掉很多无用功。写代码的路很长,祝各位都能把bug扼杀在原理层面。