Java实战之OutOfMemoryError异常问题及解决方法
作者:怪咖软妹@
在Java虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError (下文称OOM)异常的可能。本篇主要结合着【深入理解Java虚拟机】一书当中整理了本篇博客,感兴趣的跟着小编一块来学习呀!
本篇文章和上一篇写到的 Java内存区域划分 息息相关,如果您对Java内存区域划分不是很了解,建议了解一下,不然这篇文章读起来会很痛苦。。。
一、简言
本节内容的目的有两个:
- 第一,
通过代码验证Java虚拟机规范中描述的各个运行时区域储存的内容
; - 第二,希望读者在工作中遇到实际的内存溢出异常时,
能根据异常的信息快速判断是哪个区域的内存溢出
,知道怎样的代码可能会导致这些区域的内存溢出,以及出现这些异常后该如何处理。
下面代码的开头都注释了执行时所需要设置的虚拟机启动参数
(注释中“VM Args”后面跟着的参数),这些参数对实验的结果有直接影响,请读者调试代码的时候不要忽略掉。(本篇文章所有案例都采用了JDK1.8版本进行测试)
如果读者使用控制台命令来执行程序,那直接跟在Java命令之后书写就可以。如果读者使用Eclipse IDE,可以在Debug/Run页签中的设置。
二、代码实战
1、Java堆溢出
Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径
来避免垃圾回收机制清除这些对象,就会在对象数量到达最大堆的容量限制后产生内存溢出异常。
将Java堆设置大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析(内存堆转储快照 指的是溢出后,内存当中的对象占用情况)。
我用的是ider:
设置启动参数:
Xms:最小堆内存 Xmx:最大可扩展内存
XX:+HeapDumpOnOutOfMemoryError:可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析
-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
import java.util.ArrayList; import java.util.List; public class HeapOOM { static class OOMObject{ } public static void main(String[] args) { List<OOMObject> list = new ArrayList<>(); while (true){ list.add(new OOMObject()); } }
运行结果:
因为设置了-XX:+HeapDumpOnOutOfMemoryError参数,所以生成了 这个报告。可以查看对象占用内存。
Java堆内存的OOM异常是实际应用中最常见的内存溢出异常情况。出现Java堆内存溢出时,异常堆栈信息"java.lang.OutOfMemoryError”会跟着进一步提示“Java heapspace"。
要解决这个区域的异常,一般的手段是首先通过内存映像分析工具
(如EclipseMemory Analyzer、Dier的jprofiler)对dump出来的堆转储快照进行分析
,重点是确认内存中的对象是否是必要的
,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链
。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
如果不存在泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数
(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长
的情况,尝试减少程序运行期的内存消耗。
后面我会专门写一篇关于内存分析工具的博客,XX:+HeapDumpOnOutOfMemoryError这个只是有内存占用情况,工具可以帮我们看到对象的引用链情况。
2、虚拟机栈和本地方法栈溢出
由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是无效的,栈容量只由-Xss参数设定。关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常。
注意:HotSpot虚拟机的栈容量是不可以动态扩展的。
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
- 允许栈空间动态扩展时,当
扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常
public class JavaVMStackSOF { private int stackLength = 1; public void stackLeak() { stackLength++; stackLeak(); } public static void main(String[] args) { JavaVMStackSOF oom = new JavaVMStackSOF(); try { oom.stackLeak(); } catch (Throwable e) { System.out.println("stack length:" + oom.stackLength); throw e; } } }
- 当声明是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在JAVA虚拟机栈中
- 当声明的是引用变量时,所声明的变量(该变量实际上是在方法中存储的是内存地址值)是放在JAVA虚拟机的栈中,该变量所指向的对象是放在堆类存中的。
实验结果表明:在单个线程下,无论是由于栈帧太大,还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。换成远古时代的Classic虚拟机,这款虚拟机可以支持动态扩展 栈内存的容量
,这时候就会报StackOverflowError异常了。
也就是当我设置-Xss128k和不设置都是报同样的错误
,并没有出现内存溢出异常,原因就是 HotSpot虚拟机的栈容量是不可以动态扩展的
,但是值得注意的是我的电脑是16G运行内存的,当我设置-Xss128k的时候输出的长度是将近1000,当我不限制-Xss128k大小的时候输出的长度是20000左右,也就意味着每个线程的栈帧大小默认最大是2MB
。
如果测试时不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常,在这种情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。
原因其实不难理解,操作系统分配给每个进程的内存是有限制的,譬如32位Windows的单个进程 最大内存限制为2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值。那么虚拟机栈和本地方法栈内存如下:
虚拟机栈和本地方法栈内存=2GB-最大堆容量-最大方法区容量-程序计数器容量
因此为每个线程分配到的栈内存越大,可以建立的线程数量自 然就越少,建立线程时就越容易把剩下的内存耗尽。
通过上面了解到,出现StackOverflowError异常时有错误堆栈可以阅读,相对来说,比较容易找到问题的所在(一般出现死循环可能会导致)。
如果是建立过多线程导致的内存溢出,而不是栈溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程
。如果没有这方面的经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。
public class JavaVMStackOOM { private void dontStop(){ while (true){ } } public void stackLeakByThread(){ while (true){ Thread thread = new Thread(new Runnable() { @Override public void run() { dontStop(); } }); thread.start(); } } public static void main(String[] args) { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } }
注意
重点提示一下,如果读者要尝试运行上面这段代码,记得要先保存当前的工作,由于在 Windows平台的虚拟机中,Java的线程是映射到操作系统的内核线程上,无限制地创建线程会对操 作系统带来很大压力,上述代码执行时有很高的风险,可能会由于创建线程数量过多而导致操作系统 假死
(电脑可能直接死机)。
在32位操作系统下的运行结果:
原因:32位有进程大小内存限制。
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread
注意:如果要测试上面内存溢出代码,记住先保存当前的工作,避免电脑卡死带来的麻烦。
3、运行时常量池溢出
由于运行时常量池是方法区的一部分
,所以这两个区域的溢出测试可以放到一起进行。前面曾经 提到HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代
,在此我们就以测试代码来观察一下,使用“永久代”还是“元空间”来实现方法区,对程序有什么 实际的影响。
String::intern()是一个本地方法
,它的作用是如果字符串常量池中已经包含一个等于此String对象的 字符串,则返回代表池中这个字符串的String对象的引用
;否则,会将此String对象包含的字符串添加 到常量池中,并且返回此String对象的引用
。
import java.util.ArrayList; import java.util.List; public class RuntimeConstantPoolOOM { public static void main(String[] args) { // 使用List保持着常量池引用,避免Full GC回收常量池行为 List<String> list = new ArrayList<>(); // 10MB的PerSize在integer范围内足够产生00M int i = 0; while (true){ list.add(String.valueOf(i++).intern()); } } }
- JDK7及以前(了解):-XX:PermSize设置永久代初始大小。-XX:MaxPermSize设置永久代最大可分配空间。(JDK7目前已经很少用了,这两个参数在JDK8及以后已经没有了,所以不必掌握,了解一下)
- JDK8及以后:可以使用-XX:MetaspaceSize和-XX:MaxMetaspaceSize设置元空间初始大小以及最大可分配大小。
使用JDK 7或更高版本的JDK来运行这段程序并不会得到相同的结果,无论是在JDK 7中继续使 用-XX:MaxPermSize
参数或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize
参数把方法区容量同 样限制在6MB,都不会出现溢出异常,循环将一直进行下去,永不停歇。出现这种变 化,是因为自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK 7及以上版 本,限制方法区的容量对该测试用例来说是毫无意义的
。
在JDK1.7中(包括1.7以上)常量池存储的不再是对象,而是对象引用,真正的对象是存储在堆中的。把RuntimeConstantPoolOOM.java运行时的VM参数改为如下(设置堆大小)所示:
-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
运行结果:
查看生成的堆内存快照:
4、方法区溢出
方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这个区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor 和动态代理等),但在本次实验中操作起来比较麻烦。借助CGLib直接操作字节码运行时,生成了大量的动态类。
值得特别注意的是,我们在这个例子中模拟的场景并非纯粹是一个实验,这样的应用经常会出现在实际应用中:当前的很多主流框架,如Spring和Hibernate对类进行增强时,都会使用到CGLib这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载人内存
。
测试示例:
import org.springframework.cglib.proxy.Enhancer; import org.springframework.cglib.proxy.MethodInterceptor; import org.springframework.cglib.proxy.MethodProxy; import java.lang.reflect.Method; public class JavaMethodAreaOOM { public static void main(String[] args) { while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OOMObject.class); enhancer.setUseCache(false); enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { return proxy.invokeSuper(obj, args); } }); enhancer.create(); } } static class OOMObject { } }
设置元空间最大空间,和初始化空间参数:
类信息是都存在方法区的,方法区在jdk1.8将永久区改为了元空间。自此以后,常量池在元空间都是存储的引用。实际对象是在堆中。
-XX:MaxMetaspaceSize=10m -XX:MetaspaceSize=10m
运行结果:
方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比 较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场 景除了之前提到的程序使用了CGLib字节码增强和动态语言外,常见的还有:大量JSP或动态产生JSP 文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同 的加载器加载也会视为不同的类)等。
5、本机直接内存溢出
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中 定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
直接内存:可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用`进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据(但是有一点注意,虽然不占用堆内存,但是他占用了服务器内存)。
直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不 去指定,则默认与Java堆最大值(由-Xmx指定)一致。
代码示例:
越过了DirectByteBuffer类直接通 过反射获取Unsafe实例进行内存分配
(Unsafe类的getUnsafe()
方法指定只有引导类加载器才会返回实 例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe 的部分功能通过VarHandle开放给外部使用
),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢 出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会 在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()
。
import sun.misc.Unsafe; import java.lang.reflect.Field; public class DirectMemoryOOM { private static final int _1MB = 1024 * 1024; public static void main(String[] args) throws Exception { Field unsafeField = Unsafe.class.getDeclaredFields()[0]; unsafeField.setAccessible(true); Unsafe unsafe = (Unsafe) unsafeField.get(null); while (true) { unsafe.allocateMemory(_1MB); } } }
运行参数:
-Xmx20M -XX:MaxDirectMemorySize=10M -XX:+HeapDumpOnOutOfMemoryError
运行结果:
我设置了-XX:+HeapDumpOnOutOfMemoryError发现运行完成之后并没有发现有内存快照。
由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常 情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了 DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。
三、JVM常用的启动参数
堆:
-Xms3550m
:设置JVM初始内存为3550M。表示初始化JAVA堆的大小及该进程刚创建出来的时候,他的专属JAVA堆的大小,一旦对象容量超过了JAVA堆的初始容量,JAVA堆将会自动扩容到-Xmx大小。-Xmx3550m
:设置JVM最大可用内存为3550M。表示java堆可以扩展到的最大值,在很多情况下,通常将-Xms和-Xmx设置成一样的,因为当堆不够用而发生扩容时,会发生内存抖动影响程序运行时的稳定性。
栈:
-Xss128k
:规定了每个线程虚拟机栈及堆栈的大小,一般情况下,256k是足够的,此配置将会影响此进程中并发线程数的大小(和堆是不一样的,不支持动态扩展)。
方法区:
- JDK7及以前(了解):
-XX:PermSize
设置永久代初始大小。 -XX:MaxPermSize
设置永久代最大可分配空间。(JDK7目前已经很少用了,这两个参数在JDK8及以后已经没有了,所以不必掌握,了解一下)-XX:MaxMetaspaceSize
=10m:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存 大小。-XX:MetaspaceSize
=10m:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集 进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放 了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。-XX:MinMetaspaceFreeRatio
:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可 减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最 大的元空间剩余容量的百分比。
内存:
-XX:+HeapDumpOnOutOfMemoryError
可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析(内存堆转储快照 指的是溢出后,内存当中的对象占用情况)
GC:
-XX:-PrintGCDetails
:每次GC时打印详细信息。
四、面试题
public static void main(String[] args) { String str1 = new StringBuilder("计算机").append("软件").toString(); System.out.println(str1.intern() == str1); String str2 = new StringBuilder("ja").append("va").toString(); System.out.println(str2.intern() == str2); }
这段代码在JDK 6中运行,会得到两个false,而在JDK 7中运行,会得到一个true和一个false。在jdk1.8运行也是,true、false。
产 生差异的原因是,在JDK 6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池 中存储,
返回的也是永久代里面这个字符串实例的引用
,而由StringBuilder创建的字符串对象实例在 Java堆上
,所以必然不可能是同一个引用,结果将返回false。
而JDK 7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例 到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引 用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。
而对str2比较返 回false,这是因为“java
”这个字符串在执行String-Builder.toString()之前就已经出现过了
,字符串常量 池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次 出现的,因此结果返回true。(这块说实话不好理解,说白了就是java是个特殊的字符串,他在常量池里面就一直存在)
总结:在1.8之后通过intern()添加到常量池,只有字符串在常量池不存在的时候才会返回字符串的引用。
五、总结
到此为止,我们明白了虚拟机里面的内存是如何划分的,哪部分区域、什么样的代码和操作可能 导致内存溢出异常。虽然Java有垃圾收集机制,但内存溢出异常离我们并不遥远,本章只是讲解了各 个区域出现内存溢出异常的原因,下一章将详细讲解Java垃圾收集机制为了避免出现内存溢出异常都 做了哪些努力。
到此这篇关于Java实战之OutOfMemoryError异常的文章就介绍到这了,更多相关java OutOfMemoryError异常内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!