性能文章>一次 GDB 源码角度分析 jvm 无响应问题>

一次 GDB 源码角度分析 jvm 无响应问题原创

364612

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

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

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

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

java 线程堆栈

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

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

public final class CacheBuilder<KV{
    LocalCache.Strength getKeyStrength() {
        return (LocalCache.Strength)MoreObjects.firstNonNull(this.keyStrength, Strength.STRONG);
    }
}

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

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

-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,然后打印一下。

(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 我们就知道了,它现在想初始化  A**ClassStructuree 这个类。

(gdb) p this_oop._value
$20 = (Klass *) 0x7ff12eed1de0

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

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 的地址,那就直接硬转即可。

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

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

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

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

第一个线程:主线程 48303

第二个线程:线程 58936

bbbc.jpg

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

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

 

 

点赞收藏
挖坑的张师傅

机械工业出版社《深入理解 JVM 字节码》作者,掘金小册作者《JVM 字节码从入门到精通》、《深入理解TCP 协议》作者,Vim 死忠粉、Kotlin&Go 爱好者、能抓一手好包、喜欢底层技术和分享。微信公众号:张师傅的博客(shifuzhang01)

请先登录,查看1条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

从 Linux 内核角度探秘 JDK MappedByteBuffer

从 Linux 内核角度探秘 JDK MappedByteBuffer

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

2
1