深入理解Java虚拟机之GC垃圾回收器
0 Views jvm with
本文字数:5,009 字 | 阅读时长 ≈ 19 min

深入理解Java虚拟机之GC垃圾回收器

0 Views jvm with
本文字数:5,009 字 | 阅读时长 ≈ 19 min

GC 垃圾回收器

垃圾收集器(Garbage Collection, GC)。在前面介绍的Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程启动而存在,随线程死亡而死亡;栈中的栈帧随着方法的进入和退出进行出栈和入栈操作;所以这些区域的内存分配和回收都具备确定性。而Java堆和方法区作为运行时内存的共享部分,不同的类、方法所需要的堆内存大小不确定,他们最终在堆内存占用大小只有在程序运行期间才可能知道;所以这部分内存的分配和回收都是动态的,也是垃圾回收期重点关注的地方。

JDK1.7之前Java堆内存划分:

JDK1.8开始Java堆内存划分:

对象的存活

引用计数算法

判断对象是否存活的一种方式就是:引用计数算法。它将给每个对象添加一个引用计数器,每当有一个地方引用了这个对象,计数器值就+1;当引用失效,计数器值-1,当计数器值为0表明该对象不能再被引用,即对象已”死”。引用计数法实现简单,判定效率高。但Java的JVM并没有采用这种机制,举个栗子:

//JVM参数: -XX:+PrintGC 打印GC日志;-XX:+PrintGCDetails 打印GC的详细日志
public class GcTest {

    private Object instance = null;

    private byte[] size = new byte[1024];

    public static void main(String[] args) {
        //创建对象,计数器为1
        GcTest gt1 = new GcTest();
        GcTest gt2 = new GcTest();
        //两个对象相互引用,计数器为2
        gt1.instance = gt2;
        gt2.instance = gt1;

        //取消引用一次,计数器为1
        gt1 = null;
        gt2 = null;
        System.gc();
    }
}

打印:

[GC (System.gc())  3340K->504K(125952K), 0.0008667 secs]
[Full GC (System.gc())  504K->397K(125952K), 0.0039767 secs]

如果JVM采用了引用计数算法,那么上面两个对象相互引用,对象的计数器永远都不会为0,对象应该永远都不会被GC回收。然而观察上述日志,JVM并没有采用这种策略:504K->397k证明内存确实被回收了一部分。

[GC (System.gc()) [PSYoungGen: 3340K->496K(38400K)] 3340K->504K(125952K), 0.0016700 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 496K->0K(38400K)] [ParOldGen: 8K->384K(87552K)] 504K->384K(125952K), [Metaspace: 3072K->3072K(1056768K)], 0.0054180 secs] [Times: user=0.01 sys=0.01, real=0.01 secs] 
Heap
 PSYoungGen      total 38400K, used 998K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
  eden space 33280K, 3% used [0x0000000795580000,0x0000000795679bd0,0x0000000797600000)
  from space 5120K, 0% used [0x0000000797600000,0x0000000797600000,0x0000000797b00000)
  to   space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
 ParOldGen       total 87552K, used 384K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
  object space 87552K, 0% used [0x0000000740000000,0x00000007400602f8,0x0000000745580000)
 Metaspace       used 3089K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 340K, capacity 388K, committed 512K, reserved 1048576K

可达性分析算法

在Java、C#等语言中都是采用的可达性分析算法来判断对象是否存活。该算法是通过一些列称为”GC Roots”的对象作为起始节点,从这些节点向下搜索,搜索所走的路径称为引用链(Reference Chain),当一个对象没有任何引用链相连(即从GC Roots到这个对象不可达)时,则证明此对象是不可用的(即对象已”死”)。

在Java语言中,可作为GC Roots的对象包含下面几种:

再谈引用

JDK 1.2之后,Java将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次减弱:

引用类型 被回收时间 生存时间 解释
强引用 从来不会 JVM停止运行时终止 类似Object o = new Object(),只要强引用还存在就永远不会被回收
软引用 内存溢出前 内存溢出时终止 在系统将要内存溢出前,JVM将这些对象进行二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。可通过SoftReference类实现软引用。
弱引用 垃圾回收 GC运行之前 用于描述非必须对象,强度更弱,只要GC运行就会回收这部分。可通过WeakReference实现弱引用。
虚引用 为一个对象设置虚引用关联的唯一目的就是能在这个对象被GC回收时收到一个系统通知。可通过PhantomReference实现虚引用。

举个栗子:

/**
 * JVM: -XX:+PrintGC -Xms10M -Xmx10M
 */
public class GcReference {
    private static final int SIZE = 6 * 1024 * 1024;

    public static void main(String[] args) {
        strong();
        System.out.println("----------------------");
        soft();
        System.out.println("----------------------");
        weak();
    }

    private static void strong() {
        try {
            byte[] b = new byte[SIZE]; //强引用
            byte[] bb = new byte[SIZE + SIZE]; //模拟内存溢出
            System.gc();
        } catch (OutOfMemoryError e) {
            e.printStackTrace();
        }
    }

    private static void soft() {
        try {
            byte[] b = new byte[SIZE]; //强引用
            SoftReference softReference = new SoftReference(b); //显示声明为软引用
            b = null; //去除强引用
            byte[] bb = new byte[SIZE + SIZE]; //模拟内存溢出
            System.gc();
        } catch (OutOfMemoryError e) {
            e.printStackTrace();
        }
    }

    private static void weak() {
        byte[] b = new byte[SIZE]; //强引用
        WeakReference weakReference = new WeakReference(b); //显示声明为弱引用
        b = null; //去除强引用
        System.gc();
    }
}

打印结果:

[GC (Allocation Failure)  7817K->6616K(9728K), 0.0019768 secs]
[GC (Allocation Failure)  6616K->6648K(9728K), 0.0009771 secs]
[Full GC (Allocation Failure)  6648K->6544K(9728K), 0.0052729 secs]
[GC (Allocation Failure)  6544K->6544K(9728K), 0.0009530 secs]
[Full GC (Allocation Failure)  6544K->6527K(9728K), 0.0050893 secs]
java.lang.OutOfMemoryError: Java heap space
    at demo.GcReference.strong(GcReference.java:26)
    at demo.GcReference.main(GcReference.java:16)
----------------------
[GC (Allocation Failure)  6591K->6591K(9728K), 0.0012842 secs]
[Full GC (Ergonomics)  6591K->372K(9728K), 0.0043890 secs]
[GC (Allocation Failure)  6621K->6644K(9728K), 0.0006342 secs]
[Full GC (Ergonomics)  6644K->6516K(9728K), 0.0064704 secs]
[GC (Allocation Failure)  6516K->6516K(8704K), 0.0012736 secs]
[Full GC (Allocation Failure)  6516K->372K(8704K), 0.0052704 secs]
java.lang.OutOfMemoryError: Java heap space
    at demo.GcReference.soft(GcReference.java:38)
    at demo.GcReference.main(GcReference.java:18)
----------------------
[GC (System.gc())  6549K->6580K(9216K), 0.0014083 secs]
[Full GC (System.gc())  6580K->373K(9216K), 0.0051145 secs]

可以看到:

  1. 如果对象是强引用(即new出来在堆内存中存放的对象),在程序运行期间,如果内存不足,JVM即时抛出OutOfMemoryError异常也不会回收这个对象。从6544K->6527K可看出。
  2. 如果对象是软引用(直接用SoftReference显示声明),在程序运行期间,如果内存不足,会先回收这一部分对象引用所占的空间,但是,前提是必须先消除对象的强引用(因为我们这里创建的对象是new出来的强引用对象),通过b=null即可去除b所引用的SIZE大小的内存空间。从6516K->372K可看出。
  3. 如果对象是弱引用(直接用WeakReference显示声明),在程序运行期间,无论内存是否充足,只要GC运行了,这部分内存就会被回收。但是,前提是必须消除这个对象的强引用。可以从6580K->373K可看出。

注意:

  1. 首先需要设置虚拟机参数为:-XX:+PrintGC -Xms10M -Xmx10M ,仅是为了方便模拟。
  2. 在上面的例子中,创建的对象基本都是局部变量,也就是只作用于当前的方法体内,所以,我们要显示的调用System.gc();强制垃圾回收。如果方法运行完毕,对象自然就不存在了。
  3. 如果对象是强引用或软引用,一定要消除对象的强引用状态,不然GC仍然认为该对象是强引用对象,始终不会回收。

生存还是死亡

在Java虚拟机中,使用了可达性算法判断对象是否存活,进而让GC垃圾回收机制进行垃圾的回收。而在可达性算法中,通常是直接判断对象于GC Roots是否有相互连接的引用链,如果没有可达的引用链就视为该对象是死亡的应该被回收。

真正宣告一个对象的死亡,至少要经历两次标记过程:1.如果对象在进行可达性分析后判断没有于GC Roots相连接的引用链,那他将会被第一次标记并进行一次筛选;2.筛选条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过。如此,虚拟机将视为此对象”没有必要执行”,即死亡。

执行finalize()方法,虚拟机会创建一个低优先级的Finalizer线程区执行他。这是对象逃脱死亡的最后一次机会,只要在finalize()方法中重新让对象与其他任意一个对象建立关联即可保证对象继续存活。

public class FinalizeEscapeGC {

    private static FinalizeEscapeGC SAVE_HOOK = null;

    private void isAlive() {
        System.out.println("yes, I am still alive :)");
    }

    /**
     * 同一个对象的finalize()方法只会被调用一次
     * 当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过了,虚拟机将不再调用finalize()方法
     *
     * @throws Throwable
     */
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

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

        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        //因为finalize方法优先级别很低,所以暂停0.5s以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, I am dead :(");
        }

        //下面这段代码和上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        //引用finalize方法优先级很低,所以暂停0.5s以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, I am dead :(");
        }
    }
}
finalize method executed!
yes, I am still alive :)
no, I am dead :(

解释:

​ 在main方法中虽然给SAVE_HOOK对象建立了关联,但紧接着又解除了SAVE_HOOK对象的关联(SAVE_HOOK=null),此时该对象应该被回收的,但是因为GC回收对象前需要判定是否要执行finalize()方法,因为第一次调用gc,所以肯定需要执行finalize()方法,在finalize()方法中重新给SAVE_HOOK对象建立了关联,因此对象仍可存活。

​ 继续执行,再次消除了SAVE_HOOK对象的引用关联(SAVE_HOOK=null),此时虚拟机判断到当前对象已经调用过一次finalize()方法,所以不再调用,那么虚拟机判断SAVE_HOOK对象没有任何引用关联,所以直接回收。

回收方法区

方法区也称为永久代。堆中,在新生代中,常规进行一次垃圾回收一般可以回收70%~95%的空间,而回收永久代的效率远低于此。

永久代的回收主要收集”废弃常量”和”无用的类”。当一个字面量进入了常量池,但没有任何地方引用这个字面量,如果此时发生垃圾回收,就可能回收这个对象。而要判断一个类是否是”无用的类”的条件则相对苛刻:1.该类的所有实例都被回收了,Java堆中不存在该类的任何实例;2.该类的ClassLoader已经被回收;3.该类对应的java.lang.Class对象没有在任何地方被引用,无法再任何地方通过反射访问该类的方法。

垃圾回收算法

标记 - 清除算法

优点:

​ 该算法分为”标记”和”清除”两个阶段,首先标记对象,然后统一回收。是最基础的收集算法。

缺点:

​ 效率低,产生大量不连续的内存碎片,不便于以后垃圾回收申请大量连续内存空间情况。

复制算法

特点:

​ 将可用内存换分为等量的两块,每次仅适用其中一块。当这块内存使用完就将还存活的对象复制到另外一块上面,然后把上一块使用过的内存全部清理了。

优点:

​ 每次都是对内存的半个区域回收。不需要考虑太多内存碎片情况,实现简单,运行高效。

缺点:

​ 将整块内存分为原来的一半操作,代价太高。

标记 - 整理算法

标记-整理算法采用标记-清除一样的方式进行对象的标记,但在清除时不是直接对可回收的对象清理,而是让所有存活对象都向一端移动,然后直接清除端边界以外的内存。

分代收集算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。核心思想是根据对象存活的声明周期将内存划分为若干个不同的区域。一般情况下将堆内存划分为老年代(Tenured Generation)和新生代(Young Generation),在堆内存区外还有一代就是永久代(Permanet Generation)。老年代的特点是每次垃圾回收时都只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收。

年轻代(Yong Generation)的回收算法

  1. 所有新生对象首先都放在年轻代。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象
  2. 通常采用复制算法回收新生代。但并不需要将内存等量划分为两块,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor空间。当回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,然后清理使用过的Eden和Survivor空间,如果另一块Survivor空间不够,就会从老年代内存进行分配。HotSpot虚拟机默认Eden和Survivor的大小比例是 8:1
  3. 当另一块Survivor内存不足以存放前面Eden和Survivor中存活的对象,就将存活的对象直接存放到老年代。若是老年代满了就会触发一次Full GC,也就是新生代、老年代都进行回收。
  4. 新生代发生的GC叫做Minor GC, Minor GC发生的频率比较高(不一定等Eden区满了才触发)。

老年代(Old Generation)的回收算法

  1. 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会放到老年代中。因此,可以认为老年代中存放的是一些生命周期较长的对象。
  2. 内存比新生代大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC, Full GC发生频率比较低,老年代对象存活时间比较长,存活率比较高。

持久代(Permanent Generation)的回收算法

用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响。持久代也称为方法区。

总结

如上,Java的堆内存分为:新生代(Eden区和两块Survivor区)、老年代(Tenured Space区)、持久代(Permanent Space区域)。其中持久代在JDK1.7之前存在,在JDK1.8之后废弃了持久代,取而代之的是元数据区(Metaspace),区别在于:永久代使用的是JVM的堆内存区域,而元数据区直接使用本机的物理内存。

根据各个区域内存分布状态,进入堆内存的数据优先分配在Eden区和Survivor1区域,当GC回收后仍存活的对象复制到Survivor2区域,如果Survivor2区域内存不足就会将仍存活的对象存放到老年代区域,如果老年代内存不足就会触发Full GC。

HotSpot 的算法实现

枚举根节点

  1. 在从GC Roots向下查找引用链时,GC Roots的节点主要在全局的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,如果向下查找引用链时一次查找节点里面的引用,那么必然会消耗很多时间。
  2. 在查找引用链过程中,需要保证引用链的一致性,即在分析过程中对象的引用关系不能在发生变化,否则准确行无法发生保证,因此GC可能会在这一查询过程中停顿所有Java执行线程。
  3. 当执行系统停顿下来后,GC就并不需要再一个不漏的查询所有节点对象的引用位置,虚拟机通过一组OopMap的数据结构对象将GC Roots引用链上的节点对象的引用位置等数据记录下来了。所以GC在通过GC Roots向下查找引用链时通过OopMap就直接知道各个节点的引用位置关系。

安全点

上面通过OopMap可以准确的知道GC Roots引用链上的引用关系。HotSpot通过指令生成OopMap,但是因为OopMap在记录对象引用位置关系时涉及的指令很多,可能会改变对象原有的引用关系,并且这些指令的执行可能会占用很多GC的内存空间。

所以HotSpot并没有为每条指令都生成OopMap,虚拟机只在特定的位置记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有到达安全点时才能暂停。

对于Safepoint,需要考虑如何在GC发生时让所有的线程(不包括调用JNI的线程)都”跑”到最近的安全点上再停顿下来。这里提供两种方案:

安全区域

上面通过Safepoint安全点解决了如何进入GC的问题,但实际中线程如果由于CPU为分配时间执行而处在不执行状态时,JVM肯定不会等待这个线程被CPU分配时间执行了再处理这个线程。

JVM 通过安全区域(Safe Region)来解决上述问题,在安全区域这个片段中对象的引用关系时不会发生变化的。在这个区域的任意地方开始GC都是安全的,这样GC便可以通过主动中断线程,通过OopMap记录对象的引用链关系。

总结

至此: 如果GC开始执行,首先会先中断所有线程并根据GC Roots向下查找引用链上的引用关系,为了避免查询所有节点的引用关系导致大量消耗内存,虚拟机通过一些指令生成类似OopMap对象来计算并记录对象的引用关系;但OopMap的记录可能会改变对象的引用关系或占用GC过多的内存,虚拟机通过设定安全点Safepoint让程序执行到特定的位置才开始中断执行,通常我们使用主动式中断让线程主动轮询安全点的标志位,如果刚好在标志位就中断线程开始GC。但Safepoint安全点可能无法处理暂时未被CPU分配时间执行的线程,虚拟机又通过安全区域,让进入到这一区域的线程直接中断开始GC。


交流

以上仅是个人见解,欢迎提出意见或建议。

QQ交流群:671017003 欢迎各位前辈或萌新入驻


联系

If you have some questions after you see this article, you can contact me or you can find some info by clicking these links.

如果你觉得这篇文章帮助到了你,你可以帮作者买一杯果汁表示鼓励