Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >大量类加载器创建导致诡异FullGC

大量类加载器创建导致诡异FullGC

原创
作者头像
码农架构
修改于 2020-10-21 02:11:10
修改于 2020-10-21 02:11:10
1.7K00
代码可运行
举报
文章被收录于专栏:码农架构码农架构
运行总次数:0
代码可运行

首发公众号:码农架构

现象

最近接手了一个同事的项目,某一天接口的响应耗时突然增加了很多,由几十ms 增加到了几十秒。

首先查看机器上的日志,有调用第三方接口超时,查询数据库超时。立马查看第三方接口监控和数据库监控,一切正常。可能由于 GC 停顿造成统计的超时,这个时候我们通过 jstat -gcutil pid 查看 gc 情况。数据如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
  0.00   0.00   3.88  12.86  76.39  45.62    211    8.574   892  626.192  634.767
  0.00   0.00   4.10  12.86  76.39  45.62    211    8.574   893  626.192  634.767
  0.00   0.00   0.00  12.88  76.39  45.62    211    8.574   894  626.915  635.489
  0.00   0.00   0.11  12.88  76.39  45.62    211    8.574   896  627.678  636.253
  0.00   0.00   0.00  12.87  76.39  45.62    211    8.574   897  628.926  637.500
  0.00   0.00   0.00  12.87  76.39  45.62    211    8.574   899  630.381  638.956
  0.00   0.00   1.92  12.87  76.39  45.62    211    8.574   901  631.155  639.729
  0.00   0.00   0.00  12.87  76.39  45.62    211    8.574   902  632.379  640.954
  0.00   0.00   2.14  12.87  76.39  45.62    211    8.574   903  633.094  641.668
  0.00   0.00   0.00  12.88  76.39  45.62    211    8.574   904  633.859  642.433

这里我们可以看到年轻代(E) 使用率很小,老年代(O)使用率 12% 也不多,M(Metaspace) 使用率 76.39% 也没占满,Yong GC 没有变化,Full GC 一直在进行,每次耗时800多ms。结合前面 E、O 和 M 使用率都没有变化,说明内存一直回收不掉。

JVM 内存大小相关配置如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
 -Xms3g -Xmx3g -Xmn1g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m

接下来我们看下 GC 日志:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
2020-08-13T23:11:00.352+0800: 214929.371: [GC (CMS Initial Mark)  276144K(3040896K), 0.0405942 secs]
2020-08-13T23:11:00.886+0800: 214929.905: [Full GC (Metadata GC Threshold)  290482K->275966K(3040896K), 0.7939954 secs]
2020-08-13T23:11:01.693+0800: 214930.712: [Full GC (Last ditch collection)  275966K->275964K(3040896K), 0.8086755 secs]
2020-08-13T23:11:02.520+0800: 214931.539: [Full GC (Metadata GC Threshold)  295199K->273816K(3040896K), 0.8332017 secs]
2020-08-13T23:11:03.366+0800: 214932.385: [Full GC (Last ditch collection)  273816K->273799K(3040896K), 0.7748226 secs]

GC 日志中有 Metadata GC Threshold ,结合前面 Metaspace 使用率最高我们猜测可能是 Metaspace 溢出了,然后我们在日志中 grep OutOfMemory 关键字,有如下报错:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
java.lang.OutOfMemoryError: Metaspace

至此可以确认是 Metaspace 出问题了,但是为什么 jstat 输出的使用率只有 76.39% 呢?大家如果经常使用 jstat 看一下正常的程序就会很多正常情况 Metaspace 都占用 90% 以上。

Metaspace

Metaspace 元数据空间,专门用来存储类的元数据,它是 JDK8 中用来替代 Perm 的特殊数据结构

Metaspace 空间被分配在本地内存中(非堆上),默认不限制内存使用,可以使用 MaxMetaspaceSize 指定最大值。MetaspaceSize 指定最小值,默认 21 M。通过 mmap 来从操作系统申请内存,申请的内存会分成一个一个 Metachunk,以 Metachunk 为单位将内存分配给类加载器,每个 Metachunk 对应唯一一个类加载器,一个类加载器可以有多个 Metachunk 。

可以用 java -XX:+PrintFlagsFinal -version 来查看 JVM 的默认参数值

在 Java 虚拟机中,每个类加载器都有一个 ClassLoaderData 的数据结构,ClassloaderData 内部有管理内存的 Metaspace,Metaspace 在 initialize 的时候会调用 get_initialization_chunk 分配第一块 Metachunk,类加载器在类的时候是以 Metablock 为单位来使用 Metachunk。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
//classLoaderData.hpp
class ClassLoaderData : public CHeapObj<mtClass> {
...

  Metaspace * _metaspace;  // Meta-space where meta-data defined by the
                           // classes in the class loader are allocated.
  Mutex* _metaspace_lock;  // Locks the metaspace for allocations and setup.

...
}

// metaspace.hpp
class Metaspace : public CHeapObj<mtClass> {
...
 private:
  void initialize(Mutex* lock, MetaspaceType type);
  
  Metachunk* get_initialization_chunk(MetadataType mdtype,
                                      size_t chunk_word_size,
                                      size_t chunk_bunch);
...
}

// metachunk.hpp
class Metachunk : public Metabase<Metachunk>
class Metablock : public Metabase<Metablock>

// Metablock 和 Metachunk 的父类
template <class T>
class Metabase VALUE_OBJ_CLASS_SPEC {
  size_t _word_size;
  T*     _next;
  T*     _prev;
...
}

下图所示是每个类加载器分配内存结构。

接下来我们讲下什么时候会触发 FullGC,有个参数 MinMetaspaceFreeRatio(默认40) ,当满足如下条件就会进行 GC,如果当前需要申请的内存比剩余可以 commit 的空间还要大,如果还没有达到 MaxMetaspaceSize 的话,会触发扩容。

剩余可以 commit 的空间大小 < (commited 大小 * MinMetaspaceFreeRatio)

上面说到 commited 的内存,这里还有几个概念 :used、capacity、reserved,如下图所示

  • used: chunk 中已经使用的 block 内存,这些 block 中都加载了类的数据。
  • capacity:在使用的 chunk 内存。
  • commited:所有分配的 chunk 内存,这里包含空闲可以再次被利用的。
  • reserved:是可以使用的内存大小。

如下所示,是打印出来的内存信息,最后一行是开启压缩指针(64位压缩为32位)后,Metaspace 中专门存放 kclass 的信息。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
Heap
 par new generation   total 30720K, used 1519K [0x00000007f8600000, 0x00000007fa750000, 0x00000007fa750000)
  eden space 27328K,   5% used [0x00000007f8600000, 0x00000007f877bcc8, 0x00000007fa0b0000)
  from space 3392K,   0% used [0x00000007fa400000, 0x00000007fa400000, 0x00000007fa750000)
  to   space 3392K,   0% used [0x00000007fa0b0000, 0x00000007fa0b0000, 0x00000007fa400000)
 concurrent mark-sweep generation total 68288K, used 21614K [0x00000007fa750000, 0x00000007fea00000, 0x00000007fea00000)
 Metaspace       used 23505K, capacity 30704K, committed 30720K, reserved 1073152K
  class space    used 3341K, capacity 7550K, committed 7552K, reserved 1048576K

基础知识讲完了,现在我们回到开头,我们通过 jstat 打印出的 M 是怎么计算的呢?这里使用率并不是我们理解的整个 Metaspace 内存的使用率。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
M = used / commited

所以 Metaspace 内存溢出了,使用率也才 76%,有两种可能:

  1. 这次分配的内存达到了 61M( 256M*24% ) 以上?
  2. 给类加载器分配的 chunk 使用率很低?

第一种显然不太可能,一个类不可能需要这么大的内存。第二种有种情况,当创建很多类加载器,而每个类加载器又加载了很少的类。

上面我们说了剩余空闲内存小于metaspaceGC的阈值就会执行FullGC,但是我们开头说有些正常场景我们通过 jstat 打印的使用率都达到了 90% 多都没有触发 FullGC,这是为什么呢?欢迎留言分享你的答案

排查程序

首先,我们看下 Metaspace 加载的到底是哪些类

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
jcmd pid GC.class_stats |awk '{print $13}'| sort | uniq -c |sort -r| head

通过 jcmd 查看加载的类,然后统计数量,我们看到,Script1 被加载了两万多次,按 JVM 类加载的双亲委派方式,一个类最多被加载一次,这里出现了多次,可能是不同的类加载器加载的。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
27348 Script1
   3
   2 ClassName
   1 sun.util.spi.CalendarProvider
   1 sun.util.resources.en.TimeZoneNames_en
   1 sun.util.resources.en.CurrencyNames_en_US
   1 sun.util.resources.en.CalendarData_en
   1 sun.util.resources.TimeZoneNamesBundle
   1 sun.util.resources.TimeZoneNames
   1 sun.util.resources.ParallelListResourceBundle$KeySet

通过 jcmd 查看,需要在启动是加上参数:-XX:+UnlockDiagnosticVMOptions

然后我们再看下 JVM 类加载器的数据

jmap -clstats pid

这里 classes 是加载类的数量,从输出中可以看到有大量 GroovyClassLoader 类加载器。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
class_loader    classes bytes   parent_loader   alive?  type
<bootstrap>     2850    4913169   null          live    <internal>
0x000000077bc27bc0      1       1394    0x000000077bc64418      dead    groovy/lang/GroovyClassLoader$InnerLoader@0x00000007f0dcf828
0x000000077d9e7d98      0       0       0x0000000770800000      dead    groovy/lang/GroovyClassLoader@0x00000007f0af9890
0x00000007805e8050      0       0       0x0000000770800000      dead    groovy/lang/GroovyClassLoader@0x00000007f0af9890
0x000000077df07de0      0       0       0x0000000770800000      dead    groovy/lang/GroovyClassLoader@0x00000007f0af9890
0x0000000780028010      1       1394    0x000000078005a6c8      dead    groovy/lang/GroovyClassLoader$InnerLoader@0x00000007f0dcf828
0x0000000776467650      1       1394    0x000000077646b190      dead    groovy/lang/GroovyClassLoader$InnerLoader@0x00000007f0dcf828
0x000000077a167a00      1       1394    0x000000077a16b380      dead    groovy/lang/GroovyClassLoader$InnerLoader@0x00000007f0dcf828

通过统计,每个 GroovyClassLoader$InnerLoader 都只加载一个类,然后他的数量一共有 27348,跟上面的 Script1 类数量刚好对的上,说明就是这个类加载器加载的。

接下来怎么定位哪里生产的类加载器加载的类呢?

首先看 groovy 是哪里引入的,然后本地调试,加上JVM 参数:-XX:+UnlockDiagnosticVMOptions,加载类的时候控制台就会打印,就可以一步一步定位到哪里加载的。

我们项目中用 sharding 做的分表,sharding 引入的 groovy 版本如下

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<dependency>
    <groupId>io.shardingsphere</groupId>
    <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
    <version>3.0.0.M1</version>
</dependency>
<dependency>
  <groupId>org.codehaus.groovy</groupId>
  <artifactId>groovy</artifactId>
  <classifier>indy</classifier>
  <version>2.4.5</version>
</dependency>

最终定位到出现问题的代码如下,当你配置分表的表达式后,每次执行查询操作,都会创建一个 GroovyShell 来执行配置的表达式。在 GroovyShell 中,每次都会生成一个类加载器,来加载类 Script1,加载完后又无法被 GC 掉,导致内存泄露。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public InlineShardingStrategy(final InlineShardingStrategyConfiguration inlineShardingStrategyConfig) {
    Preconditions.checkNotNull(inlineShardingStrategyConfig.getShardingColumn(), "Sharding column cannot be null.");
    Preconditions.checkNotNull(inlineShardingStrategyConfig.getAlgorithmExpression(), "Sharding algorithm expression cannot be null.");
    shardingColumn = inlineShardingStrategyConfig.getShardingColumn();
    String algorithmExpression = InlineExpressionParser.handlePlaceHolder(inlineShardingStrategyConfig.getAlgorithmExpression().trim());
    closure = (Closure) new GroovyShell().evaluate(Joiner.on("").join("{it -> \"", algorithmExpression, "\"}"));
}

这里升级 sharding 新版本即可,新版本中 GroovyShell 是static 的。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public final class InlineExpressionParser {
...
    private static final GroovyShell SHELL = new GroovyShell();
...
}

这里还有个疑问,类加载器加载用完了并且状态是 dead 为什么不回收掉呢?

本地复现

复现的代码很简单,引入上述 groovy 版本,在运行时加上 JVM 参数

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// -Xmx100M -Xms100M -verbose:class -XX:+PrintGCDetails -XX:MaxMetaspaceSize=30M -XX:MetaspaceSize=30M -XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled -XX:+UnlockDiagnosticVMOptions -XX:+HeapDumpOnOutOfMemoryError
public static void main(String[] args) {
    for (int i = 0; i < 4000; i++) {
        new GroovyShell().parse("");
    }
}

接下来主要讲下,怎么用 mat 来排查这个类加载为什么没有被回收。用 mat 加载上示例程序 dump 出来的堆,选择 Histogram ,然后在正则中输入 GroovyClassLoader ,Objects 是表示创建对象数量,这里有 3255 个,说明上面的 for 循环执行了 3255 次之后 Metaspace 就溢出了。

接下来选择 Dominator Tree,然后输入 Script1 正则过滤,右键选择:Path To Gc Roots,这里我们只关心强引用,所以 execlude 其他类型引用。

如果类加载器被回收,它所加载的类也会被回收,如果类有被引用,肯定不能被回收,所以,我们从 Script1 的对象开始。如下图所有,Script1 类有被引用,最终到达 GC root (AppClassLoader),所以 Full GC 也没法回收掉。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
解决 Groovy 引起的一次 OOM 告警
从监控系统来看,被 kill 的节点 A 在重启前,堆内存使用随着 YoungGC 规律波动,元空间占用较高,且一直缓慢增长到了400MB以上——该应用代码量不大,按理不应该占用这么多。
mzlogin
2023/10/23
6260
解决 Groovy 引起的一次 OOM 告警
复杂多变场景下的Groovy脚本引擎实战
因为之前在项目中使用了Groovy对业务能力进行一些扩展,效果比较好,所以简单记录分享一下,这里你可以了解:
冬夜先生
2021/10/12
1.7K0
复杂多变场景下的Groovy脚本引擎实战
因为之前在项目中使用了Groovy对业务能力进行一些扩展,效果比较好,所以简单记录分享一下,这里你可以了解:
2020labs小助手
2021/08/03
4.8K0
java fgc_java Metaspace频繁FGC问题定位
数据服务是通过SQL对外提供数据查询的服务平台,底层存储支持HBase和MySQL两种。用户首先在管理平台上配置好接口的SQL详情
全栈程序员站长
2022/09/06
7000
彻底搞懂JVM类加载器:基本概念
在Java面试中,在考察完项目经验、基础技术后,我会根据候选人的特点进行知识深度的考察,如果候选人简历上有写JVM(Java虚拟机)相关的东西,那么我常常会问一些JVM的问题。JVM的类加载机制是一个很经典的知识点,围绕这个知识点可以有下面这些难度不同的问题。
阿杜
2019/10/08
6620
彻底搞懂JVM类加载器:基本概念
JVM架构和GC垃圾回收机制详解
Java的动态类加载功能是由类加载器子系统处理。当它在运行时(不是编译时)首次引用一个类时,它加载、链接并初始化该类文件。
用户1212940
2022/04/13
2690
JVM架构和GC垃圾回收机制详解
排查Java的内存问题
核心要点 排查Java的内存问题可能会非常困难,但是正确的方法和适当的工具能够极大地简化这一过程; Java HotSpot JVM会报告各种OutOfMemoryError信息,清晰地理解这些错误信息非常重要,在我们的工具箱中有各种诊断和排查问题的工具,它们能够帮助我们诊断并找到这些问题的根本原因; 在本文中,我们会介绍各种诊断工具,在解决内存问题的时候,它们是非常有用的,包括: HeapDumpOnOutOfMemoryError和PrintClassHistogram JVM选项 Eclipse MA
用户1263954
2018/04/08
2.9K0
排查Java的内存问题
Flink的类加载器
在运行 Flink 应用程序时,JVM 会随着时间的推移加载各种类。 这些类可以根据它们的来源分为三组:
从大数据到人工智能
2022/01/18
2.4K0
JVM:类加载器
Java虚拟机设计团队有意把类加载阶段中的"通过一个类的全限定名来获取描述该类的二进制字节流"这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何获取所需的类。实现这个动作的代码被称为"类加载器"(ClassLoader)。
HLee
2021/02/24
8820
JVM:类加载器
JVM架构和GC垃圾回收机制(JVM面试不用愁)[通俗易懂]
JVM全称是Java Virtual Machine(Java虚拟机),Java虚拟机是一种程序虚拟机(相对操作系统虚拟机),Java的运行环境实现跨平台。
全栈程序员站长
2022/08/11
4180
JVM架构和GC垃圾回收机制(JVM面试不用愁)[通俗易懂]
深入分析Java反射(五)-类实例化和类加载
其实在前面写过的《深入分析Java反射(一)-核心类库和方法》已经介绍过通过类名或者java.lang.Class实例去实例化一个对象,在《浅析Java中的资源加载》中也比较详细地介绍过类加载过程中的双亲委派模型,这篇文章主要是加深一些对类实例化和类加载的认识。
Throwable
2020/06/23
1.5K0
线程上下文类加载器ContextClassLoader内存泄漏隐患
今天(2020-01-18)在编写Netty相关代码的时候,从Netty源码中的ThreadDeathWatcher和GlobalEventExecutor追溯到两个和线程上下文类加载器ContextClassLoader内存泄漏相关的Issue:
Throwable
2020/06/23
8380
线程上下文类加载器ContextClassLoader内存泄漏隐患
类加载器详解
内容:转自 java知音 类加载器是负责将可能是网络上、也可能是磁盘上的class文件加载到内存中。并为其生成对应的java.lang.class对象。一旦一个类被载入JVM了,同一个类就不会被再次加载。 那么怎样才算是同一个类?在JAVA中一个类用其全限定类名(包名和类名)作为其唯一标识,但是在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。也就是说,在JAVA中的同一个类,如果用不同的类加载器加载,则生成的class对象认为是不同的。 当JVM启动时,会形成由三个类加载器组成的初始类加载器层
用户1257393
2018/01/30
7420
类加载器详解
详细讲解!从JVM直到类加载器
整个过程是,x.java文件需要编译成x.class文件,通过类加载器加载到内存中,然后通过解释器或者即时编译器进行解释和编译,最后交给执行引擎执行,执行引擎操作OS硬件。
java技术爱好者
2020/09/22
4650
Javaweb-类加载器-类加载器的了解入门
前面的动态代理学完了,以后在学习Spring的时候会用到这些动态代理的知识和原理,像目标对象,增强这两个术语,会经常听到。学习动态代理,就是学习JDK中反射包下的一个Proxy类,具体来说,我们只是学习newProxyInstance(ClassLoader, interfaces, hander)这个方法。这篇开始来学习下,加载器,我们在学习获取动态代理,第一个要准备的参数就是,类加载器,通过这篇的学习,稍微对类加载器有入门的了解。
凯哥Java
2019/08/31
4830
Javaweb-类加载器-类加载器的了解入门
Groovy、热部署和热加载(自定义类加载器)及spring loaded 部分源码分析
优点:不需要重启tomcat服务器,如果一个tomcat多个项目,不必因为tomcat停止而停止其他的项目。
猎户星座1
2020/08/26
3.4K0
自定义类加载器
在初学Java的时候,我们都知道.java文件转换为.class文件的过程叫做编译。
每天学Java
2020/06/02
1.7K0
相关推荐
解决 Groovy 引起的一次 OOM 告警
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验