Java堆溢出
异常原因
Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。
参数说明:
- 将堆的最小值
-Xms
参数与最大值-Xmx
参数设置为一样即可避免堆自动扩展 - 通过参数
-XX:+HeapDumpOnOutOf-MemoryError
可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析。
异常现象
Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。 出现Java堆内存溢出时, 异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”,具体如下所示:
java.lang.OutOfMemoryError: Java heap space Dumping heap to java_pid3404.hprof ... Heap dump file created [22045981 bytes in 0.663 secs] 复制代码
解决方案
要解决这个内存区域的异常, 常规的处理方法是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。
首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
- 如果是内存泄漏, 可进一步通过工具查看泄漏对象到GC Roots的引用链, 找到泄漏对象是通过怎样的引用路径、 与哪些GC Roots相关联, 才导致垃圾收集器无法回收它们, 根据泄漏对象的类型信息以及它到GC Roots引用链的信息, 一般可以比较准确地定位到这些对象创建的位置, 进而找出产生内存泄漏的代码的具体位置。
- 如果不是内存泄漏, 换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms) 设置,与机器的内存对比,看看是否还有向上调整的空间。 再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、 存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。
虚拟机栈和本地方法栈溢出
由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss
参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss
参数来设定。
参数说明:
- -Xss:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。对于不同版本的Java虚拟机和不同的操作系统, 栈容量最小值可能会有所限制,这主要取决于操作系统内存分页大小。譬如上述方法中的参数-Xss128k可以正常用于32位Windows系统下的JDK 6,但是如果用于64位Windows系统下的JDK 11,则会提示栈容量最小不能低于180K,而在Linux下这个值则可能是228K,如果低于这个最小限制,HotSpot虚拟器启动时会给出如下提示:
The Java thread stack size specified is too small. Specify at least 228k
异常原因
关于虚拟机栈和本地方法栈, 在《Java虚拟机规范》 中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度, 将抛出
StackOverflowError
异常。 - 如果虚拟机的栈内存允许动态扩展, 当扩展栈容量无法申请到足够的内存时, 将抛出
OutOfMemoryError
异常。
《Java虚拟机规范》 明确允许Java虚拟机实现自行选择是否支持栈的动态扩展, 而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError
异常,否则在线程运行时是不会因为扩展而导致内存溢出的。只会因为栈容量无法容纳新的栈帧而导致StackOverflowError
异常。
异常现象
在单个线程内,无论是由于栈帧太大还是虚拟机栈容量太小(通过-Xss
设置),当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常,如下所示:
stack length:2402 Exception in thread "main" java.lang.StackOverflowError at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:20) at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21) at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:21) …… 复制代码
如果在允许动态扩展栈容量大小的虚拟机(如:Classic虚拟机)上,相同代码则会导致不一样的情况。
如果测试时不限于单线程, 通过不断建立线程的方式,在HotSpot上也是可以产生内存溢出异常的,如下所示:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread 复制代码
但是这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关系,主要取决于操作系统本身的内存使用状态。甚至可以说,在这种情况下,给每个线程的栈分配的内存越大, 反而越容易产生内存溢出异常。
原因:操作系统分配给每个进程的内存是有限制的,譬如32位Windows的单个进程最大内存限制为2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值,那剩余的内存即为2GB(操作系统限制) 减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存就由虚拟机栈和本地方法栈来分配了。因此为每个线程分配到的栈内存越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。
解决方案
出现StackOverflowError异常时, 会有明确错误堆栈可供分析, 相对而言比较容易定位到问题所在。
如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的, 所以只能说大多数情况下) 到达1000~2000
是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。
但是,如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。
这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验, 一般比较难以想到,这一点读者需要在开发32位系统的多线程应用时注意。
由于这种问题较为隐蔽,从JDK 7起, 以上提示信息中“unable to create native thread”后面, 虚拟机会特别注明原因可能是“possibly out of memory or process/resource limits reached”。
方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,所以把这两个区域的溢出放到一起。由于 HotSpot 从JDK 7开始逐步“去永久代”的计划, 并在JDK 8中完全使用元空间来代替永久代,那么使用“永久代”还是“元空间”来实现方法区, 对程序有什么实际的影响呢。
字符串常量池内存溢出异常
具体示例代码如下,其中:String::intern()
是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
/** * VM Args: -XX:PermSize=6M -XX:MaxPermSize=6M * @author zzm */ public class RuntimeConstantPoolOOM { public static void main(String[] args) { // 使用Set保持着常量池引用, 避免Full GC回收常量池行为 Set<String> set = new HashSet<String>(); // 在short范围内足以让6MB的PermSize产生OOM了 short i = 0; while (true) { set.add(String.valueOf(i++).intern()); } } } 复制代码
JDK 6 字符串常量池内存溢出
在JDK 6或更早之前的HotSpot虚拟机中, 常量池都是分配在永久代中, 我们可以通过-XX:PermSize
和-XX:MaxPermSize
限制永久代的大小,即可间接限制其中常量池的容量。
运行时常量池导致的内存溢出异常如下所示:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space at java.lang.String.intern(Native Method) at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java: 18) 复制代码
在运行时常量池溢出时, 在OutOfMemoryError异常后面跟随的提示信息是“PermGen space”,这也说明运行时常量池的确是属于方法区(即JDK 6的HotSpot虚拟机中的永久代) 的一部分。
JDK 7 以后字符串常量池内存溢出
自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK 7及以上版本,限制方法区的容量(在JDK 7中继续使用-XX: MaxPermSize
参数,JDK 8及以上使用--XX:MaxMeta-spaceSize
)来测试字符串常量池溢出是毫无意义的。
这时候使用-Xmx
参数限制最大堆到6MB就能够看到以下两种运行结果之一, 具体取决于哪里的对象分配时产生了溢出。
// OOM异常一: Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at java.base/java.lang.Integer.toString(Integer.java:440) at java.base/java.lang.String.valueOf(String.java:3058) at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12) // OOM异常二: Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at java.base/java.util.HashMap.resize(HashMap.java:699) at java.base/java.util.HashMap.putVal(HashMap.java:658) at java.base/java.util.HashMap.put(HashMap.java:607) at java.base/java.util.HashSet.add(HashSet.java:220) at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java from InputFile-Object:14) 复制代码
关于这个字符串常量池的实现在永久代还是元空间, 还可以引申出一些更有意思的影响,示例代码如下:
public class RuntimeConstantPoolOOM { public static void main(String[] args) { String str1 = new StringBuilder().append("String").append("Test").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。
产生差异的原因是,在JDK 6中,intern()
方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在Java堆上,所以必然不可能是同一个引用,结果将返回false。