本文基于 Java 15
StackOverflowError 与 OutOfMemoryError 是两个老生常谈的 Java 错误。Java 中的虚拟机错误 VirtualMachineError 包括以下四种:
我们比较关心的就是 StackOverflowError 与 OutOfMemoryError,剩下的 InternalError 一般是内部使用错误,UnknownError 是虚拟机发生未知异常,这两种我们这里不讨论。
虚拟机规范中的 StackOverflowError 与 OutOfMemoryError
参考 Java 虚拟机规范官方文档:Run-Time Data Areas,可以知道,在如下情况下,会抛出这两种错误:
- 当某次线程运行计算时,需要占用的 Java 虚拟机栈(Java Virtual Machine Stack)大小,也就是 Java 线程栈大小,超过规定大小时,抛出 StackOverflowError
- 如果 Java 虚拟机栈大小可以动态扩容,发生扩容时发现内存不足,或者新建Java 虚拟机栈时发现内存不足,抛出 OutOfMemoryError
- 当所需要的堆(heap)内存大小不足时,抛出 OutOfMemoryError
- 当方法区(Method Area)大小不够分配时,抛出 OutOfMemoryError
- 当创建一个类或者接口时,运行时常量区剩余大小不够时,抛出 OutOfMemoryError
- 本地方法栈(Native Method Stack)大小不足时,抛出 StackOverflowError
- 本地方法栈(Native Method Stack)扩容时发现内存不足,或者新建本地方法栈发现内存不足,抛出 OutOfMemoryError
Hotspot JVM 的实现
为了进一步搞清楚 StackOverflowError 与 OutOfMemoryError,我们来看具体实现。一般的 JVM 采用的都是官网的 HotSpot JVM,我们这里就用 Hotspot JVM 的实现来说明。
JVM 内存包括什么
我们一般通过两个工具 pmap 还有 jcmd 中的 VM.native_memory 命令去查看 Java 进程内存占用,由于 pmap 命令有点复杂而且很多内存映射是 anon 的,这里采用 jcmd 中的 VM.native_memory 命令,去看一下 JVM 内存的每一部分。
1 |
|
这里的 mmap,malloc 是两种不同的内存申请分配方式,例如:
1 | Internal (reserved=3643KB, committed=3643KB) |
代表 Internal 一共占用 3643KB,其中3611KB是通过 malloc 方式,32KB 是通过 mmap 方式。
arena 是通过 malloc 方式分配的内存但是代码执行完并不释放,放入 arena chunk 中之后还会继续使用,参考:MallocInternals
可以看出,Java 进程内存包括:
- Java Heap: 堆内存,即-Xmx限制的最大堆大小的内存。
- Class:加载的类与方法信息,其实就是 metaspace,包含两部分: 一是 metadata,被-XX:MaxMetaspaceSize限制最大大小,另外是 class space,被-XX:CompressedClassSpaceSize限制最大大小
- Thread:线程与线程栈占用内存,每个线程栈占用大小受-Xss限制,但是总大小没有限制。
- Code:JIT 即时编译后(C1 C2 编译器优化)的代码占用内存,受-XX:ReservedCodeCacheSize限制
- GC:垃圾回收占用内存,例如垃圾回收需要的 CardTable,标记数,区域划分记录,还有标记 GC Root 等等,都需要内存。这个不受限制,一般不会很大的。
- Compiler:C1 C2 编译器本身的代码和标记占用的内存,这个不受限制,一般不会很大的
- Internal:命令行解析,JVMTI 使用的内存,这个不受限制,一般不会很大的
- Symbol: 常量池占用的大小,字符串常量池受-XX:StringTableSize个数限制,总内存大小不受限制
- Native Memory Tracking:内存采集本身占用的内存大小,如果没有打开采集(那就看不到这个了,哈哈),就不会占用,这个不受限制,一般不会很大的
- Arena Chunk:所有通过 arena 方式分配的内存,这个不受限制,一般不会很大的
- Tracing:所有采集占用的内存,如果开启了 JFR 则主要是 JFR 占用的内存。这个不受限制,一般不会很大的
- Logging,Arguments,Module,Synchronizer,Safepoint,Other,这些一般我们不会关心。
除了 Native Memory Tracking 记录的内存使用,还有两种内存 Native Memory Tracking 没有记录,那就是:
- Direct Buffer:直接内存
- MMap Buffer:文件映射内存
各种 StackOverflowError 与 OutOfMemoryError 场景以及定位方式
1. StackOverflowError
调用栈过深,导致线程栈占用大小超过-Xss(或者是-XX:ThreadStackSize)的限制,如果没指定-Xss,则根据不同系统确定默认最大大小。
确定默认大小的代码请参考:
- windows:os_windows.cpp
- linux:os_linux.cpp
总结起来就是,32 位的系统一般是 512k,64 位的是 1024k
一般报这个错都是因为递归死循环,或者调用栈真的太深而线程栈大小不足,比如那种回调背压模型的框架,netty + reactor 这种,一般线程栈需要调大一点。
2. OutOfMemoryError: Java heap space
堆内存不够用,无法分配更多内存,就会抛出这个异常。一般这种情况发生后,需要查看 heap dump,线上应用一般加上-XX: +HeapDumpOnOutOfMemoryError在OutOfMemoryError发生的时候,进行 heap dump,之后进行分析。
heap dump 查看工具一般通过 Memory Analyzer (MAT)
3. OutOfMemoryError: unable to create native thread
这个在创建太多的线程,超过系统配置的极限。如Linux默认允许单个进程可以创建的线程数是1024个。
一般报这个错首先考虑不要创建那么多线程,线程池化并池子尽量同业务复用。如果实在要创建那么多线程,则考虑修改服务器配置:
1 | //查看限制个数 |
4. OutOfMemoryError: GC Overhead limit exceeded
默认情况下,并不是等堆内存耗尽,才会报 OutOfMemoryError,而是如果 JVM 觉得 GC 效率不高,也会报这个错误。
那么怎么评价 GC 效率不高呢?来看下源码:gcOverheadChecker.cpp:
1 | void GCOverheadChecker::check_gc_overhead_limit(GCOverheadTester* time_overhead, |
默认配置:gc_globals.hpp
1 | product(bool, UseGCOverheadLimit, true, \ |
可以总结出:默认情况下,启用了 UseGCOverheadLimit,连续 5 次,碰到 GC 时间占比超过 98%,GC 回收的内存不足 2% 时,会抛出这个异常。
5. OutOfMemoryError: direct memory
这个是向系统申请直接内存时,如果系统可用内存不足,就会抛出这个异常,对应的源代码Bits.java:
1 | static void reserveMemory(long size, int cap) { |
在 DirectByteBuffer 中,首先向 Bits 类申请额度,Bits 类有一个全局的 totalCapacity 变量,记录着全部 DirectByteBuffer 的总大小,每次申请,都先看看是否超限,堆外内存的限额默认与堆内内存(由 -Xmx 设定)相仿,可用 -XX:MaxDirectMemorySize 重新设定。
如果不指定,该参数的默认值为 Xmx 的值减去1个 Survior 区的值。 如设置启动参数 -Xmx20M -Xmn10M -XX:SurvivorRatio=8,那么申请 20M-1M=19M 的DirectMemory
如果已经超限,会主动执行 Sytem.gc(),期待能主动回收一点堆外内存。System.gc() 会触发一个 full gc,当然前提是你没有显示的设置 -XX:+DisableExplicitGC 来禁用显式GC。并且你需要知道,调用 System.gc() 并不能够保证 full gc 马上就能被执行。然后休眠一百毫秒,看看 totalCapacity 降下来没有,如果内存还是不足,就抛出 OOM 异常。如果额度被批准,就调用大名鼎鼎的sun.misc.Unsafe去分配内存,返回内存基地址
在发生这种异常时,一般通过 JMX 的java.nio.BufferPool.direct里面的属性去监控直接内存的变化以及使用(其实就是 BufferPoolMXBean ),来定位问题。
6. OutOfMemoryError: map failed
这个是 File MMAP(文件映射内存)时,如果系统内存不足,就会抛出这个异常,对应的源代码是:
- Windows:FileDispatcherImpl.c
- Linux:FileDispatcherImpl.c
以 Linux 为例:
1 | JNIEXPORT jlong JNICALL |
这种情况下,考虑:
1.增加系统内存
2.采用文件分块,不要一次 mmap 很大的文件,也就是减少每次 mmap 文件的大小
7. OutOfMemoryError: Requested array size exceeds VM limit
当申请的数组大小超过堆内存限制,就会抛出这个异常。
8. OutOfMemoryError: Metaspace
Metadata 占用空间超限(参考上面简述 Java 内存构成, class 这一块 包含两种,一种是 metadata,一种是 class space),会抛出这个异常,那么如何查看元空间内存呢?
可以通过两个命令,这两个输出是一样的:
jmap -clstats
jcmd GC.class_stats (这个需要启动参数: -XX:+UnlockDiagnosticVMOptions)
1
2
3
4
5
6
7
8
9
10
11
12
Index Super InstBytes KlassBytes annotations CpAll MethodCount Bytecodes MethodAll ROAll RWAll Total ClassName
1 -1 214348176 504 0 0 0 0 0 24 616 640 [C
2 -1 71683872 504 0 0 0 0 0 24 616 640 [B
3 -1 53085688 504 0 0 0 0 0 24 616 640 [Ljava.lang.Object;
4 -1 28135528 504 0 0 0 0 0 32 616 648 [Ljava.util.HashMap$Node;
5 17478 12582216 1440 0 7008 64 2681 39040 11232 37248 48480 java.util.ArrayList
.........
25255 25 0 528 0 592 3 42 568 448 1448 1896 zipkin2.reporter.metrics.micrometer.MicrometerReporterMetrics$Builder
472572680 16436464 283592 41813040 225990 8361510 75069552 39924272 101013144 140937416 Total
335.3% 11.7% 0.2% 29.7% - 5.9% 53.3% 28.3% 71.7% 100.0%
Index Super InstBytes KlassBytes annotations CpAll MethodCount Bytecodes MethodAll ROAll RWAll Total ClassName
其中,每个指标的含义如下所示:
- InstBytes:实例占用大小
- KlassBytes:类占用大小
- annotations:注解占用大小
- CpAll:常量池中占用大小
- MethodCount:方法个数
- Bytecodes:字节码大小
- MethodAll:方法占用大小
- ROAll:只读内存中内存占用
- RWAll:读写内存中内存占用
9. OutOfMemoryError: Compressed class space
class space 内存溢出导致的,和上一个异常类似,需要查看类信息统计定位问题。
10. OutOfMemoryError: reason stack_trace_with_native_method
这个发生在 JNI 调用中,内存不足