Java垃圾收集(GC)详解

1、简介

    Java内存运行时区域分为堆(Heap)、方法区、虚拟机栈、本地方法栈和程序计数器,其中虚拟机栈、程序计数器和本地方法栈随线程而生、随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配所少内存基本上在类结构确定下来就是已知的(尽管在运行期会由即时编译器进行一些优化,但是在基于概念模型的讨论里,大体上可以认为是编译期可知的),这几个区域的内存分配和回收都具备确定性,因此对于这几个区域就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

    但是Java堆和方法区这两个区域就有很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象、创建多少个对象,这部分内存的分配和回收时动态的,因此,垃圾收集器所关注的正式这部分内存该如何管理。

这里先解释一下并行和并发:

并行(Parallel):

并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。

并发(Concurrent):

并发描述的是垃圾收集器线程和用户线程之间的关系,说明同一时间垃圾收集器线程和用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能够响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

2、判断对象存活

    由于在堆中存放着几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象之中哪些还“存活着”,哪些已经“死去”(即没有被任何途径使用的对象)。

2.1 引用计数算法

    给对象添加一个引用计数器,每当有一个地方使用它时,计数器加1;当引用失效时,计数器减1;任何时刻计数器为0时,说明该对象不能再被引用。

    虽然这种方法占用了一些额外的内存空间来进行计数,但它的原理很简单,判定效率也很高,在大多数情况下都是一个不错的算法,也有一些比较著名的应用案例,比如微软COM(Component Object Model)技术、使用ActionScript3的FlashPlayer、Python语言等都是用了引用计数算法进行内存管理。但是在Java领域,主流的Java虚拟机里面都没有选用引用计数算法来管理内存,因为这个看似简单的算法有很多例外情况需要考虑,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题,或者比如objA.instance=objB及objB.instance=objA这种情况下,objA和objB已经不再被访问,但因为objA和objB互相引用导致它们的引用计数都不为0,所以它们也就不会被GC。

2.2 可达性分析算法

    这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索所走过的路径称为引用链(Reference Chain),当某个对象到GC Roots间没有任何引用链相连时,证明此对象是不可用的。

    如上图所示,object5、object6和object7虽然互有关联,但是它们到GC Roots是不可达的,因此它们会被判定为可回收的对象。

    在Java语言里,可作为GC Roots的对象包括以下几种

    1)在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

    2)在方法区中常量引用的对象,譬如字符串常量池里的引用。

    3)在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。

    4)本地方法栈中的JNI(即Native方法)引用的对象。

    5)Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

    6)所有被同步锁(synchronized关键字)持有的对象。

    7)反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

   

    为什么选择这几种对象作为GC Roots?

    首先要保证被选作GC Roots的对象是存货的,静态变量的声明周期长,而栈中引用的对象肯定是正在使用的对象(是存活的)。

2.3 引用

    无论是通过引用计数算法判断对象的引用数量还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”脱不开干系,在JDK1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。

    在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

    1)强引用:强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

    2)软引用:软引用是用来描述一些还有用,但非必须的对象。如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存(如果内存够,软引用没有被回收,则可以直接使用,如果内存不够,软引用已经被回收,则重新读取数据(如从数据库中))。在JDK1.2版之后提供了SoftReference类来实现软引用。

    3)弱引用:弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2版之后提供了WeakReference类来实现弱引用。

    4)虚引用:虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版之后提供了PhantomReference类来实现虚引用。

2.4 finalize()自救

    即使在可达性分析算法中被判定为不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

    1)第一次标记并进行一次筛选

    筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize方法,或者finalize方法已经被虚拟机调用过(finalize只会调用一次),虚拟机将这两种情况都视为“没有必要执行”,对象被回收。

    2)第二次标记

    如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象finalize()方法中执行缓慢,或者发生死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。

   

    finalize()方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己----只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的会被回收了。

    finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,所以不建议使用finalize()。

3、垃圾回收算法   

3.1 标记-清除算法(mark-sweep)

    该算法分为两个部分:①首先标记处所有需要回收的对象。②在标记完成后统一回收。

    缺点:

    1)标记和清除两个过程的执行效率都随对象数量增长而降低。

    2)标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3.2 复制算法

    复制算法是为了解决标记-清除算法面对大量可回收对象时执行效率低而提出的。

    复制算法将可用内存按容量划分成大小相等的两块(图中的未使用区域和保留区域),每次只使用一块。当这一块内存用完了,就将还存活着的对象复制到另一块上面去,然后再把已使用过的内存空间一次清理掉。

    优点: 不用考虑内存碎片的问题,只需要移动堆顶指针,按顺序分配即可。

    缺点:内存将缩小为原来的一半,代价太高。

3.3 标记整理算法(mark-compact)

    标记整理算法是针对老年代对象存亡特征所提出的方法,其标记过程和“标记-清除算法一样”,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

    

    老年代由于对象存活率高、没有额外空间进行分配担保(比如复制算法需要一半的内存空间),必须使用“标记-清理”或“标记-整理”算法。

4、垃圾收集器

    各款垃圾收集器之间的关系如下图:

    图中的Tenured generation就是Old generation

    如果两个收集器之间存在连线,就说明它们可以搭配使用,根据收集器所在的区域可以划分为新生代收集器(Serial、ParNew、Parallel Scavenge)还是老年代收集器(CMS、Serial Old、Parallel Old),G1收集器则不细分老年代和新生代。

4.1 Serial收集器

    Serial是一个单线程收集器(只会使用一个处理器或一条收集线程去完成垃圾收集工作,基于复制算法),在它进行垃圾收集时,必须暂停其他所有工作线程(Stop The World)

    迄今为止,Serial依然是HotSpot虚拟机运行在Client模式下的默认新生代收集器,它的特点就是简单而高效,垃圾收集的停顿时间在几十到一百多毫秒以内,对用户来说完全可以接受。

    Serial/Serial Old收集器运行示意图:

4.2 ParNew收集器

    ParNew收集器其实就是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,,其余的行为包括Serial收集器可用的所有控制参数、说计算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。

    ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器。除去性能因素,很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作

    ParNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果,甚至优于存在线程交互的开销,该收集器在通过超线程技术事项的伪双核处理器环境中都不能百分之百保证超越Serial收集器。当然,随着可以被使用的处理器核心数量的增加,ParNew对于垃圾收集时系统资源的高效利用还是很有好处的。

ParNew/Serial Old收集器运行示意图:

4.3 Parallel Scavenge收集器

    Parallel Scavenge收集器也是一款新生代收集器,同样也是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器

    Parallel Scavenge收集器和其他收集器所关注的不同的地方是:CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(所以Parallel Scavenge收集器也称为“吞吐量优先收集器”),吞吐量越大,垃圾收集的时间越短,用户代码可以充分利用CPU资源,尽快完成程序的运算任务。

    需要注意的是,垃圾收集停顿时间并不是越短越好,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:比如收集100MB新生代肯定比收集300MB快,但是这也直接导致垃圾收集发生得更频繁,原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间确实在下降,但是吞吐量也下来了。

4.4 Serial Old收集器

    Serial Old收集器是Serial收集器的老年代版本,它同样也是一个单线程收集器,使用标记-整理算法,其运行过程和Serial收集器一样。

    它的主要意义也是供Client模式下的HotSpot虚拟机使用。如果在Server模式下,它也可能有两种用途:一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

  

    Serial/Serial Old收集器运行示意图:

4.5 Parallel Old收集器

    Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现

    在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge/Parallel Old收集器这个组合。

    Parallel Scavenge/Parallel Old收集器运行示意图:

4.6 CMS收集器

    CMS收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法实现,它的运作相对于前面几种收集器来说要更复杂一些,整体分为四个步骤:

    1)初始标记:标记老年代中所有的GC Roots对象和新生代中活着的引用到的老年代对象(新生代中还存活的运用类型对象,引用指向老年代中的对象),速度快,需要Stop The World

    2)并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程(可达性分析),耗时较长但是不需要停顿用户线程(可以和垃圾收集线程并发运行)。

图中3号结点的引用关系发生改变。

    3)重新标记:修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,速度快,需要Stop The World

    4)并发清除:清理删除掉标记阶段的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以和用户线程同时并发的

    

    CMS的优点很明显:并发收集、低停顿。在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作

    缺点:

    1)CMS收集器对CPU资源非常敏感,虽然在并发标记阶段用户线程没有暂停,但是由于收集器占用了一部分CPU资源,导致程序的响应速度变慢。

    2)CMS无法处理浮动垃圾。所谓“浮动垃圾”,就是在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在当次几种处理它们,只好在下一次GC的时候处理,这部分未处理的垃圾就称为“浮动垃圾”。由于CMS是基于“标记-清除算法”的(可能是为了时间短),大量的浮动垃圾会产生大量的空间碎片,一旦空间碎片过多,大对象就没有办法给其分配内存,那么即使内存还有剩余空间容纳这个大对象,但是却没有足够连续足够大的空间放下这个对象,所以java虚拟机就会触发一次Full GC。

CMS收集器运行示意图:

4.7 G1(Garbage First)收集器

    使用G1收集器时,它将整个Java对分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了。它可以面向堆内任何部分来组成回收集(Collection Set)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大

    每个Region都可以扮演新生代的Eden空间、Survivor空间或者老年代空间,Region中还有一类特殊的Humongous区域,专门用来存储大对象,G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。

G1收集器Region分区示意图:

    G1收集器的运作过程划分如下:

    1)初始标记:仅仅标记一下GC Roots能直接关联到的对象,需要Stop The World,时间很短。

    2)并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,耗时较长,可并发

    3)最终标记:处理并发阶段结束后仍遗留下来的少量SATB记录,需要Stop The World,时间很短。

          什么是SATB,可以参考这篇博客:https://blog.csdn.net/weixin_30814223/article/details/95706567

    4)筛选回收:更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。需要Stop The World,由多条收集器线程并行完成。

    G1特点:

    1)并行与并发。

    2)分代收集,不需要配合其他收集器。

    3)空间整合,整体采用“标记-整理”,局部(两个Region之间)采用复制。

    4)G1跟踪各个Region里面的垃圾堆积价值大小(回收所获得的空间大小以及回收所需的时间),在后台维护一个优先列表,每次优先收集价值最大的Region(所以叫Garbage-First),从而保证了G1在有限时间内可以获取尽可能高的收集效率。

G1收集器运行示意图:

内容来源:

《深入理解Java虚拟机第3版》.周志明

猜你喜欢

转载自blog.csdn.net/qq_41834553/article/details/113784912