浅谈Java虚拟机(三)—垃圾回收

一、简介

    (建议读者了解Java虚拟机运行时数据区域后再阅读本章节)

    在上一篇文章《浅谈Java虚拟机(二)—运行时数据区域》中,我们一起学习了Java虚拟机的内存区域划分以及每个区域的作用,并且知道了其中的虚拟机栈、本地方法栈、程序计数器是线程私有的,它们的生命周期是随线程诞生和消亡,其中所占用的内存会随着消亡而自动释放,因此,Java虚拟机不需要管理这部分的内存

    Java堆与方法区都是所有线程共享的区域,其中Java堆作为Java虚拟机内存中主要存放对象的区域,当某个对象不会再被使用到时,就需要Java虚拟机维护并清理这个对象所占有的内存,否则大量“无用”的对象堆积在Java堆中会很容易出现内存溢出的现象,而这个回收“无用”对象的机制就称为垃圾回收(Garbage Collection,以下简称GC)机制,实现这个机制的代码模块被称为垃圾收集器。这里延伸补充一点,垃圾回收并不是Java虚拟机的伴生产物,更不是Java虚拟机独有的特性,事实上,垃圾回收的历史比Java的诞生都更久远,只是Java使用了这个机制来管理内存而已。

    在上一章节的讲解中提到过,方法区存储了大量的常量、静态变量,在JDK1.7以前(此处仍然以主流商用虚拟机Sun HotSpot为例),其中的运行时常量池还包含了字符串常量池,此时的方法区甚至是用永久代来实现,方便GC的分代收集机制(下面会讲到)回收此区域的内存,然而,Java虚拟机开发人员逐渐意识到,方法区中存放的不论常量、静态变量还是字符串,可回收的价值都不高(它们大多都是整个程序运行期间一直存在的,回收价值相对较高的就是字符串),还浪费了GC的性能,于是,逐步放弃用永久代实现方法区,在JDK1.7时,把字符串常量池放进了Java堆中,在JDK1.8时,使用元空间替代永久代实现方法区,元空间在Java虚拟机之外,使用的本地内存,不易发生OOM,但同样会触发GC,只是GC后会动态扩展元空间的内存,关于方法区更多细节可关注我的上一篇文章。

    即使其他虚拟机对方法区的实现有所差异,Java堆仍是GC主要作用的目标

    GC如此智能,那么我们为什么还要了解它呢,答案引用《深入理解Java虚拟机—周志明》中的原话:

    当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集称为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节

    下面将从GC是如何判断对象可回收的,如何回收的(即有哪些回收算法),以及何时触发回收这几个方面详解垃圾回收机制。

二、如何判断对象可回收

    垃圾回收的前提就是正确判断可回收的对象,否则这个机制就乱套了。那么GC是如何判断一个对象是否能回收呢,只要有以下几种方法:

   2.1 引用计数法    

        原理很简单,就是给每一个对象维护一个引用计数器(这里的引用计数器只是一个算法理念,具体实现方案有很多,例如直接在对象中维护一个整型字段,又比如每一个Class对象生成实例时为其伴随生成一个计数器对象以完成更复杂的工作,当然,计数器对象本身对原对象的引用需要特殊处理,这里暂不讨论具体实现方案,明白原理即可),初始值为0,当有任何一个引用指向该对象时,引用计数器触发自增1操作,反之,当引用断开,引用计数器触发自减1操作,任何时刻计数器为0的对象就会被GC视为不会再被使用可以清理掉的对象

引用计数法

        如上图所示,引用计数法看似很完美得解决了GC如何判断对象是否能回收的问题,但却有一个致命缺陷:

引用计数法的缺陷

        如上图所示,引用计数的缺陷就是无法解决循环引用,理论上,图中的Hello和Hi对象实例除了彼此引用,不会再被任何地方用到,那么就应该被GC清理掉,却因为引用计数始终为1无法执行清理,如果大量这样的对象存在就会导致内存溢出。

    2.2 可达性分析算法

        也称作GC Root搜索法,这个算法的基本思想就是通过一系列被称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索引用,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

        光看定义可能有些抽象,下面先用一张图来表示可达性分析算法,再来解释其中包含的一些定义:

可达性分析算法

        如图所示,在Java虚拟机中维护了一个GC Roots集合,里面装的是该算法认为可以作为GC Roots的对象,以这些对象作为起点来检查引用链分析一个对象是否可用:

        对象Object1被其中一个GC Roots引用,那么可判断它为存活状态,同理可推,被Object1所引用的Object2,Object3也存活,被Object3引用的Object4存活,至此,该引用链上的所有对象都被判断为存活;反之,Object5没有被任何GC Roots引用,它就被判断为不可用对象,Object6、Object7即使被Object5引用,但由于Object5不可用,因此Object6、Object7也不可用,这三个对象将会是被回收的对象。

        理解了可达性分析算法的原理,那么就剩一个问题:什么对象会被作为GC Roots呢?其实答案显而易见,正在被使用的和长期存在的对象,这两种对象经由上一章的讲解也很明了,他们就是存在于方法区的静态变量、常量以及存在于栈(包括Java虚拟机栈和本地方法栈)中的局部变量。

        再回到引用计数法中的循环引用问题就迎刃而解了,使用可达性分析算法代替引用计数法后,存在于栈帧c中的局部变量hi和hello就会作为GC Roots,他们分别指向堆中的Hi和Hello对象,此时的Hello和Hi对象是存活的,但当方法c执行完,栈帧c被弹出,局部变量hi和hello随即被回收,引用断开,那么堆中的Hi和Hello对象再无其他GC Roots引用,即使他们相互持有对方的引用,也会被判断为可回收的对象。

    2.3 JVM使用哪种方法判断对象是否可回收

        这个问题直接上代码验证:

/**        

* @Author: Lv

* @Date: 2020/11/17 10:35

* @Description: GC 测试类

*/

public class TestGC {

    private static class Hello{

        private static final int _1MB=1024*1024;

        //这个属性是为了占内存,方便查看GC日志时看清楚是否被回收过

        private byte[] size=new byte[2*_1MB];

        private Hi hi;

        private void setHi(Hi hi){

                this.hi=hi;

        }

}

/**

    * 其实创建两个Hello对象互相引用就可以,这里再写一个一模一样

    * 的Hi对象只是为了让循环引用更清晰一点

    */

    private static class Hi{

        private static final int _1MB=1024*1024;

        private byte[] size=new byte[2*_1MB];

        private Hello hello;

        private void setHello(Hello hello){

                this.hello=hello;

        }

}

public static void main(String[] args) {

        Hello hello=new Hello();

        Hi hi=new Hi();

        hello.setHi(hi);

        hi.setHello(hello);

        hello=null;//手动断开连接,与栈帧中的局部变量被回收效果一样

        hi=null;

        System.gc();//这个方法只是通知JVM垃圾回收,至于什么时候垃圾回收,要看JVM自身的调度

    }

}

        在JVM启动配置项里添加参数:-XX:+PrintGCDetails,用来在控制台输出里查看GC日志,上面代码执行后完整输出日志如下:

[GC (System.gc()) [PSYoungGen: 8846K->712K(38400K)] 8846K->720K(125952K), 0.0187288 secs] [Times: user=0.00 sys=0.00, real=0.02 secs]

[Full GC (System.gc()) [PSYoungGen: 712K->0K(38400K)] [ParOldGen: 8K->654K(87552K)] 720K->654K(125952K), [Metaspace: 3221K->3221K(1056768K)], 0.0089721 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

Heap

    PSYoungGen total 38400K, used 333K [0x00000000d5c00000, 0x00000000d8680000, 0x0000000100000000)

        eden space 33280K, 1% used [0x00000000d5c00000,0x00000000d5c534a8,0x00000000d7c80000)

        from space 5120K, 0% used [0x00000000d7c80000,0x00000000d7c80000,0x00000000d8180000)

        to space 5120K, 0% used [0x00000000d8180000,0x00000000d8180000,0x00000000d8680000)

    ParOldGen total 87552K, used 654K [0x0000000081400000, 0x0000000086980000, 0x00000000d5c00000)

        object space 87552K, 0% used [0x0000000081400000,0x00000000814a38e8,0x0000000086980000)

        Metaspace used 3228K, capacity 4500K, committed 4864K, reserved 1056768K

        class space used 351K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 0

        这里简单梳理一下日志内容,前两句概述了此次GC的总览,后面是新生代和老年代(这两个分代下面会讲到)各区域的空间详细信息,这里主要讲解加粗的头两句:

        开头的GC和Full GC表示GC的停顿类型(并不是下面要讲的分代收集中的触发了新生代和老年代垃圾收集,切勿混淆),如果出现了Full,则表示发生了Stop-The-World(STW),意思是执行这次GC时,挂起其他所有线程,只让GC线程执行。

        紧跟的括号中System.gc()表示触发此次GC的类型,我们这里是手动调用System.gc()触发GC,所以显示这个,如果是系统触发,则不会有这个括号内容。

        (以第一句为例)[PSYoungGen,[ParOldGen: ,[Metaspace:这些是发生GC的区域名,名称不固定,这与具体实现垃圾回收机制的垃圾收集器有关,不同的垃圾收集器有不同的命名。这里三个区域分别代表新生代,老年代以及元空间。

       (以第一句为例) 8846K->712K(38400K) 表示 GC前该区域已用容量->GC后该区域已用容量(该区域总容量)。

       (以第一句为例)方括号之外的8846K->720K(125952K) 表示 GC前Java堆已用容量->GC后Java堆已用容量(Java堆总容量)

       (以第一句为例)最后的 0.0187288 secs 是本次GC的时长。至于后面还有个 [Times: user=0.00 sys=0.00, real=0.02 secs]只有部分垃圾收集器会给出这样的更详细的时间,分别是用户态消耗的CPU时间、内核态消耗的CPU时间以及整个过程的墙钟时间(即不包含非运算时间,例如不包含IO操作耗时),这些概念了解即可,有兴趣的可以自查。

        了解完日志内容含义后,就可以从PSYoungGen: 8846K->712K(38400K)这一句分析出,我们创建的两个对象都在新生代,且在GC后它们都被回收了,这就反证出JVM并未使用引用计数法。

        事实上,在主流的商用程序中(Java,C#),都是使用可达性分析算法来判定对象是否存活。 而引用计数法优点在于实现简单,效率也很高,它的使用场景主要在微软的COM技术、使用ActionScript3的FlashPlayer、Python等等,了解即可。

三、垃圾收集算法

    上面我们已经知道JVM采用可达性分析算法来标记一个对象是否可以被回收,那么具体如何回收这些被标记的对象呢,有以下几种算法(以下都是算法理论,并不是具体实现):

    3.1 标记-清除算法

        这是最基础粗暴的一种算法,原理也很简单,当堆中的有效内存空间被耗尽的时候,就会停止整个程序,然后进行两步工作,第一步先把所有的可回收对象利用上述可达性分析算法进行标记,第二项就是统一回收被标记的对象(这里请注意,一个对象在该过程中只有两种状态,一个是存活,一个是可回收,假设这个标志位为0/1,初始值都为0,因此这里所说的标记可回收对象的意思可以理解为沿着GC Roots的引用链把存活的对象都标记为1,那么相当于把可回收对象标记为0),如图所示:

标记-清除算法

        从图中可以看出该算法存在一个很直观的不足之处:在回收之后,内存中会存在大量的不连续内存碎片,这样的碎片太多就会导致以后在程序运行过程中需要为大对象分配空间时,找不到连续内存来存放,从而提前触发下一次垃圾回收,甚至可能导致内存溢出。

        标记清除算法的另一个不足之处就是它的效率不高,毕竟经历标记和清除两次遍历(第一次遍历标记了存活对象,遍历完成后,相当于可回收对象也被标记出来,第二次遍历找到这些可回收对象进行清除),这两个过程加起来会比较费时。

        但所有的教材都会把标记清除算法放在讲解GC算法的第一个位置来讲,就是因为它是最基础的算法,后面的GC算法都是在它基础上做的优化。

    3.2 复制算法

        复制算法将内存等分为两个区域,所有动态分配的对象都只能分配在其中一个区域,而另外一个区域始终保持空闲状态

        当正在使用的一块区域有效内存空间耗尽时,GC线程会将该区域内的存活对象,全部复制到另一个空闲区域,且严格按照内存地址依次排列,然后更新存活对象的内存引用地址指向新的内存地址。同时,将原区域中的内存(只剩下了可回收对象)一次性全部回收,两个区域就完成了互换。

复制算法

        很明显,复制算法效率比标记清除算法要高很多,全部过程都可以在一次遍历中完成,而且不会产生内存碎片,新来的对象分配内存也只需要移动堆顶指针,按顺序存放即可。

        缺点也很明显,那就是永远都要浪费一半的内存空间,假设极端条件下,内存中的对象全部存活且触发了GC,那么活着的对象集体进行无用的复制转移,原区域也没有可回收的对象进行回收,新的对象还是没有地方存放,白白浪费了一半的空间,最后造成了内存溢出。由这个极端情况不难考虑出,复制算法对于对象存活率越高时,整体空间使用率和效率就会显得越低。

    3.3 标记-整理算法

        为了应对上述复制算法的缺点,标记整理算法出现了。该算法标记出所有存活的对象,然后让所有存活对象向一端进行移动,最后直接清理存活对象端边界以外的内存。

标记整理算法

        该算法也仅需一次遍历,互补于复制算法,对象存活率越高时,效率越高,试想一下对象存活率极高时,存活对象总在内存的一端,触发GC也不再需要去移动它们,反之,如果对象存活率很低,每一次GC都需要不断清理可回收对象,又将新的存活对象标记移动到存活的一端,那么效率就很低了。

    3.4 分代收集算法

        分代收集算法与其说是新的算法,不如说是上述算法的整合和再优化。使用分代收集算法会把Java堆分为几个区域,针对每个区域不同的对象存活情况使用不同的垃圾回收算法,以提高整体的效率。

分代收集算法对Java堆的分区

        如图所示,该算法总体把Java堆分为两个区域:新生代(Young Generation)和老年代(Old Generation),内存空间占比1:2。

        新生代

        几乎所有新产生的对象都会首先进入新生代,这里的对象有一个特点,那就是“朝生夕死”,换句话说就是存活率不高(大量方法的运行中创建对象,方法运行完,局部变量被回收断开引用),因此这里采用复制算法,每次只有少量对象需要被复制迁移,大量对象被直接清除,完美利用复制算法的机制,效率很高。

        更具体得来说,新生代被分为伊甸区(Eden)和生存者区(Survivor),生存者区又等分成了From和To两个区域,三个分区的内存空间占比为8:1:1(并没有机械得使用复制算法原生定义的两区等分,而是进行了优化,因为新产生的对象总是很多,而GC完后存活的对象却很少),新生成的对象会进入较大的Eden区,当Eden区空间不足会进入其中一个Survivor区,另一个Survivor区就作为复制算法的空闲区域,当触发GC时,将Eden和一块Survivor区域的所有存活对象复制到另一块Survivor区域,然后清理到刚存放对象的区域,如此循环。

        虽然每次只会剩少量存活对象,但如果它们始终存在于新生代,新生代就将无法存放新对象,这是无法接受的,因此JVM在使用分代收集分代时对对象进行了“年龄”标记,对象初始化“年龄”都为0,每次新生代GC后存活的对象年龄会+1,达到一定数值后会被移动至老年代(这个数值可以配置,通用默认值是15,仍然看具体实现)。

        老年代

  老年代存放的对象都是存活率较高的对象(例如缓存对象、数据库连接对象、单例对象等等),因此可以采用标记-清除或者标记-整理算法,具体采用哪种根据使用的垃圾收集器来进行判断。

        上面说几乎所有对象都会首先被分配到伊甸区,但也有例外,比如特别大的对象(例如很长的String或很大的byte[])会直接进入老年代,因为它需要很大的连续存储空间,这个阈值也可以使用虚拟机参数-XX:PretenureSizeThreshold进行配置,内存大于配置数的对象就会直接进入老年代,默认值为3MB。

四、何时触发垃圾回收

     JVM在进行GC时,并非每次都对所有内存区域一起回收的,大部分时候回收的都是指新生代。因此GC按照回收的区域又分了两种类型,一种是普通GC,一种是全局GC,它们所针对的区域如下:

    普通GC(Minor GC):只针对新生代区域的GC。

    全局GC(Major GC or Full GC):主要针对老年代的GC,偶尔伴随对新生代的GC以及对永久代的GC(仅JDK1.8以前,现在已经由元空间代替永久代实现方法区,元空间有自己的内存管理,并不需要JVM的GC干预),由于老年代相对来说GC效果不好,而且内存使用增长速度也慢,因此正常情况下,需要经过好几次普通GC,才会触发一次全局GC。

    第一种触发GC的方式就是虚拟机会自行根据当前内存大小,判断何时进行垃圾回收。即当新生对象进入伊甸区无法申请到足够内存时就会触发普通GC,而当大对象或新生代里年龄达到阈值的对象无法申请到足够的老年代内存时将会触发全局GC。

    第二种是程序员手动调用 System.gc()方法通知JVM进行垃圾回收,且这种方式触发的是全局GC,只是它不能保证垃圾回收一定会进行,具体什么时候进行是取决于具体的虚拟机的,不同的虚拟机有不同的对策。

五、总结

    JVM(Sun HotSpot )采用可达性分析算法判断一个对象是否可回收,并使用分代收集算法进行垃圾回收,只是Java虚拟机规范对垃圾收集器应该如何实现分代收集算法,并没有任何的规定,所以不同的厂商、不同版本的虚拟机所提供的垃圾收集器都会有所不同,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个分年区域所使用的收集器。

    另外,如果我们的程序频繁触发全局GC,将会直接影响程序执行效率,因此在对JVM调优的过程中,很大一部分工作就是对于全局GC的调节,下一章节我们将一起学习JVM调优。

《浅谈Java虚拟机(一)—什么是Java虚拟机》

《浅谈Java虚拟机(二)—运行时数据区域》

《浅谈Java虚拟机(三)—垃圾回收》

《浅谈Java虚拟机(四)—JVM调优》


本系列文章参考文档:《深入理解Java虚拟机:JVM高级特性与最佳实践》-- 周志明

你可能感兴趣的:(浅谈Java虚拟机(三)—垃圾回收)