一次从源码角度分析 jvm 类加载死锁问题

线上的服务小概率出现 jvm 启动阶段 hang 住,日志也不再继续输出。 经过分析是 jvm 线程出现了死锁,且不是 java 代码层面的死锁,是出现在 C++ 层面的,大量线程 block。

背景:这个 java 服务被两个 javaagent 进行了字节码改写(apm 和压测),去掉任何一个 agent 服务可正常启动。

因为这不是一个典型的通过 java 线程堆栈就可以分析出来的死锁问题,于是写了这篇文章记录了一下,包括下面信息:

  • GDB 在调试 jvm 中使用
  • 如何找到参与死锁的线程

java 线程堆栈

通过 jstack -F 可以将当前的线程状态 dump 出来,但是看不出啥,大部分线程都处于 block。

以上面的堆栈为例,最顶层调用的方法是 guava 中的 CacheBuilder.getKeyStrength 方法,这个方法不存在任何可能导致 block 的条件,只是一个简单的内存操作。

java 复制代码
public final class CacheBuilder<K, V> {
    LocalCache.Strength getKeyStrength() {
        return (LocalCache.Strength)MoreObjects.firstNonNull(this.keyStrength, Strength.STRONG);
    }
}

接下来开始从 jvm 层面去分析,首先找一个有调试信息(debug-info)的 jvm,这里我们自己编译或者直接从网上下载别人编译好的都可以,这里偷懒用 adoptium JVM,下载地址在这里:https://github.com/adoptium/temurin8-binaries

这里有一个小注意事项:为了更简单的使用指针,可以先关闭压缩指针选项,不然有些指针地址还要转来转去。

shell 复制代码
-XX:-UseCompressedOops -XX:-UseCompressedClassPointers

GDB 调试找到死锁的线程

首先 gdb attach 上 jvm 进程,使用 thread apply all bt,可以找到十几个处于 block 状态的线程,

从 java 的主线程开始看,线程 id 为 48303

可以看到其中这个线程正在调用 SystemDictionary::resolve_instance_class_or_null 为了获取对象锁进入了等待状态,对应的代码行在 673 行,如下:

SystemDictionary::resolve_instance_class_or_null 函数的作用是解析给定的类名,如果该类已经被加载,它会返回一个指向该类的引用。如果类尚未加载,它会触发类加载过程。

可以看到,它想获取了 class_loader 的锁,那这个 classloader 的类名是什么呢?很不幸,classloader 被编译优化了,暂时不好打印

但是classloader 是从上面函数传进来的,往上找一找。

可以看到 loader 是从这里传入的

切换到这个 frame,然后打印一下。

less 复制代码
(gdb) frame 8
#8  0x00007ff27808a7e6 in ConstantPool::klass_at_impl (this_oop=..., which=which@entry=136, __the_thread__=__the_thread__@entry=0x7ff27000e800)
    at src/hotspot/src/share/vm/oops/constantPool.cpp:252
(gdb) x/s this_oop._value._pool_holder._class_loader_data._class_loader._metadata._klass._name._body
0x7ff08c01ddb8:	"com/masaike/instrument/simulator/agent/SimulatorClassLoader"

这里就知道了,48303 号线程因为想持有 SimulatorClassLoader 类加载器的锁进入等待状态。

有果必有因,那接下来就是找,到底是哪个线程持有了 SimulatorClassLoader 类加载器的锁。

还是这个堆栈

我们切换到 5 号栈帧,也就是 ObjectMonitor::enter,看下到底是谁持有了这个

ObjectMonitor 类中有一个 _owner 表示持有这个对象锁的线程是谁。通过 p 命令就可以知道 owner 对象的线程 id 是多少了。

gdb 显示是 58936 号线程持有了锁,找到这个线程,通过 thread 命令切换到这个线程

可以看到 58936 号线程持有了 SimulatorClassLoader 类加载器锁,但它自己也因为要初始化某个类而进入了等待状态。

先看看代码

这个等待出现的条件是

  • 当前 oop 正在被初始化
  • 当前 oop 正在被别的线程初始化

所以我们要知道它现在想初始化什么类,以及这个类正在被哪个线程初始化。

通过 this_oop 我们就知道了,它现在想初始化 AsmClassStructuree 这个类。

ini 复制代码
(gdb) p this_oop._value
$20 = (Klass *) 0x7ff12eed1de0

接下来我们来搞清楚,到底是哪个线程在初始化这个类。从 is_reentrant_initialization 函数可以看到,当前 Klass 为 InstanceKlass 类型,且包含一个 _init_thread 的成员变量。

cpp 复制代码
class InstanceKlass: public Klass {
    // Pointer to current thread doing initialization (to handle recursive initialization)
    Thread* _init_thread;
    bool is_reentrant_initialization(Thread *thread)  { 
        return thread == _init_thread; 
    }
}

现在已经知道了 InstanceKlass 的地址,那就直接硬转即可。

这样就知道了,AsmClassStructure 正在被 48303 号线程初始化,而这个线程正是我们前面看到的一开始分析的线程。

现在竞争条件的终态看起来就比较清楚了。

线程 t1 t2
主线程 48303 初始化 AsmClassStructure 类ing 想持有 SimulatorClassLoader 锁
线程 58936 持有 SimulatorClassLoader 锁 想初始化 AsmClassStructure 类

我们来对应看一下这两个线程 java 层的堆栈

第一个线程:主线程 48303

第二个线程:线程 58936

可以看到因为有了 elastic-apm 的加入,会导致本来正在初始化的 AsmClassStructure 被再次触发初始化,进而导致了死锁的发生。

修改的方式:在两个 apm 中互相加入对对方的类字节码改写的忽略

相关推荐
许野平20 分钟前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono
duration~35 分钟前
Maven随笔
java·maven
zmgst38 分钟前
canal1.1.7使用canal-adapter进行mysql同步数据
java·数据库·mysql
跃ZHD1 小时前
前后端分离,Jackson,Long精度丢失
java
blammmp1 小时前
Java:数据结构-枚举
java·开发语言·数据结构
暗黑起源喵1 小时前
设计模式-工厂设计模式
java·开发语言·设计模式
WaaTong2 小时前
Java反射
java·开发语言·反射
齐 飞2 小时前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb
九圣残炎2 小时前
【从零开始的LeetCode-算法】1456. 定长子串中元音的最大数目
java·算法·leetcode
wclass-zhengge2 小时前
Netty篇(入门编程)
java·linux·服务器