垃圾回收算法与实现系列-Java堆内存溢出原因

2022-09-22 09:36:38

导语
  内存一直是所有开发人员探索的一片天地,再JVM中,内存往往会被分为几块,了解不同的内存区域对编写出优质的代码有很大的帮助。堆内存作为JVM中比较重要的区域,有很多值得我们探索的地方。下面就来介绍一下Java堆内存溢出的解决思路

分析内存溢出的原因

  OOM 作为比较令人头痛的问题一直困扰着很多程序员,它通常出现在某一块内存空间将要消耗尽的时候,在JVM中,导致内存溢出的原因也是比较多的,这里就来主要讨论一下最为常见的几种问题,包括堆溢出、直接内存溢出、永久区溢出等。

堆溢出

  Java堆作为JVM中最为重要的内存区域,由于大量的对象需要在堆内存进行分配,所以它是最有可能发生溢出的区域。大多数情况下的内存溢出都是堆内存溢出。通过上面描述可以知道,堆内存的溢出就是堆内存中出现大量的对象,没有及时的回收,占据了堆内存中的很大的空间,当对象大小之和大于由Xmx参数指定的堆空间大小的时候,溢出的错误就发生了。例如

publicclassSimpleHeapOOM{publicstaticvoidmain(String args[]){ArrayList<byte[]> list=newArrayList<byte[]>();for(int i=0;i<1024;i++){
			list.add(newbyte[1024*1024]);}}}

  运行上面的程序,马上就会抛出内存溢出的异常。Java heap space。

  为了减少堆溢出的错误,一方面可以使用-Xmx 参数指定一个大一点的堆空间,另一方面,由于堆空间不能无限的增加,通过MAT或者Visual VM等工具,找到大量占用堆空间的对象并在程序层面上做出合理的优化,这个是十分必要的。

直接内存溢出

  Java在NIO操作中支持使用直接内存,也就是通过Java代码可以额外获得一块堆外的内存空间,这个内存空间是直接向操作系统申请了,至于为什么会有这样一块内存空间,这个就跟NIO的原理有关了,这里就不多做说明了。直接内存的申请速度要比JVM的内存申请要慢,但是它的访问速度确实要比堆内存要快。所以这里就又得提到复用了,并且对于经常被访问的空间,使用直接内存可以提高系统的性能。但是由于直接内存没有被JVM所管理,所以使用不当的时候容易导致直接内存溢出。

publicclassDirectBufferOOM{publicstaticvoidmain(String args[]){for(int i=0;i<1024;i++){ByteBuffer.allocateDirect(1024*1024);System.out.println(i)//System.gc();}}}

  运行上述代码,不用多久就会出现内存溢出,导致程序退出。
  当然读者实验的时候可以稍微的降低JDK的版本,降低操作系统的配置,可能在高性能的机器上,这种代码不会出现任何的问题。
  或许有人会问,通过这种方式,JVM的垃圾回收机制为什么没有起作用呢?在程序的第四行,分配的直接内存并没有被任何对象引用,为什么没有被垃圾收收集器回收呢?可以加入如下的参数再次运行

-XX:+PrintGCDetails

  会发现并没有垃圾收集日志打印出来,也就是说整个的执行过程并没有涉及到GC操作。实际上直接内存不一定触发垃圾回收操作,除非是直接内存使用量达到了-XX:MaxDirectMemorySize的设置值。所说保证直接内存不溢出的方式就是可以合理的进行Full GC。或者设定一个系统可以达到的-XX:MaxDirectMemorySize值,默认是-Xmx。因此,如果系统的堆内存少有GC发生,而直接内存申请频繁,会比较容易导致直接内存溢出,这个在32位机器上比较明显,64位机器上除非是极限情况。

  上述代码第六行代码,去掉注释,使得显式的GC生效,那么整个程序就可以正常的结束,这个就说明GC是可以回收直接内存的。

  而另一个让上述代码正常执行的操作就是设置一个较小的堆,在不指定–XX:MaxDirectMemorySize的情况下,最大可以使用的直接内存是堆内存的大小。

-Xmx512M-XX:+PrintGCDetails

  这里将最大堆内存限制在512M,这种情况下,最大可用直接内存也是512M。操作系统可以同时为堆和直接内存提供足够的空间,当直接内存使用达到512M的时候,也会进行GC操作,释放无用的空间。

  显式设置-XX:MaxDirectMemorySize也是解决这个问题的方法,只要设置一个系统实际可以达到的最大直接内存值,实际上不应该触发的内存溢出就不会发生。

  综上,为了避免直接内存溢出,在确保空间不浪费的基础上,合理执行显式GC可以降低直接内存溢出的概率,设置合理的-XX:MaxDirectMemorySize大小值,也可以避免直接内存溢出。

过多线程导致OOM

  由于为每个线程开辟空间,都要占用内存,因此当线程数量太多的时候,就可能导致OOM问题。由于线程的栈空间也是在堆外分配的,所以与直接内存相似,如果想让系统支持更多的线程,那么应该使用一个较小的堆内存。

publicclassMultiThreadOOM{publicstaticclassSleepThreadimplementsRunable{publicvoidrun(){try{Thread.sleep(100000000)}catch(InterruptedException e){
				e.printStackTrace();}}}publicstaticvoidmain(String args[]){for(int i=0;i<1500;i++){newThread(newSleepThread(),"Thread - "+i).start();}}}

  运行上述代码可以知道,可以看到控制台打印 unable to create new native thread ,标识系统创建线程的数量已经饱和,原因是Java进程已经达到了内存使用的上限。要解决这个问题,从如下两个方面下手。

  • 1、尝试减少堆空间,可以使用-Xmx 进行设置
  • 2、减少每个线程所占用的内存空间,使用-Xss 参数可以指定线程的栈空间

注意 如果减少线程的栈空间大小,栈溢出的风险就会相应上升
  综上,处理这一类的OOM,除了合理的减少线程总数,减少最大堆空间数、减少线程栈空间也是可行的,但是需要考虑到栈溢出风险。

永久区溢出

  永久区(Perm) 是存放类元数据的区域。如果一个系统中有太多的类型,那么就会导致永久区溢出。在JDK1.8中,永久区改为叫做元数据区,但是它与永久区的功能是类似的,都是为了保存类的元数据。

  如果一个系统不断的产生新的类,没有回收,最终非常有可能导致永久区溢出。如下,这段代码每次都会循环生成一个新的类,是类,而不是对象实例。

publicclassPermOOM{publicstaticvoidmain(String[] args){try{for(int i=0;i<100000;i++){CglibBean bean=newCglibBean("com.test.bean"+,newHashMap());}}catch(Error e){
			e.printStackTrace();}}}

  博主为了测试是使用JDK1.6,并且使用了如下的参数执行上述代码

-XX:MaxPermSize=5M

  程序运行一段时间之后,抛出 PermGen space 异常

  一般来说,要解决永久区溢出的问题,可以从如下的几个方面来考虑

  • 1、增大MaxPermSize的值。
  • 2、减少系统需要的类的数量。
  • 3、减少系统需要的类的数量
  • 4、使用ClassLoader合理地装载各个类,并定期进行回收。

GC效率低下引起的OOM

  GC作为内存回收的关键,如果GC效率低下,那么整体的系统性能都会受到影响。如果系统的堆空间太小,那么GC话费的时间就会比较多,并且回收释放掉的内存会较少。

  根据GC占用的系统时间,以及释放内存的大小,虚拟机就会评判GC的效率,一旦效率过低,就会直接抛出OOM。但JVM一般情况下不会随意判断,因为即使GC效率不高,强制终止程序也是不合适的,一般会检查如下的几种情况

  • 1、花在GC上的时间是否超过98%
  • 2、老年代释放的内存是否小于2%
  • 3、eden区释放的内存是否小于2%
  • 4、是否连续5次GC都出现了上述情况

  当满足条件的时候,才会抛出OOM异常 GC overhead limit exceeded

  尽管虚拟机限制的很严格,但是再大部分的使用场景中还是会有堆溢出的异常。这个OOM只起到辅助的作用,提示堆内存太小,因此虚拟机也不是非要开启这个提示,可以通过关闭-XX:-UseGCOverheadLimit来禁止上述问题的出现。

  • 作者:nihui123
  • 原文链接:https://blog.csdn.net/nihui123/article/details/118155655
    更新时间:2022-09-22 09:36:38