De Front-end Dev Engineer

Java GC

2018-07-04

Java 的垃圾回收。

GC之标记 什么是标记?怎么标记?

第一个问题相信大家都知道,标记就是对一些已死的对象打上记号,方便垃圾收集器的清理。 至于怎么标记,一般有两种方法:引用计数和可达性分析。

引用计数实现起来比较简单,就是给对象添加一个引用计数器,每当有一个地方引用它时就加1,引用失效时就减1,当计数器为0的时候就标记为可回收。这种判断效率很高,但是很多主流的虚拟机并没有采用这种方法,主要是因为它很难解决几个对象之间循环引用的问题,向下面这个例子这样发生循环引用。虽然不怎么用了,但还是值得我们学习!

    public class Test {
      private Object obj;

      public void test(){
        Test t1 = new Test();
        Test t2 = new Test();

        // 循环引用
        t1.obj = t2;
        t2.obj = t1;
      }
    }

可达性分析的基本思路就是:通过将一些称为”GC Roots”的对象作为起始点,从这些节点开始搜索,搜索和该节点发生直接或者间接引用关系的对象,将这些对象以链的形式组合起来,形成一张“关系网”,又叫做引用链。最后垃圾收集器就回收一些不在这张关系网上的对象。

连接GC Roots对象的object是确定还存活的对象,而右边的die obj由于和GCROOTS没有关系,所以会标记为可回收的对象。目前主流的商用虚拟机用的都是类似的方法。那什么对象才能作为“GC Roots”呢?在java中,有四种对象可以作为“GC Roots”

  1. 栈帧(第一章的名词)中的引用对象。(栈中的)
  2. 静态属性引用的对象。(方法区中的)
  3. 常量引用的对象。(方法区中的)
  4. 本地方法栈中JNI引用的对象。(本地方法栈中的)

二次标记

上一章我们讲到了标记,但是不是被标记了就肯定会被回收呢?不知道小伙伴们记不记得Object类有一个finalize()方法,所有类都继承了Object类,因此也默认实现了这个方法。

finalize的工作原理应该是这样的:一旦垃圾收集器准备好释放对象占用的存储空间,它首先调用finalize(),而且只有在下一次垃圾收集过程中,才会真正回收对象的内存.所以如果使用finalize(),就可以在垃圾收集期间进行一些重要的清除或清扫工作.

finalize()在什么时候被调用? 有三种情况

  1. 所有对象被Garbage Collection时自动调用,比如运行System.gc()的时候.
  2. 程序退出时为每个对象调用一次finalize方法。
  3. 显式的调用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();

            // 等待1秒钟,让低优先级的线程执行完
            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()方法不一定会在对象第一次标记后执行。用一句清晰易懂的话来说就是:虚拟机确实有调用方法的动作,但是不会确保在什么时候执行完成。因此也就出现了上面输出的结果,对象被回收之后,那个低优先级的线程才执行完。

讲到这里,堆相关的知识也就大概讲完了,讲了好几章。我们时常Mark,是时候停下来整理这些Mark了!


Similar Posts

Comments