JVM(三)OutOfMemoryError异常

1 Java堆溢出

堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证 GC Roots 到对象之间有可达路径来避免垃
圾收集回收机制清除这些对象,当这些对象所占空间超过最大堆容量时,就会产生 OutOfMemoryError 的异常。堆
内存异常示例如下:

public class HeapOOM {
    // 设置JVM参数最大堆和最小堆:-Xms20m -Xmx20m
    static class OOMObject {}

    public static void main(String[] args) {
        List<OOMObject> oomObjectList = new ArrayList<>();
        while (true) {
            oomObjectList.add(new OOMObject());
        }
    }
}

运行后会报异常,在堆栈信息中可以看到:
java.lang.OutOfMemoryError: Java heap space,说明在堆内存空间产生内存溢出的异常。
新产生的对象最初分配在新生代,新生代满后会进行一次 Minor GC ,如果 Minor GC 后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足时会进行 Full GC ,之后如果空间还不足以存放新对象则抛出 OutOfMemoryError 异常。
常见原因:

  • 内存加载数据过多,如一次从数据库中取出过多数据
  • 集合对对象引用过多且使用完后没有清空。
  • 代码中存在死循环或者循环产生过多的重复对象
  • 堆内存分配不合理

2 虚拟机栈和本地方法栈溢出

由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈, 因此对于HotSpot来说, -Xoss参数(设置本地方法栈大小) 虽然存在, 但实际上是没有任何效果的, 栈容量只能由-Xss参数来设定。 关于虚拟机栈和本地方法栈, 在 《Java虚拟机规范》 中描述了两种异常:
1) 如果线程请求的栈深度大于虚拟机所允许的最大深度, 将抛出StackOverflowError异常。
2) 如果虚拟机的栈内存允许动态扩展, 当扩展栈容量无法申请到足够的内存时, 将抛出 OutOfMemoryError异常。
《Java虚拟机规范》 明确允许Java虚拟机实现自行选择是否支持栈的动态扩展, 而HotSpot虚拟机的选择是不支持扩展, 所以除非在创建线程申请内存时就因无法获得足够内存而出现 OutOfMemoryError异常, 否则在线程运行时是不会因为扩展而导致内存溢出的, 只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。 为了验证这点, 我们可以做两个实验, 先将实验范围限制在单线程中操作, 尝试下面两种行为是 否能让HotSpot虚拟机产生OutOfMemoryError异常: 使用-Xss参数减少栈内存容量。 结果: 抛出StackOverflowError异常, 异常出现时输出的堆栈深度相应缩小。
定义了大量的本地变量, 增大此方法帧中本地变量表的长度。 结果: 抛出StackOverflowError异常, 异常出现时输出的堆栈深度相应缩小。 首先, 对第一种情况进行测试

public class JavaVMStackSOF {
    // 启动是添加VM Args: -Xss128k
    
    private int stackLength = 1;
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable ex) {
            System.out.println("stack length:" + oom.stackLength);
            throw ex;
        }
    }
}

运行结果:

stack length:1407
Exception in thread "main" java.lang.StackOverflowError
    at com.ymj.jvm01.c3_code.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
    at com.ymj.jvm01.c3_code.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)

对于不同版本的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

继续验证第二种情况,为了多占局部变量表空间, 不得不定义一长串变量,具体如代码

public class JavaVMStackSOF2 {
    private static int stackLength = 0;
    public static void test() {
        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;
        stackLength ++;
        test();
        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;
    }

    public static void main(String[] args) {
        try {
            test();
        } catch (Error e) {
            System.out.println("stack length:" + stackLength);
            throw e;
        }
    }
}

运行结果:

stack length:7983
Exception in thread "main" java.lang.StackOverflowError
    at com.ymj.jvm01.c3_code.JavaVMStackSOF2.test(JavaVMStackSOF2.java:33)
    at com.ymj.jvm01.c3_code.JavaVMStackSOF2.test(JavaVMStackSOF2.java:33)

实验结果表明: 无论是由于栈帧太大还是虚拟机栈容量太小, 当新的栈帧内存无法分配的时候, HotSpot虚拟机抛出的都是StackOverflowError异常。

3 运行时常量池和方法区溢出

由于运行时常量池是方法区的一部分, 所以这两个区域的溢出测试可以放到一起进行。前面曾经提到HotSpot从 JDK 7开始逐步“去永久代”的计划, 并在JDK 8中完全使用元空间来代替永久代的背景故事, 在此我们就以测试代码来观察一下, 使用“永久代”还是“元空间”来实现方法区, 对程序有什么 实际的影响。
String::intern()是一个本地方法, 它的作用是如果字符串常量池中已经包含一个等于此String对象的 字符串, 则返回代表池中这个字符串的String对象的引用; 否则, 会将此String对象包含的字符串添加到常量池中, 并且返回此String对象的引用。 在JDK 6或更早之前的HotSpot虚拟机中, 常量池都是分配在永久代中, 我们可以通过-XX: PermSize和-XX: MaxPermSize限制永久代的大小, 即可间接限制其中常量池的容量, 具体实现如代码清单

3.1 运行时常量池内存溢出

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        // VM Args: -XX:PermSize=6M -XX:MaxPermSize=6M

        // 使用Set保持着常量池引用, 避免Full GC回收常量池行为
        Set<String> set = new HashSet<String>();
        // 在short范围内足以让6MB的PermSize产生OOM了
        short i = 0;
        while (true) {
            set.add(String.valueOf(i++).intern());
        }
    }
}

运行结果:

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来运行这段程序并不会得到相同的结果, 无论是在JDK 7中继续使 用-XX: MaxPermSize参数或者在JDK 8及以上版本使用-XX: MaxMeta-spaceSize参数把方法区容量同 样限制在6MB, 也都不会重现JDK 6中的溢出异常, 循环将一直进行下去, 永不停歇。 出现这种变 化, 是因为自JDK 7起, 原本存放在永久代的字符串常量池被移至Java堆之中, 所以在JDK 7及以上版本, 限制方法区的容量对该测试用例来说是毫无意义的。 这时候使用-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异常二: 
//根据Oracle官方文档,默认情况下,如果Java进程花费98%以上的时间执行GC,并且每次只有不到2%的堆被恢复,则JVM抛出 此错误 
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.lagou.unit.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:17)

3.2 方法区内存溢出

方法区的其他部分的内容, 方法区的主要职责是用于存放类型的相关信息, 如类名、 访问修饰符、 常量池、 字段描述、 方法描述等。 对于这部分区域的测试, 基本的思路是运行时产 生大量的类去填满方法区, 直到溢出为止。
虽然直接使用Java SE API也可以动态产生类(如反射时的 GeneratedConstructorAccessor和动态代理等) , 但在本次实验中操作起来比较麻烦。 在代码清单
借助CGLib使得方法区出现内存溢出异常:

public class JavaMethodAreaOOM {

    static class OOMObject { }
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws ThrowException{
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }
}

在JDK 6中的运行结果:

Caused by: java.lang.OutOfMemoryError: PermGen space 
at java.lang.ClassLoader.defineClass1(Native Method) 
at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632) 
at java.lang.ClassLoader.defineClass(ClassLoader.java:616) ... 8 more

方法区溢出也是一种常见的内存溢出异常, 一个类如果要被垃圾收集器回收, 要达成的条件是比较苛刻的。 在经常运行时生成大量动态类的应用场景里, 就应该特别关注这些类的回收状况。 这类场 景除了之前提到的程序使用了CGLib字节码增强和动态语言外, 常见的还有: 大量JSP或动态产生JSP 文件的应用(JSP第一次运行时需要编译
为Java类) 、 基于OSGi的应用(即使是同一个类文件, 被不同 的加载器加载也会视为不同的类) 等。 在JDK 8以后, 永久代便完全退出了历史舞台, 元空间作为其替代者登场。 在默认设置下, 前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。 不过 为了让使用者有预防实际应用里出现类似于代码清单2-9那样的破坏性的操作, HotSpot还是提供了一 些参数作为元空间的防御措施, 主要包括:
-XX: MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存 大小。
-XX: MetaspaceSize: 指定元空间的初始空间大小, 以字节为单位, 达到该值就会触发垃圾收集 进行类型卸载,同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放 了很少的空间, 那么在不超过-XX: MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。
-XX: MinMetaspaceFreeRatio: 作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。 类似的还有-XX: Max-MetaspaceFreeRatio, 用于控制最大的元空间剩余容量的百分比。

4 直接内存溢出

直接内存(Direct Memory) 的容量大小可通过-XX: MaxDirectMemorySize参数来指定, 如果不去指定, 则默认与Java堆最大值(由-Xmx指定) 一致, 越过了DirectByteBuwer类直接通 过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例, 体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能, 在JDK 10时才将Unsafe 的部分功能通过VarHandle开放给外部使用) , 因为虽然使用DirectByteBuwer分配内存也会抛出内存溢出异常, 但它抛出异常时并没有真正向操作系统申请分配内存, 而是通过计算得知内存无法分配就会 在代码里手动抛出溢出异常, 真正申请分配内存的方法是Unsafe::allocateMemory()

public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;

    // VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
    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);
        }
    }
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError
    at sun.misc.Unsafe.allocateMemory(Native Method)
    at com.ymj.jvm01.c3_code.DirectMemoryOOM.main(DirectMemoryOOM.java:22)

由直接内存导致的内存溢出, 一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常 情况, 如果发现内存溢出之后产生的Dump文件很小, 而程序中又直接或间接使用了 DirectMemory(典型的间接使用就是NIO) ,那就可以考虑重点检查一下直接内存方面的原因了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容