关于jvm调优:深入理解JVM-分区是如何溢出的

8次阅读

共计 9735 个字符,预计需要花费 25 分钟才能阅读完成。

深刻了解 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 – 案例实战

正文完
 0