当前位置: 首页 > 科技观察

排查

时间:2023-03-19 18:19:18 科技观察

JVM堆外内存导致的FGC问题发现问题是服务线环境充满了频繁的FullGC。打开相关运行时数据区的监控,发现堆外内存一直在上升。我用的版本是java8,jvm厂商是orcalehotspot,垃圾收集器用的是CMS+ParNew。我使用的jvm参数是:-Xmx6g-Xms6g-XX:NewRatio=1-XX:+UseConcMarkSweepGC-XX:CMSInitiatingOccupancyFraction=75-XX:+UseCMSInitiatingOccupancyOnly-XX:MaxTenuringThreshold=6-XX:+ParallelRefProcEnabled-XX:+CMSParallelXX:+UseCMSCompactAtFullCollection-XX:+heapDumpOnOutOfMemoryError-XX:+PrintGCDetails-XX:+PrintGCDateStamps-Xloggc:/export/Logs/gc.log为了明确排查方向,有必要研究一下内存外的内容堆。于是看了下jvm的虚拟机规范。解释如下:Java虚拟机运行时数据区Java虚拟机定义了程序执行过程中使用的各种运行时数据区。这些数据区有一部分是在Java虚拟机启动时创建的,只有在Java虚拟机退出时才会被销毁,这部分是线程共享的。其他数据区域是每个线程。per-thread数据区在线程创建时创建,线程退出时销毁,是线程私有的。运行时数据区分为以下几个部分:1.PC寄存器(ThepcRegister)是每个线程一个,用来保存当前正在执行的指令的地址。一旦指令被执行,PC寄存器将被下一条指令更新。2、虚拟机栈(JavaVirtualMachineStacks)每个Java虚拟机线程都有一个私有的Java虚拟机栈,它是和线程同时创建的。虚拟机栈存储栈帧,栈帧保存局部变量和部分结果。可能会出现虚拟机栈,Java虚拟机会抛出StackOverflowerError。3、堆(Heap)Java虚拟机线程共享堆,而且只有一个堆。堆是为所有类实例和数组分配内存的运行时数据区域。这也是我们创建的对象将被放置的区域。是最大最需要调优的。堆是在虚拟机启动时创建的。垃圾收集器回收对象的堆存储;对象永远不会显式释放。如果计算所需的堆超过自动存储管理系统的可用堆,则Java虚拟机抛出OutOfMemoryError。4.方法区存放所有类级别的数据,包括所有线程共享的静态变量。Java虚拟机只有一个方法区。存储运行时常量池、字段和方法数据等类结构,以及方法和构造函数的代码,包括类和实例初始化以及接口初始化中使用的特殊方法。5、运行时常量池(Run-TimeConstantPool)运行时常量池是class文件中常量池表的per-class或per-interface运行时表示。它包含从编译时已知的数字文字到必须在运行时解析的方法和字段引用的常量。运行时常量池的功能类似于传统编程语言的符号表,尽管它包含的数据范围比典型的符号表更广泛。这一段是我抄的,为了保持完整性,运行时常量池其实是方法区的一部分。6.本地方法栈(NativeMethodStacks)存放本地方法信息,线程私有。整体结构代表了以下问题:方法区和元空间之间的关系是什么?简单理解,方法区是java的定义,元空间是1.8及以后热点虚拟机的实现。1.7之前称为永久代(永久代也包括一些老对象)。如果使用java8,直接忽略永久代即可。按照jvm的规范,方法区存放的数据都是jvm类级别的数据,包括什么构造方法,什么常量池等等。那么什么操作会让这个相位不断上升呢?带着疑问,一步步做。一个简单的尝试是先确定元空间的大小,防止它动态扩展,因为每次调整元空间的大小时都会进行一次fullgc。添加了jvm启动参数。-XX:MetaspaceSize=512m-XX:MaxMetaspaceSize=512m但是发现没有用。你能从堆中看出一些线索吗?查看堆外内存没有特别好的方法。我决定转储堆内存,看看能不能通过堆内存看出点花样。转储堆以供分析。使用命令jps查找java进程pid并指定生成文件的路径。在jmap-dump:file=/path${pid}dump完成后。借助工具查询首先使用mat,官网:https://www.eclipse.org/mat/。在这里看到了很多Netty的PoolThreaCache。联想到netty使用directmemory,是不是跟这个有关系?为此,我查询了很多资料,找到了一个参数:-Dio.netty.maxDirectMemory。这个参数大概意思是调整netty的堆外内存。它有三个值,无论调成什么,都没有办法阻止堆外内存的上升。其实这里有点没头没脑。确实,只有两种情况会导致netty相关的堆外内存上去。1.要么netty有bug。2、要么是使用方法不对。Netty有bug,因此请忽略这种可能性。使用的版本不是最新的,也没有直接引用netty包。比如通过http-client或者rpc框架引入的netty。使用方法不对?查看了一些http客户端或者rpc服务的代码,基本都是比较简单的使用方式,没有直接设置奇怪的参数或者很不常规的操作。确实在堆里找不到有用的线索。好像找不到原因。然后问了我们公司的超级老板:森哥。推测可能是C2Compiler或者一些实时编译导致的问题,因为heap堆满了jvm级别的数据,通过例行排查确实很难找到蛛丝马迹。听了觉得堆外的方法区不是。我用的java8热点虚拟机就是metaspace。什么会导致元空间在代码中上升?元空间是jvm层面存放数据的,是不是有很多类加载?有了这个猜想,找到对应的参数-verbose:class,就会打印出所有的类加载。如下图所示:发现ASMAccessorImpl_很多,而且不会停,一直加载。礼物蟹,就是这个道理。什么是ASM?如果你学过spring,就会知道字节码是在aop扩展中动态生成的。底层其实是由ASM生成的,其实是一个字节码编辑框架。官网:https://asm.ow2.io/。换句话说,我的代码中有一个地方一直在动态生成类字节码并将其加载到方法区。结果堆外内存一直在上升,导致fullgc。代码修改如何定位到哪一段代码?这个简单,打开idea,doubleshift,到处调整搜索。发现是mvel的依赖框架生成的。关于mvel,几乎就是spel,一个表达式解析引擎。在项目中,我们只用了两行代码就使用了mvel。MVEL.executeExpression()MVEL.compileExpression()然后我们把编译好的也缓存起来,按理说不会一直生成类。因为mvel的框架相关文档太少,感觉没人维护。抱着死马当活马医的态度去github上提issue,然后同时继续调查。好在这个框架还没死,还有人回帖。大概意思就是我问为什么用你的mvel会导致我的jvm出现oom错误(频繁fullgc),而且如果每次都编译相同的内容,为什么不在框架层面缓存。答案是需要自己缓存。也就是说,我的代码还是缓存失败。当我在缓存中找到该行时,我使用了地图。当我用key搜索的时候,我发现我用的是contains而不是containsKey。这导致永远找不到,从而导致永远重新编译。修改后问题解决。一条平线,而且没有fullgc,大家乐此不疲地总结堆外内存有点难处理,很难和代码挂钩。提供一个思路:可以通过-verbose:class查看类加载情况,再详细分析。