线上的服务小概率出现 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 中互相加入对对方的类字节码改写的忽略