深入分析 jvm 的分代模型

 2023-01-21
原文作者:silly8543 原文地址:https://juejin.cn/post/6977883075233464328

分代模型介绍

根据写代码方式的不同,采用不同的方式来创建和使用对象,其实对象的生存周期不同,所以JVM将Java堆内存划分为两个区域:年轻代、老年代

202301011609464781.png

通过下面的代码,来看下方法区,Java虚拟机栈和Java堆内存的关系图

    public class HelloWorld {
        private static Demo1 demo1 = new Demo1();
    
        public static void main(String[] args) throws InterruptedException {
            executeDemo2();
            while (true) {
                executeDemo1();
                Thread.sleep(6000);
            }
        }
    
        public static void executeDemo2() {
            Demo2 demo2 = new Demo2();
            demo2.execute();
        }
    
        public static void executeDemo1() {
            demo1.execute();
        }
    }

HelloWorld类中一个静态变量demo1引用了Demo1对象,由于静态变量会长期留存在内存中使用,则demo1对象会在年轻代中留存一会,然后最终进入老年代,此时的关系图如下:

202301011609470022.png

进入main方法后,会先调用executeDemo2(),执行Demo2对象的execute()方法,在executeDemo2方法会创建Demo2对象,这个对象他是用完就会回收,所以是会放在年轻代里的,由栈帧里的局部变量来引用

202301011609474853.png

一旦executeDemo2()方法执行完毕后,方法的栈帧则会出栈内栈,栈内存立即回收,对应的年代里的Demo2对象会被称为垃圾对象,等待垃圾回收机制回收

202301011609479524.png

紧接着会执行while循环代码,会周期性的调用executeDemo1()方法

202301011609484345.png

垃圾回收机制算法

复制算法

针对新生代的垃圾回收算法,叫做复制算法,顾名思义是将存货的对象复制到另外的地方。

这就是所谓的“ 复制算法 “,把新生代内存划分为两块内存区域,然后只使用其中一块内存

待那块内存快满的时候,就把里面的存活对象一次性转移到另外一块内存区域,保证没有内存碎片

接着一次性回收原来那块内存区域的垃圾对象,再次空出来一块内存区域。 两块内存区域就这么重复着循环使用

202301011609489066.png

  • 复制算法缺点

按照上面的思路,从图中可以看出,每次都需要空出一般的内存不使用,非常的浪费内存,加入给新生代1G的内存空间,但是按照上面的模型只有512MB的空间可以使用,这样的话对 内存的使用率只有50%

我们的代码中不停的创建对象然后分配在新生代中,但是一般很快创建的对象没人引用,成为垃圾对象,此时被垃圾回收机制回收,绝大多数的对象都是存活周期非常短的对象,可能被创建出来1毫秒之后就没人引用了,所以在每一次新生代垃圾回收后,绝大多数数对象都被垃圾回收,只有极少个数的对象存活下来。

  • 解决办法

所以JVM内存模型中, 把新生代内存区域划分为三块:

1个Eden区,2个Survivor区 ,其中Eden区占80%内存空间,每一块Survivor区各占10%内存空间,比如说Eden区有800MB内存,每一块Survivor区就100MB内存,如下图:

202301011609493247.png

平时可以使用的,就是Eden区和其中一块Survivor区,那么相当于就是有900MB的内存是可以使用的

刚开始对象都是分配在Eden区内的,如果Eden区快满了,此时就会触发垃圾回收,此时就会把Eden区中的存活对象都一次性转移到一块空着的Survivor区。接着Eden区就会被清空,然后再次分配新对象到Eden区里,此时:Eden区和一块Survivor区里是有对象的,其中Survivor区里放的是上一次Minor GC后存活的对象。

如果下次再次Eden区满,那么再次触发Minor GC,就会把Eden区和放着上一次Minor GC后存活对象的Survivor区内的存活对象,转移到另外一块Survivor区去

202301011609498888.png

这么做 最大的好处 ,就是只有10%的内存空间是被闲置的,90%的内存都被使用上了

无论是垃圾回收的性能,内存碎片的控制,还是说内存使用的效率,都非常的好

内存标记算法

被使用的内存区域中的垃圾对象进行标记,标记出哪些对象可以被垃圾回收,然后直接对内存区域中的对象进行垃圾回收,把内存空出来,在被内存使用的区域里,回收掉了大量的垃圾对象,但是保留了一些被人引用的存活对象。但是存活的对象在内存区域中东一个西一个,非常的凌乱,而且造成了大量的内存碎片

202301011609503649.png

图中圈红的则为出来的内存碎片,有些内存太小则无法被其他对象使用,则会造成 内存浪费

年轻代

  • 大部分的正常对象,都是优先在新生代分配内存的

老年代

新生代里的对象一般在什么场景下会进入老年代?

  • 躲过15次GC之后进入老年代

    如果一个实例对象在新生代中,成功的在15次垃圾回收后,还没有被回收掉,则会被转移到老年代中

    可以通过JVM参数“-XX:MaxTenuringThreshold”来设置,默认是15岁

  • 动态对象年龄判断

    此规则让对象不用等待15次GC后就进入老年代

    假如当前对象的的Survivor区域里,一批对象的总大小大于了这块Survivor区域的内存大小的50%,那么此时大于等于这批对象年龄的对象,就可以直接进入老年代了

  • 大对象直接进入老年代

    JVM参数:是“-XX:PretenureSizeThreshold”,可以把他的值设置为字节数,比如“1048576”字节,就是1MB,如果创建的一个大于这个参数设置的大小对象,就直接把这个大对象放到老年代去,不会经过新生代

  • Minor GC后的对象太多,无法放入Survivor区

    如果在Minor GC之后发现剩余的存活对象太多了,没办法放入另外一块Survivor区怎么办

2023010116095092810.png

在经过GC后,Edenl区里还有150MB的对象存活,Survivor区的内存只有100MB,此时无法放入S区, 则将这些对象直接转移到老年代中去

2023010116095172911.png

老年代空间分配担保规则

存在一个问题:如果新生代里有大量对象存活下来,确实是Survivor区装不下,必须转移到老年代中去,但是如果老年代里空间也不够放这些对象,此时JVM会怎么操作?

首先,在执行任何一次Minor GC之前,JVM会先检查一下老年代可用的可用内存空间,是否大于新生代所有对象的总大小。

**为啥检查这个呢?**因为最极端的情况下,可能新生代Minor GC过后,所有对象都存活下来了,那岂不是新生代所有对象全部要进入老年代?

如果发现老年代的内存大小是大于新生代所有对象的,就可以放心大胆的对新生代发起一次MinorGC,因为即使Minor GC之后所有对象存活,Survivor区放不下了,也可以转移到老年代去。

但 是假如执行Minor GC之前,发现老年代的可用内存已经小于了新生代的全部对象大小了

那么这个时候是不是有可能在Minor GC之后新生代的对象全部存活下来,然后全部需要转移到老年代去, 但是老年代空间又不够?

所以假如Minor GC之前,发现老年代的可用内存已经小于了新生代的全部对象大小了,就会看一个“- XX:-

**HandlePromotionFailure”**的参数是否设置

下一步判断,就是看看老年代的内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小

举个例子,之前每次Minor GC后,平均都有10MB左右的对象会进入老年代,那么此时老年代可用内存大于10MB。

这就说明,很可能这次Minor GC过后也是差不多10MB左右的对象会进入老年代,此时老年代空间是够的

如果上面那个步骤判断失败了,或者是“-XX:-HandlePromotionFailure”参数没设置,此时就会直接触发一次“Full GC”,就是对老年代进行垃圾回收,尽量腾出来一些内存空间,然后再执行Minor GC

如果上面两个步骤都判断成功了,那么就是说可以冒点风险尝试一下Minor GC。此时进行Minor GC有几种可能

  • 第一种可能,Minor GC过后,剩余的存活对象的大小,是小于Survivor区的大小的,那么此时存活对象进入Survivor区域即可。
  • 第二种可能,Minor GC过后,剩余的存活对象的大小,是大于 Survivor区域的大小,但是是小于老年代可用内存大小的,此时就直接进入老年代即可。
  • 第三种可能,很不幸,Minor GC过后,剩余的存活对象的大小,大于了Survivor区域的大小,也大于了老年代可用内存的大小。此时老年代都放不下这些存活对象了,就会发生“Handle Promotion Failure”的情况,这个时候就会触发一次“Full GC”。

Full GC就是对老年代进行垃圾回收,同时也一般会对新生代进行垃圾回收。

因为这个时候必须得把老年代里的没人引用的对象给回收掉,然后才可能让Minor GC过后剩余的存活对象进入老年代里面。

如果要是Full GC过后,老年代还是没有足够的空间存放Minor GC过后的剩余存活对象,那么此时就会导致所谓的

“OOM”内存溢出

上面的文字描述比较绕,可参考下面的流程图查看:

2023010116095231612.png

老年代垃圾回收算法

触发垃圾回收的时机

  • 在Minor GC之前,一通检查发现很可能Minor GC之后要进入老年代的对象太多了,老年代放不下,此时需

    要提前触发Full GC然后再带着进行Minor GC

  • 在Minor GC之后,发现剩余对象太多放入老年代都放不下

老年代采取的是 标记整理算法

如果系统频繁出现老年代的Full GC垃圾回收,会导致系统性能被严重影响,出现频繁卡顿的情况