供稿 | eBay Infrastructure Engineering
翻译&编辑 | 顾欣怡
本文共2155字,预计阅读时间7分钟
更多干货请关注“eBay技术荟”公众号
导读
从Java 8开始,元数据区(Metaspace)被引入,替换永久代 (Permanent Generation) ,用以保存JVM用到的类的元数据、字节码及编译后的代码。这两者最大的区别是 元数据区在原生内存(Native Memory)申请一块区域来保存这些数据 。本文将分享一个SRE处理的与元数据区相关,且发生在生产环境的真实案例。
一、问题描述
某天,SRE部门观察到一个非常重要的应用里面有一台服务器的业务处理时间(Transaction time)在某个时间点变为平时的 3倍 。 虽然只持续了短暂的 2秒 ,但是如果观察其一周的指标曲线,就会发现在这一周之内,同应用的其它服务器也出现过类似现象。
进一步分析,发现该应用服务器在那个时间点之后立马被重启了。从公司的 PaaS平台 上看,并没有发现人工或者自动化运维机器人引起重启。 看来这是一次非正常重启。
如图1所示,从JVM的系统日志中,我们发现了 重启的原因: 无法申请到更多的原生内存,也就是说:内存被用光了。
JVM 系统日志 :
图1(点击可查看大图)
内存被用光之后,JVM发生了 OOM(Out of memory,内存溢出) ,系统配置的自动重启服务就把这个应用重启了。
二、初步分析
从上面的日志中,虽然可以明显看到是原生内存被耗光,却不能找出更明确的耗光原因。在同应用的其他服务器上,如果观察内存的使用情况,能明显看到空闲内存逐渐变少。
从下图2可以看到,在一个拥有 8G 内存的服务器上,只空闲了 288M 。其中,JVM的年轻代占用 260M 左右,老年代占用 1.3G 左右, 而 元数据区竟然使用了 2G 多的内存,这在一般的Java应用程序里面很少见。
JVM内存占用情况及系统内存空闲情况:
图2(点击查看大图)
为了查明是何原因导致占据如此之多的内存,SRE侦探们请出了好帮手: heap dump分析。
我们通过 jcmd命令 做了一个heap dump。遗憾的是,似乎没有从中发现什么有用的信息。
但是细心的SRE侦探不会放过任何蛛丝马迹。
三、进一步分析
我们注意到这个heap dump其实非常小,只有 285M 左右。 从上述JVM的内存占用信息来看,这个heap dump的大小实在是不太匹配。
一个可能的原因是: 有些内存在做heap dump的过程中被释放了。 因为使用jcmd默认参数做heap dump之前会做一次 Full GC ,然后内存就基本只剩下活着的对象了。Full GC也会对元数据区里面的内存做回收,所以很有可能一些原本占用元数据区内存的数据被回收了。
我们对另外一台服务器使用带 -all参数 的jcmd命令 (jcmd <pid> GC.heap_dump -all), 进行了一次 不做Full GC的heap dump 。如下图3所示,这次的heap dump相对大很多。
两次heap dump文件 :
图3(点击可查看大图)
由于元数据区和永久代里面大部分都是与 Class 相关的元数据信息,加之所有的类都是由各种 ClassLoader 加载的,所以我们首先分析ClassLoader。
我们使用MAT对heap里面的ClassLoader进行分析。运用MAT的ClassLoader Explorer工具后,发现这份heap dump里面,竟然有 17万多 的ClassLoader 。具体如图4所示:
使用MAT的Class Loader Explorer分析:
图4(点击可查看大图)
如图5所示,通过OQL查询可以看到,这个ClassLoader非常之多( 17万+ ),并且很多都是 Unreachable (不可达),也就是说如果这时候发生一次Full GC,它们将会被回收掉。
使用OQL查询特定的ClassLoader:
图5(点击可查看大图)
为什么会产生这么多的 TransletClassLoader 呢?
对这些TransletClassLoader以及相关联的类/实例进行分析,并没有发现有用的线索。
即使对上面没有标明Unreachable的TransletClassLoader进行分析,也会发现它关联的其他类/实例很快也变成了Unreachable。从这个方向进行追踪,很难发现到底是哪一步创建了这些TransletClassLoader。
不过,在JVM因为内存溢出而退出的时候,它生成了一个 hs_err_pid21618.log日志文件 ,该文件详细记录了 系统崩溃时间点的所有Java线程栈和native线程栈,以及当时操作系统的一些其他信息。
导致系统崩溃的线程栈,就在所有线程栈的最上方,可以告诉我们当时这个线程正在做什么。如下图6所示:
JVM崩溃时产生的hs_err_pid文件片段 :
图6(点击可查看大图)
线程栈的最底层是一些 native代码 ,从中我们可以看出这正是为元数据区申请内存的线程。下面的Java线程栈告诉了我们这个请求是从哪里一步步进入到某个方法。 其中正有我们上面查到的:
尽管这和heap dump分析中的一致,但并不能代表heap dump中的那些都是该类请求造成的,不过也确实给了我们一些提示。
接下来,我们可以根据这个线程栈给的提示,通过重现的方式去验证猜想。
四、场景重现
根据上面线程栈提供的信息,我们找出了对应的请求。根据它运行的路径,我们找出了一种特定的请求及其参数和请求数据。同时我们把相关的应用代码在本地以 调试(debug)模式 启动。在TemplatesImpl$TransletClassLoader.defineClass()方法上添加断点, 发现该类请求每次都创建一个新的ClassLoader,并且这个ClassLoader还会创建新的Class。
除此之外,我们还对调试服务器大量发出这种特殊请求, 用来观察 JVM 内存各个区域的使用情况.
如图7所示,通过 jcmd<pid>GC.heap_info命令 去查看其元数据空间的使用情况,能明显观察到它在慢慢增长。如果中间强制做一次Full GC,能看到元数据空间被大量释放。
观察短时间内元数据空间的变化及Full GC后空间的变化:
图7(点击可查看大图)
五、代码分析
对业务逻辑代码分析发现,当这种特定的请求进来之后,处理过程中会每次创建一个 ClassLoader ,而这个ClassLoader在仅仅创建一个相关的类之后,就 永远不会被使用 。久而久之,这个ClassLoader及相关的Class会越来越多。而元数据区默认使用操作系统所有可用内存,直到内存完全被耗尽。
六、解决方案
01
临时方案
通过设置JVM参数“-XX:MaxMetaspaceSize =***M”,可以在元数据区快要到达这个大小的时候,让JVM去做Full GC来回收元数据,这样就不会导致OOM。这也是为什么相同的代码在使用Java 7的应用里没有发生该问题的原因。 因为Java 7使用的是PermGen(永久代),必须设置MaxPermSize来限定它的最大值。 但缺点是该临时方案中的Full GC会导致较长时间的业务停顿。
02
长期方案
每次都创建一个ClassLoader的代码是不正确的, 通过重用,让所有的请求共用一份ClassLoader。
七、总结
从Java 8开始的元数据区默认是没有最大空间限制的,这是因为在Java代码稳定运行前,很难确定需要加载多少类,因此也就很难确定元数据区的大小。
正因为没有设置最大值,所以会有耗尽内存的潜在可能。当应用程序大概知道需要使用多少元数据的时候,为了让元数据区内存保持在合理的大小范围之内,不至于耗尽所有可用内存,可以设置以下参数:
同时,如果有 自定义的ClassLoader ,一定要注意是否会创建N个实例,从而造成元数据空间的不断消耗。
什么?!
我的 服务器
又双叒叕 不响应了?
软硬件 牟问题
网络防火墙 OK
服务器配置 完美
那,到底是谁?
动了我eBayer的奶酪?
出动!
9月20日 下下周五 敬请关注:
不见不散!
SRE重案调查组 第一集 | 高延迟问题的罪魁祸首System.gc()
SRE重案调查组 第二集 | 挖掘应用处理变慢的“真相”
SRE重案调查组 第三集 | 探秘HTTP异步请求的“潘多拉魔盒”
eBay2020校招 现已正式启动!详情点击下方链接:
2020校招 | 你想要的eBay全都有! eBay校园招聘正式启动!
你还可以:
↓点击 阅读原文 ,直接开启网申之旅!