1. 项目概述:为什么多线程面试题是Java工程师的“必答题”?
干了这么多年Java开发,也面过不少人,我发现一个挺有意思的现象:甭管你是面初级、中级还是高级,面试官手里那张问题清单上,多线程相关的问题几乎从不缺席。这玩意儿就像武侠小说里的内功心法,你说它平时开发用得多吧,好像也不是天天都在写Thread和Lock;但你说它不重要吧,但凡系统出点性能瓶颈、响应变慢、数据错乱之类的“玄学”问题,最后追查下去,十有八九都能跟多线程扯上关系。所以,面试官问多线程,本质上不是在考你死记硬背几个概念,而是在考察你解决复杂并发问题的底层思维和实战经验。一个对多线程理解透彻的程序员,写出来的代码在健壮性、扩展性和性能上,跟只会写单线程业务的程序员,完全是两个层次。
这篇文章,我就从一个面试官和一线开发者的双重角度,跟你聊聊Java多线程那些高频、核心的面试题。我不会只给你干巴巴的答案,那样你背了也容易忘。我会把每个问题背后的“为什么”讲清楚,结合我实际踩过的坑和优化的案例,让你不仅知道“是什么”,更能理解“怎么用”以及“为什么这么用”。无论你是正在准备面试,还是想系统梳理一下自己的知识体系,相信这篇长文都能给你带来实实在在的帮助。
2. 核心概念与底层原理:从JVM视角看线程
2.1 线程的生命周期与状态流转
很多朋友背“新建、就绪、运行、阻塞、死亡”这五种状态背得很熟,但一到线上问题排查就懵。其实,从Java代码和JVM的层面看,线程的状态要更精细一些。java.lang.Thread.State这个枚举类定义了六种状态:
- NEW(新建):
new Thread()之后,但还没调用start()方法。这时候它就是个普通的Java对象,操作系统层面还没对应的线程。 - RUNNABLE(可运行):调用了
start()方法。注意,这个状态包含了操作系统线程调度中的“就绪”和“运行”两种。也就是说,正在CPU上执行的线程和正在等待CPU时间片的线程,在JVM看来都是RUNNABLE。这是最容易误解的一点。 - BLOCKED(阻塞):线程在等待进入一个synchronized同步块或方法的监视器锁(monitor lock)时进入此状态。比如,线程A持有了锁,线程B也想获取同一把锁,那么线程B就会进入
BLOCKED状态。关键点:这个状态只与synchronized关键字竞争锁相关。 - WAITING(无限期等待):线程进入此状态后,需要等待其他线程显式地通知(notify)或中断(interrupt)才能唤醒。触发方式包括:
Object.wait()(不传超时参数)Thread.join()(不传超时参数)LockSupport.park()
- TIMED_WAITING(限期等待):和
WAITING类似,但设定了超时时间。时间一到,即使没有被通知,线程也会自动返回RUNNABLE状态。常见方法:Thread.sleep(long millis)Object.wait(long timeout)Thread.join(long millis)LockSupport.parkNanos(long nanos)
- TERMINATED(终止):线程执行完毕(
run()方法正常退出)或因异常而终止。
面试避坑点:当被问到“
sleep()和wait()有什么区别”时,不要只停留在“所属类不同、是否释放锁”这个层面。可以从状态机角度深入:sleep()会让线程进入TIMED_WAITING状态,不释放任何锁;而wait()会释放当前对象锁,然后线程进入WAITING或TIMED_WAITING状态,等待被notify()唤醒后重新去竞争锁。
2.2 Java内存模型(JMM)与线程安全的核心
这是多线程最难也最核心的部分。JMM定义了一种抽象规范,它规定了线程如何以及何时可以看到其他线程修改过的共享变量,以及如何同步地访问共享变量。它的核心是围绕主内存和工作内存的交互。
- 主内存:可以粗略理解为堆内存,存储所有共享变量。
- 工作内存:每个线程私有的,存储了该线程使用到的变量的主内存副本。
线程对变量的所有操作(读、写)都必须在工作内存中进行,不能直接读写主内存。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存来完成。这套机制带来的直接问题就是可见性和有序性。
可见性问题:线程A修改了共享变量flag的值,但只是写回了自己的工作内存,还没来得及刷新到主内存。此时线程B从自己的主内存副本(还是旧值)中读取flag,就看不到A的修改。volatile关键字、synchronized和Lock锁都能保证可见性,因为它们都会在释放锁前将工作内存的修改刷新到主内存,并在获取锁后从主内存重新读取最新值。
有序性问题:为了性能,编译器和处理器可能会对指令进行重排序。在单线程下,这种重排序不会影响最终结果(遵循as-if-serial语义)。但在多线程下,重排序可能导致意想不到的结果。最经典的例子就是“双重检查锁定(DCL)”单例模式,如果不用volatile修饰实例变量,可能会得到一个“半初始化”的对象。
public class Singleton { private static volatile Singleton instance; // 必须加volatile private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 问题所在! } } } return instance; } }instance = new Singleton()这行代码在JVM中大致分为三步:1. 分配内存空间;2. 初始化对象;3. 将引用指向内存地址。步骤2和3可能被重排序。如果线程A执行了1和3后(此时instance已非空但对象未初始化),线程B进入第一个if (instance == null)判断,会发现instance不为空,直接返回一个尚未初始化的对象,导致程序出错。volatile关键字通过禁止指令重排序,确保了写操作之前的指令不会重排到写操作之后,从而解决了这个问题。
2.3 synchronized的锁升级过程
synchronized以前被称为“重量级锁”,性能差。但在JDK 1.6之后,Java团队对它进行了大规模优化,引入了“偏向锁”、“轻量级锁”等概念,其性能已经非常不错。了解这个升级过程,能让你在回答“synchronized原理”时脱颖而出。
锁的状态保存在对象头的Mark Word中。升级过程是单向的,目的是减少获得锁和释放锁带来的性能消耗。
- 无锁状态:一个新创建的对象。
- 偏向锁:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁就是为了在只有一个线程执行同步块时提高性能。当线程第一次进入同步块时,JVM会使用CAS操作将线程ID记录到对象头的Mark Word中,并将锁标志位设为“01”(偏向模式)。之后该线程再进入和退出同步块时,不需要进行CAS操作来加锁和解锁,只需简单测试一下对象头的Mark Word里是否存储着自己的线程ID。如果测试成功,表示线程已经获得了锁。
- 轻量级锁:当有第二个线程尝试获取锁时(发生了竞争),偏向锁就会升级为轻量级锁。JVM会在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word拷贝。然后,JVM会尝试使用CAS操作将对象头的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程会尝试使用自旋来获取锁(空循环等待)。
- 重量级锁:如果轻量级锁的竞争加剧(比如自旋超过一定次数,或者等待的线程数超过一个阈值),轻量级锁就会膨胀为重量级锁。此时,锁标志位变为“10”,Mark Word中存储的是指向操作系统互斥量(mutex)的指针,等待锁的线程会进入
BLOCKED状态,由操作系统负责调度。这个状态下的性能开销最大。
实操心得:理解锁升级后,你就明白为什么说“在低竞争场景下,
synchronized性能并不差”。同时,也要知道锁升级是不可逆的。如果你的应用是明确的高并发场景,锁竞争激烈,那么一开始就使用ReentrantLock这类显式锁,可能更能满足你对超时、中断等复杂控制的需求。
3. 核心工具类与并发容器详解
3.1 AQS:并发包的心脏
java.util.concurrent.locks.AbstractQueuedSynchronizer(AQS)是JUC(Java并发工具包)的基石。像ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock等,内部都有一个静态内部类继承自AQS。理解AQS,就等于拿到了理解JUC并发工具的一把万能钥匙。
AQS的核心思想是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制。这个机制AQS是用一个**双向队列(CLH队列的变种)**来实现的,将暂时获取不到锁的线程加入到队列中。
AQS内部维护了一个volatile int state变量和一个FIFO线程等待队列。state的具体含义由子类去定义和实现。例如:
- 在
ReentrantLock中,state表示重入次数。 - 在
Semaphore中,state表示剩余的许可证数量。 - 在
CountDownLatch中,state表示倒计数的数值。
子类需要重写AQS提供的几个protected方法,如tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared等,来定义如何获取和释放资源。AQS的模板方法(如acquire、release)会调用这些钩子方法。
以ReentrantLock的非公平锁为例:
- 线程调用
lock()时,会直接尝试用CAS修改state(从0改为1)。如果成功,就获取了锁,并设置当前线程为独占线程。这体现了“非公平”性:新来的线程可能插队。 - 如果CAS失败,说明锁已被占用。则调用AQS的
acquire(1)方法。 acquire会先调用子类的tryAcquire再次尝试获取(非公平锁的tryAcquire逻辑里,依然有直接插队抢锁的尝试),如果失败,则将当前线程包装成节点加入等待队列,并进入自旋或阻塞状态。
3.2 ConcurrentHashMap的演进与分段思想
HashMap是线程不安全的,Hashtable用synchronized修饰所有方法,性能堪忧。ConcurrentHashMap是并发编程中的明星容器。
JDK 1.7的实现(分段锁): 它将数据分成一段一段(Segment)的存储,每一段数据配一把锁。当一个线程访问其中一段数据时,其他段的数据也能被其他线程访问,实现了真正的并发访问。Segment继承自ReentrantLock。这种设计降低了锁的粒度,提升了并发度。但缺点是,在定位元素时需要进行两次哈希计算(先找到Segment,再找到Segment中的桶),稍显复杂。
JDK 1.8及之后的实现(CAS + synchronized): 放弃了分段锁,采用了和HashMap更相似的结构:数组+链表/红黑树。锁的粒度进一步细化到了每个数组桶(bucket)的头节点。
- 插入:如果桶为空,直接用CAS操作设置头节点。
- 更新:如果桶不为空,则使用
synchronized锁住这个桶的头节点,再进行链表或红黑树的插入、更新操作。 - 扩容:支持多线程协同扩容,设计非常精妙。
为什么用synchronized替代ReentrantLock?因为JDK 1.6后synchronized已经做了大量优化,性能不差,且synchronized是JVM原生支持,能享受JVM后续的持续优化;而ReentrantLock是API级别,需要额外的内存开销。在锁粒度极细(锁住单个桶)的场景下,synchronized是更轻量、更合适的选择。
3.3 线程池的七大参数与工作流程
“说一下线程池的构造参数”,这几乎是送分题,但能说清楚工作流程和拒绝策略的才是高手。
ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 空闲线程存活时间 unit, // 时间单位 workQueue, // 工作队列 threadFactory, // 线程工厂 handler // 拒绝策略 );工作流程(务必理解并画出来):
- 提交一个任务。
- 如果当前运行的线程数 <
corePoolSize,则创建新线程(核心线程)来处理任务。 - 如果运行的线程数 >=
corePoolSize,则将任务放入workQueue。 - 如果
workQueue已满,且运行的线程数 <maximumPoolSize,则创建新线程(非核心线程)来处理任务。 - 如果
workQueue已满,且运行的线程数 >=maximumPoolSize,则触发handler(拒绝策略)处理该任务。
四种拒绝策略:
AbortPolicy(默认):直接抛出RejectedExecutionException异常。CallerRunsPolicy:让调用者线程(比如主线程)自己执行该任务。这提供了一个简单的反馈机制,可以降低新任务提交的速度。DiscardPolicy:直接丢弃任务,不做任何处理。DiscardOldestPolicy:丢弃队列中最老的一个任务,然后尝试重新提交当前任务。
踩坑实录:线上曾有一个服务用了
FixedThreadPool(Executors.newFixedThreadPool(n)),它的队列是LinkedBlockingQueue,默认长度是Integer.MAX_VALUE。在流量洪峰时,任务不断堆积在无界队列里,导致内存飙升,最终OOM。所以,阿里巴巴开发规范强制要求使用ThreadPoolExecutor构造函数来创建线程池,就是为了让开发者明确指定有界队列和合理的拒绝策略。
4. 高级主题与实战场景剖析
4.1 原子类与CAS的ABA问题
java.util.concurrent.atomic包下的原子类(如AtomicInteger)是保证线程安全的高性能工具。其底层核心是CAS(Compare-And-Swap)操作,一种CPU级别的原子指令。
CAS操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。当且仅当V的值等于A时,处理器才会用B更新V的值,否则不执行更新。整个操作是一个原子过程。
但CAS存在一个经典问题:ABA问题。线程1读取内存值V为A,此时线程2将V从A改为B,然后又从B改回A。接着线程1执行CAS,发现V还是A,于是操作成功。尽管线程1的CAS操作成功了,但这个过程可能已经产生了非预期的副作用(比如链表结构发生了变化)。
解决方案:
- 加版本号:
AtomicStampedReference。它内部维护了一个Pair对象,包含对象引用和一个int类型的版本戳(stamp)。每次更新时,版本戳+1。CAS时同时比较引用和版本戳。 - 加布尔标记:
AtomicMarkableReference。与AtomicStampedReference类似,但版本戳简化为一个boolean标记。
适用场景:原子类适用于计数器、状态标志等简单的同步场景。对于复杂的复合操作(比如“先检查后执行”),可能需要结合锁或使用更高级的并发容器。
4.2 ThreadLocal的内存泄漏隐患
ThreadLocal提供了线程局部变量,每个线程都有自己独立的变量副本,避免了共享。它是实现线程隔离的利器,常用于存储用户会话信息、数据库连接等。
原理:每个Thread对象内部都有一个ThreadLocal.ThreadLocalMap类型的变量threadLocals。这个Map的key是ThreadLocal实例本身(弱引用),value是存储的值。当线程访问ThreadLocal变量时,实际上是在访问自己线程内部的这个Map。
内存泄漏风险: 由于ThreadLocalMap的key是弱引用(WeakReference<ThreadLocal>),而value是强引用。当ThreadLocal实例没有外部强引用时(比如被置为null),在GC时,key会被回收,但value不会被回收。这就导致了一个key为null,但value有值的Entry存在于Map中,无法被访问到,也无法被回收。如果线程是线程池中的核心线程,生命周期很长,这个泄漏就会一直持续,最终可能引发OOM。
正确使用姿势:
- 务必在finally块中调用
remove():在使用完ThreadLocal变量后,手动调用ThreadLocal.remove()方法,清除当前线程的Map中对应的Entry。 - 尽量使用
private static final修饰:将ThreadLocal变量声明为静态的,这样它的生命周期就和类一样长,可以避免因实例被回收而导致的不可预测行为(虽然弱引用能回收key,但静态引用能保证key一直存在,逻辑更清晰)。
private static final ThreadLocal<UserContext> userContextHolder = new ThreadLocal<>(); try { userContextHolder.set(currentUser); // ... 执行业务逻辑 } finally { userContextHolder.remove(); // 关键! }4.3 死锁的诊断、避免与解决
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们都将无法推进下去。
死锁产生的四个必要条件(必须同时满足):
- 互斥条件:资源一次只能被一个线程使用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
如何定位死锁?
- 使用jstack命令:
jstack <pid>可以打印出线程堆栈信息。在输出的最后,JVM通常会有一个“Found one Java-level deadlock”部分,清晰地指出哪些线程在等待哪些锁,形成了循环等待。 - 使用JConsole或VisualVM:这些可视化工具可以连接上Java进程,直接查看线程状态,并检测死锁。
如何避免和解决死锁?
- 破坏“请求与保持”条件:一次性申请所有需要的资源。例如,在获取锁的时候,按照一个全局固定的顺序去申请(锁排序)。这是最常用、最有效的预防策略。
- 破坏“不剥夺”条件:使用
Lock接口的tryLock(long time, TimeUnit unit)方法,在尝试获取锁时设置超时时间。如果超时还未获取到,就释放自己已经持有的锁,然后进行重试或回退。 - 破坏“循环等待”条件:同样可以通过锁排序来实现。
实战案例:转账操作需要锁定两个账户。
// 错误的写法,可能死锁 public void transfer(Account from, Account to, int amount) { synchronized(from) { synchronized(to) { // ... 转账逻辑 } } } // 如果线程A: transfer(account1, account2, 100) 和线程B: transfer(account2, account1, 200) 同时执行,就可能死锁。 // 正确的写法:通过锁排序破坏循环等待 public void transfer(Account from, Account to, int amount) { Account firstLock = from.id < to.id ? from : to; Account secondLock = from.id < to.id ? to : from; synchronized(firstLock) { synchronized(secondLock) { // ... 转账逻辑 } } }5. 面试高频问题深度解析与回答思路
5.1 volatile关键字的作用与原理
作用:
- 保证可见性:当一个线程修改了
volatile变量的值,新值会立即被刷新到主内存。当其他线程需要读取这个变量时,它会从主内存重新读取,而不是使用自己工作内存中的旧值。 - 禁止指令重排序:通过插入内存屏障(Memory Barrier)指令,确保编译器和处理器不会对
volatile变量读写操作前后的指令进行重排序。
原理:volatile的底层实现依赖于CPU的内存屏障指令和缓存一致性协议(如MESI)。
- 写操作:在写一个
volatile变量时,JVM会向处理器发送一条Lock前缀的指令(或类似效果的指令),这个指令会将当前处理器缓存行的数据写回到系统内存,并使其他CPU里缓存了该内存地址的数据无效。 - 读操作:在读一个
volatile变量时,JVM会从主内存中重新读取数据到工作内存。
与synchronized的区别:
| 特性 | volatile | synchronized |
|---|---|---|
| 原子性 | 仅保证单次读/写的原子性(如long/double),不保证复合操作(如i++) | 保证,整个同步块是原子的 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证(禁止重排序) | 保证(as-if-serial,但同步块内可重排序) |
| 阻塞 | 不会造成线程阻塞 | 会,未获取锁的线程会进入BLOCKED状态 |
| 使用场景 | 状态标志、一次性安全发布(如DCL单例) | 多步骤复合操作、需要互斥访问的临界区 |
5.2 实现线程安全的单例模式
这是考察对类加载机制、内存模型、锁理解的综合题。除了最经典的DCL(Double-Checked Locking)外,还有更优雅的写法。
1. 饿汉式(线程安全):类加载时就初始化,浪费内存。
public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }2. 懒汉式(DCL + volatile,线程安全):上文已分析,需注意volatile。
public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }3. 静态内部类(推荐):利用类加载机制保证线程安全,且实现了懒加载。
public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; // 只有在调用此方法时,Holder类才会被加载,INSTANCE才会被初始化 } }4. 枚举(最安全、最简洁):Effective Java作者Josh Bloch推荐的方式。能防止反射攻击和序列化破坏单例。
public enum Singleton { INSTANCE; public void doSomething() { ... } } // 使用:Singleton.INSTANCE.doSomething();5.3 对“锁”的深度理解
可重入锁(ReentrantLock):指同一个线程在外层方法获取锁之后,在进入内层方法时会自动获取锁(前提是锁对象是同一个)。synchronized和ReentrantLock都是可重入锁。这避免了线程因等待自己已持有的锁而造成的死锁。
公平锁 vs 非公平锁:
- 公平锁:多个线程按照申请锁的顺序来获取锁,先到先得。
ReentrantLock(true)。 - 非公平锁:多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。
synchronized和ReentrantLock()默认都是非公平的。 - 性能对比:非公平锁的吞吐量通常高于公平锁。因为公平锁需要维护一个有序队列,唤醒线程是严格按顺序的,上下文切换开销大。非公平锁允许“插队”,减少了线程挂起和唤醒的开销,但可能导致某些线程“饥饿”。
读写锁(ReadWriteLock):允许多个读线程同时访问,但写线程访问时,所有读线程和其他写线程均被阻塞。适用于“读多写少”的场景,能显著提升性能。ReentrantReadWriteLock是其实现。
乐观锁 vs 悲观锁:
- 悲观锁:认为并发冲突是常态,每次操作数据前都先加锁。
synchronized和ReentrantLock就是悲观锁思想的实现。 - 乐观锁:认为并发冲突不常发生,只在提交更新时去检查数据是否被其他线程修改过。通常使用版本号或CAS机制实现。数据库的
version字段、AtomicInteger的incrementAndGet()都是乐观锁思想的应用。
6. 性能调优与监控实战
6.1 如何合理配置线程池参数?
这是一个没有标准答案,但必须能自圆其说的问题。核心思路是根据任务类型和系统资源来估算。
任务类型分析:
- CPU密集型:任务主要消耗CPU资源,如计算、逻辑判断。线程数不宜过多,一般设置为CPU核心数 + 1。+1是为了防止线程因页缺失或其他原因阻塞时,能有替补线程利用CPU。
- IO密集型:任务大部分时间在等待IO(网络、磁盘、数据库)。此时CPU经常空闲,可以设置较多的线程。一个经验公式是:CPU核心数 * (1 + 平均等待时间 / 平均计算时间)。如果等待时间远大于计算时间(如Web应用),可以设置为CPU核心数 * 2或更高。更精确的做法是通过压测来寻找性能拐点。
参数设置实战:
corePoolSize:根据上述分析设定。对于需要快速响应的服务,可以设置得和maximumPoolSize一样大,避免队列排队。maximumPoolSize:在corePoolSize的基础上,考虑系统资源(内存、句柄数等)。设置过大可能导致线程切换开销剧增,甚至OOM。workQueue:强烈建议使用有界队列,如ArrayBlockingQueue。队列大小需要权衡:太小容易触发拒绝策略,太大可能掩盖问题,导致响应时间变长。handler:根据业务容忍度选择。CallerRunsPolicy是一个不错的兜底策略,能让提交任务的线程慢下来,起到负反馈作用。
动态调整:Apache Dubbo、Netty等框架的线程池支持动态调整核心和最大线程数。在监控到队列持续积压时,可以适当调大参数。
6.2 多线程上下文切换开销与优化
线程数不是越多越好。每个线程都需要占用一定的内存(栈空间),并且操作系统在线程间切换(上下文切换)时,需要保存和恢复寄存器、程序计数器等状态,这是一个昂贵的操作。频繁的上下文切换会导致CPU将大量时间花在调度上,而非实际执行任务。
如何监控上下文切换?
- Linux命令:
vmstat 1查看cs(context switch)列。 - Java工具:使用
pidstat(pidstat -t -p <pid> 1)或perf等更专业的工具。
优化方向:
- 减少锁竞争:锁是导致线程挂起、触发上下文切换的主要原因。可以通过缩小锁粒度、使用读写锁、无锁数据结构(如
ConcurrentLinkedQueue)、乐观锁等方式减少竞争。 - 使用协程(用户态线程):协程的切换在用户态完成,开销远小于操作系统线程的切换。Java原生不支持,但可以通过Quasar纤维库或Project Loom(预览特性)来体验。
- 优化线程池配置:避免创建过多线程,尤其是CPU密集型任务。
6.3 线上死锁/高CPU问题排查流程
当收到告警,发现某个应用CPU飙高或线程池满,可以按以下步骤排查:
- 定位问题进程:
top命令找到CPU或内存占用高的Java进程ID(PID)。 - 定位问题线程:
top -Hp <pid>:查看该进程内所有线程的CPU占用情况,记录下占用高的线程ID(十进制)。- 将线程ID转换为十六进制:
printf “%x\n” <线程ID>。
- 分析线程堆栈:
jstack <pid> > thread_dump.log:导出线程快照。- 在
thread_dump.log文件中,搜索上一步得到的十六进制线程ID(nid=0x...),找到对应的线程堆栈。查看它在执行什么代码,是否卡在某个锁、某个IO操作或死循环中。
- 结合其他信息:
- 死锁:
jstack输出末尾通常会直接标明。 - 锁竞争激烈:大量线程处于
BLOCKED状态,且等待同一个锁。 - CPU空转:线程状态为
RUNNABLE,且堆栈显示在频繁执行某个循环或计算。
- 死锁:
- 使用Arthas等在线诊断工具:无需下载日志,直接连接线上JVM,执行
thread -b(查找阻塞线程)、thread <nid>(查看指定线程)、monitor(方法监控)等命令,效率更高。
我个人的经验是,多线程问题的根因往往不在多线程代码本身,而在于对共享资源(数据、连接、文件)的访问设计。在设计和评审代码时,多问一句“这段代码被多个线程同时访问会怎样?”,能避免很多线上问题。多线程编程就像走钢丝,平衡性能和正确性需要深厚的功底和不断的实践,希望这篇长文能成为你行走在这根钢丝上的一根可靠扶手。