JVM OutOfMemoryError 与 StackOverflowError 异常

27 篇文章 0 订阅
23 篇文章 0 订阅

目录

前言

堆溢出

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

方法区溢出


前言

        JVM规范中规定, 除了程序计数器之外, 其他的运行时数据区域, 例如堆栈, 方法区, 都会出现OutOfMemoryError异常.

        那么到底是怎么样的代码, 才会引起堆溢出, 栈溢出, 或者是方法区的溢出呢? 如果遇到了又该如何解决? 这就是我们本节内容的主题. 

        我们在书写代码案例的时候, 会使用JVM参数手动调节堆等内存区域的大小, 方便观察结果.  但是不同的发行版的JVM在相同的参数下可能会出现些许差异. 


堆溢出

        JVM的堆区用于存放类的实例对象. 我们只需要不断的创建对象, 并且避免JVMGC回收这些对象, 久而久之就会将堆的空间沾满, 考虑如下代码: 

    public static void main(String[] args) {
        List<Student> list = new LinkedList<>();

        while (true) {
            list.add(new Student("John", 25));
        }
    }

        为了快速得到结果, 我们需要减小堆的可用空间, 如下: 

  • -verbose:gc   这个参数启用了垃圾收集(Garbage Collection, GC)的详细日志输出
  • -Xms20M    这个参数设置了Java堆的初始大小(Initial Heap Size)为20MB
  • -Xmx20M    这个参数设置了Java堆的最大大小(Maximum Heap Size)为20MB
  • -Xmn10M  这个参数设置了年轻代(Young Generation)的大小为10MB,年轻代是堆内存的一部分,主要用于存放新生成的对象
  • -XX:+HeapDumpOnOutOfMemoryError    这个参数启用了当OutOfMemoryError(内存溢出错误)发生时,自动生成堆内存转储(Heap Dump)的功能

可以在idea中配置JVM参数 , 如下: 

 

勾选JVM选项: 

添加参数: 

运行结果如下: 

显示OOM异常, 然后问题就出在这个LinkedList中. 

注意到这句话:  

Dumping heap to java_pid20664.hprof ...

说明堆转储文件已经生成, 我们去当前项目下的文件里面去查看: 

发现存在这个文件, 我们使用的IDEA, 可以直接点击分析查看是什么地方出了问题, 如下: 

 可以看到排在前面的就是这个LinkedList和Student, 因此可以快速排查问题所在. 

但是我们应该区别一下, 到底是因为内存泄漏还是内存溢出: 

  • 内存泄漏,  创建了很多类的对象, 这类对象不是必要的, 并且这些类对象应该在被使用完之后被垃圾回收器回收, 但是因为某些疏忽导致没有被回收, 此种情况被成为内存泄漏, 一般会引起非常大的故障
  • 内存溢出则跟泄漏相近, 但是内存溢出创建的对象是有必要的, 创建的对象在运行期间一直需要, 但是再进行额外的创建的时候, 因为内存空间不够而抛出OOM异常, 就是内存溢出. 

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

         由于HotSpot虚拟机是不区分虚拟机栈和本地方法栈的, 因此对于HotSPot来说, 设置Xoss参数(设置本地方法栈的大小) 虽然存在, 但实际不会有任何效果, 栈的容量在HotSpot上面只能由-Xss参数来设定, 

        java虚拟机规范中描述了两种异常: 

  • 如果一个线程请求的栈深度大于虚拟机所允许的最大深度, 就会抛出StackOverflowError异常. 
  • 如果虚拟机支持动态栈内存扩展, 也就是在栈空间不足的时候, 继续申请内存, 但是如果由于某些原因不能继续申请足够的空间的时候, 就会抛出: OutOfMemoryError异常

        Java的虚拟机规范中, 没有明确的说明虚拟机必须支持动态栈内存扩展, 并且HotSpot是不支持动态扩展的, 因此在栈内存不足的时候, 是不会继续申请内存的, 那么多的栈帧会因为没有足够空间, 而无法被载入虚拟机栈, 这个时候, 就必须表示这种情况是一种异常情况, 因此就会抛出StackOverflowError异常 

        为了验证这几点, 我们设计几个场景 : 

  1. 减少栈容量, 通过使用JVM参数的形式
  2. 增大每个入栈的栈帧的大小, 通过增加局部变量表的大小的方式

        先来看看第一种, 设置JVM参数: -Xss128k

考虑如下代码: 

public final class Test  {
    private int stackLength = 1;
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args) {
        Test oom = new Test();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

 这个代码不断的递归, 并且没有递归结尾, 会一直持续下去, 直到溢出. 

第二种是增加局部变量表的大小, 来让每一个栈帧的大小变大, 那么入栈之后, 剩余的空间就会减小的更多(对比正常的栈帧)

考虑如下代码: 

我们只需要在递归的时候, 定义足够多的局部变量就行

public final class Test {
    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();
    }

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

输出如下: 

        我们对比一下, 第一种减小栈容量的方法(-Xss128k), 会让main线程在栈调用深度在小于1000的时候就排除栈StackOverflowError溢出异常. 而通过增加变量表的方式, 会在6000多深度的时候出现异常. 

结果表明: 无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候, HotSpot虚拟机抛出的都是StackOverflowError异常(因为不会动态扩展内存, 所以不会因为无法申请足够的空间而发生OOM异常) 

        但是, 如果在支持动态内存扩展的虚拟机上, 你像上述代码那样, 就会出现不同的结果, 就很有可能会触发OOM异常, 因为新的栈帧因为内存不够的时候, 就会申请新的内存, 但是由于某种限制, 申请失败, 就会抛出OOM异常. 

       

        注意这里的情况仅限于main线程, 也就是单线程, 如果在多线程的情况, 又会有所不同, 很容易李姐, 因为创建线程, 也会消耗内存资源, 总所周知, win32的系统下一个进程最多2GB, 对于一个java进程而言, 出去堆空间, 方法区, 和本地方法栈的空间, 加上程序计数器的空间, 计算如下: 

2GB - 最大堆内存  - 最大方法区内存  - 程序计数器内存  - 直接内存  - 虚拟机进程 = 虚拟机栈 + 本地方法栈内存. 

        因此每个线程分配到的栈内存越大, 也就是一个线程在进行递归调用的时候, 如果递归深度很深导致一个线程的栈帧数量很多, 或者是在固定的栈帧数量的情况下, 每个栈帧的局部变量表的大小太大, 导致一个线程的一个栈帧很大(局部变量表内存占用很大), 导致当前线程的虚拟机栈内存占用很大.  从而削减了其他线程的栈可用空间,  能创建的其他的线程的数量自然就减小. 此时建立线程就更容易因为栈不足而内存溢出. 

        下面的代码就是因为创建线程数过多导致内存溢出. 

 设置虚拟机参数-Xss2M限制栈空间为2M

注意, 不要轻易尝试这个代码, 系统会因为线程数激增而导致系统假死

注意, 不要轻易尝试这个代码, 系统会因为线程数激增而导致系统假死

注意, 不要轻易尝试这个代码, 系统会因为线程数激增而导致系统假死

public class Test {
    private void dontStop() {
        while (true) {
        }
    }

    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args) throws Throwable {
        Test oom = new Test();
        oom.stackLeakByThread();
    }
}

运行结果如下:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread

        如果是由于建立过多的线程数导致内存溢出, 在不能减少线程数或者更换64位机器的情况下, 就只能通过减少最大堆或者方法区的容量的方法(上图等式中等号左边的部分)

        这种"减少内存"来避免内存溢出的情况比较少见. 


方法区溢出

         大家应该都忘记这个图了吧, 翻出来给大家看看. 

         学过JVM应该都知道, HotSpot在JDK1.7开始, 去永久代, 并且在JDK1.8中, 使用元空间来代替永久代的故事, 我们就来看看永久代和元空间的区别: 

  • 永久代, 永久代是JVM堆中的一部分, 使用着java堆区的GC方式, 其大小可以在启动时使用-XX:MaxPermSize来这是, 并且是不能动态扩展的, 这也就意味着如果一次性加载大量类, 或者过多的生成了大量的动态类, 就会导致永久代内存溢出, 从而引发: java.lang.OutOfMemoryError: PermGen space错误
  • 元空间: 直接使用本地内存, 其大小可以根据需要进行动态调整, 初始大小和最大内存大小都可以通过虚拟机参数设置,  默认可以扩展至几乎所有的本地内存, 减少了内存溢出的风险. 

        要想常量池溢出, 你只需要往常量池中塞入足够的对象即可, 如下: 

限制容量, 设置JVM参数: -XX:PermSize=6M -XX:MaxPermSize=6M

请以JDK1.6运行. 因为1.7起常量池被移动到java堆中, 限制方法区容量可以说毫无意义.

    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        String str = "hello";
        int i = 0;
        while (true) {
            set.add((str + (i++)).intern());
            str += i;
            System.out.println(str);
        }
    }

        String的intern方法, 它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用。否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用.

        此代码运行结果如下: 

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

        显示PermGen空间溢出, 也就是永久代OOM. 

        但是可以将堆区的空间限制到6MB即可, 如下: -Xmx6M

        方法区的主要职责就是存放类型相关的信息, 例如泪目, 访问限定修饰符, 常量池, 字段描述方法描述等, 对这部分数据进行测试的主要思路就是产生大量的运行时类去填满方法区, 直到溢出, 这样的场景也很多, 例如Spring框架中的AOP, 代理等, 就是在运行时产生了大量的动态类去增强功能和方法. 

        在元空间替代了永久代之后, 前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了, 但是为了防御破坏性操作, HotSpot还是提供了一些参数作为元空间的防御措施: 

  • -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小
  • -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值