贾哇技术指南
2022/04/08阅读:68主题:默认主题
Java各种内存溢出异常实践
Java各种内存溢出异常实践
1.前言
在生产环境运行中,我们可能见到过各种内存溢出异常,本篇文章我们来实践下可能出现的内存异常。一是可以验证运行时数据区域中存储的内容;二是我们在遇到内存溢出异常时,根据异常信息可以快速得知是哪个区域的内存溢出,可以帮助快速定位问题,以及出现这些异常后知道应该如何处理。
2.回顾运行时数据区
我的上一篇文章:《详解JVM运行时数据区》中讲过JVM运行时数据区主要包含以下内容:
-
堆 -
程序计数器 -
Java虚拟机栈 -
本地方法栈 -
方法区
除了程序计数器不会内存溢出外,其他数据区都有可能产生内存溢出异常。 下面我们就模拟下这些区域的溢出异常。
声明:我的JDK
版本是:java version "1.8.0_301"
,操作系统是Mac OS
。
2.1 堆
/**
* 启动参数: -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOMDemo {
public static void main(String[] args) {
List<HeapOOMDemo> list = new ArrayList<>();
while (true) {
list.add(new HeapOOMDemo());
}
}
}
异常信息:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
说明:这里将堆的初始值和最大值都设置为10m,list实例对象存放在堆中,不断的向list中添加实例,直到堆内存 放不下,抛出OOM
异常。由于启动参数里加了-XX:+HeapDumpOnOutOfMemoryError
,在出现OOM
时会自动 dump
内存。 这里生成了文件: ,我们可以通过线下工具:
Jprofiler
、MAT
等来分析 dump
文件。也可以通过在线网站比如:https://gceasy.io/ 来上传dump
文件进行分析。由于生成环境的dump
文件 很大,所以不推荐使用在线分析工具。下面演示用Jprofiler
怎么来定位问题。
首先打开Jprofiler
,点击工具栏session
,选择Open Snapshot
,
这里会让我们选择文件,我们选择刚才OOM
时生成的文件即可。 选择文件后,依次点击Heap Walker
、Current Object Set
,然后选择Classes
按照对象大小倒序排序:

可以看到最大的对象是HeapOOMDemo
,选中这一行,右键,选择Use Selected Objects
:

References
中选择Incoming references

点击OK
,在下面的页面中点击show more
,就能看到具体报错的的行数:

这一行就是代码:
list.add(new HeapOOMDemo());
所在的行,这样我们就定位到了具体出问题的代码。
2.2虚拟机栈和本地方法栈溢出
关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:
-
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError
异常。 -
如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出 OutOfMemoryError
异常。
2.2.1 栈的StackOverflowError
异常
关于栈的StackOverflowError
异常,我们只需要写一个无限递归,就肯定能让栈深度大于虚拟机所允许的最大深度,代码如下:
public class StackOverFlowDemo {
public static void main(String[] args) {
main(args);
}
}
异常信息如下:
Exception in thread "main" java.lang.StackOverflowError
at com.jts.jvm.gc.oom.StackOverFlowDemo.main(StackOverFlowDemo.java:10)
main()方法中还能调main()方法。

ps: main方法除了做了程序的入口外,和其他方法并没有什么区别
2.2.2 栈的OOM
异常
关于栈的OOM
异常这里对《深入理解Java虚拟机》中的代码做了点优化,不用等到机器假死就能看到抛出的异常,优化后的代码如下:
/**
* 启动参数: -Xss2m
*/
public class StackOOMDemo {
public static void main(String[] args) {
while (true) {
new Thread(() -> {
while (true) {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
}
异常信息:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:717)
at com.jts.jvm.gc.oom.StackOOMDemo.main(StackOOMDemo.java:18)
可以看到抛出了unable to create new native thread
异常信息。这里设置的每个线程栈的大小为2m, 我之前的文章《Java线程是如何启动的》 有讲到过在我们调用了Thread.start()
方法之后,会真正的创建线程,代码在jvm.cpp
里面,下面我们来分析下主要代码:
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
// 是否应该抛出异常标志
bool throw_illegal_thread_state = false;
{
// 栈大小,我们设置的为2m
jlong size = java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
size_t sz = size > 0 ? (size_t) size : 0;
// 创建线程
native_thread = new JavaThread(&thread_entry, sz);
}
// 如果创建线程为NULL,说明没有足够的内存能被分配
if (native_thread->osthread() == NULL) {
delete native_thread;
// 抛出OOM异常
THROW_MSG(vmSymbols::java_lang_OutOfMemoryError(),
"unable to create new native thread");
}
Thread::start(native_thread);
JVM_END
可以看到,创建线程的时候传递了栈大小的参数,如果栈剩余的内存不够创建新的线程,就会抛出java_lang_OutOfMemoryError()
, 错误信息就是"unable to create new native thread"
。
3. 方法区溢出
首先我们需要了解方法区都有什么:
-
class定义信息、class常量池 -
运行时常量池 (类加载后将class常量池中的信息加载到运行时常量池中) -
字符串常量池(JDK7及其之后字符串常量池虽然逻辑上属于方法区,但是在物理上已经被划分到堆中了) -
。。。
首先利用Cglib
来动态创建类,类信息就会一直往方法区中添加,直到达到方法区大小,就会抛出异常了。代码如下:
/**
* 启动参数: -XX:MaxMetaspaceSize=20m
*/
public class JavaMethodAreaOOM {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(JavaMethodAreaOOM.class);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor) (obj, method, arg, proxy) -> proxy.invokeSuper(obj, arg));
enhancer.create();
}
}
}
报错信息:
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
上面我们提到:字符串常量池在JDK6
及其之前是在方法区中的,在JDK7
及其之后被移到了堆中。这个比较特殊,可以理解为,虽然它在逻辑上是被划分到方法区
,但是它在物理上其实是在堆
中。
所以下面的代码并不会直接抛出方法区的异常:
/**
* 启动参数: -XX:MaxMetaspaceSize=20m -Xms20m -Xmx20m -XX:-UseGCOverheadLimit
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 保持字符串常量池引用,不让其进行垃圾回收
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
异常信息:
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.lang.Integer.toString(Integer.java:401)
at java.lang.String.valueOf(String.java:3099)
at com.jts.jvm.gc.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)
java.lang.OutOfMemoryError: GC overhead limit exceeded
这个异常的含义是:超过98%的时间用来做GC
并且回收了不到2%的堆内存则会抛出此异常。 我们可以添加启动参数:-XX:-UseGCOverheadLimit
禁用此检查,添加后再运行结果如下:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.lang.Integer.toString(Integer.java:403)
at java.lang.String.valueOf(String.java:3099)
at com.jts.jvm.gc.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)
可以看到这个时候就是报的死堆内存溢出异常,就可以验证字符串常量池并不是在存放在方法区了,这是个反例。
4.直接内存溢出
直接内存的容量大小可以通过 -XX:MaxDirectMemorySize=20m 来指定,如果不指定,则默认与Java堆最大值一致。 《深入理解Java虚拟机》中的例子我并没有试验成功,代码如下:
// 启动参数: -Xmx20M -XX:MaxDirectMemorySize=10M
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);
}
}
}
启动参数也一致,但是并没有重现异常。
查询资料的结果:
-
如果通过反射的方式拿到Unsafe的实例,然后用Unsafe的allocateMemory方法分配堆外内存. 确实不受-XX:MaxDirectMemorySize这个JVM参数的限制 . 所以限制的内存大小为操作系统的内存. -
如果使用Java自带的 ByteBuffer.allocateDirect(size) 或者直接 new DirectByteBuffer(capacity) , 这样受-XX:MaxDirectMemorySize 这个JVM参数的限制. 其实底层都是用的Unsafe#allocateMemory,区别是对大小做了限制. 如果超出限制直接OOM. 原文链接:https://blog.csdn.net/zhanglong_4444/article/details/116701143
所以我用下面代码去测试,就成功出现了OOM
异常:
public class DirectMemoryOOM {
// 分配直接内存大小为 20M
private static final int size = 1024 * 1024*20;
// 启动参数: -Xmx20M -XX:MaxDirectMemorySize=10M -XX:+HeapDumpOnOutOfMemoryError
public static void main(String[] args) throws Exception {
ByteBuffer.allocateDirect(size);
}
}
异常信息:
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:695)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
at com.jts.jvm.gc.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:16)
虽然在启动时加了-XX:+HeapDumpOnOutOfMemoryError
参数,但是在OOM
的时候,并没有dump
文件。这点也是和《深入理解Java虚拟机》中不一致的地方。猜测可能和操作系统有关,不过我用windows
系统也没有得到和书上一样的结果。
总结
通过上面的案例,我们能更清楚什么样的代码在哪块区域会产生内存溢出异常了,在出现异常时能有大致的分析方向,然后利用工具或者查看错误日志就能精准定位问题了。
参考:
-
《深入理解Java虚拟机》 -
堆外内存不受 -XX:MaxDirectMemorySize 限制
好了,这篇文章就到这里了,感谢大家的观看!如有错误,请及时指正!欢迎大家关注我的公众号:贾哇技术指南
。

作者介绍