背景
背景是在公司业务中开发一款需要高并发支持的流程引擎时,我遇到了一次典型的多线程“血案”:任务悄无声息地丢失、提交的任务迟迟不处理,最终排查出竟然是死锁的锅。
下面分享这次完整的排查与解决过程,希望能给同样在并发世界中摸爬滚打的你一些启发。
技术环境
整体框架基于 SpringBoot,但问题与 SpringBoot 毫无关系,核心是我纯 Java 编写的流程引擎以及配套的智能体逻辑。多线程部分采用自研的资源池工具,没有引入任何第三方线程池库。
为了保证线程安全,最初我选择在方法上直接加 synchronized 关键字。
出现的问题
随着并发量增加开始出现问题:
① 先是开始丢失处理任务,任务莫名其妙的就消失了。
② 提交的任务也不处理了。
③ 控制台看不到任何异常堆栈。
下面是排查过程。先透露一下最终排查出来的问题,是因为多线程使用不当,造成了死锁问题。
什么是死锁
我用一张图通俗的描述一下啥叫死锁,张三拿着红色外屋的钥匙进入了外屋,但需要蓝色里屋钥匙才能进入蓝屋,而蓝色里屋的钥匙在李四那里,而李四又在等着张三手里红色外屋的钥匙才能进去给张三送蓝色里屋的钥匙;于是乎就尬在这了,我动不了,你也动不了。
排查过程
确定大致问题
通过打印日志,确定问题跟线程有关,但不确定是业务问题,还是技术引擎有关。
Debug
先是以为业务逻辑有问题,在debug过程中排除掉业务逻辑部分后,开始排查线程技术部分,下面是当前流程引擎线程工具方法。
public void scheduleTask(String taskId, long delay, Runnable task) {
cancelTask(taskId); // 如果任务已存在,则取消它
// 包装任务,捕获异常
Runnable safeTask = () -> {
try {
task.run();
} catch (ProcessEngineBusinessException exception) {
log.error("异步任务 - {} - 执行异常", taskId, exception);
// 停止流程并推送通知
RunnerControl.PROJECT_LIST_RUNNER_MAP
.get(exception.getFlowIdAndRunner())
.getTimeManager()
.stopFlowIfNotStopped(FlowEndType.EXCEPTION_END.setType(exception.getMessage()));
} catch (Throwable e) {
log.error("异步任务 - {} - 执行异常", taskId, e);
}
};
ScheduledFuture<?> scheduledFuture = scheduler.schedule(safeTask, delay, TimeUnit.MILLISECONDS);
if (delay > 0) {
tasks.put(taskId, scheduledFuture);
}
}
将断电打在task.run();
行时,发现断点无法到这里,说明异步任务在此之前就已经丢失了,并且通过断点发现scheduler
线程池对象目前是饱和状态,线程池中的核心线程都在工作中,所以初步确定是线程池处于满载状态后,任务进入了等待队列中,而随着队列中堆积任务越来越多,最后导致队列也满了,但核心线程就是不释放。
不释放就只有一个原因了,死锁了;那就好办了,找到死锁原因就解决了。
寻找死锁
使用JDK自带的jconsole
工具,就可以一键找到死锁位置,并且能给我解释的明明白白为啥会死锁。
① 在JDK的bin目录下找到jconsole.exe
文件,双击启动。
② 找到我们的本地服务进程,连接。
③ 找到我们命名的线程池名称,可以看到该线程池中有很多线程,随意点几个就可以看到当前状态是BLOCKED,表示这个线程正在尝试获取一个同步锁(synchronized),但该锁当前被别的线程持有,因此挂起。
点击下面的检测死锁
按钮,可以看到更多信息。
④ 死锁分析,点击检测死锁后界面后展示了与当前线程竞争锁对象的有哪些线程,一看好家伙,在这套娃呢,你抢我的锁,我抢他的锁,他又抢你的锁,正好形成了一个回路。
解决死锁
解读死锁信息
通过jconsole
工具给出的信息,简要信息如下,更多信息就不列了:
ActivityNode.start(ActivityNode.java:138)
- 已锁定 ActivityNode@6944ba35
ActivityNode.dispatch(ActivityNode.java:110)
- 已锁定 ActivityNode@6944ba35
简单理解就是ActivityNode.start
和 dispatch
方法里都有synchronized
关键字(或进入了某个同步块),并且锁对象是同一个 **ActivityNode@6944ba35**
。
换成人话就是线程38持有锁 A (6944ba35),试图获取锁 B (44b39f9d)。线程36持有锁 B,同时需要锁 A。完美复原上面讲到的死锁原理。
解决死锁问题
知道问题在哪了,直接把start
和dispatch
方法的synchronized
关键字先去掉,经过理解业务之后,是因为不同任务在同一个智能体中处理不同任务造成的。
所以给每一个任务创建一个锁对象,在start方法中进行手动加锁,既保证了线程安全,又保障了并发。下面是核心代码。
private final ConcurrentHashMap<String, ReentrantLock> projectLocks = new ConcurrentHashMap<>();
public void start(String projectId, long dispatchTime, long startTime, ProjectListCallbackFunction callback) {
ReentrantLock lock = lockFor(projectId);
lock.lock();
try {
// 业务代码
} finally {
lock.unlock();
}
}
public ReentrantLock lockFor(String projectId) {
return projectLocks.computeIfAbsent(projectId, k -> new ReentrantLock());
}
总结
这次排查的最大收获是:
① 粗粒度锁易引发死锁,尤其在高并发场景;
② 合理拆分锁、按业务维度精细化加锁;
③ JDK 自带的 jconsole
是个神器。