JVM读书笔记之OOM

  在Java虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(OOM)异常的可能,本文总结了若干实例来验证异常及发生的场景。

  下文代码的开头都注释了执行时所需要设置的虚拟机启动参数(注释中VM Args后面跟着的参数),如果使用控制台命令来执行程序,那直接跟在Java命令之后书写就可以。如果使用Eclipse IDE,则可以参考下图在Debug/Run页签中设置。本文的代码都是基于Sun公司的HotSpot虚拟机运行的,对于不同公司的不同版本的虚拟机,参数和程序运行的结果可能会有所差异。

1.Java堆溢出

  Java堆用于存储对象实例,只要不断创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数据量到达最大堆的容量限制后就会产生内存溢出异常。如下代码清单中,限制Java堆的大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前内存堆转储快照以便事后分析。

//代码清单1-1,Java堆内存溢出异常测试
/**
* 虚拟机参数: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/

public
class HeapOOM{ static class OOMObject{} List<OOMObject> list = new ArrayList<OOMObject>(); while(true) { list.add(new OOMObject()); } }
运行结果:
java.lang.OutOfMemoryError: Java heap space Dumping heap to java_pid10544.hprof ... Heap dump file created [
28023233 bytes in 0.083 secs]

  Java堆内存的OOM异常是实际应用中常见的内存溢出异常情况。当出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示"Java heap space"。

  要解决这个区域的异常,一般的手段是先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄露(Memory Leak)还是内存溢出(Memory Overflow)。

  如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息及GC Roots引用链信息,就可以比较准确地定位出泄漏代码的位置。

  如果不存在泄漏,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

  以上是处理Java堆内存问题的简单思路,处理这些问题需要实战经验的积累。

2.虚拟机栈和本地方法栈溢出

  关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

  测试代码如下:

/**
* 代码清单2-1,虚拟机参数: -Xss128k
*/
public
class QuickTest { private int stackLength = 1; public static void main(String[] args) throws Throwable{ QuickTest qu = new QuickTest(); try { qu.stackLeak(); }catch(Throwable e) { System.out.println("stack length:" + qu.stackLength); throw e; } } public void stackLeak() { stackLength++; stackLeak(); } }
运行结果:
Exception in thread "main" stack length:992
java.lang.StackOverflowError
    at sort.QuickTest.stackLeak(QuickTest.java:78)
    at sort.QuickTest.stackLeak(QuickTest.java:79)

  如上实验是在单线程下测试,表明当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。如果测试时不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常,如下代码所示。但是这样产生的内存溢出异常就和栈空间是否足够大并不存在任何联系,或者说,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。

  其实原因不难理解,操作系统分配给每个进程的内存是有限制的,譬如32位的Windows限制为2GB。虚拟机提供了参数来控制Java堆和方法区的这两部分内存的最大值。剩余的内存为2GB(操作系统限制)减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈"瓜分"了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。

  这一点在开发多线程的应用时特别注意,出现StackOverflowError异常时有错误堆栈可以阅读,相对来说,比较容易找到问题所在。而且,如果使用虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说在大多数情况下)达到1000-2000完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64为虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。如果没有这方面的处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。

3.运行时常量池溢出

  如果要向运行时常量池中添加内容,最简单的做法就是使用 string.intern( )这个Native方法。该方法的作用是:如果池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 string 对象;否厕,将此 string 对象包含的字符串添加到常最池中,并且返回此 string 对象的引用。由于常量池分配在方法区内,我们可以通过-XX:PermSize和-XX:MaxPermSize限制方法区的大小,从而间接限制其中常量池的容最,如代码清单3-1所示。

/**
* 代码清单3-1,VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
*/
public
static void main(String[] args){ List<String> list = new ArrayList<String>(); int i = 0; while(true) { System.out.println("hello"); list.add(String.valueOf(i++).intern()); } }

  对于如上的代码,书中的结果是:

Exception in thread "main" java.lang.OutOfMemoryError:PerGen space
at java.lang.String.intern(Native Method)
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)

  解释是:从运行结果中可以看到,运行时常量池溢出,在OutOfMemoryError后面跟随的提示信息是"PermGen space",说明运行时常量池属于方法区(HotSpot虚拟机中的永久代)的一部分。

  可是我自己同样的代码在自己电脑上实验的结果却是---》死循环O__O",这是什么情况,想一想,问题应该是出在环境上,我的JDK是1.8,作者当时的环境可能是低版本的JDK(1.6),而java6中,JVM字符串常量值使用了固定大小的内存区域(PermGen),java7和8字符串常量池在堆内存中,问题应是出在这里,通过调整虚拟机参数-Xms10m -Xmx10m,然后报OOM了,显示是堆内存溢出了,说明分析正确,结果如下。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.io.PrintStream.write(PrintStream.java:530)
    at java.io.PrintStream.print(PrintStream.java:669)
    at java.io.PrintStream.println(PrintStream.java:806)
    at testPackage.Test.main(Test.java:89)

 4.方法区溢出

  方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这个区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用 Java SE API 也可以动态产生类(如反射时的 GeneratedconstructorAccessor 和动态代理等),但操作起来比较麻烦。在代码清单4-1中,借助 CGLib 。直接操作字节码运行时,生成了大量的动态类。

/**
* 代码清单4-1,VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
*/
public
class Test{ public static void main(String[] args) { while(true) { System.out.println("hello"); Enhancer en = new Enhancer(); en.setSuperclass(OOMObject.class); en.setUseCache(false); en.setCallback(new MethodInterceptor(){ public Object intercept(Object obj,Method method,Object[] args,MethodProxy proxy)throws Throwable{ return proxy.invokeSuper(obj,args); } }); en.create(); } } static class OOMObject{}; }

  方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收掉,判定条件是非常苛刻的。在经常动态生成大量 class 的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了 GCLib 字节码增强外,常见的还有:大量 JSP 或动态产生 JSP 文件的应用( JSP 第一次运行时需要编译为 Java 类)、基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

5.总结

  总结到这里,明白了在一些基本场景中什么样的代码和操作可能导致内存溢出异常,虽然Java有垃圾回收机制,但内存溢出异常离我们其实并不遥远,本文也只是学习了一些导致内存溢出异常的常见操作,并未对出涉及到Java的垃圾收集机制,后续文章将总结Java为了避免内存溢出异常的出现做了哪些努力。

 

posted on 2018-09-11 19:52  木瓜芒果  阅读(257)  评论(0编辑  收藏  举报