模拟JVM各数据区内存溢出

在Java虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生 OutOfMemoryError(下文称OOM)异常的可能,本文将通过若干实例来验证异常发生的场景,并且会初步介绍几个与内存相关的最基本的虚拟机参数。

1、Java堆溢出

Java堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。
限制Java堆的大小为20MB,不可扩展(将堆的最小值Xms参数与最大值Xmx参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dmp出当前的内存堆转储快照以便事后进行分析。

/**
 * -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 **/
public class HeapOOM {
    static class OOMObject{

    }

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

输出结果如下

这里虽然模拟出了OOM,但是从Error Message来看是GC overhead limit exceeded,并非预期的Java heap space,原因是这个是JDK6新添的错误类型。是发生在GC占用大量时间为释放很小空间的时候发生的,是一种保护机制。一般是因为堆太小,导致异常的原因:没有足够的内存。 Sun 官方对此的定义:超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常。JVM给出这样一个参数:-XX:-UseGCOverheadLimit  禁用这个检查,其实这个参数解决不了内存问题,只是把错误的信息延后,我们加上这个参数再次运行一次。结果如下:

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

由于在 HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于 HotSpot来说,虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数设定。关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError异常。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError异常。

这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。

《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机 的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。 为了验证这点,我们可以做两个实验,先将实验范围限制在单线程中操作,尝试下面两种行为是 否能让HotSpot虚拟机产生OutOfMemoryError异常:

· 使用 -Xss 参数减少栈内存容量。
结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。
· 定义了大量的本地变量,增大此方法帧中本地变量表的长度。
结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。
下面使用-Xss参数指定栈内存容量为128KB。
/*** VM Args:-Xss128k **/
public class JavaVMStackSOF { 
    private int stackLength = 1; 
    public void stackLeak() { 
        stackLength++; stackLeak(); 
    }
    public static void main(String[] args) throws Throwable { 
        JavaVMStackSOF oom = new JavaVMStackSOF(); 
        try {
            oom.stackLeak(); 
        } catch (Throwable e) { 
            System.out.println("stack length:" + oom.stackLength); 
            throw e; 
        } 
    }
}

这里没有如我所愿出现StackOverflowError,而是提示程序运行所需要的栈内存至少为160K,我们需改-Xss为160K重新运行。

我们继续验证第二种情况,这次代码就显得有些 丑陋 ”了,为了多占局部变量表空间,不得 不定义一长串变量
public class JavaVMStackSOF { 
    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; 
        } 
     } 
}

实验结果表明:无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候, HotSpot 虚拟机抛出的都是 StackOverflowError异常。可是如果在允许动态扩展栈容量大小的虚拟机上,相同代码则会导致不一样的情况。譬如远古时代的 Classic虚拟机,这款虚拟机可以支持动态扩展 栈内存的容量,在 Windows 上的 JDK 1.0.2 运行代码清单 2-5的话(如果这时候要调整栈容量就应该改用 -oss 参数了),得到的结果是:
可见相同的代码在 Classic 虚拟机中成功产生了 OutOfMemoryError 而不是 StackOver-flowError异 常。如果测试时不限于单线程,通过不断建立线程的方式,在HotSpot上也是可以产生内存溢出异常 的,具体如代码如下所示。但是这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关 系,主要取决于操作系统本身的内存使用状态。甚至可以说,在这种情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。
原因其实不难理解,操作系统分配给每个进程的内存是有限制的,譬如 32 Windows的单个进程 最大内存限制为 2GB HotSpot 虚拟机提供了参数可以控制 Java 堆和方法区这两部分的内存的最大值,那剩余的内存即为2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量,由于程序计数器 消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存 就由虚拟机栈和本地方法栈来分配了。因此为每个线程分配到的栈内存越大,可以建立的线程数量自 然就越少,建立线程时就越容易把剩下的内存耗尽。

下面通过不断地建立线程的方式产生内存溢出异常。

public class StackOOM {
    private void dontStop(){
        try {
            Thread.sleep(Long.MAX_VALUE);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    public void stackLeak(){
        while (true){
            new Thread(()->dontStop()).start();
        }
    }

    public static void main(String[] args) {
        StackOOM oom=new StackOOM();
        oom.stackLeak();
    }
}

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

由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。自JDK1.7开始逐步“去永久代”, 并在JDK 8中完全使用元空间来代替永久代的背景, 在此我们就以测试代码来观察一下,使用永久代还是元空间来实现方法区对程序的实际影响。
String.intern()是一个 Native方法,它的作用是:如果字符串常量池中已经包含一个等于此 String对象的字符串,则返回代表池中这个字符串的 String对象;否则,将此 String对象包含的字符串添加到常量池中,并且返回此 String对象的引用。在JDK1.6及之前的版本中,由于常量池分配在永久代内,我们可以通过- XX:PermSize和-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量。

/**
 * -XX:PermSize=10M -XX:MaxPermSize=10
 **/
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        //使用List保持着常量池引用,避免Full GC回收常量池行为
        List<String> list = new LinkedList<String>();
        //10MB的PermSize在integer范围内足够产生OOM了
        int i = 0;
        while (true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

运行结果:

从运行结果中可以看到,运行时常量池溢出,在 OutOfMemoryError后面跟随的提示信息是“ PermGen space”,说明运行时常量池属于方法区(HotSpot虚拟机中的永久代)的一部分。
而使用JDK1.7和JDK1.8运行这段程序就不会得到相同的结果, while循环将一直进行下去。并且JDK1.8还会提示“ignoring option PermSize=10M; support was removed in 8.0”如下,因为从Java8开始移除了永久代。

关于这个字符串常量池的实现问题,还可以引申出一个更有意思的影响,如下所示。

String str1=new StringBuilder("计算机").append("软件").toString();
System.out.println(str1==str1.intern());

String str2=new StringBuilder("ja").append("va").toString();
System.out.println(str2==str2.intern());

这段代码在JDK1.6中运行,会得到两个 false,而在JDK1.7中运行,会得到一个true和一个 false。产生差异的原因是:在JDK1.6中, intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由 StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用,将返回 false。而JDK1.7(以及部分其他虚拟机,例如 JRockit)的 intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此 intern()返回的引用和由 StringBuilder刨建的那个字符串实例是同一个。对st2比较返回 false是因为“java”这个字符串在执行 StringBuilder.toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回true。

方法区用于存放 Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用 Java SE API也可以动态产生类(如反射时的 GeneratedConstructor Accessor和动态代理等),但在本次实验中操作起来比较麻烦。在下面代码中借助CGLib直接操作字节码运行时生成了大量的动态类。

/**
 * -XX:PermSize=8M -XX:MaxPermSize=8M -Xmx8M
 **/
public class JavaMethodAreaOOM {
    public static void main(final String[] args) {
        while (true){
            Enhancer enhancer=new Enhancer();
            enhancer.setSuperclass(JavaMethodAreaOOM.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o,args);
                }
            });
            enhancer.create();
        }
    }
}

上面这段代码在JDK1.6中会报java.lang.OutOfMemoryError: PermGen space;而在JDK1.7中则报java.lang.OutOfMemoryError: Java heap space。在JDK1.8中移除了永久代,增加了Meta space因此不受堆内存限制,我们指定MetaSpaceSize 和 MaxMetaSpaceSize来限制MetaSpace大小,运行结果如下。

4、本机直接内存溢出

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值