java OutOfMemoryError异常
怪咖软妹@ 人气:0在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垃圾收集机制为了避免出现内存溢出异常都 做了哪些努力。
加载全部内容