JVM Metaspace OOM的排障以及原理分析

3,507 阅读5分钟

前言

本文记录了排查java.lang.OutOfMemoryError: Metaspace问题的处理过程,解决方案并不是通过调整-XX:MaxMetaspaceSize来解决问题,经过排查最终定位到的问题是JVM参数中配置-XX:SoftRefLRUPolicyMSPerMB=0引起的。@空歌白石

问题

近期在生产上有个应用频繁发生java.lang.OutOfMemoryError: Metaspace的报错,每个一到两天就会有机器触发OOM的告警,详细的堆栈如下:

java.util.concurrent.CompletionException: java.lang.OutOfMemoryError: Metaspace
	at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:331)
	at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:346)
	at java.base/java.util.concurrent.CompletableFuture$UniApply.tryFire(CompletableFuture.java:632)
	at java.base/java.util.concurrent.CompletableFuture.postComplete(CompletableFuture.java:506)
	at java.base/java.util.concurrent.CompletableFuture.completeExceptionally(CompletableFuture.java:2088)
    // 空歌白石:省略部分业务堆栈
	at com.google.common.util.concurrent.Futures$6.run(Futures.java:1764)
	at com.google.common.util.concurrent.MoreExecutors$DirectExecutor.execute(MoreExecutors.java:456)
	at com.google.common.util.concurrent.AbstractFuture.executeListener(AbstractFuture.java:817)
	at com.google.common.util.concurrent.AbstractFuture.complete(AbstractFuture.java:753)
	at com.google.common.util.concurrent.AbstractFuture.setException(AbstractFuture.java:634)
	at com.google.common.util.concurrent.SettableFuture.setException(SettableFuture.java:53)
    // 空歌白石:省略部分业务堆栈
	at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:515)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
	at java.base/java.lang.Thread.run(Thread.java:829)
Caused by: java.lang.OutOfMemoryError: Metaspace

JMX的MetaSpace监控如下图:

jmx meta space metric.png

分析

首先大家应该都知道,JVM的MetaSpace存储JVM的元信息,而且是在堆外存储。主要涉及以下内容:

  • JVM中类的元数据在Java堆中的存储区域
  • Java类对应的HotSpot虚拟机中的内部表示也存储在这里
  • 类的层级信息,字段,名字
  • 方法的编译信息及字节码
  • 变量
  • 常量池和符号解析

JAVA永久代的演化

  • JDK7开始,字符串常量和符号引用等就被移出永久代,字符串字面量迁移至Java堆 /符号引用转移到了native heap。
  • JDK8,永久代被彻底地移出了JVM,取而代之的是元空间MetaSpace,把类的元数据放到本地化的堆内存native heap中,这块区域就叫Metaspace。

理论上以上信息占用的内存空间在服务启动后就会比较稳定,并不会出现上文中提到的一直增长的,甚至导致OOM的情况出现。

class数量不断增加

分析到这里,已经有理由相信,是在程序运行中某段代码在不断的生成新得class,导致了metaSpace的空间一直上升。那么哪些情况下会引起class的动态增加呢?可能包含以下情况:

  1. 由于反射类加载,动态代理生成的类加载
  2. 动态或自定义的ClassLoader,在运行时不断的加载新的Class

不论哪种情况,一定是Metaspace的大小和加载类的数据有关系,加载的类越多metaspace占用的内存也就越大。

jmap

有了以上分析,接下来要做的就是如何查看当前JVM加载了哪些class呢?答案是可以借助于jdk自带的工具jmap来分析。

获取Java进程PID

使用以下命令获取到Java进程的PID。

ps -ef | grep 'java'

打印类信息

sudo -u ${loginUser} /usr/java/jdk11/bin/jmap -clstats ${pid} > /tmp/jamp-class-state.txt

  • loginUser:用当前登录用户替换
  • pid:上文中获取的PID

jvm param.png

对比两次class差异

正常情况下JVM的metasp并不会明显增加,为了获取哪些class在不断增长,我们可以间隔一定时间后,再次执行jmap命令,重新获取class的状态,对比前后两次class的差异,其中的差异就是新增的class。

jmap result compare.png

可以从对比图中看出,jdk.internal.reflect.GeneratedSerializationConstructorAccessor这个类在不断的增长。接下来的事情就是将问题定位到具体的代码中,那么如何实现呢?

arthas

为了回答上文的问题,如何定位具体的代码调用链,可以借助于arthas完成。相信大家应该都有用过,这里不过多的介绍如何使用了,未使用过的同学可以查看官方文档学习下。

curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

有几个常用的命令可以简单介绍下:

  1. thread -n 10 -i 1000 : 列出1秒内最忙的10个线程栈
  2. profiler start -d 10 --file /tmp/flame-graph.svg:生产3秒内的火焰图
  3. trace demo.MathGame run -n 1: 查看指定函数内部各模块调用时间,其中 -n 指定捕捉次数

这里有点需要注意,一般生产环境是不允许访问外网的,需要将arthas的jar包自己通过正常的发布渠道添加的机器中。

我是通过分析火焰图来判断堆栈的。火焰图如下:

flame graph.png

通过指定筛选条件,可以订位到具体的受影响class,进而可以获取到具体的代码逻辑。

flame search.png

代码分析

基于以上定位的代码,可以看出诱发jdk.internal.reflect.GeneratedSerializationConstructorAccessor类不断增加的原因是和我们使用的RPC框架有关。

其中一段核心代码如下,客户端通过clientClass.getDeclaredConstructor获取实例。

DerivedClient client = (DerivedClient) _clientCache.get(clientKey);
if (client == null)
	synchronized (_clientCache) {
			// 空歌白石:省略部分代码
			Constructor<DerivedClient> ctor = clientClass.getDeclaredConstructor(paramTypes);
			ctor.setAccessible(true);
			client = ctor.newInstance(paramValues);
			// 空歌白石:省略部分代码
	}
return client;

具体的底层都是用到了Reflection相关的代码,ReflectionDataClass类的内部静态类被缓存起来,里面的属性就是反射操作时需要用的属性Field,方法Method和构造函数等。

private static class ReflectionData<T> {
	volatile Field[] declaredFields;
	volatile Field[] publicFields;
	volatile Method[] declaredMethods;
	volatile Method[] publicMethods;
	volatile Constructor<T>[] declaredConstructors;
	volatile Constructor<T>[] publicConstructors;
	// Intermediate results for getFields and getMethods
	volatile Field[] declaredPublicFields;
	volatile Method[] declaredPublicMethods;
	volatile Class<?>[] interfaces;

	// Cached names
	String simpleName;
	String canonicalName;
	static final String NULL_SENTINEL = new String();

	// Value of classRedefinedCount when we created this ReflectionData instance
	final int redefinedCount;

	ReflectionData(int redefinedCount) {
		this.redefinedCount = redefinedCount;
	}
}

Class中的reflectionData()方法负责延迟创建和缓存ReflectionData

private ReflectionData<T> reflectionData() {
	SoftReference<ReflectionData<T>> reflectionData = this.reflectionData;
	int classRedefinedCount = this.classRedefinedCount;
	ReflectionData<T> rd;
	// 空歌白石:判断缓存
	if (reflectionData != null &&
		(rd = reflectionData.get()) != null &&
		rd.redefinedCount == classRedefinedCount) {
		return rd;
	}
	// else no SoftReference or cleared SoftReference or stale ReflectionData
	// -> create and replace new instance
	return newReflectionData(reflectionData, classRedefinedCount);
}

看到这里并没有什么问题,继续看源码,发现ReflectionData是被SoftReference包装的。

// 空歌白石:reflectionData属性定义
private transient volatile SoftReference<ReflectionData<T>> reflectionData;

根源分析

ReflectionData是被SoftReference软引用修饰的,如果是软引用的话在内存空间不足时就可能会被回收掉,如果回收掉那下次再使用的话只能重新通过反射获取。

SoftReference是否被回收又和JVM的配置项SoftRefLRUPolicyMSPerMB参数的值有关系。还记得上文中我们查看JVM参数时的截图吗?其中蓝色的部分就是关于SoftRefLRUPolicyMSPerMB的配置,显然,我们配置的是0。

jvm param.png

大家可能会问,SoftRefLRUPolicyMSPerMB表示的含义是什么呢?可以这样理解SoftRefLRUPolicyMSPerMB表示每MB堆空闲空间的 Soft Reference 保持存活的毫秒数,JDK1.7默认值为1000,也就是1秒。超过时间会被回收。

参数调整

根据以上分析,我们将生产上JVM参数进行了调整,可以有两种方案,一种直接删除SoftRefLRUPolicyMSPerMB的配置,一种是将SoftRefLRUPolicyMSPerMB=1000,两种效果是一样。最后将服务重新发布后,观察一天metaSpace的变化情况,发现MetaSpace已经不再像之前问题出现时经过一天的时间Metaspace不断的上升进而引起OOM了。

为什么不调整MaxMetaspaceSize

可能有同学一开始会问,为何不调整MaxMetaspaceSize呢?MaxMetaspaceSize表示最大元空间(Metaspace)内存大小,我们是基于以下方面考量,首先,我们的服务中并没有热加载或使用自定义ClassLoader的地方,起码业务代码可以明确是没有的,在服务启动时占用的metaspace并不高,在现有的MaxMetaspaceSize配置为256MB情况下已经足够应用使用了。因此,我们从始至终都没有通过调整MaxMetaspaceSize的方式来排查问题。

总结

线上任何服务的告警都应该得到重视,并深入分析其中的原因,通过对问题的深入分析和探究是提升自己的技术能力最有效的方法。

参考文献