JDK11现存性能bug(JDK-8221393)深度解析(1)

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 作为一名工程师,面对上面的现象,你会怎么做? 我想你的第一反应肯定是业务代码有问题?是不是有什么地方导致内存泄露? 是不是业务代码里有什么地方加载的数据太多,越来越慢?…… 同事尝试过dump堆里的内容,dump jstak线程…… 都没看出来什么异常,也优化了业务代码里之前一些不合理的逻辑,始终没有解决问题。 当时的问题是他们都没有往热点代码的方向排查,主要是因为他们不知道有啥好用的工具。

这是一篇鸽了很久的博客,因为博客内容和素材早就准备差不多了,但就是一直懒得整理,今天终于下定决心终于整理出来了,这也是这个bug JDK-8221393唯一一篇中文介绍博客。


先大致介绍下这个bug,准确说这个应该是jdk11新引入的zgc的一个bug,该bug在被触发的情况下会导致进程CPU使用率会逐渐升高,如果不管的话最终CPU会到100% 影响服务可用性,而且这个性能bug在jdk11最新的代码中仍未修复。不过不用担心,这个bug触发的要求比较苛刻,估计这也是jdk开发者不修复该bug的原因之一。另外,我在翻看jdk13源码时发现该bug已被修复,并且有些相关设计上的提升。

ba995bce102ee4df1edcde9c9b69816a_watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3hpbmRvbw==,size_16,color_FFFFFF,t_70#pic_center.png

该bug的表象如上图,在业务和代码逻辑无变更的情况下,CPU使用率大概每隔10天就会翻倍,重启后恢复正常。上图中我们分别在11月19和11月29日重启过,CPU明显下跌,然后又开启一轮新的轮回…… 这个服务是我们很重要的一个服务,我们有同事很长时间都在找原因,也尝试做过很多优化,一直都没解决,我们的服务只好靠定期重启续命了。 不过这事在我介入后的第二天就立马有了眉目,嘿嘿嘿。。。 (不是我能打,而是他们缺少一把趁手的“兵器”)


排查过程

作为一名工程师,面对上面的现象,你会怎么做? 我想你的第一反应肯定是业务代码有问题?是不是有什么地方导致内存泄露? 是不是业务代码里有什么地方加载的数据太多,越来越慢?…… 同事尝试过dump堆里的内容,dump jstak线程…… 都没看出来什么异常,也优化了业务代码里之前一些不合理的逻辑,始终没有解决问题。 当时的问题是他们都没有往热点代码的方向排查,主要是因为他们不知道有啥好用的工具。


而我恰好当时在关注过阿里的Arthas,知道当时正好Arthas新增了性能排查命令profiler 可以生成CPU火焰图,而我又恰好看过阮一峰老师的如何读懂火焰图?。然后我就给同事推荐了Arthas并建议同事用Arthas生成CPU的火焰图,看下到底是哪个地方耗费CPU,然后就发现了异常。

b16e1199144e5352042a6da25568a966_watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3hpbmRvbw==,size_16,color_FFFFFF,t_70#pic_center.jpg


火焰图该怎么读?

不知道怎么看火焰图不要紧,其实很简单,具体可参考阮一峰老师的如何读懂火焰图?。


y 轴表示调用栈,每一层都是一个函数。调用栈越深,火焰就越高,顶部就是正在执行的函数,下方都是它的父函数。

x 轴表示抽样数,如果一个函数在 x 轴占据的宽度越宽,就表示它被抽到的次数多,即执行的时间长。注意,x 轴不代表时间,而是所有的调用栈合并后,按字母顺序排列的。

火焰图就是看顶层的哪个函数占据的宽度最大。只要有"平顶"(plateaus),就表示该函数可能存在性能问题。


你可以简单认为每一块越宽,它就越耗费CPU,用火焰图做性能分析的关键就是去找那些你觉得本应该很窄但实际却很宽的函数。上图中就是我们有问题服务长期运行后和刚启动时的生成的火焰图(同样的代码和环境),左图明显有异常,它有好几个"平顶",也就是说该函数消耗了过多的CPU,大概率这就是问题的根源了。


这里我特意找到了该函数调用的堆栈信息,然后就发现了我们代码中不合理的地方。


[0x00007f2091c15000]
   java.lang.Thread.State: RUNNABLE
  at java.lang.StackStreamFactory$AbstractStackWalker.fetchStackFrames(java.base@11/Native Method)
  at java.lang.StackStreamFactory$AbstractStackWalker.fetchStackFrames(java.base@11/StackStreamFactory.java:386)
  at java.lang.StackStreamFactory$AbstractStackWalker.getNextBatch(java.base@11/StackStreamFactory.java:322)
  at java.lang.StackStreamFactory$AbstractStackWalker.peekFrame(java.base@11/StackStreamFactory.java:263)
  at java.lang.StackStreamFactory$AbstractStackWalker.hasNext(java.base@11/StackStreamFactory.java:351)
  at java.lang.StackStreamFactory$StackFrameTraverser.tryAdvance(java.base@11/StackStreamFactory.java:593)
  at java.util.stream.ReferencePipeline.forEachWithCancel(java.base@11/ReferencePipeline.java:127)
  at java.util.stream.AbstractPipeline.copyIntoWithCancel(java.base@11/AbstractPipeline.java:502)
  at java.util.stream.AbstractPipeline.copyInto(java.base@11/AbstractPipeline.java:488)
  at java.util.stream.AbstractPipeline.wrapAndCopyInto(java.base@11/AbstractPipeline.java:474)
  at java.util.stream.FindOps$FindOp.evaluateSequential(java.base@11/FindOps.java:150)
  at java.util.stream.AbstractPipeline.evaluate(java.base@11/AbstractPipeline.java:234)
  at java.util.stream.ReferencePipeline.findFirst(java.base@11/ReferencePipeline.java:543)
  at org.apache.logging.log4j.util.StackLocator.lambda$getCallerClass$6(StackLocator.java:54)
  at org.apache.logging.log4j.util.StackLocator$$Lambda$94/0x00007f238e2cacb0.apply(Unknown Source)
  at java.lang.StackStreamFactory$StackFrameTraverser.consumeFrames(java.base@11/StackStreamFactory.java:534)
  at java.lang.StackStreamFactory$AbstractStackWalker.doStackWalk(java.base@11/StackStreamFactory.java:306)
  at java.lang.StackStreamFactory$AbstractStackWalker.callStackWalk(java.base@11/Native Method)
  at java.lang.StackStreamFactory$AbstractStackWalker.beginStackWalk(java.base@11/StackStreamFactory.java:370)
  at java.lang.StackStreamFactory$AbstractStackWalker.walk(java.base@11/StackStreamFactory.java:243)
  at java.lang.StackWalker.walk(java.base@11/StackWalker.java:498)
  at org.apache.logging.log4j.util.StackLocator.getCallerClass(StackLocator.java:53)
  at org.apache.logging.log4j.util.StackLocatorUtil.getCallerClass(StackLocatorUtil.java:61)
  at org.apache.logging.slf4j.Log4jLoggerFactory.getContext(Log4jLoggerFactory.java:43)
  at org.apache.logging.log4j.spi.AbstractLoggerAdapter.getLogger(AbstractLoggerAdapter.java:46)
  at org.apache.logging.slf4j.Log4jLoggerFactory.getLogger(Log4jLoggerFactory.java:29)
  at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:358)
  at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:383)
  at com.xxxxxxx.infra.cache.redis.ReadCommandTemplate.<init>(ReadCommandTemplate.java:21)
  at com.xxxxxxx.infra.cache.redis.RedisClient$4.<init>(RedisClient.java:138)
  at com.xxxxxxx.infra.cache.redis.RedisClient.hget(RedisClient.java:138)
  ...
  ...


ReadCommandTemplate是我们公司封装的一个redis client,每读写一次redis就会实例化一个ReadCommandTemplate,我们的业务逻辑中每个请求都会读一次redis,所以导致ReadCommandTemplate这个类生成很多个实例。本身这个类很轻量,按理来说多次实例话也没关系,但其中用了logger,而且logger没有声明为static,所以没new 一个ReadCommandTemplate,都会同时生成一个logger,从火焰图来看,似乎是生成logger的过程中产生了性能问题。


public abstract class ReadCommandTemplate<T> {
    private Logger logger = LoggerFactory.getLogger(ReadCommandTemplate.class);
    /** 
     * 其他代码逻辑省略
     */
}


有经验的java开发者一眼就能看出来上面代码的不合理之处,但应该只是稍微影响性能,不会出现文首那种诡异的现象! 确实,这个redis client在我们部分被广泛使用,其他的应用都没出现问题。。。 会不会是这个应用恰巧凑齐了某个bug触发的所有条件??


Bug的确认

要想确认bug是否是这个非static的logger导致的,有两个方案:1. 修复logger的问题,看CPU是否会上涨。 2. 真正搞清楚这个bug的原理。因为方案1需要至少等待一周才能实锤,所以我们选择了二者并行。同事修复了logger,而我开启了问题探索和jvm源码阅读之旅。后来方案1确实也正式了是logger导致的,而我也找到了19年有人给jvm团队反馈bug后jvm团队讨论的邮件列表。

38304e70cecf179826951338aff7b3de_watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3hpbmRvbw==,size_16,color_FFFFFF,t_70#pic_center.png

jdk的开发者Stefan Karlsson确认并大致给出了这个bug出现的原因,如上图。这里我大致解释下,JVM在查找方法的时候会调用"ResolvedMethodTable::lookup",其实这里是查找一个只有1007个bucket的hash表,因为jdk11的zgc没有定期对ResolvedMethodTable做清理,所以里面的数据会越来越多,查询会越来越慢。


问题是用jdk11+zgc+log4j组合的人也非常多,为什么偏偏就我们的代码触发了bug??


深度剖析

为了深入理解这个bug,我们首先来看下我们调LoggerFactory.getLogger()的时候发生了什么!!

209d60a7c2c0c55d6b5404986ee67abc_watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3hpbmRvbw==,size_16,color_FFFFFF,t_70#pic_center.png

在jdk9及以后的版本中,log4j使用了新的StackWalker来获取线程的堆栈信息,然后遍历每个栈帧,所以StackWalker就调用native方法fetchStackFrames从JVM里获取这个栈帧。


我翻阅了JVM代码,找到了ResolvedMethodTable::lockup()在做啥! 不过首先我们得知道ResolvedMethodTable是啥? ResolvedMethodTable可以简单认为是JVM中存储所有已经解析到的方法信息的一个hashtable,它只有1007的bucket(为什么设计这么小?),这么小的bucket必然很容易产生hash冲突,处理hash冲突的方式就是开链,而lockup()在查找过程中,需要遍历单链表,所以如果单链表过长,lookup的查询性能就下来了,lookup()的源码如下。

oop ResolvedMethodTable::lookup(Method* method) {
  unsigned int hash = compute_hash(method);
  int index = hash_to_index(hash);
  return lookup(index, hash, method);
}
oop ResolvedMethodTable::lookup(int index, unsigned int hash, Method* method) {
  for (ResolvedMethodEntry* p = bucket(index); p != NULL; p = p->next()) {
    if (p->hash() == hash) {
      oop target = p->object_no_keepalive();
      if (target != NULL && java_lang_invoke_ResolvedMethodName::vmtarget(target) == method) {
        ResourceMark rm;
        log_debug(membername, table) ("ResolvedMethod entry found for %s index %d",
                                       method->name_and_sig_as_C_string(), index);
        return p->object();
      }
    }
  }
  return NULL;
}


现在的问题是究竟是什么导致ResolvedMethodTable数据太多,从而使得其中单个bucket的链表过长? 我们还是从该bug的邮件讨论列表里找到答案http://mail.openjdk.java.net/pipermail/zgc-dev/2019-March/000612.html,这里我大概翻译如下:


GC除了卸载不用的类时,也会做好多其他的清理工作,比如清理ResolvedMethodTable和StringTable中不用的信息。ResolvedMethodTable保存着java中ResolvedMethodName类实例的弱指针,如果ResolvedMethodName不可达,正常情况下gc就会清理掉这个不可达对象。而ResolvedMethodName是MemberName中的一部分。StackWalker遍历栈帧的时候,就会创建MemberName java对象并放到StackFrameInfo.memberName中,jvm中的代码实现如下:


void java_lang_StackFrameInfo::set_method_and_bci(Handle stackFrame, 
const methodHandle& method, int bci, TRAPS) {
...
   // 创建ResolvedMethodName对象并插入到ResolvedMethodTable中
   CallInfo info(method(), ik, CHECK);
   // 把ResolveMethodName对象放到MemberName中
   MethodHandles::init_method_MemberName(mname, info);


堆栈调用信息如下:


java_lang_StackFrameInfo::set_method_and_bci(Handle, methodHandle 
const&, int, Thread*)
  JavaFrameStream::fill_frame(int, objArrayHandle, methodHandle const&, 
Thread*)
  StackWalk::fill_in_frames(long, BaseFrameStream&, int, int, 
objArrayHandle, int&, Thread*)
  StackWalk::fetchNextBatch(Handle, long, long, int, int, 
objArrayHandle, Thread*)
  JVM_MoreStackWalk
  java.lang.StackStreamFactory$AbstractStackWalker.fetchStackFrames


后续,如果StackFrameInfos不用之后,这些对象会被从类的引用关系中移除,并被gc回收掉,然后gc会移除ResolvedMethodTable中相应的ResolvedMethodName。但问题是jdk11中,zgc并没有真正移除这些ResolvedMethodName,从而导致ResolvedMethodTable中的数据量越来越大,单链越来越长,查找效率越来越低。

69afeb8b23109080347ae24eb9962a12_watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3hpbmRvbw==,size_16,color_FFFFFF,t_70#pic_center.png

在JDK11的源码中SymbolTable::unlink()中实现了ResolvedMethodTable中无用信息的卸载,其他几个老gc都调用了,而zgc中确实没有调用,不知道这是忘记了还是特意为之……


简单梳理下就是:每次调用StackWalker遍历栈帧的时候,每个栈帧都会生成一个ResolvedMethodName对象放到jvm中的ResolvedMethodTable中,但jdk11的zgc不能有效清理其中不用的对象。因为ResolvedMethodTable是个定容的hashtable,随着其中的数据越来越多,每个bucket的单链表越来越长,查询效率会越来越慢。 所以最终导致CPU的使用率越来越高。

目录
相关文章
|
19天前
|
存储 运维 资源调度
阿里云服务器经济型e实例解析:性能、稳定性与兼顾成本
阿里云经济型e云服务器以其高性价比、稳定可靠的性能以及灵活多样的配置选项,成为了众多企业在搭建官网时的首选。那么,阿里云经济型e云服务器究竟怎么样?它是否能够满足企业官网的搭建需求?本文将从性能表现、稳定性与可靠性、成本考虑等多个方面对阿里云经济型e云服务器进行深入剖析,以供大家参考选择。
|
22天前
|
缓存 算法 Oracle
深度干货 如何兼顾性能与可靠性?一文解析YashanDB主备高可用技术
数据库高可用(High Availability,HA)是指在系统遇到故障或异常情况时,能够自动快速地恢复并保持服务可用性的能力。如果数据库只有一个实例,该实例所在的服务器一旦发生故障,那就很难在短时间内恢复服务。长时间的服务中断会造成很大的损失,因此数据库高可用一般通过多实例副本冗余实现,如果一个实例发生故障,则可以将业务转移到另一个实例,快速恢复服务。
深度干货  如何兼顾性能与可靠性?一文解析YashanDB主备高可用技术
|
2月前
|
安全 Java 编译器
一个 Bug JDK 居然改了十年?
你敢相信么一个简单的Bug,JDK 居然花了十年时间才修改完成。赶快来看看到底是个什么样的 Bug?
55 1
一个 Bug JDK 居然改了十年?
|
3月前
|
存储 机器学习/深度学习 编解码
阿里云服务器计算型c8i实例解析:实例规格性能及使用场景和最新价格参考
计算型c8i实例作为阿里云服务器家族中的重要成员,以其卓越的计算性能、稳定的算力输出、强劲的I/O引擎以及芯片级的安全加固,广泛适用于机器学习推理、数据分析、批量计算、视频编码、游戏服务器前端、高性能科学和工程应用以及Web前端服务器等多种场景。本文将全面介绍阿里云服务器计算型c8i实例,从规格族特性、适用场景、详细规格指标、性能优势、实际应用案例,到最新的活动价格,以供大家参考。
|
3月前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
124 2
|
4月前
|
缓存 Java 程序员
Map - LinkedHashSet&Map源码解析
Map - LinkedHashSet&Map源码解析
98 1
|
4月前
|
算法 Java 容器
Map - HashSet & HashMap 源码解析
Map - HashSet & HashMap 源码解析
82 0
|
2月前
|
设计模式 存储 安全
【23种设计模式·全精解析 | 创建型模式篇】5种创建型模式的结构概述、实现、优缺点、扩展、使用场景、源码解析
创建型模式的主要关注点是“怎样创建对象?”,它的主要特点是"将对象的创建与使用分离”。这样可以降低系统的耦合度,使用者不需要关注对象的创建细节。创建型模式分为5种:单例模式、工厂方法模式抽象工厂式、原型模式、建造者模式。
【23种设计模式·全精解析 | 创建型模式篇】5种创建型模式的结构概述、实现、优缺点、扩展、使用场景、源码解析
|
2月前
|
存储 设计模式 算法
【23种设计模式·全精解析 | 行为型模式篇】11种行为型模式的结构概述、案例实现、优缺点、扩展对比、使用场景、源码解析
行为型模式用于描述程序在运行时复杂的流程控制,即描述多个类或对象之间怎样相互协作共同完成单个对象都无法单独完成的任务,它涉及算法与对象间职责的分配。行为型模式分为类行为模式和对象行为模式,前者采用继承机制来在类间分派行为,后者采用组合或聚合在对象间分配行为。由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象行为模式比类行为模式具有更大的灵活性。 行为型模式分为: • 模板方法模式 • 策略模式 • 命令模式 • 职责链模式 • 状态模式 • 观察者模式 • 中介者模式 • 迭代器模式 • 访问者模式 • 备忘录模式 • 解释器模式
【23种设计模式·全精解析 | 行为型模式篇】11种行为型模式的结构概述、案例实现、优缺点、扩展对比、使用场景、源码解析
|
2月前
|
设计模式 存储 安全
【23种设计模式·全精解析 | 创建型模式篇】5种创建型模式的结构概述、实现、优缺点、扩展、使用场景、源码解析
结构型模式描述如何将类或对象按某种布局组成更大的结构。它分为类结构型模式和对象结构型模式,前者采用继承机制来组织接口和类,后者釆用组合或聚合来组合对象。由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象结构型模式比类结构型模式具有更大的灵活性。 结构型模式分为以下 7 种: • 代理模式 • 适配器模式 • 装饰者模式 • 桥接模式 • 外观模式 • 组合模式 • 享元模式
【23种设计模式·全精解析 | 创建型模式篇】5种创建型模式的结构概述、实现、优缺点、扩展、使用场景、源码解析

推荐镜像

更多