写点什么

JVM 进阶 (六):鲜为人知的二次标记

  • 2022 年 2 月 05 日
  • 本文字数:2363 字

    阅读完需:约 8 分钟

JVM进阶(六):鲜为人知的二次标记

一、前言

在前期博文《JVM进阶(五):JAVA GC 之标记》讲到了标记,是不是被标记了就肯定会被回收呢?不知道小伙伴们记不记得Object类有一个finalize()方法,所有类都继承了Object类,因此也默认实现了这个方法。


finalize()方法的用途就是:在该对象被回收之前,该对象的finalize()方法会被调用。这里的回收之前指的就是被标记之后,问题就出在这里,有没有一种情况就是原本一个对象开始不在上一章所讲的“关系网”(引用链)中,但是当开发者重写了finalize()后,并且将该对象重新加入到了“关系网”中,也就是说该对象对我们还有用,不应该被回收,但是已经被标记啦,怎么办呢?

二、二次标记

针对这个问题,虚拟机的做法是进行两次标记,即第一次标记不在“关系网”中的对象。第二次的话就要先判断该对象有没有实现finalize()方法了,如果没有实现就直接判断该对象可回收;如果实现了就会先放在一个队列中,并由虚拟机建立的一个低优先级的线程去执行它,随后就会进行第二次的小规模标记,在这次被标记的对象就会真正的被回收了。我们来看下面的代码:


public class Test {  private static Test test = null; // 声明一个类静态变量  public static void main(String[] args) throws InterruptedException {    test = new Test(); // 生成实例    testHelp(); // 第一次调用    testHelp(); // 第二次调用  }  public static void testHelp() throws InterruptedException {    test = null; // 将其从关系网中移出    // 通知要进行垃圾回收,但不一定会执行垃圾回收,只是一个通知而已    // 因此在开发中不要过多的依赖这个方法,这里只是做个测试    System.gc();    // 等待1s,让低优先级的线程执行完    Thread.sleep(1000);    if(test === null){      System.out.println("我挂啦");    } else {      System.out.println("我还活着");    }  }  @override  protected void finalize() throws Throwable {    super.finalize();    test = this; // 将其重新加入关系网中    System.out.println("我被调用啦");  }}
复制代码


大家觉得结果会输出什么?最后的结果是:


我被调用啦我还活着我挂啦


有木有觉得很诧异,明明调用了两次同样的方法,但输出怎么不同呢?而且明明调用了两次 gc()方法(这里确认是执行了 gc),那怎么只进入了一次finalize()方法?


嘿嘿,其实面对同一个对象,finalize()方法只会被调用一次,因此第一次调用的时候会进行finalize()方法,并且成功的将该对象加入了“关系网”中,但当第二次回收的时候并不会进入,所以第二次不能将对象加入“关系网”中,导致被回收了。


上面的代码块中有一行让程序睡眠一秒钟的代码,为的就是确保让低优先级的执行 finalize()方法线程执行完成。那如果我们把他注释了会怎样呢?输出结果是:


我挂啦我被调用啦我挂啦


很奇怪吧,不过如果执行很多次的话,也会出现最开始那样的结果,但多数会是这个结果。因为我们已经说了,执行finalize()的是一个低优先级的线程,既然是一个新的线程,虽然优先级低了点,但也是和垃圾收集器并发执行的,所以垃圾收集器没必要等这个低优先级的线程执行完才继续执行。也就是说,finalize()方法不一定会在对象第一次标记后执行。用一句清晰易懂的话来说就是:虚拟机确实有调用方法的动作,但是不会确保在什么时候执行完成。因此也就出现了上面输出的结果,对象被回收之后,那个低优先级的线程才执行完。

三、拓展阅读

四、延伸阅读 从 GC 日志分析堆内存

在前期博文中,我们只设置了整个堆的内存大小。但是我们知道,堆又分为了新生代年老代。他们之间的内存怎么分配呢?新生代又分为EdenSurvivor,他们的比例大小能改变吗?其实这些都是可控的,以前没有讲到是因为就算讲了也只是讲讲而已,看不到实质性的东西。因此,这篇博文我们通过分析GC日志来一步步讲解如何细化设置堆内存。


首先我们来了解几个相关的参数:


  • -XX:+PrintGCDetails:用于告诉虚拟机回收垃圾的时候顺便打印日志;

  • -Xloggc:路径 :将打印出来的日志信息保存至指定的路径;

  • -Xmn10M:设置新生代的内存大小;

  • -XX:SurvivorRatio=8:1:调整EdenSurvivor的比例为 8:1;

4.1 示例讲解

我们还是用前面的代码例子来讲:


public class Test {  private static List<Test> list = new ArrayList<Tets>();  public static void main(String[] args){    while (true) {      Test test = new Test();      list.add(test);    }  }}
复制代码


然后用参数-Xms20m -Xmx20m -Xmn10-XX:+PrintGCDetails -Xloggc:d:\gc1.log启动。表示给堆分配 20M,给新生代分配 10M,并打印 GC 日志,并将其输出至 D 盘的 gc1.log 文件中。运行后得到以下日志,这是第一部分:



现在我们来分析下每个部分代表的含义:


  • 0.090:就是虚拟机从启动到现在经历的时间(单位:s)。

  • GC:指的是停顿类型(留着下一章讲)

  • PSYoungGen:发生GC的区域,这里指的是年轻代。根据收集器的种类而定。

  • 7284K->1016K(9216K):该区域GC前当前区域所使用的容量-->该区域GC后已使用的容量(该区域的总容量),也就是新生代的容量。

  • 7284K->6139K(19456K):整个堆GC前当前区域所使用的容量-->整个堆GC后已使用的容量(整个堆的总容量)。

  • 0.0078481:这次GC所占用的时间(单位:s)。


我们再来看看第二部分:



看图画红线部分,表示当前的堆中新生代可用内存的大小(一个eden和一个Survivor视为可用内存),红色框下面则是年老区的大小,加上一共是 20m,符合我们所设置的。


红色框中的部分则是新生代中eden区和两个Survivor区的大小,可以看出他们的比例是 8:1,如果设置为-XX:SurvivorRatio=3的话,结果如下



到这里以上几个参数的作用以及分析就讲完啦,小伙伴们可以打开自己的工具试一试,感受一下。以后碰到了内存泄漏或者内存不足的话就可以直接查看日志来进行分析调优了!

发布于: 刚刚阅读数: 2
用户头像

No Silver Bullet 2021.07.09 加入

岂曰无衣 与子同袍

评论

发布
暂无评论
JVM进阶(六):鲜为人知的二次标记