Java内存溢出的几种情况

Java
276
0
0
2023-06-15

Jvm 内存分区中,除了程序计数器外,其他几个分区都有发生OOM异常的可能。本文将通过一些代码实例来验证各个分区发生OOM异常的场景以及相关的一些JVM虚拟机参数。

本文代码都是基于JDK v1.8.0_151的HotSpot虚拟机上进行过实测。

使用的IDE为STS(Spring Tool Suite 4),在运行下文介绍的各代码实例时,需要设置相应的JVM参数。本文在一个 Java Application工程中创建了一个类JvmSample.java,直接执行main方法即可。设置J VM 参数的方法为,右键点击JvmSample. java ,选择“Run as → Run Configurations…”菜单,在弹出窗口的右侧“ Arguments ”页面中VM arguments栏输入相应的JVM参数。如下图(如使用其他IDE也有类似设置)。

堆溢出

如果创建了大量的对象实例,并且GC Roots到对象之间有可达路径避免这些对象被垃圾回收机制清理掉,当创建的对象实例越来越多时,达到堆容量最大限制后,就会触发OOM异常。本实例设置JVM参数为:-verbose:gc -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+HeapDumpOn OutOfMemory Error

主要参数

  • -verbose:gc

输出虚拟机GC日志信息

  • -Xms

JVM启动时分配的堆内存空间(或最小堆内存空间)

  • -Xmx

堆内存最大值

  • -XX:+PrintGCDetails

在控制台打印GC详细信息

  • -XX:+HeapDumpOnOutOfMemoryError

当堆内存空间溢出时输出堆的内存快照到一个.hprof后缀名的文件(即我们通常所说的dump文件),默认输出到运行Java程序的工作目录,可使用-XX:HeapDumpPath参数指定位置。

将堆内存最大值-Xmx与最小值-Xms都设置为10M,是为了便于演示代码实例。Java堆溢出的实例代码:

 public class JvmSample {
     @Data
      static  class User {
          private  Long id;
         private Integer age;
         private String name;
     }

     /**
	* VM参数:-verbose:gc -Xmsm -Xmx10m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
       */
     public static  void  main(String[] args) {
          List<User> list = new ArrayList<User>();
          while (true) {
              list.add(new User());
           }
     }
} 

控制台输出的结果中,可看到:

 java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid.hprof ...
Heap dump file created [  Byte s in 0.325 secs]	 

Java堆溢出是最常见的OOM异常了,其异常信息中明确指出了 Java heap space 。我们可以使用Memory Analyzer Tools(MAT)工具打开刚刚生成的堆快照文件来分析堆溢出的原因。

Java内存溢出的几种情况

点击“Reports → Leak Suspects”链接,查看内存泄露可疑点

Java内存溢出的几种情况

在main线程中持续引用本地变量达到了8.64M内存,占整个堆内存95.03%。

The memory is accumulated in one instance of “java.lang.Object[]” loaded by “<system class loader>”.

内存累积在一个由System ClassLoader加载的对象数组实例上:java.long.Object[]。

点击上图中的“Details”链接,可以进一步查看详细说明

Java内存溢出的几种情况

通过Details信息可以看到,在main线程引用了一个java.util.ArrayList对象,ArrayList实质是一个对象数组java.lang.Object[],进而可以看到对象数组中的对象User。


找到了堆内存溢出的具体对象,还需要进一步分析该对象是不是程序中必要的,也就是说这些对象是不是在程序运行期必须一直存活。如果不是,就需要分析对象的GC Roots引用链,找到垃圾收集器无法回收它们的原因。如果是,就应该检查JVM堆参数(-Xms与-Xmx)设置,看是否有调大的空间,再从程序设计上审视,看程序设计和结构是否有更好方案,朝着尽量减小程序运行期内存消耗方向优化。

栈溢出

HotSpot虚拟机的实现将虚拟机栈和本地方法栈合二为一,即不区分虚拟机栈和本地方法栈,栈容量由-Xss参数设置(-Xoss参数用来设置本地方法栈大小,对于HotSpot虚拟机无效)。


Java虚拟机 规范描述了两种栈溢出情况

  • 如果 线程 请求的栈深度大于虚拟机允许的最大深度,将抛出StackOverflowError异常;
  • 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。


因HotSpot虚拟机不支持栈内存动态扩展,所以在线程运行期间上述第二种情况不会发生,除非在创建线程时就因内存不足而出现OutOfMemoryError异常。


栈溢出实例代码:

 public class JvmSample {
    private int stackLength =;
    public void recursion() {
        stackLength++;
        recursion();
    }
    /**
	     * VM参数:-verbose:gc -Xmsm -Xmx10m -Xss128k -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
       */
    public static void main(String[] args) {
        JvmSample sample = new JvmSample();
        try {
            sample.recursion();
        } catch (Throwable t) {
            System.out.println("stack length = " + sample.stackLength);
            throw t;
        }
    }
} 

运行结果:

 stack length =
 Exception  in thread “main” java.lang.StackOverflowError
       at com.esgoon.tool.JvmSample.recursion(JvmSample.java:) 
       at com.esgoon.tool.JvmSample.recursion(JvmSample.java:)
       at com.esgoon.tool.JvmSample.recursion(JvmSample.java:)
…… 

这个实例说明,当设置栈容量较小时,可能会导致栈溢出。

注意,对于不同版本的Java虚拟机或不同的操作系统,栈最小容量会有限制,主要取决于操作系统内存分页大小。比如,在我的实测环境中,- Xss 不可小于108k,否则运行程序时会直接抛出异常:

The stack size specified is too small, Specify at least 108k

另一方面,如果-Xss参数值设置过大,程序运行过程中需要创建大量线程时,会由于耗尽系统内存而产生OOM异常。这种情况跟栈空间是否充足无直接关系,而是取决于操作系统本身内存使用状况,操作系统分配给每个进程的内存大小是由限制的,在一个Java进程中,除去堆、方法区、 程序计数器 等区域消耗的内存,剩下的内存就由栈分配了,所有当-Xss参数设置栈容量越大,那么应用程序可以创建的线程数就越少,当创建线程把可以使用的内存耗尽时,就会导致OOM异常:

Exception in thread “main” java.lang.OutOfMemoryError: unable to create native thread

当发生StackOverflowError异常时,通常根据异常信息可以很容易定位到问题点。使用默认的JVM参数,栈深度也可以满足绝大多数应用程序场景。如果是大量线程导致的OOM异常,在不能减少线程数量的情况下,可以通过减少最大堆内存和减少栈容量,即将-Xmx与-Xss参数值调小,来允许创建更多线程,显然,这也并不是说可以无限制创建线程,而只是根据实际情况的一种优化技巧。


常量池溢出

可以通过往常量池增加大量 String 对象来验证。在JDK8以前HotSpot虚拟机是使用永久代来实现方法区,JDK6或更早以前常量池是分配在永久代,可以通过设置-XX:PermSize和-XX:MaxPermSize参数值来限制永久代大小。JDK7及更新版本把原本永久代中的 字符串常量 池移到Java堆中,JDK8把原本永久代中的其他部分移到元空间作为方法区,完全废弃了永久代的概念。


常量池溢出代码实例(JDK6环境运行):

 public class JvmSample {
	
	/**
	 * VM参数:-verbose:gc -Xmsm -Xmx10m -XX:PermSize=5M -XX:MaxPermSize=5M
	 * -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
	 */
	public static void main(String[] args) {
		   //使用List持有String常量引用,避免被垃圾回收
		   List<String> list = new ArrayList<String>();
		   int i =;
		   while (true) {			
			     list.add(String.valueOf(i++).intern());
		   }
	}
} 
intern()方法:在JDK6中,intern()方法会把首次遇到的 字符串 实例复制到永久代的字符串常量池中存储,并返回永久代里面这个字符串实例的引用;在JDK7及更新版本中,字符串常量池已经移到Java堆中实现,intern()方法只是在常量池中记录字符串首次出现的实例引用并返回该引用对象。

JDK6环境运行结果:

 Exception in thread “main” java.lang.OutOfMemoryError: PermGen space
     at java.lang.String.intern(Native Method)
     at com.esgoon.tool.JvmSample.main(JvmSample.java:) 

从异常信息中可以看到,已指明了发生OOM的区域是PermGen space(即JDK6的HotSpot虚拟机永久代)。

当在JDK7或JDK8中运行这段程序的时候,无论是设置-XX:MaxPermSize参数或-XX:MaxMetaspaceSize参数值为5M,都不会触发上述OOM异常,程序将一直运行下去。正如上文所提,自JDK7开始,原本在永久代的常量池已被移到Java堆中实现,所以这两个参数对常量池来说是无效的,而应该设置堆大小,将-Xmx设置为5M,运行程序将触发OOM异常。

方法区溢出

方法区的主要职责是存放类型的相关信息,如类名、访问修饰符、常量池、字典描述、方法描述等。当程序运行时产生了大量的类,如反射中的Generated Constructor Accessor和动态代理等,以及目前很多主流框架,如Spring等对类进行增强时,会使用到 CGLib 这类 字节码 技术,运行时需要方法区保证动态生成的新类型载入内存,这种场景下有可能导致方法区溢出。

在JDK8及更新版本,元空间的出现,默认情况下很难产生方法区溢出。其涉及的相关参数默认也不需要配置。主要有以下参数

  • -XX:MaxMetaspaceSize

设置元空间最大值,默认-1表示不限制(只受限于本地内存大小)。


  • -XX:MaxMetaspace

设置元空间的初始大小,以字节为单位,达到该值就会触发垃圾收集器卸载类型,同时对该值调整:如果释放了大量的空间,就适当降低该值;如果释放的空间很少,且设置了-XX:MaxMetaspaceSize,在不超过它的情况下适当调高该值。


  • -XX:MinMetaspaceFreeRatio

在垃圾收集之后控制最小的元空间剩余容量百分比,可减少因元空间不足导致的垃圾收集频率。


  • -XX:MaxMetaspaceFreeRatio

用于控制最大的元空间剩余容量百分比。

直接内存溢出

直接内存(Direct Memory)的容量大小可通过参数-XX:MaxDirectMemorySize来设置。不设置的情况下,默认与Java堆最大值一致,即-Xmx值。 JDK 的ByteBuffer类提供了一个接口allocateDirect(int capacity)进行直接内存的申请,底层通过unsafe.allocateMemory(size)实现。

直接内存溢出实例代码:

 public class JvmSample {
	   private static final long S_M = 1024 * 1024;
	   /**
	     * VM参数:-verbose:gc -Xmsm -Xmx20m -XX:MaxDirectMemorySize=10M -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
	     */
	   public static void main(String[] args) throws Exception {
		     Field unsafeField = Unsafe.class.getDeclaredFields()[];
		     unsafeField.setAccessible(true);
		     Unsafe unsafe = (Unsafe)unsafeField.get(null);		
		     while (true) {
			       unsafe.allocateMemory(S_M);
		     }
	   }
} 

运行结果:

 Exception in thread "main" java.lang.OutOfMemoryError
	at sun.misc.Unsafe.allocateMemory(Native Method)
	at com.esgoon.tool.JvmSample.main(JvmSample.java:) 

直接内存溢出的异常信息中, java.lang .OutOfMemoryError后面没有指明区域信息,此外dump文件也很小,这些可以作为判断是直接内存溢出的依据。

直接内存溢出,主要排查两种问题:

  • 如实例代码中所示通过Unsafe.allocateMomory或Java NIO的ByteBuffer.allocateDirect申请直接内存没有释放。
  • 通过 JNI 调用的本地方法申请的内存没有释放。