线程调试技巧:从卡死到顺滑的实战经验
前几天帮同事看一个项目,程序跑着跑着就卡住不动,界面冻结,日志停在某个地方不再更新。一看就是多线程出问题了,不是死锁就是资源竞争。这种问题不比语法错误,编译不过一眼就能发现,线程问题往往藏得深,复现难,查起来头疼。
打日志别只打“进入方法”
很多人加日志,习惯性写一句“进入XXX方法”,但在线程场景下,光知道“进入”没用,关键得知道“谁在执行”、“在哪卡住”。建议每条关键日志带上线程名或ID:
System.out.println(Thread.currentThread().getName() + ": 正在处理订单ID=" + orderId);这样你一看日志,发现两个线程名字一样,或者某个线程迟迟没输出下一步,基本就能定位到瓶颈点。
善用IDE的线程视图
像IntelliJ IDEA或Eclipse,调试时左侧线程列表别忽略。程序挂起时,切过去看看每个线程的状态。是不是某个线程一直BLOCKED?是不是有线程在wait()没被notify?
有一次我发现主线程停了,但其他几个工作线程还在RUNNABLE状态,点进去一看,原来它们在无限循环处理任务,而主线程等它们结束——结果根本没人通知它们该收工了。
避免过度同步
synchronized关键字用起来简单,但一不小心就埋雷。比如下面这段代码:
public synchronized void methodA() {
// 做一些事
methodB();
}
public synchronized void methodB() {
// 另一件事
}看起来没问题,但如果methodA和methodB互相调用,或者多个实例共享锁,很容易形成死锁。更稳妥的做法是缩小同步块范围,只锁真正需要保护的代码段:
private final Object lock = new Object();
public void methodA() {
synchronized(lock) {
// 仅保护关键操作
}
}用jstack抓现场快照
线上环境不能随便调试,这时候jstack就派上用场了。程序卡住时,赶紧执行:
jstack <pid> > thread_dump.txt生成的文件里会列出所有线程的调用栈。重点关注状态为BLOCKED、WAITING的线程,通常能看到它在等哪个锁,而那个锁又被谁拿着不放。
有次我们发现一个定时任务总不执行,jstack一抓,发现线程池里的所有线程都在等待同一个锁,而持有锁的那个线程正在处理一个超长任务,根本没释放。后来改成异步处理,问题消失。
模拟高并发别靠猜
本地测试线程问题,不能只跑一次。写个简单的压力测试脚本,启动几十个线程同时调用目标方法:
ExecutorService pool = Executors.newFixedThreadPool(30);
for (int i = 0; i < 100; i++) {
pool.submit(() -> {
yourService.processData();
});
}这样更容易暴露竞态条件。有时候单线程跑十遍都没事,一并发,数据就乱了。
别忽视ThreadLocal的副作用
ThreadLocal用来存用户上下文很方便,但在线程池环境下容易出问题。线程被复用后,之前的ThreadLocal值可能还留着,导致下一个任务误读数据。
记得有一次登录用户信息错乱,查了半天才发现是线程池里的线程没清空ThreadLocal。解决办法很简单:用完手动remove()
try {
userIdHolder.set(currentId);
// 执行业务逻辑
} finally {
userIdHolder.remove(); // 关键!
}小动作,大作用。
把异常抛出来看看
有些线程出了异常,默默吞掉,连日志都不打。比如Runnable里一个空指针,整个线程就静默退出了,任务没了影子。建议统一加个未捕获异常处理器:
Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
System.err.println("线程 " + t.getName() + " 抛出异常:" + e.getMessage());
e.printStackTrace();
});至少让你知道“有人倒下了”。
线程调试不像修水管,拧紧就行。它更像找一只躲在墙里的老鼠,得有点耐心,也得会布陷阱。工具用熟了,经验攒多了,那些曾经让你熬到凌晨的问题, eventually become routine.