在 《 Java 虚拟机规范》 中,除了程序计数器之外,虚拟机的其它区域都有可能发生 OutOfMemoryError 。本章的所有代码是 "一定会出现问题的" :我们的目的是要能够从错误异常中分析出来 OOM 异常发生的原因,这样才能采取针对性的调优措施。
全篇的代码部分均在 Oracle JDK 当中执行,由于 OOM 异常和虚拟机的实现也密切相关,因此不同的 JDK 发行商,不同版本的 JDK 以及虚拟机,都有可能导致程序运行的结果出现少许偏差。本篇涉及的启动参数属于 vm options,而非主程序的启动参数。
堆溢出
堆内存溢出是最常见的溢出情况:要么是创建了大量的对象,要么是对象的所占空间过大。下面用一个代码块来演示:
public class HeapOOM {
static class OOMObject {}
/**
* VM args : -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=C:\Users\ljh\Desktop\dumping
* @param args args
*/
public static void main(String[] args) {
// 强引用
List<OOMObject> list = new ArrayList<>();
while (true){
list.add(new OOMObject());
// 避免 GC 回收
System.out.println(list.size());
}
}
}
出于节省本机资源的目的,在运行这个程序之前,我们最好再通过-Xms 和 -Xmx vm options 限制堆的大小。此外,-XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath 允许你存储堆快照文件,以便在发生堆溢出时我们可以通过此文件来分析程序各个对象的空间占用情况。
这个程序将在运行不久就会抛出异常。同时,控制台还会提示堆快照文件已经被保存到了指定的路径当中。
java.lang.OutOfMemoryError: Java heap space
Dumping heap to C:\Users\ljh\Desktop\dumping\java_pid292.hprof ...
Heap dump file created [28126913 bytes in 0.112 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:2245)
at java.util.Arrays.copyOf(Arrays.java:2219)
at java.util.ArrayList.grow(ArrayList.java:242)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:216)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:208)
at java.util.ArrayList.add(ArrayList.java:440)
at heapOOM.HeapOOM.main(HeapOOM.java:18)
对于 Windows 平台,我们可以从 JDK 的 /bin 目录下找到一个名为 jvisualvm.exe 程序,它可以用于分析 .hprof 后缀的堆快照文件。
如图所示,该快照文件提示 OOMObject 内部类实例占据了大部分堆空间,的确如此。当在实际开发中遇到堆内存溢出的问题时,我们就要考虑占据着大量内存的对象是否有必要一直存在。如果是,我们要么就设置 -Xms 和 -Xmx 参数来向本机 "借用" 更多的内存空间,要么就尝试使用单例模式或者对象复用来解决问题。
心态崩溃的 GC
堆溢出还有一种和 GC 相关的异常:
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
这个异常表明:程序已经耗尽了所有可用堆内存,而 GC 的运行效率过低——根据 Oracle 官方的解释是:JVM 花费 98% 的时间使用 GC 进行垃圾回收,但是只能得到 2% 的内存。而这极少的内存也会随着程序运行又被快速填满,从而导致虚拟机再一次使用 GC 低效回收,如此形成了恶性循环。
最严重的情况下,应用程序会将 100% 的资源全部用在无意义的 GC 回收上,此时就会出现卡死现象。因此,当数次进行了上述的低效 GC 回收之后,虚拟机就会直接抛出一个 OOM 异常并提示上述的信息。
通常的绥靖策略是设置更大的堆内存,然而这种方式只是延缓了 OOM 发生的时机。我们仍然需要通过堆快照文件分析哪个对象占用着大量空间,并尝试着优化它们。
栈溢出
HotSpot 虚拟机并不区分虚拟机栈和本地方法栈,因此 -Xoss 参数对于它而言没有意义。在本篇文章中,栈指代虚拟机栈。
虚拟机栈的总空间 Size = 每个线程分配的栈空间 M * 线程数 N。在 Size 一定的情况下,M 和 N 的任何一个变量增大,都会导致另一个变量减小。这分别对应着两种情况:
- 为了创建尽可能多的线程 N,导致 M 过小,因此某个线程在某个时刻无法再创建更多的栈帧,此时线程应当抛出一个 StackOverFlowError 异常。
- 每个栈分配到的空间 M 过大,导致 N 过小,因此在某个时刻,虚拟机无法再创建一个新的线程,此时应当抛出一个 OutOfMemoryError 异常。
下面分别针对这两种情况编写了代码,并观察这两种情况是否会抛出对应的异常。
栈容量过小引发异常
针对情况 1,使用 -Xss 参数减少每个线程的栈容量,然后设置一个无限循环的递归函数迫使主线程不断放入新的栈帧,这个递归我们仅在一个主线程中执行。下面给出代码块:
public class JavaVMStackSOF {
/**
* VM args : -Xss 128k
*
* @param args
*/
public static void main(String[] args) {
StackSof stackSof = new StackSof();
try {
stackSof.suicide();
} catch (StackOverflowError e) {
e.printStackTrace();
System.out.println("depth of stack :" + stackSof.stackDepth);
}
}
}
class StackSof {
public int stackDepth = 0;
public void suicide() {
stackDepth++;
suicide();
}
}
该主程序将抛出以下错误:
java.lang.StackOverflowError
at stackOOM.StackSof.suicide(JavaVMStackSOF.java:27)
at stackOOM.StackSof.suicide(JavaVMStackSOF.java:28)
at stackOOM.StackSof.suicide(JavaVMStackSOF.java:28)
at stackOOM.StackSof.suicide(JavaVMStackSOF.java:28)
at stackOOM.StackSof.suicide(JavaVMStackSOF.java:28)
...
实际上,《Java 虚拟机规范》 中允许栈内存进行动态拓展,只不过 HotSpot 虚拟机并没有选择这么做。因此,除非在虚拟机创建新线程时可能会因栈空间不足引发 OOM 异常,否则线程在执行时不会因为拓展栈空间而引发 OOM ,而是因无法引入新的栈帧而出现 StackOverFlowError。
局部变量表对栈深度的影响
设线程分配到的栈空间一定,若它执行的函数的局部变量越多,则其局部变量表也会愈加膨胀,那么线程执行这个函数需要的栈帧也会越大。在 -Xss 参数约束不变的情况下,该线程对应的最大栈深度就会相应减少。为了证明这一点,我们首先声明一个具有 100 个 Long 类型局部变量的递归函数:
public class JavaStackSOF1 {
private static int stackDepth = 0;
private static void suicide() {
//填充栈帧, 本地变量占 800 KB
long unused1, unused2, unused3, unused4, unused5, unused6, unused7, unused8, unused9, unused10,
unused11, unused12, unused13, unused14, unused15, unused16, unused17, unused18, unused19, unused20,
unused21, unused22, unused23, unused24, unused25, unused26, unused27, unused28, unused29, unused30,
unused31, unused32, unused33, unused34, unused35, unused36, unused37, unused38, unused39, unused40,
unused41, unused42, unused43, unused44, unused45, unused46, unused47, unused48, unused49, unused50,
unused51, unused52, unused53, unused54, unused55, unused56, unused57, unused58, unused59, unused60,
unused61, unused62, unused63, unused64, unused65, unused66, unused67, unused68, unused69, unused70,
unused71, unused72, unused73, unused74, unused75, unused76, unused77, unused78, unused79, unused80,
unused81, unused82, unused83, unused84, unused85, unused86, unused87, unused88, unused89, unused90,
unused91, unused92, unused93, unused94, unused95, unused96, unused97, unused98, unused99, unused100;
unused1 = unused2 = unused3 = unused4 = unused5 = unused6 = unused7 = unused8 = unused9 = unused10 =
unused11 = unused12 = unused13 = unused14 = unused15 = unused16 = unused17 = unused18 = unused19 = unused20 =
unused21 = unused22 = unused23 = unused24 = unused25 = unused26 = unused27 = unused28 = unused29 = unused30 =
unused31 = unused32 = unused33 = unused34 = unused35 = unused36 = unused37 = unused38 = unused39 = unused40 =
unused41 = unused42 = unused43 = unused44 = unused45 = unused46 = unused47 = unused48 = unused49 = unused50 =
unused51 = unused52 = unused53 = unused54 = unused55 = unused56 = unused57 = unused58 = unused59 = unused60 =
unused61 = unused62 = unused63 = unused64 = unused65 = unused66 = unused67 = unused68 = unused69 = unused70 =
unused71 = unused72 = unused73 = unused74 = unused75 = unused76 = unused77 = unused78 = unused79 = unused80 =
unused81 = unused82 = unused83 = unused84 = unused85 = unused86 = unused87 = unused88 = unused89 = unused90 =
unused91 = unused92 = unused93 = unused94 = unused95 = unused96 = unused97 = unused98 = unused99 = unused100 = 0;
stackDepth++;
suicide();
}
/**
* VM : args : Xss128k
*/
public static void main(String[] args) {
try {
suicide();
} catch (Throwable e) {
e.printStackTrace();
System.out.println("depth of stack:" + stackDepth);
}
}
}
将这个递归函数放到主函数当中去执行,发现栈深度在 50 左右时就发生了 StackOverFlowError 异常。当注释掉声明的 100 个 Long 变量之后重新执行这个主函数,则栈深度最高可达到 1000 左右。
大量线程引发异常
这种异常主要取决于主机操作系统本身的内存使用状况。对于 32 位 Windows 系统而言,每个进程可用的最大内存是 2 GB ,除去 Java 堆,方法区所占用的空间,剩下的就是 Java 栈空间的最大容量。如果无限制地创建线程(每多一个线程就意味着多分配一点栈空间),理论上就会导致虚拟机无法为新的线程分配额外的栈空间,而导致 OOM 异常。
/**
* !! 请不要在 64 位 Windows 下直接运行此代码 !!
*/
public class JavaStackOOM{
private static void continuing(){
while (true){}
}
/**
* 单个线程可操作的栈内存大小为 2M .
* VM args : -Xss2M
* @param args
*/
public static void main(String[] args) {
while(true){
Thread thread = new Thread(JavaStackOOM::continuing);
System.out.println(thread.getName() +" is running...");
thread.start();
}
}
}
这段代码在 Windows 系统下运行起来有极高的风险,因为虚拟机的线程会映射到系统的内核线程,无限制创建线程大概率会导致异常抛出之前系统就会被卡死 ( 笔者的机器是 8 核 + 8 GB 内存,在创建完 6000 个线程之后基本处于瘫痪状态 )。在 32 位操作系统下运行此代码,会显示:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread
同样的道理,每个线程如果可分配到的栈空间越大,那么对于虚拟机而言,它所能创建的线程数量反而会越少。
方法区溢出
首先回顾方法区的内部结构。方法区可以细分为:字符串常量池(JDK 7 之后迁移到堆当中)和运行时常量池(JDK 8 之后迁移到元空间中)。
被编译成的 .class 文件除了包含有关于类的版本,字段,方法,接口等描述信息以外,还有用于记录编译器生成的字面量与符号引用的 class 文件常量池。而当该 .class 文件被虚拟机加载之后,其所有的信息都会被加载进虚拟机的运行时常量池当中。换句话说,每个被加载的类都有一个运行时常量池。
而 .class 文件定义的字符串字面量会被存储到字符串常量池当中。字符串常量池全局只有一个,但具体实现由各个厂商来决定。对于 HotSpot 虚拟机而言,字符串常量池的实现方式是 String Table。
在 JDK 6 版本及之前,字符串常量池,运行时常量池都在方法区内的永久代空间中,因此当它们发生 OOM 错误时,程序会提示错误 PermGen space 。此时,我们要结合自己的 JDK 版本来查清是哪里出现了问题。在本小节,我们将在 JDK 6,7,8 三个不同的环境下调试代码。
Oracle JDK 6 下载地址 | Oracle JDK 7 下载地址
使用 intern 方法填满字符串常量池
String::intern 是一个本地方法,在这里有必要先介绍它在不同 JDK 版本中的具体实现方式。
在 JDK 6 及之前,如果字符串常量池记录了此字符串,则返回它的引用。否则,将字符串值添加到池中,再返回其引用。(目前的字符串常量池只存值)
在 JDK 6 及之后,如果字符串常量池记录了此字符串,则返回它的引用。否则,如果字符串在堆中,则将字符串引用存储到池中,再返回它的引用。如果还不满足,则将字符串值添加到池中,再返回其引用。(此时的字符串常量池可存值,也可存引用)
我们使用此方法在 JDK 6 环境运行下方代码来填充字符串常量池,然后计划 "引爆" 一个错误出来。这里使用 PermSize 和 MaxPermSize 限制永久代的空间,如果这两个 VM 参数被设置成了相同的值,那么永久代空间将不会自动拓展。
public class RuntimeConstantOOM {
/**
* VM args : -XX:PermSize=6M -XX:MaxPermSize=6M (before JDK 1.7)
* VM args : -Xms20m -Xmx20m (after JDK 1.7)
*/
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
String str = base + base;
base = str;
list.add(str.intern());
}
}
}
由于填满字符串常量池进而导致永久代出现 OOM 错误,因此可以证明:在 JDK 6 版本中,字符串常量池属于方法区永久代的一部分。
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
at RuntimeConstantOOM.main(RuntimeConstantPoolOOM.java:17)
但是,在 JDK 7 环境下编译并执行相同的代码,得到的异常却是不一样的。
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.lang.Integer.toString(Integer.java:333)
at java.lang.String.valueOf(String.java:2954)
at RuntimeConstantOOM.main(RuntimeConstantOOM.java:17)
其根本原因是,在 JDK 7 及之后,字符串常量池被迁移到了堆当中。此时字符串常量池受堆空间参数 -Xmx ,-Xms 的影响,而使用 VM 参数 -XX:PermSize 和 -XX:MaxPermSize 对它而言是没有意义的,尤其在 JDK 8 之后这两个参数已经完全不起作用。
引申:一个 String 对象的内存占用情况
String 类的源码中有三个成员:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
/** use serialVersionUID from JDK 1.0.2 for interoperability */
private static final long serialVersionUID = -6849794470754667710L;
//......
}
显然,一个 int 和 long 成员已经占据了 12 字节,而 value 变量是一个 char[] 对象(在 Java 中,数组也属于对象)。 而任何一个对象内部包含了:对象头 Header ( 8 字节 ),引用 Reference ( 4 字节 ),在填充之后一共是 16 字节。
一个 String 对象的内部成员一共占据了 12 + 16 = 28 字节,算上头部和引用一共是 12 + 28 = 40 字节。该长度是 8 字节的倍数,因此不再需要填充部分。考虑到一个 char 类型数据占 2 个字节,一个 String 的实际占用空间的计算公式为:40 + 2n 。n 是字符串内容的长度。
警惕 CGLIB 动态代理导致运行时常量池溢出
方法区的主要功能是保存类型信息,此次我们希望通过让虚拟机用不断加载类型信息的方式导致运行时常量池溢出。思路是利用 JDK 动态代理,cglib 动态代理等技术在程序运行时动态生成大量的类去填满它,本文使用的方式是 cglib。
这个例子并非单纯的出于实验目的。当今的各种主流框架 Spring,Hibernate 等对类进行增强时,都是利用 cglib 这类字节码技术。一个类越复杂,显然它对应的运行时常量池会占据更大的空间。另外,基于虚拟机运行的语言(如 Groovy )可能都会动态生成类型来使语言具备动态性。随着上述技术的流行,运行时常量池的 OOM 异常也将越来越容易遇到。
下方给出的代码会不断地使用 Enhancer 动态生成类,直到发生 OOM 错误。( 需要导入 cglib 和 asm 依赖,建议使用 Maven )首先在 JDK 7 环境下运行它:
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
class NonSubject {}
class MyMethodInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
methodProxy.invokeSuper(o, args);
return null;
}
}
public class RuntimeConstantPoolOOM {
/**
* VM args : -XX:PermSize=10M -XX:MaxPermSize=10M
*/
public static void main(String[] args) throws OutOfMemoryError {
for (; ; ) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(NonSubject.class);
enhancer.setUseCache(false);
//这里的回调函数相当于方法拦截器。
enhancer.setCallback(new MyMethodInterceptor());
enhancer.create();
}
}
}
按理说它应当抛出一个 PermSize 异常(包括一些资料里也是这么描述的),而这是笔者遇到的异常。不过笔者通过修改 -XX:PermSize 参数发现,动态生成大量类确实对永久代空间造成了影响。
Exception in thread "main"
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
然而在 JDK 8 之后,永久代已经完全退出了历史舞台,修改 -XX:PermSize 参数已不能够再迫使方法区抛出错误了。这一次,我们通过参数修改元空间大小为 20 M(太小的话虚拟机会无法启动):-XX:MetaspaceSize=20M ,-XX:MaxMetaspaceSize=20M 。显然,此次运行结果的异常记录是和 JDK 7 完全不同的:
Exception in thread "main" net.sf.cglib.core.CodeGenerationException: java.lang.reflect.InvocationTargetException-->null
at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:237)
at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:377)
at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:285)
at createProxy.JavaMethodAreaOOM.main(JavaMethodAreaOOM.java:24)
Caused by: java.lang.reflect.InvocationTargetException
at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:384)
at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:219)
... 3 more
Caused by: java.lang.OutOfMemoryError: Metaspace
上述的报错信息说明,本质的错误发生在了元空间当中,由于程序利用 cglib 工具动态加载了大量的类型信息因此导致元空间抛出了 OOM 错误。
直接内存溢出
Unsafe 类原本只为虚拟机标准类库里面的类开放功能,然而下面的代码越过了 DirectByteBuffer 类直接通过反射获取 Unsafe 实例,以此试图进行直接内存分配。这里我们使用 VM options 将本地内存设置为 10 M 。在默认情况下,直接内存的大小和 Java 的堆最大值( -Xmx ) 一致。
public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
/**
* VM args : -Xmx20M -XX:MaxDirectMemorySie=10M
* @throws IllegalAccessException from Unsafe
*/
public static void main(String[] args) throws IllegalAccessException {
Field field = Unsafe.class.getDeclaredFields()[0];
field.setAccessible(true);
Unsafe unsafe =(Unsafe)field.get(null);
for (;;){
unsafe.allocateMemory(_1MB);
}
}
}
当直接内存发生溢出时,控制台打印的异常不会再提示额外的信息。这里还有一点细节要注意,在本地内存即将溢出之前,该程序并没有真正向系统申请分配内存,而是通过计算得知下一次将无法分配到指定大小的内存,然后再抛出 OOM 异常。
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at directMemoryOOM.DirectMemoryOOM.main(DirectMemoryOOM.java:25)
当这种问题发生时,检查 .hprof 文件通常不会有异样。这时需要考虑是否因为使用了 NIO 等工具导致了直接内存发生了异常。
小结
针对本章代码所涉及到的 OOM 及其产生原因,笔者在这里使用思维导出整理出来。
本章涉及到的虚拟机参数
提示:VM options 在 java 命令之后,在类名之前。比如:
./java -XX:PermSize=10M HelloWorld
| VM options | 作用 |
|---|---|
-Xms{size} | 设置堆的最小空间。 |
-Xmx{size} | 设置堆的最大空间。 |
-XX:HeapDumpOnOutOfMemoryError | 当出现栈溢出错误时,打印堆快照文件 .hprof。 |
-XX:HeapDumpPath={path} | 堆快照文件的输出路径。 |
-XX:Xss | 设置每个线程可分配到的栈空间。 |
-XX:PermSize | |
-XX:MaxPermSize | |
-XX:MetaspaceSize | 设置元空间的初始大小。 |
-XX:MaxMetaspaceSize | 设置元空间的最大大小。 |
-XX:MinMetaspaceFreeRatio | 设置元空间最小的剩余容量百分比,可以减少空间不足导致频繁的GC。类似功能的还有 -XX:MaxMetaspaceFreeRatio。 |
-XX:MaxDirectMemorySize | 设置直接内存的最大空间,默认和 -Xmx 参数设定保持一致。 |
参考链接
- 《深入理解 Java 虚拟机 第三版》
- cglib 基本使用方式详见:CGLIB 动态代理
- 使用插件压缩为 jar 包 [CSDN] maven 打包jar 并连带自己依赖的jar
- 对于本实验,笔者是将代码连同 cglib 依赖一同打包并发送到虚拟机环境下运行的。见:使用maven-assembly-plugin将依赖打包进jar并指定JDK版本
- 若想复习动态代理相关的内容,见:有关于 Java 动态代理部分的理解
- 字符串常量池、class常量池和运行时常量池
- [CSDN] Java String 占用内存大小分析
- java.lang.OutOfMemoryError GC overhead limit exceeded 原因分析及解决方案