OutOfMemoryError的几大症状
2016 年 04 月 30 日
jvm

    不出意料,java.lang.OutOfMemoryError可以算是Java应用中最常见的JVM错误了,这类错误往往是Java堆没有足够的空间容纳新对象,但引起这类错误的原因还有诸多种,可能平时很少有缘分能相见,本文将探究其中几种引起该错误的场景,以备不时之需。

  • java.lang.OutOfMemoryError: Java heap space

  • 这应该是最常见的OutOfMemoryError错误场景,通常JVM内存会被分成堆(Heap)永久代(Perm Gen,Java 8中已被Metaspace取代),可分别通过-Xmx-XX:MaxPermSize来设置。而通常引起该错误的原因是,当程序正在尝试创建新对象,此时JVM堆中没有足够连续的空间容纳该对象。除此之外,程序中的一些问题也可能引起这类错误:

    用户/数据量达到峰值。在业务面临峰值时,会有可能触发java.lang.OutOfMemoryError: Java heap space,但随着业务趋于平缓,这种错误也会随之消失,这通常受限于自身配置。
    内存泄露。由于开发人员编写程序不当,造成某些对象既没有被应用程序使用,也不能被垃圾收集器回收,这类对象将不断消耗内存,最终也会导致java.lang.OutOfMemoryError: Java heap space
  • 案例

  • 内存不足

  • class OOM {
    
        static final int SIZE = 2 * 1024 * 1024;
    
        public static void main(String[] a) {
            int[] i = new int[SIZE];
        }
    }
        

    当配置-Xmx12m时,程序如下抛出了异常,配置为-Xmx13m正常运行:

    Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at me.hao0.jvm.oom.OOM.main(OOM.java:8)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:606)
        
  • 内存泄露

  • 在Java中其实并没有确切存在内存泄露,内存管理都交由JVM自身去处理,如分配内存回收不用的对象等。但在实际中,由于开发人员的疏忽,有可能导致内存泄露的现象,如不再被应用使用的对象不能被垃圾收集器识别等,导致Java堆占用持续增长,最终触发java.lang.OutOfMemoryError: Java heap space。如下面的代码片段:

    public class KeylessEntry {
    
        static class Key {
            Integer id;
    
            Key(Integer id) {
                this.id = id;
            }
    
            @Override
            public int hashCode() {
                return id.hashCode();
            }
        }
    
        public static void main(String[] args){
            Map m = new HashMap();
            while (true){
                for (int i=0; i<10000; i++){
                    if (!m.containsKey(new Key(i))){
                        m.put(new Key(i), "Number:" + i);
                    }
                }
            }
        }
    }
        

    上面的代码看似通过containsKey来保证Map中最多含有10000个对象,但由于Key没有正确重写equals()方法,导致containsKey内部比较对象时,始终调用Object.equals(),两个对象始终不相等,因此会不断往Map中放入新对象,造成java.lang.OutOfMemoryError: Java heap space(但需将-Xmx配置小些,如8M,否则抛出的是GC overhead limit exceeded)。修复这个问题也很简单,即Key重写equals()方法:

    @Override
    public boolean equals(Object o) {
        boolean response = false; 
        if (o instanceof Key) {
            response = (((Key)o).id).equals(this.id); 
        }
        return response; 
    }
        

    但通常面临java.lang.OutOfMemoryError: Java heap space,第一时间可能是增加堆大小,但只要堆配置不是特别不合理,那么原因可能并不是堆大小的问题,所以建议启动JVM进程时都将GC日志堆dump信息打开,便于排查。

  • java.lang.OutOfMemoryError: GC overhead limit exceeded

  • 与其他一些语言相比,JVM提供了一个举足轻重的功能就是自动内存回收,JVM通过在运行时启动了专门的GC线程来进行内存回收的工作,使得开发人员从内存管理的痛苦中得以解脱。但不是任何时候,GC线程都能安然无恙地工作,比如抛出异常java.lang.OutOfMemoryError: GC overhead limit exceeded时,该错误表明垃圾收集器正在尝试释放内存,但实际释放的内存却很少(默认情况下,当JVM在GC上花费了总时间的98%,然后GC之后却回收了不到2%的堆内存),出现这样的情况,则是由于应用程序耗尽了所有可用的内存,多次GC都不能回收这些内存

  • 案例

  • 正如之前的代码片段,若将堆大小设置大一些(如-Xms16m),最终抛出的异常会是GC overhead limit exceeded

    Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
        at java.lang.Integer.valueOf(Integer.java:642)
        at me.hao0.jvm.oom.KeylessEntry.main(KeylessEntry.java:31)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:606)
        at com.intellij.rt.execution.application.AppMain.main(AppMain.java:144)
        

    但当将GC换为-XX:+UseConcMarkSweepGC-XX:+UseG1GC,上面的代码片段则只会抛出Java heap space,JVM中也提供了-XX:-UseGCOverheadLimit来去除GC overhead limit exceeded这种异常消息,但并不推荐这么做,而是针对该问题进行修复。

  • java.lang.OutOfMemoryError: Permgen space

  • Java 7及其之前,JVM内存被分为了几个不同的区:

    Permgen space表明该异常发生在永久代,即永久代内存已经耗尽。从JVM内存管理中可知,永久代主要用于存放已加载类的元数据(包括类名称,字段及其方法字节码等),常量池信息类相关的对象数据和类型数组,及JIT编译器的优化信息等,而往往出现这类错误的原因是JVM加载的类太多或太大

  • 案例

  • 下面的例子通过javassist库不断生成class:

    import javassist.ClassPool;
    
    public class MicroGenerator {
    
        public static void main(String[] args) throws Exception {
            for (int i = 0; i < 100_000_000; i++) {
                generate("eu.plumbr.demo.Generated" + i);
            }
        }
    
        public static Class generate(String name) throws Exception {
            ClassPool pool = ClassPool.getDefault();
            return pool.makeClass(name).toClass();
        }
    }
        

    然而,在本机环境(Mac OSX 10.11.5JDK 1.7.0_80)中并没有抛出java.lang.OutOfMemoryError: PermGen space,而是:

    Exception in thread "main" 
    Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
        

    大概意思是说,java.lang.OutOfMemoryError没有被main线程catch,调用了线程的UncaughtExceptionHandler,出现该情况时,表明JVM已经没有足够内存分配OutOfMemoryError实例,因此将堆栈信息输出到了打印流(print stream)中,开发人员可以在运行程序前设置线程的UncaughtExceptionHandler,输出对应的错误信息,如:

    Thread.currentThread().setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
        @Override
        public void uncaughtException(Thread t, Throwable e) {
            System.err.println(e.getMessage());
        }
    });
        

    除了上述这种情况,在平时重新部署应用时,有可能由于一些第三方库错误的资源处理,在每次重部署时,导致Class的旧版本不能被ClassLoader卸载,最终造成java.lang.OutOfMemoryError: PermGen space,对于这类比较隐藏的问题,可以dump堆信息(jmap -dump:format=b, le=dump.hprof ),通过MAT工具进行分析,如:

    还有可能就是,JVM使用CMS垃圾收集器时,会默认关闭卸载类的选项,因而不会卸载一些已经不再使用类数据信息,导致java.lang.OutOfMemoryError: PermGen space,可以通过-XX:+CMSClassUnloadingEnabled开启该选项。

  • java.lang.OutOfMemoryError: Metaspace

  • Java 8之后,永久代(Perm Gen)Metaspace取代:

    而之所以要用Metaspace取代Perm Gen,大致有如下原因:

    Perm Gen大小难以确定。如果设置太小,则有可能导致java.lang.OutOfMemoryError: Permgen,设置过大,则浪费了系统资源。
    通过并发地为类数据分配内存,且不用暂停垃圾收集特定的元数据迭代器,提升了垃圾收集性能。
    针对G1垃圾收集器,支持并发地卸载类

    而引起java.lang.OutOfMemoryError: Metaspace异常的原因,通常则是有太多的类或太大的类被加载到Metaspace区。

  • 案例

  • 同样,利用javassist不断生成类:

    public class Metaspace {
    
        static javassist.ClassPool cp = javassist.ClassPool.getDefault();
    
        public static void main(String[] args) throws Exception {
            for (int i = 0; ; i++) {
                Class c = cp.makeClass("eu.plumbr.demo.Generated" + i).toClass();
            }
        }
    }
        

    Metaspace设置小一些,如-XX:MaxMetaspaceSize=64m,运行一段时间,则会抛出:

    Exception in thread "main" javassist.CannotCompileException: by java.lang.OutOfMemoryError: Metaspace
        at javassist.ClassPool.toClass(ClassPool.java:1170)
        at javassist.ClassPool.toClass(ClassPool.java:1113)
        at javassist.ClassPool.toClass(ClassPool.java:1071)
        at javassist.CtClass.toClass(CtClass.java:1264)
        at me.hao0.jvm.gc.Metaspace.main(Metaspace.java:9)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at com.intellij.rt.execution.application.AppMain.main(AppMain.java:144)
    Caused by: java.lang.OutOfMemoryError: Metaspace
        at java.lang.ClassLoader.defineClass1(Native Method)
        at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
        at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
        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 javassist.ClassPool.toClass2(ClassPool.java:1183)
        at javassist.ClassPool.toClass(ClassPool.java:1164)
        ... 9 more
        

    面对这类错误,比较直接的办法就是增大Metaspace,或者不设置MaxMetaspaceSize,使得Metaspace区能自动增长,但这有可能导致系统内存资源不足,造成严重的Swap操作。更适合的做法,还是需要通过堆dump分析是否存在内存泄露,确认是由于Metaspace不足导致的。

  • java.lang.OutOfMemoryError: Unable to create new native thread

  • Unable to create new native thread意味着,Java应用已经达到其能申请的最大线程数,而该值取决于不同的平台,而该异常通常会在以下几个阶段发生:

    Java应用尝试创建一个新线程;
    JVM本地代码代理应用程序,向操作系统发起创建本地线程的请求;
    操作系统尝试创建新的本地线程,并为其分配内存;
    由于Java进程已耗尽了自己限制的内存地址空间,或者系统虚拟内存已经耗尽,操作系统拒绝本地内存分配;
    java.lang.OutOfMemoryError: Unable to create new native thread异常被抛出。
  • 案例

  • 下面的代码片段,不断地创建新线程,不同平台创建的最大线程数也会不一致:

    public class UnableCreateNativeThread {
    
        private static int threadCount = 0;
    
        public static void main(String[] args) {
    
            while (true) {
                new Thread(new Runnable() {
                    public void run() {
                        try {
                            System.out.println(++threadCount);
                            Thread.sleep(10000000);
                        } catch (InterruptedException e) {
                        }
                    }
                }).start();
            }
        }
    }
        

    默认情况下,在本机Mac 10.11.5,JDK8_91上,默认创建了2027个线程后,抛出了该异常:

    堆dump信息中看,可见Thread实例过多:

    除此外,线程dump信息(jstack pid),可以看见太多线程处于TIMED_WAITING (sleeping)。对于这类情况,有时可以通过增大系统最大用户进程数来解决无法创建本地线程的问题,如:

    ulimit -a
    ...
    -u: processes                       709
    ...
        
  • java.lang.OutOfMemoryError: Out of swap space

  • JVM启动时,指定的堆大小(-Xmx)比操作系统的可用内存大时,操作系统将会把内存数据保存至虚拟内存(Swap space),如:

    当Java应用请求内存分配时,但此时本地堆空间已经耗尽,则会抛出java.lang.OutOfMemoryError: Out of swap space异常,而通常该异常由一些系统级别问题引起,如:

    操作系统配置的虚拟内存(Swap space)空间不足;
    系统中的其他进程占用了所有的内存资源。

    当然,除了操作系统级别的问题,也有可能是Java应用程序的本地代码没有正确释放内存资源,导致本地内存泄露所致。当遇到这样的情况时,最直接简单的办法就是增加虚拟内存(Swap space),如:

    swapoff -a
    dd if=/dev/zero of=swap le bs=1024 count=655360 
    mkswap swapfile
    swapon swapfile
        

    但单纯增加虚拟内存(Swap space),是有可能延长GC暂停时间的,所以需要慎重考虑。也有可能Java应用所在机器上,有其他进程在争用物理内存,这时应该将该进程与Java进程隔离,或升级物理内存,以减少内存争用。而针对内存使用优化方面,则可以通过分析堆dump信息,减少一些不必要的内存分配。

  • java.lang.OutOfMemoryError: Requested array size exceeds VM limit

  • 对于Java应用中的数组对象,其最大长度(取决于不同的平台,通常在10亿到21亿之间)是有限制的:

    该异常由JVM本地代码抛出,JVM在分配内存,会检查该数据结构在当前平台上是否可寻址。然而这个错误不会太常见,因为Java中数组以int型为下标,即最大值为2^31 – 1 = 2,147,483,647,而平台对数据长度限制也接近该值,因此可能会优先抛出java.lang.OutOfMemoryError: Java heap space

  • 案例

  • 下面的代码片段设置数组长度接近为Integer.MAX_VALUE

    public class ArrayLenLimit {
    
        public static void main(String[] args) {
            for (int i = 3; i >= 0; i--) {
                try {
                    int[] arr = new int[Integer.MAX_VALUE - i];
                    System.out.format("Successfully initialized an array with %,d elements.\n", Integer.MAX_VALUE - i);
                } catch (Throwable t) {
                    t.printStackTrace();
                }
            }
        }
    }
        

    结果抛出了如下异常:

    前两次由于分配内存时,超出了JVM堆大小,但未超出数组最大长度限制,因此抛出了java.lang.OutOfMemoryError: Java heap space异常,后面两次优先超出了最大长度限制,抛出java.lang.OutOfMemoryError: Requested array size exceeds VM limit

    在实际应用中,有可能由于数组使用不当,造成数组长度不断增加,最终超出最大长度限制,遇到这种情况,通常很少会需要这种大的数组,或者将大数组分割为多个小数组。

  • Out of memory: Kill process or sacrifice child

  • Out of memory: Kill process or sacrifice child主要由操作系统内置的OOM Killer机制造成的。OOM Killer是Linux中的一种内存保护机制,当系统内存已经极限不足时,OOM Killer将对现有进程作评分,将评分最低的进程kill掉,以释放内存供其他进程使用:

    当可用的虚拟内存(包括swap)达到某一程度(将给操作系统的稳定性带来风险)时,OOM Killer就会尝试kill掉那些游手好闲的进程。

    针对这种情况,最简单直接的办法是将Java应用迁移到内存足够的机器上,或者当前机器进行OOM Killer内存调优,以减少应用之间的内存争用。另外一个不推荐的做法,是增加虚拟内存(Swap space),但这却有可能带来系统性能上的问题。

  • 总结

  • 以上,则是java.lang.OutOfMemoryError异常相关的情形,有一些比较常见,有一些可能会很少碰到,待在实际中碰到时,能够对症下药。

  • 参考文献

  • OOM错误指南
    如何使永久代内存泄漏
    OOM杀手
    Linux内存调优

好人,一生平安。