一次从源码角度分析 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 中互相加入对对方的类字节码改写的忽略

相关推荐
考琪7 分钟前
Nginx打印变量到log方法
java·运维·nginx
wangjialelele17 分钟前
Linux中的进程管理
java·linux·服务器·c语言·c++·个人开发
历程里程碑19 分钟前
普通数组----轮转数组
java·数据结构·c++·算法·spring·leetcode·eclipse
李日灐23 分钟前
C++进阶必备:红黑树从 0 到 1: 手撕底层,带你搞懂平衡二叉树的平衡逻辑与黑高检验
开发语言·数据结构·c++·后端·面试·红黑树·自平衡二叉搜索树
晔子yy28 分钟前
如何设计让你的程序同时处理10w条数据
java
Yvonne爱编码35 分钟前
链表高频 6 题精讲 | 从入门到熟练掌握链表操作
java·数据结构·链表
lpfasd12339 分钟前
物联网后端岗位java面试题
java·物联网·php
毕设源码李师姐40 分钟前
计算机毕设 java 基于 java 的图书馆借阅系统 智能图书馆借阅综合管理平台 基于 Java 的图书借阅与信息管理系统
java·开发语言·课程设计
忆~遂愿41 分钟前
Runtime 上下文管理:计算实例的生命周期、延迟最小化与上下文切换优化
java·大数据·开发语言·人工智能·docker
powerfulhell1 小时前
寒假python作业5
java·前端·python