深刻了解JVM - 分区是如何溢出的?

前言

JVM运行时候区溢出学习JVM必须把握的一块内容,同时因为JVM的升级换代,JVM的外部分区也在逐步的变动,比方办法区的实现由永恒代改为了元空间这些内容都是须要把握的,这一节将会是一篇对于JVM分区的总结,同样依据两个案例来说下如何排查JVM令人头痛的OOM问题。

前文回顾:

上一期次要是对JVM调优以及工具的应用做了一个专栏的阶段总结,这里不再赘述,能够看个人主页的历史文章。

概述:

  1. 用图解的形式理解哪些分区会存在分区溢出的问题。
  2. 如何用代码来模拟出各个分区的溢出。
  3. 用两个案例来解说分区的溢出是如何排查和解决的。

分区结构图简介:

在理解分区是如何溢出之前,这里先简略画一个JVM的分区运行图:

其实这一段代码在专栏开篇曾经讲过,这里间接挪用过去,同时标记了会呈现溢出的分区,下面的图对应上面这一段代码:

public class OneWeek {    private static final Properties properties = new Properties();    public static void main(String[] args) throws IOException {        InputStream resourceAsStream = OneWeek.class.getClassLoader().getResourceAsStream("app.properties");        properties.load(resourceAsStream);        System.out.println("load properties user.name = " + properties.getProperty("user.name"));    }}

代码非常简单,当然和本文没有什么关系,这里间接跳过。

咱们能够看到,容易呈现办法区溢出的中央通常是这三个:办法区,JAVA虚拟机栈和JAVA堆(精确来说是老年代溢出)。这三个分区的溢出也是日常写代码当中很容易呈现溢出的状况,结构图的最上方还有一个间接内存,因为这块空间平时可能用不上然而很容易出问题所以也放进来解说,上面一一剖析他们产生溢出会呈现什么状况:

办法区:因为古代框架广泛应用动静代理+反射,所以办法区通常会产生很多的代理对象,尽管少数状况下spring的bean都是单例的通常不会产生影响,然而遇到一些须要创立大量非单例对象状况(比方并发问题)下就很容易呈现办法区的溢出。

虚拟机栈:这里看到下面的结构图可能会想1M是不是也太小了?其实每一个调配1M对于绝大多数状况下齐全够用了,让虚拟机栈溢出也比较简单, 那就是死循环或者有限递归,下文会用代码进行演示。

堆:用的最多的分区也是最容易出问题的一个分区,堆内存须要配合垃圾收集器一起进行工作,通常状况下堆溢出是因为老年代回收之后还是有很多对象(占满),导致对象无奈再持续调配而产生OOM的异样。

间接内存:因为篇幅无限间接内存是什么东东请同学们自行百度,这一块空间少数和Netty以及NIO等工具打交道的时候会有机会应用到,在这里重点解释下这块区域怎么溢出,只有记住当JVM申请不到足够的Direct Memory的时候就会造成间接内存的溢出。

会产生溢出的分区都曾经被咱们找进去了,上面就来介绍一下各自的分区是如何用代码来模仿溢出的。

分区溢出模仿:

办法区:

首先是办法区的空间溢出,这里不介绍过多的概念,上一节也提到了办法区少数状况下是因为动静生成类过多导致办法区产生了溢出,上面用一段代码来模仿:

建设我的项目的步骤这里省略,间接应用IDE简略生成一个Maven的我的项目即可,首先咱们再Pom.xml文件中导入CGLIB的依赖,不分明这个CGLIB是什么也没关系,只有简略了解为能够帮忙咱们生产动静JAVA类的工具即可,即能够不应用手动new的模式实现一个对象的构建:

<dependency>    <groupId>cglib</groupId>    <artifactId>cglib</artifactId>    <version>3.3.0</version></dependency>

上面是具体的测试代码:

public class CglibTest {    static class Man {        public void run() {            System.out.println("走路中。。。。。");        }    }    public static void main(String[] args) {        while (true) {            Enhancer enhancer = new Enhancer();            enhancer.setSuperclass(Man.class);            enhancer.setUseCache(true);            enhancer.setCallback(new MethodInterceptor() {                @Override                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {                    if(method.getName().equalsIgnoreCase("run")){                        System.out.println("遇到红绿灯,期待.....");                        return methodProxy.invokeSuper(o, objects);                    }                    return methodProxy.invokeSuper(o, objects);                }            });            Man man = (Man) enhancer.create();            man.run();        }    }}

这里简略解读一下代码:

在代码的第一句,应用while(true)语句构建一个死循环,让外部的代码一直的循环工作。接着咱们首先应用上面这段代码初始化一个生成类的API对象,同时设置生成的类的super类是Man.class,也就是说咱们只能生产Man这个类的超类,同时咱们开启对象缓存,至于有什么作用无需关注。

Enhancer enhancer = new Enhancer();enhancer.setSuperclass(Man.class);enhancer.setUseCache(true);

接着咱们用回调的匿名钩子函数,在办法调用之间减少一个拦挡办法,在这里咱们做的事件是匹配到run办法的调用的对象之前做一些咱们自定义的操作,比方像上面这样减少一条打印语句:

enhancer.setCallback(new MethodInterceptor() {    @Override    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {        if(method.getName().equalsIgnoreCase("run")){            System.out.println("遇到红绿灯,期待.....");            return methodProxy.invokeSuper(o, objects);        }        return methodProxy.invokeSuper(o, objects);    }});

接着GCLIB就会通过JDK的动静代理构建代理对象并且实现办法的调用。

既然这里能够对于Man对象的办法进行拦挡,那对他的子类当然也是同样实用的,咱们能够减少一个新的类继承Man类,比方像上面这样:

static class OldMan extends Man{    @Override    public void run() {        System.out.println("走的很慢很慢。。。。。。。");    }}

而后咱们在死循环的结尾减少上面的代码:

OldMan oldMan = (OldMan) enhancer.create();oldMan.run();

紧接着,咱们就能够开始运行代码了,而后,而后你会发现程序报错了。。。。。。谬误内容如下:

遇到红绿灯,期待.....走路中。。。。。Exception in thread "main" java.lang.ClassCastException: com.xd.test.jvm.CglibTest$Man$$EnhancerByCGLIB$$ba733242 cannot be cast to com.xd.test.jvm.CglibTest$OldMan    at com.xd.test.jvm.CglibTest.main(CglibTest.java:50)

这里其实是类强制转换的异样,咱们不能把一个动静生成的代理父类转为一个代理的子类,这里要改成上面的格局,利用多态的个性把superclass设置为Man子类即可:

enhancer.setSuperclass(OldMan.class);

限度办法区大小:

重点来了,当初咱们限度一下办法区的大小,这里应用了JDK8的版本,参数和JDK8以下的参数不一样,因为JDK8应用了元空间,咱们须要应用上面的参数来进行元空间的大小设置:

-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m

留神MetaspaceSize这个值不是初始化元空间大小哦,而是首次触发元空间扩大的大小,而MaxMetaspaceSize才是元空间真正容许扩大的最大大小,尽管默认设置下这两个参数的值都很小,然而JVM会在每次FULL GC之后主动扩充元空间,实践上来说能够有限靠近于零碎内存的大小,然而毫无疑问JVM会有限度,在扩大到肯定水平之后会间接让办法区溢出,所以在这里这两个参数咱们设置为一样的大小即可。

接着运行代码,不须要多长时间,控制台就会爆出如下的提醒,通知咱们办法去区溢出了:

Caused by: java.lang.OutOfMemoryError: Metaspace

以上便是办法区的溢出测试。

虚拟机栈:

虚拟机栈的溢出是最简略的,这里间接上代码演示一下:

首先咱们须要设置一下栈内存大小,这里咱们为每个线程调配1M的栈内存大小:

-XX:ThreadStackSize=1M

接着应用上面的代码跑一下,代码内容非常简略就是一个单纯的有限递归调用的代码:

public static void main(String[] args) {    int count = 0;    work(count);}public static void work(int count){    System.out.println("一共运行了:"+ (count++) +"次");    work(count);}

运行后果如下,从个人电脑来看运行了6000屡次:

一共运行了:6466次一共运行了:6467次一共运行了:6468次一共运行了:6469次一共运行了:6470次一共运行了:6471次java.lang.StackOverflowError    at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)    at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)    at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)    at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)    at java.io.OutputStreamWriter.write(OutputStreamWriter.java:207)    at java.io.BufferedWriter.flushBuffer(BufferedWriter.java:129)    at java.io.PrintStream.write(PrintStream.java:526)    at java.io.PrintStream.print(PrintStream.java:669)    at java.io.PrintStream.println(PrintStream.java:806)

栈内存的溢出比拟好了解,少数状况下是因为编程引发的谬误,比方循环调用,有限递归调用等等,栈内存溢出的状况比拟常见的,个别是开发人员编程谬误(这里也不必放心失常办法调用链过长的可能性)。

栈的溢出也能够形象了解为往一个纸箱外面放书,当书放不进纸箱的时候,零碎只能报错了,另外特地留神栈帧弹出虚拟机栈之后变量是间接销毁的,所以不存在垃圾回收这一个概念,再次强调,虚拟机栈和垃圾回收器没有半毛钱关系。

堆内存:

堆内存的溢出模仿测试也比较简单,就是一直创立 无奈被垃圾回收器回收的对象,比如说大字符串,或者占用很多内存的数组,最简略的方法就是调配一个一次性无奈包容下的超大数组,是不是非常简单?上面同样演示一段代码进行解说:

同样,咱们须要先给堆空间限度一下大小,应用-Xms20M -Xmx20M 来限度一下堆内存的大小,而后编写上面的代码并且执行:

public class Test {    public static void main(String[] args) {        byte[] arr = new byte[1024*1024*20];    }}

运行代码,会取得上面的后果:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space    at Test.main(Test.java:11)

下面是模仿溢出的一种最简略的方法,更多的溢出这里不再过多探讨,上面咱们来看下一个重点:实在场景下如何排查TOMCAT溢出问题?

如何排查分区溢出问题?

Tomcat呈现OOM如何排查?

在这个案例中,一个每秒仅仅只有100+申请的零碎却频繁的因为OOM而解体,上面会一步步排查一个这样的问题是如何牵扯到Tomcat和分区溢出扯上关系的。

还原案发现场:

首先,咱们须要还原案发现场,在某一天零碎忽然收到报警,告诉说线上零碎出了问题。于是排查异样信息,这时候第一件事件是跑到线上看一下日志的后果,后果十分诧异的发现报错信息如下:

Exception in thread "http-nio-8080-exec-1089" java.lang.OutOfMemoryError: Java heap space

这一段和下面模仿的代码后果十分相似,不同的是这个线程是一个Tomcat的线程,当然这个音讯十分不好,因为线上竟然产生了OOM!这是一个十分重大的BUG。上面来看下是如何一步步进行排查的。

简略理解Tomcat的底层原理

首先咱们来简略理解一下Tomcat这种web服务器的工作原理是什么呢?其实学了JVM之后,对这个概念应该有了更深刻的理解,毫无疑问就是底层仍然还是一个JVM的过程,通常状况下,咱们应用Tomcat都会绑定一个8080的端口启动,Tomcat在最开始的时候申请是通过一个叫做Servlet的货色进行解决的,而这个Servlet在起初通过框架的包装就变成了spring mvc的一个mapping,到后续随着框架的演进,当初通常都会应用框架比如说spring boot内置的Tomcat进行web服务器的治理,咱们不再须要独自的Tomcat进行我的项目部署操作,间接集成式一键启动即可。

那么咱们的申请是如何被Tomcat解析的呢?Tomcat通过监听端口并把咱们发送过去的网络申请通过解析的和解决,而后再传给MVC的进行包装散发,最初到具体的某一个映射(Mapping),整个业务过程其实就是Tomcat把咱们写好的类(controller)通过他的类加载器加载到Tomcat的外部进行执行,执行实现之后再后果返回给申请发送方。

那么tomcat是如何监听端口的呢?其实tomcat自身就是一个工作线程,对于咱们的每一个申请,tomcat的工作线程都会从本人治理的线程池中调配一个工作线程来负责进行解决,也就是说多个申请之前是互相独立并且互不烦扰的。为了更好的了解,上面画了图来简略解释一下上文形容的工作机制:

小贴士:

倡议在JVM调优的时候务必加上此参数:-XX:+HeapDumpOnOutOfMemoryError。一旦呈现OOM等内存溢出的状况时候,JVM会从内存中备份一份以后的溢出日志,依据日志也能够很快的定位到问题产生的点以及产生了什么问题。

内存快照剖析:

言归正传,这里再次回到案例来,这里省略具体的剖析步骤,最初通过内存快照的后果发现,导致内存泄露的竟然是数组,从日志的剖析后果中发现了如下的内容:

byte[10008192] @ 0x7aa800000 GET /order/v2 HTTP/1.0-forward...byte[10008192] @ 0x7aa800000 GET /order/v2 HTTP/1.0-forward...byte[10008192] @ 0x7aa800000 GET /order/v2 HTTP/1.0-forward...byte[10008192] @ 0x7aa800000 GET /order/v2 HTTP/1.0-forward...

就是相似这样的byte数组,在每一次调用的时候,都产生了大略10M左右大小的数组,最终因为几百次的调用的同时这些数组对象 并不能回收掉,最终导致了内存的溢出。

这时候你可能会认为有人要来背锅了,然而工程师判定代码泄露不是集体编写的代码导致的。接着咱们持续思考,不是开发人员造成的,排查代码的确没有看到显著会导致溢出的点,那么这是怎么回事呢?

进一步排查,发现是有一个对象存在长期的占用那就是:org.apache.tomcat.util.threads.TaskThread,从包名字就能够晓得是Tomcat本人的线程,这意味着Tomcat在这个线程当中创立了数组并且没有被回收。

既然数组没有被回收也就意味着工作线程还在执行工作,按理来说工作线程执行工作通常都是很快就会实现的,为什么TaskThread会长期期待呢?这里再通过测试和剖析发现每一个工作线程竟然停留了4秒以上,咱们都晓得通常状况下一个申请都是在一秒以内实现的,而这里的线程竟然停留了长达4秒,这里是一个问题点,另外这里还发现每一个工作线程就创立了2个10M的数组,导致数组也因为还在应用而无奈开释,所以这里2个10M的数组又是怎么来的?这是第二个问题点,

通过配置文件的查找发现了上面的内容:

max-http-header-size: 10000000

就是这个货色导致了每次Http申请都会创立20M的数组内容。

这里小结一下下面的内容,其实就是说每一个工作线程都创立一个20M数组,意味着100个工作线程就是2000M的大小,并且2000M还无奈回收。依照这个速度累积,一旦申请过多JVM就会立马解体。

解决问题:

既然晓得了是工作线程的问题,那么接下来就要着手解决为什么线程会停留4秒以上以及这个20M数组的起源,在接下来的排查中还发现日志中还有如下的内容:

Timeout Exception....

日志大抵内容就是RPC的申请呈现了大量的连贯超时,而连贯超时的工夫刚好是4秒!通过询问发现原来工程师设置了RPC的超时工夫刚好也是4秒,水落石出了!在每一次的工作线程执行代码的时候,都会执行一次RPC的近程调用,而当RPC服务挂掉的时候,此时因为连贯近程服务器迟迟得不到响应导致系统须要期待4秒才会开释线程,在期待的时候工作线程会占用这个申请的资源并且卡死在线程上期待后果,如果在同一时间有很多的申请就会呈现百来个工作线程挂在本人的线程卡死并且期待响应的后果,最终因为堆内存占用过多的数组对象,无奈再调配新的对象导致OOM!

晓得了问题,下一步就是解决问题了,这里解决办法也比较简单,首先咱们须要把超时的工夫设置为1S,一旦RPC连接时间超过1S就立马开释资源,避免工作线程呈现近程调用的长时间期待占用资源的问题,另外就是max-http-header-size这个参数能够适当的调整小一点,这样在每次调用的时候就不须要占用过大的数组导致资源利用缓和的问题了。

RPC通信框架导致的内存溢出:

问题状况:

  1. A服务器进行了降级之后,B的近程服务器宕机了,查看日志发现了OOM的异样
  2. 呈现了超过4G的数组调配动作,因为JVM的堆不可能放下这种对象,间接导致了OOM
  3. 发现这个对象是RPC外部应用的通信对象构建进去的数组。

案发现场:

零碎上有两个服务,服务A和服务B,服务A和服务B之间应用RPC的通信形式,同时应用的序列化协定是ProtoBuf,在通信协议上对立封装的对象格局这里假如应用的是Request,同时在近程调用的时候序列化和反序列化的封装和解析都是自定义的,接着咱们在拓展一下细节:

  1. 首先在申请服务A的时候,须要对传输过去的对象进行序列化,这个序列化就是相似于把你的对象转为一个byte[]数组(字节流)
  2. 服务A承受申请并且通过RPC发送到近程服务器B之后,应用RPC通信规定对内容进行序列化转为byte[],服务B承受之后应用反序列化把byte[]数组进行反序列化,拿到对象的内容进行逻辑解决。
  3. 这两个服务之间遵循了自定义的Request的对象格局进行通信,保障序列化和反序列化之后的对象格局和内容统一。

那么问题是什么呢,问题很奇怪,当服务A进行降级之后,部署下来没有几分钟,发现服务B挂了!很奇怪,明明改的是服务A却是服务B挂了,通过日志剖析,发现也是Byte[]数组太大导致溢出,这里浏览日志之后发现,竟然调配了一个4G的数组......

RPC类的框架规定

这里先不急着阐明产生问题的起因,咱们先补充一下RPC框架的通信规定实践:

一个RPC 的通信框架大抵过程:试想一下为了让服务A的所有申请在近程服务B承受之后都可能解决,服务器两边的RPC框架必定是要对所有的申请对象做对立标准的,比方RPC应用了ProtoBuf作为序列化的协定规范,同时应用固定的对象格局对于对象数据进行序列化和反序列化操作,这里假如服务A序列化应用Request对象进行定制序列化之后发送到服务B,而服务B天然须要应用对应的Request将服务A传来的序列化对象来反序列化。

这里就有一个问题了,如果服务A改变了Request对象的定制格局,比方通信应用Request A+C被序列化之后发送的到服务B了,服务B依照之前的Request解开之后发现自己解不开对象,于是会创立一个byte[] 数组来寄存序列化的数据,让客户端本人去实现反序列化的操作。

排查后果:

排查的后果就是服务A改了Request而m没有告诉服务B批改对应的对象,导致反序列化失败并且新建了一个Byte[]数组来寄存序列化的数据,而这个数组默认值刚刚好就设置了4G的大小!为什么要设置这么大的数组?开发人员说怕数组过小放不下,所以构建了一个4G的数组,保障无论A服务发送的对象如何大也不会影响到B。

解决问题:

很简略只有把这个反序列化失败之后创立数组的大小改小一点就行了,改成4M的大小根本足够应酬大部分的状况。

总结

这一节次要讲述了分区的问题以及理论的案例中分区溢出的问题是如何排查的,能够看到尽管咱们都非常分明分区溢出是什么状况,然而到理论的案例中进行排查却又是形形色色的问题呈现,心愿通过案例解说让更多的同学能够理解到JVM是如何进行问题排查,同时这里也能够发现,平时还是须要对于底层基础知识进行多积攒,很多时候并不是学到的货色用不上,而是到了用上的时候你没学。所以平时多磨难一下本人的脑袋,遇到问题才不会不知所措。

写在最初:

不晓得为什么时候在办法区的溢出理论运行测试的时候集体的笔记本电脑死活不能溢出,即便设置了参数也是办法区一直的溢出,难道是我是AMD的CPU的问题???

往期回顾:

留神这里应用的是“有道云笔记”的链接,不便大家珍藏和自我总结:

深刻了解JVM - 阶段总结与回顾(二)

深刻了解JVM - 案例实战