第二章 垃圾回收

对象生死

前言

在堆中存放的着Java世界中几乎所有的对象实例,垃圾收集器在对堆中进行回收前,需要判断哪些对象“存活”,哪些对象已经“死亡”(不可能再被任何途径使用)。


引用计数法

很多教科书判断对象是否存活的算法是这样的:在对象中添加一个计数器,每当有一个地方引用它时,计数器就+1;当饮用失效时,计数器就-1;当计数器为0时,则标示这个对象失去引用,成为一个死亡对象。客观的来说,引用计数法虽然占用了一些额外的内存空间,但是原理简单,判定效率很高,在大多数情况下都是一个不错的算法。但是在Java领域,主流的Java虚拟机中都没有采用这种算法。这个看似简单的算法有很多例外的情况需要考虑,最简单的例子就是对象之间循环引用。比如

public class Main {

    public Object instance = null;

    //没有实际意义,仅仅是占用一点内存空间,方便观察
    private static final byte[] nothing=new byte[1024*1024*10];

    public static void main(String[] args) {

        Main a = new Main();
        Main b = new Main();

        a.instance = b;
        b.instance = a;

        a = null;
        b = null;

        System.gc();
    }
}


/Users/lezzy/openJdk/jdk11/build/macosx-x86_64-normal-server-slowdebug/jdk/bin/java -Xlog:gc* Main.java
[0.009s][info][gc,heap] Heap region size: 1M
[0.021s][info][gc     ] Using G1
[0.021s][info][gc,heap,coops] Heap address: 0x0000000700000000, size: 4096 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
[2.540s][info][gc,start     ] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
[2.540s][info][gc,task      ] GC(0) Using 6 workers of 13 for evacuation
[2.567s][info][gc,phases    ] GC(0)   Pre Evacuate Collection Set: 0.1ms
[2.567s][info][gc,phases    ] GC(0)   Evacuate Collection Set: 20.3ms
[2.567s][info][gc,phases    ] GC(0)   Post Evacuate Collection Set: 5.7ms
[2.567s][info][gc,phases    ] GC(0)   Other: 1.3ms
[2.567s][info][gc,heap      ] GC(0) Eden regions: 24->0(150)
[2.567s][info][gc,heap      ] GC(0) Survivor regions: 0->3(3)
[2.567s][info][gc,heap      ] GC(0) Old regions: 0->0
[2.567s][info][gc,heap      ] GC(0) Humongous regions: 0->0
[2.567s][info][gc,metaspace ] GC(0) Metaspace: 13879K->13879K(1062912K)
[2.568s][info][gc           ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 24M->2M(256M) 27.395ms
[2.568s][info][gc,cpu       ] GC(0) User=0.21s Sys=0.00s Real=0.03s
[3.729s][info][gc,task      ] GC(1) Using 6 workers of 13 for full compaction
[3.735s][info][gc,start     ] GC(1) Pause Full (System.gc())
[3.736s][info][gc,phases,start] GC(1) Phase 1: Mark live objects
[3.834s][info][gc,stringtable ] GC(1) Cleaned string and symbol table, strings: 4293 processed, 11 removed, symbols: 47995 processed, 11 removed
[3.834s][info][gc,phases      ] GC(1) Phase 1: Mark live objects 97.542ms
[3.834s][info][gc,phases,start] GC(1) Phase 2: Prepare for compaction
[3.837s][info][gc,phases      ] GC(1) Phase 2: Prepare for compaction 3.772ms
[3.837s][info][gc,phases,start] GC(1) Phase 3: Adjust pointers
[3.855s][info][gc,phases      ] GC(1) Phase 3: Adjust pointers 17.930ms
[3.855s][info][gc,phases,start] GC(1) Phase 4: Compact heap
[3.928s][info][gc,phases      ] GC(1) Phase 4: Compact heap 72.599ms
[3.938s][info][gc,heap        ] GC(1) Eden regions: 27->0(22)
[3.938s][info][gc,heap        ] GC(1) Survivor regions: 3->0(3)
[3.938s][info][gc,heap        ] GC(1) Old regions: 0->6
[3.938s][info][gc,heap        ] GC(1) Humongous regions: 11->11
[3.938s][info][gc,metaspace   ] GC(1) Metaspace: 15405K->15405K(1062912K)
[3.938s][info][gc             ] GC(1) Pause Full (System.gc()) 40M->14M(57M) 202.631ms
[3.940s][info][gc,cpu         ] GC(1) User=0.41s Sys=0.10s Real=0.21s
[3.947s][info][gc,heap,exit   ] Heap
[3.947s][info][gc,heap,exit   ]  garbage-first heap   total 58368K, used 14576K [0x0000000700000000, 0x0000000800000000)
[3.947s][info][gc,heap,exit   ]   region size 1024K, 1 young (1024K), 0 survivors (0K)
[3.947s][info][gc,heap,exit   ]  Metaspace       used 15409K, capacity 15815K, committed 15872K, reserved 1062912K
[3.947s][info][gc,heap,exit   ]   class space    used 1626K, capacity 1776K, committed 1792K, reserved 1048576K
[3.950s][info][gc,verify,start] Verifying 
[4.097s][info][gc,verify      ] Verifying  146.887ms

Process finished with exit code 0

观察GC日志可以发现,在JVM中,循环引用的对象还是被GC收集掉了,这也侧面证明了JVM采用多并不是引用计数法来判断对象是否存活。

可达性分析算法

说实话,在现在卷的不能在卷的环境下,提到JVM就想到GC,提到GC就想到GC Roots,GC Roots就是通过一系列被称之为“根节点”的对象作为起始节点集,从这些节点开始,根据引用关系进行向下搜索,搜索的过程中所走过的路径叫做引用链(Reference Chain),如果这个对象到GC Roots之间没有任何引用链相连,则证明此对象不可达,也就是说这个对象不可能再被使用。GC Roots的对象包括以下:

  1. 虚拟机栈中(栈帧中的本地变量表)引用的对象。譬如各个线程被调用的方法堆栈中使用的参数、局部变量、临时变量等。
  2. 方法区中的类静态属性引用的对象。譬如Java类的引用类型静态变量
  3. 方法区中常量引用的对象,譬如字符串常量池中的引用。
  4. 本地方法栈中Native引用的对象
  5. Java虚拟机内部引用对象,譬如Class对象,SystemClassLoader、以及一些常驻异常对象NullPointException,OOM等。
  6. 所有被同步锁持有的对象(synchrnized)
  7. 反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。(这个是什么东西我目前也不太清楚)

再谈引用

无论哪种算法,判断对象是否存活都离不开“引用”的概念,在JDK1.2之后的版本,Java对引用这个概念进行了充分的扩展。将引用划分成了以下四个级别,这四个级别的强度依次减弱:

  1. 强引用,指代码之中普遍存在的引用赋值,譬如Object obj=new Object(),无论什么情况,只要强引用关系存在,那么这个对象永远不会被GC回收。
  2. 软引用,描述一些还有用,但不是必须的对象,被软引用关联的对象,将在发生OOM之前被GC列入回收范围进行第二次回收,如果这次回收结束还没有足够的内存,将会发生OOM。
  3. 弱引用,用来描述那些非必需对象,但是他的强度比软引用更弱,被弱引用关联的对象无论当前内存是否足够都将会在下一次GC活动时被回收。
  4. 虚引用,强度最弱,甚至无法通过虚引用来取得一个对象都实例。虚引用存在的唯一目的就是对象在被GC回收时,能收到一个系统通知。

再谈引用

在GC Roots判定无法到达的对象也并非立即执行“死刑”,还是有一个“改过自新”的方法,那就是finalize()。宣布一个对象真正的死亡至少要经历两次被标记的经历。首先GC Roots在发现有不可达对象时,将这些对象进行第一次标记。然后进行筛选。筛选的标准就是否有必要执行finalize()。

  1. 如果该对象已经执行过一次finalize(),虚拟机将认为该对象没必要再次执行finalize(),任何一个对象有且只有一次机会执行finalize()。否则认为该对象有必要执行finalize()。
  2. 如果该对象覆盖了finalize()方法,虚拟机将认为该对象有必要执行finalize(),否则没必要执行finalize(),个人理解如果类中重写了finalize()方法,那么当该类对象失去引用后一定会调用finalize()方法。

如果这个对象被认为需要执行finalize()方法,那么会有个F-Queue的队列对这些需要执行finalize()的对象进行收集管理。随后虚拟机将会开辟一个低优先级的Finalize线程执行他们的finalize()方法。虚拟机只承诺会开始执行finalize()方法,但是并没有保证一定会执行完毕。因为如果某个对象的finalize()方法执行的很慢,将会发生阻塞,更严重将会发生死循环,整个GC将会直接崩溃。所以finalize()方法是这些对象最后一次进行逃逸的机会。下面代码示例

public class FinalizeTest {

    private static FinalizeTest instance = null;

    public static void isAlive(boolean flag){
        String str=flag? "i am still alive":"i am dead";
        System.out.println(str);
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize() is executed by gc");
        FinalizeTest.instance=this;
    }

    public static void main(String[] args) throws InterruptedException {
        instance=new FinalizeTest();

        //instance第一次自救模拟
        //成为了不可达对象
        instance=null;
        //进行gc,首先他已经是一个GC Roots不可达对象,那么进行判断
        //1。是否覆盖finalize()
        //2。是否执行过finalize()
        //很显然满足1,已经重写了finalize()方法,那么gc认为有必要执行finalize()
        System.gc();
        //Finalize现场优先级很低,先暂停1s等待一下
        //注意这里已经触发了finalize()方法。但是由于虚拟机只会保证finalize()运行,并未承诺一定会等待他运行结束
        //在结束之前发现instance=this,变成了GC Roots可达对象。这里就是instance发生了逃逸。
        TimeUnit.SECONDS.sleep(1);
        isAlive(Objects.nonNull(instance));

        //instance第二次自救
        instance=null;
        //满足1,不满足2
        //因为已经执行过一次finalize()。由于任何对象的finalize()都只会被执行一次。
        //所以这次不会再执行finalize()方法,直接被GC回收
        System.gc();
        //Finalize现场优先级很低,先暂停1s等待一下
        TimeUnit.SECONDS.sleep(1);
        //因为不执行finalize(),这里instance仍为null。
        isAlive(Objects.nonNull(instance));
    }
}

心心念念的方法区回收

之前提过方法区也被叫做永久代,其目的就是希望GC能接管方法区的回收。然而效果很难令人满意,一方面是方法区回收条件较为苛刻,另一方面就是性价比很低。
方法区主要回收有废弃常量以及废弃的类型。常量简单,就是没有任何引用就可以进行回收。废弃类型就比较苛刻:

  1. 该类所有的实例都被回收。
  2. 加载该类的ClassLoader已经被回收,这个除非精心设计的自定义类加载器不然很难达成。
  3. 无法在任何地方通过反射访问该类的方法。

之前提过方法区也被叫做永久代,其目的就是希望GC能接管方法区的回收。然而效果很难令人满意,一方面是方法区回收条件较为苛刻,另一方面就是性价比很低。
方法区主要回收有废弃常量以及废弃的类型。常量简单,就是没有任何引用就可以进行回收。废弃类型就比较苛刻:


垃圾收集算法

前言

当前主流的虚拟机中的垃圾收集器,几乎都采用可达性分析算法判断对象是否存活。因此大都遵循类分代收集理论。分代收集理论建立在两个分代假说上:

  1. 弱分代:绝大多数对象都是朝生夕灭的,想一想前面提过的Appel式回收。便基于此假说
  2. 强分代:熬过GC次数越多的对象越难以被回收。

基于这两个假说,那么堆内存就应该划分为不同的区域,根据其对象的年龄将其分配到不同的存储空间,采用不同的算法进行管理。基于第一个假说,如果大量对象都会消亡,那么我们只需要关注那些能够存活的对象即可,这样既提升效率,又节省空间。经历长时间的论证与演化,当下主流的划分是将堆内存分成两块区域,其中三分之一叫做新生代(Young)和老年代(Old),又将Yong中划分为一块较大的Eden区域,两块一样大小的Suvivor区域。前文已经提及不在多说。这里主要说一下夸代引用假说这个理论,在这之前,约定俗成一下后文出现的Minor GC指仅仅发生在新生代的GC活动,Major GC指仅仅发生在老年代的GC活动,Miexde GC就是收集整个新生代和老年代,Full GC当然就是全GC包括方法区。将堆空间划分如此细致以后,我们还面临一个问题,不同的对象在不同的区域存在着相互引用,我们仅仅在新生代中进行一次GC,那么除了在新生代中进行GC Roots,我们还需要在老年代中同样进行GC Roots确保新生代和老年代之间是否有引用关系,我们实际上相当于遍历了整个堆空间,为了优化这个问题,便产生了第三个分代假说:跨代引用相对于同代引用比例很小。根据这条假说,我们只需要在新生代上建立一个全局的数据结构——记忆集,这个结构把老年代划分为若干小块,标示出老年代的那一块存在跨代引用。此后发生Minor GC时,老年代只有小块区域才会随之进行GC Roots。提高了效率。这三种算法很多博文都有详细解释和描述,这里仅做知识点记录,不做过多长篇论述。


标记-清除算法

长话短说,就是将GC Roots不可达对象/可达对象进行标记,标记完成后进行回收不可达对象。缺点非常明显

  1. 效率低下,如果堆中有大量对象,标记效率低下
  2. 空间碎片化严重,如果没有足够的连续空间存放大对象,不得不再次进行一次标记-清除。

空间碎片化严重,如果没有足够的连续空间存放大对象,不得不再次进行一次标记-清除。

标记-复制算法

将堆内存一分为二,首先在其中一块半区进行内存分配,在需要GC时先将可达对象进行标记,然后将其复制到另一块半区,在整个清除掉这个半区,整个过程重复这个动作即可。优点是简单高效,不用考虑空间碎片化。缺点也很明显:严重浪费堆空间。

标记-整理算法

标记复制算法在对象存活率较高的时候需要进行较多的复制操作,效率将下降,老年代普遍采用的是一种标记-整理的算法,先标记,在移动,这种算法必须全程暂停用户程序才行,当然最新的ZGC和Shenandoah技术实现了并发过程

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值