Java虚拟机(二)垃圾收集

判断对象是否存活

引用计数算法

给对象中添加一个引用计数器,每有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1;任何时刻计数器值为0表示对象不能再被使用。
该算法实现简单,判定效率高,大部分情况下都是一个不错的算法。但它很难解决对象间相互引用的问题。

public class ReferenceCountingGC {
	public Object instance = null;
	public static void testGC() {
		ReferenceCountingGC objA = new ReferenceCountingGC();
		ReferenceCountingGC objB = new ReferenceCountingGC();
		objA.instance = objB;
		objB.instance = objA;

		objA = null;
		objB = null;
	}
}

在以上代码中,两个对象再无其他引用,已经不可能再被访问,但由于它们还相互引用对方,故计数器值不为0。而在实际Java垃圾收集时,这种情况两个对象都会被回收,故Java中使用的并不是这种算法。

可达性分析算法

《深入理解Java虚拟机》
通过一系列称为GC Roots的对象作为起始点,若某个对象从GC Roots不可达,则证明该对象是不可用的。上图中object5、object6、object7之间虽然相互引用,但从GC Roots不可达,故其会被垃圾回收。
在Java中可作为GC Roots的对象包括几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(Native方法)引用的对象。

对象引用类型

  • 强引用(Strong Reference)
    在程序代码中普遍存在的,类似Object obj = new Object()这类的引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象
  • 软引用(Soft Reference)
    用来描述一些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。可以用SoftReference类来实现软引用
  • 弱引用(Weak Reference)
    也是用来描述非必需对象的,但其强度比软引用弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。可以用WeakReference类来实现弱引用。
  • 虚引用(Phantom Reference)
    最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置一个虚引用的唯一目的就是能在这个对象被垃圾收集器回收时收到一个系统通知。可以用PhantomReference实现虚引用。

finalize()方法

如果对象在可达性分析后发现没有与GC Roots相连接的引用链,它会被第一次标记并进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已被虚拟机调用过,都可视为没有必要执行。如果在finalize()方法中要被回收的对象重新与引用链中任何一个对象建立关联,则该对象则可以在第二次标记前移除回收队列,逃脱垃圾回收,但这样的逃脱只能成功一次,因为任何对象的finalize()方法只会被系统调用一次。
finalize()方法不是C/C++中的析构函数,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。该方法能做的工作,try/finally或者其他方式都可以做的更好,应尽量避免使用该方法。

方法区回收

方法区的垃圾收集回收效率远比在堆中尤其是新生代中的垃圾回收效率要低,该区域的垃圾回收“性价比”一般较低。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。若没有其他地方引用常量池中的一个常量,那么发生内存回收时,如果必要的话,就会将其清理出常量池。而判定一个类是否是无用的类则苛刻许多,需要同时满足以下3个条件:

  • 该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

满足以上条件仅仅表明类“可以”被回收,而不是必然会被回收。在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

垃圾收集算法

标记-清除算法

分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。是最基础的收集算法,后续的收集算法都是基于这种思路并对其不足进行改进而得到的。
它的主要不足有两个:

  • 效率问题,标记和清除两个过程的效率都不高
  • 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的内存而不得不提前触发另一次垃圾收集动作。
    《深入理解Java虚拟机》

复制算法

将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另一块上,然后再把已使用过的内存空间一次清理掉。
优点:每次都是对整个半区进行内存回收不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效
缺点:将内存缩小为了原来的一半,代价太高。
《深入理解Java虚拟机》
现代商业虚拟机都采用这种收集算法来回收新生代。研究表明,新生代中98%的对象是“朝生夕死”的,并不需要按照1:1的比例划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。
回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。Hotspot虚拟机默认Eden和Survivor的大小比例是8:1,也就是说只有10%的内存会被浪费。
当另一块Survivor空间不足以存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代,即借用老年代的空间来存放对象。

标记-整理算法

在对象存活率较高时,复制收集算法 要进行较多的复制操作,效率变低。“标记-整理算法”根据老年代的特点,在标记阶段和“标记-清除算法”一样,而后续步骤不会直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
《深入理解Java虚拟机》

分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”算法,它根据对象存活周期的不同将内存划分为几块。一般把Java堆分为新生代和老年代。新生代中,每次垃圾收集都发现有大批对象死去,只有少量存活,就用复制算法;老年代中,对象存活率高、没有额外空间对它进行分配担保,就用“标记-清除”或“标记-整理”算法。

垃圾收集器

《深入理解Java虚拟机》
在上图中展示了7种作用于不同分代的收集器,若两个收集器之间存在连线,就说明它们可以搭配使用。

Serial收集器

最基本、发展历史最悠久的收集器。是一个单线程的收集器,只会使用一个CPU或一条收集线程去完成垃圾收集工作,在进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束。下图展示了Serial/Serial Old收集器的运行过程。
《深入理解Java虚拟机》
是虚拟机运行在Client模式下的默认新生代收集器。
优点:简单高效。单个CPU时,Serial收集器没有线程交互的开销。在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,垃圾收集的停顿时间可以控制在几十最多一百多毫秒内,只要不是太频繁,这点停顿可以接受。
缺点:“Stop The World”,若计算机运行一个小时就要停顿5分钟,用户体验可想而知。

ParNew收集器

其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为都与Serial收集器完全一样。下图为ParNew/Serial Old收集器的运行过程

《深入理解Java虚拟机》
是许多运行在Server模式下的虚拟机中的首选新生代收集器,一个重要原因是除了Serial外只有它可以和CMS收集器配合工作。
在单CPU环境下性能不会比Serial收集器好,但随着CPU数量的增加,ParNew收集器对系统资源的利用率会得到提高。

Parallel Scavenge收集器

和ParNew一样是新生代收集器,使用复制算法,是并行的多线程收集器。其与其他垃圾收集器的区别在于Parallel Scavenge目标在于达到一个可控制的吞吐量,被称为“吞吐量优先”收集器。
有两个参数:-XX:MaxGCPauseMillis-XX:GCTimeRatio可以控制最大垃圾收集停顿时间和直接设置吞吐量大小。降低停顿时间会减小新生代空间,但这样会使垃圾收集更频繁,吞吐量下降。
有一个开关参数(-XX:+UseAdaptiveSizePolicy)可以让虚拟机动态调节其他参数以提供最合适的停顿时间或最大的吞吐量,这也是其与ParNew收集器的一个重要区别。

Serial Old收集器

Serial收集器的老年代版本,使用标记-整理算法,主要意义在于给Client模式下的虚拟机使用。在Server模式下,有两大用途:

  • 在JDK1.5及之前的版本与Parallel Scavenge搭配使用
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
    《深入理解Java虚拟机》

Parallel Old收集器

在JDK1.6才开始出现,是ParNew收集器的老年代版本,使用多线程和标记-整理算法。在关注吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。
《深入理解Java虚拟机》

CMS收集器

CMS(Concurrent Mark Sweep)以获取最短回收停顿时间为目标,从名字就看得出来其使用标记-清除算法,运作过程包括四个阶段:

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  • 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除:运行时间一般比初始标记长,但远比并发标记短,不需要停顿。

《深入理解Java虚拟机》

其为一个十分优秀的收集器,但有3个明显的缺点:

  • CMS对CPU资源非常敏感。并发阶段虽然不会导致用户停顿,但占用了一部分线程,应用程序变慢,总吞吐量降低。
  • 无法处理“浮动垃圾”,可能出现"Concurrent Mode Failure"失败而导致另一次Full GC的产生。由于在垃圾收集过程中用户进程也在运行,运行过程中新的垃圾也会不断产生,这部分垃圾只能等到下一次GC时再清理掉。由于垃圾收集时用户进程也在进行,所以不能等到老年代几乎完全被填满才进行收集,需要预留一部分内存空间给用户使用,如果这部分预留的空间还是不够用,就会发生"Concurrent Mode Failure"失败,临时使用Serial Old收集器来重新进行老年代的垃圾收集。
  • 由于基于标记-清除算法,垃圾收集后会产生大量内存碎片。空间碎片过多时,若给大对象分配空间时找不到足够大的连续内存,不得不提前触发一次Full GC。有个开关参数(-XX:+UseCMSCompactAtFullCollection,默认开启)可以让下一次Full GC后对内存碎片进行合并整理,但这样就会使停顿时间变长。

G1收集器

在之前的垃圾收集器的堆空间划分:
在这里插入图片描述
在G1收集器中的堆空间划分:
在这里插入图片描述
G1收集器不需要与其他收集器配合使用,其保留了年轻代和老年代,不同的是堆空间被划分为一个个逻辑连续,物理不连续的小内存块(Region空间),每个小内存块单独进行内存回收,这使得可预测的停顿时间模型成为可能。图中H是以往算法中没有的,它代表Humongous,这表示这些Region存储的是巨型对象(humongous object,H-obj),当新建对象大小超过Region大小一半时,直接在新的一个或多个连续Region中分配,并标记为H。
G1收集器通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(Garbage First)。
在分代收集算法中,老年代对象和新生代对象之间可能互相引用,进行Minor GC时就不得不同时扫描老年代,这就使性能下降很多,而这个问题在G1中还更加突出。为了解决这个问题,G1中每个Region都会有一个Remembered Set,用来记录该Region所引用的对象的另一个Region,这样就可以避免完全堆扫描来使可达性分析不会有遗漏。
如果不计算维护Remembered Set的操作,G1收集器的运作大概分为几个步骤:

  • 初始标记:和CMS非常类似,为了让下一阶段用户进程运行时,在正确可用的Region中创建新对象,耗时很短,需要停顿
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,找出存活对象,耗时较长,不需要停顿
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

《深入理解Java虚拟机》
G1从整体来看(整个Java堆)使用标记-整理算法,从局部来看(两个Region之间)使用复制算法,无论怎样都不会产生内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为找不到连续空间而提前触发下一次GC。G1还可以让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎是实时Java垃圾收集器的特征了。
其垃圾回收模式分为3种:

  • Young GC:所有eden region被耗尽时触发一次Young GC,活跃对象被拷贝到survivor region或者晋升到old region,空闲的region被放入空闲列表中,等待下次使用。
  • Mixed GC:越来越多的对象晋升到老年代时(老年代大小占整个堆空间大小达到阈值),为了避免堆内存被耗尽,执行一次Mixed GC,在回收整个年轻代的同时回收一部分老年代,执行过程分为上述四个阶段。
  • Full GC:对象分配内存过快,mixed GC来不及回收导致老年代被填满时,触发Full GC,使用Serial Old收集器进行垃圾回收,停顿时间非常长,需要不断优化来避免Full GC的产生。

ZGC收集器

在JDK 11中新加入了一个ZGC收集器,在官方给出的测试数据中其吞吐量与Parallel GC大致相当,而停顿时间甚至比G1短的多(平均1ms),其通过着色指针和读屏障来实现这一点。

内存分配与回收策略

对象优先在Eden分配

大多数情况下,对象在新生代Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

大对象直接进入老年代

  • 大对象指的是需要大量连续内存空间的Java对象,最典型的就是很长的字符串及数组。大对象尤其是“朝生夕灭”的“短命大对象”的出现对内存分配是个坏消息,写程序时应当避免。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续内存来安置它们。
  • 可以用-XX:PretenureSizeThreshold参数来使大于设置值的对象直接分配在老年代,但这个参数只对Serial和ParNew两款收集器有用,这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。

长期存活的对象进入老年代

为每个对象定义一个对象年龄计数器,如果对象在Eden出生后熬过一次Minor GC后被移动到Survivor空间,年龄就被设为1,此后对象每熬过一次Minor GC,年龄就增加1岁,当其年龄达到一定阈值后(可以用-XX:MaxTenuringThreshold设置,默认为15)就会被晋升到老年代中。

动态对象年龄判定

为了更好适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到MaxTenurningThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄

空间分配担保

  • 在之前垃圾收集算法的复制算法里,若垃圾收集后另一个Survivor区不足以存放存活的对象,这些对象会通过担保机制直接进入老年代,但老年代的空间也是有限的,所以在每次进行Minor GC前,都会判断当前状态是否安全。
  • 为了确保接下来的Minor GC是安全的,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,若这个条件成立,则Minor GC可以确保是安全的。如果不成立,虚拟机会查看HandlePromotionFailure参数是否允许担保失败。如果允许,会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则尝试进行一次Minor GC;如果小于,或者HandlePromotionFailure被设置为不允许冒险,则改为进行一次Full GC。虽说担保失败时绕的圈子是最大的,但大部分时候还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

Full GC触发条件

调用System.gc()

这个调用只会建议虚拟机去执行Full GC,但虚拟机不一定真正会去执行。一般不建议采用这种方式,而是让虚拟机自己管理内存。

老年代空间不足

大量对象涌入老年代使得老年代不足以存放接下来要晋升的对象时,会进行一次Full GC。
为了避免过于频繁的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

Concurrent Mode Failure

CMS 垃圾收集并发阶段会预留一部分空间给用户代码使用,若这部分预留的空间还是不足,就会发生"Concurrent Mode Failure"失败,临时使用Serial Old收集器来重新进行老年代的垃圾收集。

连续内存不足以存放大对象

在使用标记-清除算法的垃圾收集器中(CMS),垃圾收集后会产生大量内存碎片。空间碎片过多时,若给大对象分配空间时找不到足够大的连续内存,不得不提前触发一次Full GC。还有就是G1收集器中分配巨型对象时无法找到足够的连续分区,也会触发Full GC。

空间分配担保失败

Minor GC前若老年代空间不足以进行空间分配担保,会进行一次Full GC

转移失败(Evacuation Failure)

有时候,G1收集器在试图从一个年轻代分区中拷贝存活对象或从一个老年代分区中转移存活对象时,无法找到可用的空闲分区,就会导致转移失败,虚拟机会尝试扩展Java堆得使用量。如果扩展不成功,G1就会触发安全措施机制,请求执行一次Full GC。

参考资料

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值