前言
JVM运行时分区溢出学习JVM必须掌握的一块内容,同时由于JVM的升级换代,JVM的内部分区也在逐渐的变化,比如方法区的实现由永久代改为了元空间这些内容都是需要掌握的,这一节将会是一篇关于JVM分区溢出的总结,同样根据两个案例来说下如何排查JVM令人头痛的OOM问题。
前文回顾:
上一期主要是对JVM调优以及工具的使用做了一个专栏的阶段总结,这里不再赘述,可以看个人主页的历史文章。
概述:
- 用图解的方式了解哪些分区会存在分区溢出的问题。
- 如何用代码来模拟出各个分区的溢出。
- 用两个案例来讲解分区的溢出是如何排查和解决的。
分区结构图简介:
在了解分区是如何溢出之前,这里先简单画一个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溢出问题?