线程是Java并发编程的基石,也是从初级走向高级必须跨过的一道坎。很多同学写了好几年业务代码,线程也用了不少,但一遇到线上问题就抓瞎:线程池参数怎么配?CPU飙高怎么排查?死锁怎么定位?这些背后其实都是对线程模型、生命周期、同步机制的理解不够扎实。
这篇文章我打算从线程的本质讲起,沿着创建方式、生命周期、同步机制、线程池、并发工具类到线上排查这一条主线往下走。全文不搞虚的,每一段都是这些年实打实踩过的坑和验证过的方案,适合想系统啃下Java并发这块硬骨头的同学,也适合已经在写多线程代码但总觉得哪里没透亮的工程师对照自查。
1. 线程的本质:为什么并发编程绕不开它
1.1 从进程到线程,到底省了什么
我们得先把"线程到底解决了什么问题"这件事想清楚。早期操作系统里,一个进程就是一条执行流,进程之间内存相互隔离,切换进程的开销非常大——要保存和恢复完整的CPU上下文、页表、文件描述符等一大堆资源。后来人们发现,很多任务并不需要完全隔离,它们更希望共享同一块内存、同时并发执行,这时候再开进程就太重了。
线程的出现,就是在进程内部再切出几条独立的执行流。线程是CPU调度的基本单位,同一个进程下的线程共享堆内存和方法区,只保留自己独占的程序计数器、虚拟机栈和本地方法栈。这意味着线程之间的通信成本极低——直接读写同一块内存就行,但代价也来了:数据同步、可见性、死锁这些问题全都跟着冒出来了。
在JVM层面,Java线程和操作系统线程是一一对应的。也就是说,你每new一个Thread,底层就会通过pthread_create之类的方式创建一个原生线程,由操作系统负责调度。这个设计在JDK的很多版本里都是这么干的,后来引入的虚拟线程(Project Loom)又是另一套玩法,但在传统并发模型里,理解原生线程的映射关系是做一切分析的基础。
1.2 你写的多线程代码,底层发生了什么
很多初学者会有一个疑惑:我明明就是调了thread.start(),为什么代码就突然"同时"跑了?背后的关键是CPU的时间片轮转。操作系统把CPU时间切成极小的片断,分配给不同的线程,由于切换极快,人眼感知上就像在同时执行。实际上,在单核CPU上多线程本质是伪并行,真正并行需要多核CPU并且在同一个时刻有多个线程被分配到不同核心上执行。
这里有个特别容易踩的坑:不要以为写了几行线程代码就一定"更快"。如果任务是纯CPU密集型的,线程数超过核心数反而会因为上下文切换导致性能下降。上下文切换不是免费的,每次切换要保存寄存器、程序计数器等信息,线程越多,这个开销就越大。我在给一些团队做代码评审时经常看到,有人为了"并发"硬把一个简单循环拆成几十个线程跑,实测下来比单线程还慢好几个数量级,这就是没搞懂底层模型。
1.3 并发三要素:原子性、可见性、有序性
聊线程必然绕不开并发编程的三要素,这是理解后面所有同步工具的前提。
原子性说的是一个操作或者多个操作在CPU执行过程中不被中断的特性。比如count++这个操作,看起来是一行代码,但底层要经历读取变量、计算加一、写回变量三个步骤,任何一个线程在这中间插一脚,结果就乱了。可见性是指一个线程修改了共享变量之后,其他线程能不能立刻看到这个修改。由于CPU缓存的存在,每个线程操作的有可能是副本,改完不写回主内存,别的线程就感知不到。有序性则是编译器和CPU对指令进行重排优化后,代码执行顺序可能和书写顺序不一致,这在单线程里没问题,多线程共享变量时就会产生意外。
我之前带过一个小伙伴,他写了一个用boolean变量控制线程停止的demo。主线程改了标志位,子线程却停不下来,他百思不得其解。我让他给标志位加上volatile,问题瞬间解决,他当时就很惊讶。其实这就是典型的可见性问题——子线程从CPU缓存里一直读旧值,根本没注意到主内存已经变了。
2. 创建线程的三种姿势,以及源码层面发生了什么
2.1 三种创建方式的对比与选择
创建线程的常规姿势有三种:继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask。
先看继承Thread的方式:
public class MyThread extends Thread { @Override public void run() { System.out.println("继承Thread方式"); } } new MyThread().start();这种方式最直观,但有几个硬伤:一来Java是单继承,你继承了Thread就不能继承别的类了;二来线程的任务逻辑和线程本身耦合在一起,扩展性差;三来拿不到执行结果。
实现Runnable接口是目前最推荐的基础方式:
public class MyTask implements Runnable { @Override public void run() { System.out.println("实现Runnable方式"); } } new Thread(new MyTask()).start();把任务和线程解耦,同一个任务可以丢给不同线程跑,线程池底层也是基于这套抽象。
如果需要返回结果,就得用Callable:
Callable<Integer> task = () -> 42; FutureTask<Integer> futureTask = new FutureTask<>(task); new Thread(futureTask).start(); Integer result = futureTask.get();Callable的call()方法有返回值,还能抛出受检异常。拿FutureTask包装后交给线程执行,通过futureTask.get()阻塞等待结果。
工程上我个人的习惯是:能不用Thread子类就不用,Runnable和Callable按需选择。而且从Java 8开始,lambda表达式让这三种写法都可以变得非常简洁,Runnable直接写成new Thread(() -> System.out.println("hello"), "线程名").start()就行。
2.2 start()与run():这俩天差地别
这是面试里几乎必问的一个问题,但很多人实际写代码时也犯糊涂。调用run()只是普通的方法调用,会在当前线程里同步执行run方法体的代码,根本没有新线程。只有调用start(),才会真正开启一个新线程。
start()的底层逻辑值得看一眼:它会调用一个native方法start0(),这个方法会向JVM申请创建一个新的原生线程,然后由操作系统调度执行,最终由新线程去执行run()。也就是说,start()是"创建并启动线程"的入口,run()是"新线程要执行的逻辑"。
一个特别容易踩的坑:线程只能start一次。如果对一个已经start过的线程再次调用start(),会抛出IllegalThreadStateException。原因也很好理解——线程一旦进入运行状态,它的状态已经被JVM记录在案了,重复启动从语义上就不允许。
2.3 线程命名的艺术
我想单独拎出来说这个,因为太重要又太容易被忽略。线上排查问题时,线程名就是你的"门牌号"。我见过太多生产事故里,线程池跑出来的线程全都叫pool-1-thread-1,dump出来的日志根本分不清是哪个业务在跑。
正确做法是创建线程时显式给它一个有意义的名字:
Thread t = new Thread(runnable, "order-sync-worker");如果是线程池,用ThreadFactory来统一命名:
ThreadFactory factory = new ThreadFactory() { private final AtomicInteger cnt = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { return new Thread(r, "payment-callback-" + cnt.getAndIncrement()); } };用ThreadFactory还有一个隐藏好处:可以顺便设置守护线程、优先级、异常处理器。我在项目里一般会在这里统一加上UncaughtExceptionHandler,保证线程挂了至少能留下日志,不至于静默消失。
3. 线程生命周期:六种状态与关键方法详解
3.1 线程状态的完整流转
JDK里Thread.State枚举定义了六种线程状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。需要注意的是,Java层面的RUNNABLE状态其实包含了操作系统层面的"运行中"和"就绪中"两个状态,因为JVM认为只要线程可以被调度执行,就算RUNNABLE。
整个流转过程是这样的:new Thread()创建后处于NEW状态,还没启动;调用start()进入RUNNABLE;线程进入synchronized同步块或者同步方法时,如果没拿到锁,就会进入BLOCKED阻塞状态;拿到锁之后重新回到RUNNABLE;当线程执行了Object.wait()、Thread.join()等操作时,进入WAITING状态,等待被显式唤醒;如果是带超时时间的wait(1000)、sleep(1000)、join(1000),则进入TIMED_WAITING;run方法执行完毕或者抛出未捕获异常后,线程就进入TERMINATED状态。
我经常用一张比喻帮人记:RUNNABLE是在候机厅等飞机(随时可能被调度上跑道),BLOCKED是进了安检但被卡住排队(等着别人把锁让出来),WAITING是跑到咖啡厅休息没有闹钟(必须别人来叫),TIMED_WAITING是定了个闹钟睡一觉(时间到自动醒或者提前被叫醒)。
3.2 wait、sleep、join、yield的行为差异
这几个方法初学者特别容易混。先说sleep和wait:sleep是Thread的静态方法,调用后线程进入TIMED_WAITING,但不会释放任何对象锁;wait是Object的实例方法,调用后线程进入WAITING或TIMED_WAITING,并且会释放持有的对象锁。这个"释放不释放锁"的差异,直接决定了你在写同步代码时该用哪个。
举个实际例子:
// 错误示范:sleep不释放锁,会导致其他线程一直拿不到锁 synchronized (lock) { Thread.sleep(5000); } // 正确用法:wait会释放锁,让其他线程有机会进入 synchronized (lock) { lock.wait(5000); }join的作用是"当前线程等待调用join的线程终止"。比如在main线程里调thread.join(),main会进入WAITING状态,直到thread跑完。join的本质其实是基于wait/notify机制实现的,JVM在Thread线程终止时会自动调用notifyAll来唤醒等待在join上的线程。
yield是让出当前CPU时间片,让同优先级的其他线程有机会执行,但线程状态还是RUNNABLE。这个用得很少,因为调度完全由操作系统决定,yield顶多算一个"建议"。
3.3 中断机制:优雅地停止线程
这是Java并发里最容易出问题的一个主题。JDK里Thread.stop()被标记为废弃了,因为它会直接终止线程的执行,导致数据不一致甚至资源不释放。Stop一个线程就等于在执行现场直接拔电源,任何同步块、锁清理逻辑全部失效,所以严禁使用。
正确的停止方式是通过interrupt机制。线程实例上有一个中断标志位,调用interrupt()会把这个标志位置为true。对于sleep、wait、join这种阻塞中的线程,会直接抛出InterruptedException。这里有个非常关键的细节:catch到InterruptedException之后,中断标志位会被清除,也就是重新变为false。所以正确的处理方式是先恢复中断标志:
try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断状态 // 根据业务决定是否退出 return; }很多人不恢复中断标志,导致外层代码虽然收到了异常信号,但线程的中断状态还是false,后续逻辑可能继续运行,线程根本停不下来。这是我排查线上问题时见过的高频bug之一。
对于一般任务线程,我习惯在循环体里判断中断标志:
while (!Thread.currentThread().isInterrupted() && 还有任务) { doWork(); }这样外部调用interrupt后,线程会在下一个循环条件判断时体面退出,把正在处理的这一件事办完。
4. 线程安全的核心:synchronized、volatile、CAS与锁升级
4.1 synchronized的三种写法与锁的持有者
锁,本质上就是给共享资源划定一条"同一时间只允许一个线程通过"的红线。Java中最基础的锁是synchronized,它有三种用法:
// 用法一:修饰实例方法,锁的是当前实例对象 public synchronized void methodA() {} // 用法二:修饰静态方法,锁的是当前类的Class对象 public static synchronized void methodB() {} // 用法三:同步代码块,锁的是括号里指定的对象 public void methodC() { synchronized (this) { // 临界区 } }注意,实例方法和静态方法用的是两把不同的锁。同一个类的两个实例方法被两个线程各自访问时,因为锁对象是各自实例,互不干扰。静态方法锁的是Class对象,和所有实例对象都不同。
synchronized的底层原理在JVM层面是monitor enter和monitor exit指令。每个Java对象都有一个监视器锁(monitor),线程进入同步代码块前要获取锁,退出时要释放锁。整个过程是JVM帮我们自动完成的,不需要手动管理,这也是它相比Lock接口更基础、更安全的原因。
4.2 锁升级:从偏向锁到重量级锁
很多人以为synchronized锁一上来就是"重"的,其实在HotSpot虚拟机里,锁有四种状态:无锁、偏向锁、轻量级锁、重量级锁。这是个逐渐升级的过程,而且只升不降。
偏向锁是针对"只有一个线程访问同步块"的场景设计的。第一个拿到锁的线程会在对象头里记录自己的线程ID,之后再次进入同步块时,不需要CAS去抢锁,直接判断线程ID匹配就行。如果出现第二个线程竞争,偏向锁会撤销并升级为轻量级锁。轻量级锁通过CAS自旋来尝试获取锁,适合锁持有时间很短的场景。自旋有一定次数上限,超过之后或者锁竞争非常激烈时,就膨胀为重量级锁,由操作系统内核的互斥量来实现,这时候线程拿不到锁就会进入BLOCKED状态,涉及到用户态和内核态的切换,开销最大。
从工程优化的角度看,理解锁升级最大的意义在于:不要在小范围内无脑加锁,也不要把持锁时间拖得太长。锁持有时间长,自旋的线程就多,等待的线程也多,压力全堆在锁上。
4.3 volatile:可见性问题的一剂良药
volatile解决了可见性和有序性问题,但不解决原子性。它有两个核心语义:第一,每次读取 volatile 变量会直接从主内存读;每次写入 volatile 变量会立即写回主内存。这样其他线程就能读到最新值。第二,通过内存屏障禁止指令重排序,保证volatile变量前后的代码顺序不被打破。
经典的双重检查锁单例模式就是靠volatile修好的。静态实例字段用volatile修饰,防止其他线程读到"半初始化的对象":
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }不加volatile为什么不行?因为new Singleton()这一步底层有多个步骤:分配内存、初始化对象、把引用指向内存。这三个步骤可能被重排序,如果先把引用指向了还没完成初始化的内存,另一个线程读到这个引用就会返回一个"半成品"对象。加上volatile之后,重排序被禁止,初始化一定发生在引用赋值之前。
4.4 CAS与原子类:无锁并发的思路
CAS(Compare And Swap)是一种无锁的原子操作。它的核心逻辑是:先比较内存中的值是否等于我预期的旧值,如果相等,就把新值写入;否则说明别人改过了,操作失败。整个过程在硬件层面是原子指令,不需要加锁。
java.util.concurrent.atomic包下所有原子类都基于CAS实现。比如AtomicInteger的incrementAndGet:
AtomicInteger counter = new AtomicInteger(); counter.incrementAndGet();它的底层会在一个循环里做CAS尝试,直到成功为止。这叫做自旋。CAS也不是没有毛病,最著名的就是ABA问题:变量值从A改成B又被改回A,CAS检查时发现还是A,就认为没被修改过,但中间其实经历了一次变更。解决ABA问题可以用带版本号的AtomicStampedReference。
从一个过来人的角度看,CAS和锁的选择不是"谁优谁劣",而是看场景:竞争激烈时CAS会疯狂自旋浪费CPU,不如直接上锁;竞争缓和时CAS没有线程切换开销,性能更好。
5. 死锁的产生、排查与工程化规避
5.1 死锁的四个必要条件
死锁虽然听起来很"高级",但原理极其简单:一组线程互相持有对方需要的锁,谁也不让,谁都进行不下去。教科书上讲死锁需要满足四个条件:互斥、持有并等待、不可剥夺、循环等待。
实际开发中我遇到最多的死锁场景是:线程A拿到锁1,等待锁2;线程B拿到锁2,等待锁1。这种交叉持锁,用大白话说就是"你踩了我的脚,我踩了你的脚,谁先疼谁先求饶,但没人求饶"。
5.2 用jstack定位死锁的完整步骤
有一年我们在压测一个交易系统时,接口突然大面积超时,服务还在运行但请求基本不返回。我第一时间打了线程dump,用jstack命令抓下现场:
jstack -l 进程PID > thread_dump.txt打开dump文件,最显眼的位置就是"Found one Java-level deadlock"这几个字,后面会列出参与死锁的线程、各自持有的锁、正在等待的锁。配合代码行号,几乎可以立刻锁死问题代码。
那次的问题出在两个不同服务之间同步调用了对方的接口,各自线程池里的线程都被对方"等着",典型的分布式环境下的等待链死锁。这也是一个很深刻的教训:死锁不只是synchronized之间的事,任何"等待资源释放"的场景都可能演化成死锁,包括等待另一个线程池的线程归还。
5.3 工程化规避死锁的手段
规避死锁最有效的办法是破坏"循环等待"条件。规则很简单:多个锁必须按照固定的全局顺序去获取。比如我们要同时操作订单和库存两张表,就定死规矩——先锁订单,再锁库存。人人都遵守,就不会出现交叉持锁。
另一种办法是使用带超时时间的锁获取,比如Lock接口的tryLock(timeout):
Lock lock1 = new ReentrantLock(); if (lock1.tryLock(2, TimeUnit.SECONDS)) { try { // 拿不到锁就放弃,不让线程无限等下去 } finally { lock1.unlock(); } }tryLock拿不到锁时可以选择释放之前的锁、回滚、重试或者降级,给系统留一条活路。我在设计对账任务时就用过这个方案,实测下来能避免相当一部分隐性死锁。
6. 线程池实战:核心参数、工作流程与配置经验
6.1 七个核心参数到底在说什么
线程池是生产环境中使用频次最高的并发组件。ThreadPoolExecutor的构造方法有七个参数,每个都别有深意。核心线程数(corePoolSize)是线程池的基本盘,即使空闲也倾向于保留的数量。最大线程数(maximumPoolSize)是线程池能容纳的线程上限。keepAliveTime和unit配合使用,指的是非核心线程空闲多久后被回收。阻塞队列(workQueue)是核心线程都在忙时,新任务排队等待的区域。线程工厂(threadFactory)控制线程的创建方式,拒绝策略(handler)则决定队列也满、线程也达到上限时,新任务怎么处理。
这几个参数之间的关系可以用一个简单的流程记忆:任务来了,先看核心线程有没有空闲,有就干;没有就丢队列排队;队列满了再看能否加非核心线程;加到最大值还不行,就触发拒绝策略。
6.2 一个任务进来后,经历了什么
我们用一个具体例子把流程走一遍:核心线程数2,最大线程数4,队列容量3。提交第1个任务,核心线程1执行;提交第2个任务,核心线程2执行。这时再提交第3个任务,核心线程都忙,丢进队列。第4、5个任务同样进队列。当第6个任务提交时,队列已经满了(容量3),于是尝试创建非核心线程,最大线程数4还有余量,创建线程3来执行。第7个任务来时队列仍然满,创建线程4来执行。第8个任务再来,核心线程2+非核心线程2都忙、队列也满,触发拒绝策略。
这里有一个特别容易踩的坑:很多人以为队列满了才会创建非核心线程,这是对的,但要注意队列"满不满"取决于你传入的是有界还是无界队列。如果用无界队列,比如LinkedBlockingQueue默认的Integer.MAX_VALUE容量,那队列永远不会满,意味着maximumPoolSize完全没意义,非核心线程永远不会创建。
6.3 为什么我建议你远离Executors的快捷方法
Executors提供了一批现成方法,比如newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor。我不建议在正式项目里直接使用,因为它们的默认参数全是坑。
newFixedThreadPool用的是无界队列,积压任务可能挤爆内存。newCachedThreadPool的最大线程数是Integer.MAX_VALUE,任务一多疯狂建线程,导致文件描述符耗尽。newScheduledThreadPool也是无界队列。用《Java并发编程实战》里那句经典总结:这些快捷方法没有一个适合生产环境的。
正确的做法是用ThreadPoolExecutor显式指定所有参数,尤其是有界队列和拒绝策略。我在项目里的一个典型配置是:
ExecutorService executor = new ThreadPoolExecutor( 8, 16, 30L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), new ThreadPoolExecutor.CallerRunsPolicy() );6.4 核心线程数怎么算:公式与经验值
线程数的计算是个老生常谈但又谁也绕不开的问题。通用经验法则是:CPU密集型任务,线程数设为CPU核数+1,让每个线程都被充分压榨CPU;IO密集型任务,线程数设为CPU核数×2,因为IO等待期间线程在阻塞,需要更多线程来顶上。
如果要做得更精细,可以用下面的公式估算:
线程数 = CPU核数 × (1 + 等待时间/计算时间)比如一个任务平均计算耗时20毫秒,IO等待耗时80毫秒,等待/计算=4,8核CPU的合理线程数就是8×(1+4)=40。
我在实际调优时通常不是一上来就算死,而是先给一个保险值,压测看CPU利用率和RT(响应时间),再逐步调整。线程数不是越多越好,线程多了上下文切换开销就大,线程少了压不满CPU。
6.5 拒绝策略怎么选
JDK提供了四种拒绝策略。AbortPolicy是默认的,直接抛RejectedExecutionException,适合能接受"任务丢弃"的场景或者你想显式感知系统过载。DiscardPolicy和DiscardOldestPolicy都是静默丢弃,风险很大,一般不建议。CallerRunsPolicy特别有意思,它不让线程池执行任务,而是让提交任务的那个线程自己来跑,相当于一个天然的节流阀——提交线程都在忙着执行任务了,哪里还有精力继续提交任务。我在关键链路里比较喜欢用CallerRunsPolicy,宁可拖慢提交方,也不丢任务。
7. JUC并发工具类:并发协作的正确打开方式
7.1 CountDownLatch:一个线程等N个线程
CountDownLatch的经典场景是主线程等待多个子线程都完成任务后再继续。比如并发查询多个维度数据,每个查询一个线程,主线程用await()等它们全部完成再汇总。
关键在于countDown()和await()的配合。初始计数器为N,每个子线程任务完成后调用一次countDown()把计数器减一,主线程await()会阻塞直到计数器归零。计数器不能重置,所以它适合"一次性"的并发协作。
实际开发中要注意:countDown()最好放在finally里执行,不然子线程中途抛异常会导致计数器永远减不到零,主线程就永远等下去。
7.2 CyclicBarrier:N个线程互相等待
CyclicBarrier和CountDownLatch方向相反。它是让N个线程都到达某个屏障点之后,再一起继续往下走。典型的场景是批量任务中的"分阶段同步":多线程各自处理数据,处理完一批后在屏障处集合,等大家都到了再开始下一批。
CyclicBarrier的"Cyclic"就是可循环的意思,计数器到达归零后会自动重置,所以可以重复使用。它和CountDownLatch最本质的区别:CountDownLatch是"别的线程等我去减数",CyclicBarrier是"所有线程等我到了再一起走"。
7.3 Semaphore:信号量控制并发上限
Semaphore就是"许可"的管理员。初始化一个数字N,表示最多允许N个线程同时访问某个资源。线程执行前acquire()拿许可,执行完release()还许可。没有许可以就得阻塞等待。
它的典型应用是限流和连接池管理。比如某个下游接口只允许同时5个请求,超出只能等待,信号量就是最佳工具。有一点必须注意:release()必须在finally里调用,否则一旦线程执行中途异常,许可就"烂"在手里,其他线程越积越多等在这里,系统很快瘫痪。
7.4 ConcurrentHashMap:从分段锁到CAS+锁
这是并发容器里最值得钻研的一个类。Java 7时代的ConcurrentHashMap用分段锁,把整个Map分成若干段,每段独立加锁,多个线程只要操作不同段就能并发执行。Java 8之后改成了CAS+synchronized的组合:数组每个元素节点独立加锁,锁粒度更细,并发度更高。
这里也解释了为什么并发场景下优先用它而不是Hashtable或者Collections.synchronizedMap。后两者是全局锁,所有操作串行化,性能差得不是一星半点。而ConcurrentHashMap的get操作连锁都不用直接读,写操作也只在对应桶上锁,高并发下吞吐量优势非常明显。
我个人实战里还有个小提醒:ConcurrentHashMap的size()方法在高并发下不一定返回精确值,它为了性能允许一定的近似性。如果需要绝对精确的统计值,考虑额外计数器或者加锁保证。
8. 线上线程问题排查与性能调优实录
8.1 线程dump的正确抓取姿势
线上出了问题,线程dump是第一手现场证据。最常用的命令是jstack,但要抓得"准"是有讲究的。如果进程刚好在线,直接jstack PID可以;如果问题是偶发性的,建议连续抓多份dump文件,间隔几秒,对比看线程状态的变化趋势。单份dump可能什么都看不出来,多份dump才能反映"线程到底卡在哪了"。
还有一种特殊情况:进程假死,JStack命令本身都卡住了,这时候要用jstack -F强制模式。JVM参数层面,我习惯在启动脚本里加上输出线程dump到本地文件的能力,出问题时快速把现场保留下来。
8.2 CPU飙高:三步定位到线程代码行
线上CPU飙高是并发问题里最醒目也最急的故障。整套排查流程我已经固化下来了,基本三步走。
第一步,top命令找到CPU占用最高的进程PID。第二步,top -Hp PID找到该进程内CPU占用最高的线程ID,把它转成十六进制:printf "%x\n" PID。第三步,执行jstack PID打出线程dump,在dump文件里搜索十六进制线程ID,那一段堆栈几乎直接就是问题代码的位置。
这个问题如果出现在我们自己的代码里,最常见的几个原因:无限循环、空转自旋、无阻塞的while循环、或者某些资源竞争导致线程疯狂重试。曾经我排查过一个CPU打满的问题,最后定位到是某个日志框架在极高并发下内部有自旋锁竞争,线程全在自旋空转,把CPU耗干了。找到之后换成异步日志,问题当场解决。
8.3 高频坑位:被反复踩的并发错误
第一个坑是在循环里new线程。每循环一次就创建一个线程,任务稍多线程数量就失控。正确做法是先用线程池缓存任务,把创建线程的开销和数量管理统一收口。
第二个坑是忘记设置线程名。出问题时dump打出来一堆默认名字,排查效率极低。线程名在排查工具里就是一线情报,务必从创建之初就好好起名。
第三个坑是ThreadLocal内存泄漏。ThreadLocal在线程池环境下等于一个隐藏的"脏数据仓库",线程池里的线程复用不会销毁,ThreadLocal里的值会一直残留。用完之后务必调用remove()清理。
第四个坑是任务中吞掉了InterruptedException。日志没打、标志位没恢复,导致线程无法被正确中断。这在前面中断机制小节里已经详细讲过,是最容易忽略但又影响很大的一个细节。
写在最后的小心得
抓线程这块,我觉得最大的坎不在API,而在思维方式。写并发代码时,脑子里必须始终绷着一根弦:共享变量是否安全、锁的顺序是否一致、线程是否会被一直阻塞、任务堆积时系统怎么兜底。这些年我处理过的并发线上事故,十次里有八次不是输在方案设计,而是输在对细节的忽视上——忘了finally里释放锁、忘了捕获中断异常后恢复标志、忘了限流、忘了给线程起名字。
如果你刚入门,先把synchronized、volatile、线程池这几个基础吃透,再去碰各种并发工具类。如果你已经在写并发代码,我强烈建议你从今天开始给每个线程起好名字,亲手抓一次线程dump分析一下。这个习惯养成了,后面排查任何并发问题都会省力很多。