《深入理解java虚拟机》-垃圾收集器与内存分配策略

如何判断对象已死?

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器减1;其中计数器为0的对象是不可能再被使用的已死对象。

引用计数算法的实现很简单,但有个巨大的缺点,当两个对象相互引用时,这两个对象就不会被回收,导致内存泄漏。

可达性分析算法

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所经过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(在图论中称为对象不可达)时,这个对象就是不可用的。

在java语言中,可作为GC Roots的对象包括:

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

关于java中的引用

jdk1.2之前,java中的引用指的就是另一块内存的起始地址。

jdk1.2之后,java对引用的概念进行了扩充,将引用分为:

  • 强引用(Strong Reference):在程序代码中普遍存在,只要强引用还存在,垃圾回收器就不会回收被引用对象 ==> Object obj = new Object();
  • 软引用(Soft Reference):是用来描述一些还有用但非必须的对象,在系统将要发生OutOfMemoryError时,才会对被引用对象进行回收 ==> SoftReference类
  • 弱引用(Weak Reference):跟软引用一样也是来描述非必需对象的,但强度更弱,被引用对象只能存活到下次垃圾收集发生之前。无论当前内存是否充足,都会进行回收。 ==> WeakReference类,WeakHashMap类
  • 虚引用(Phantom Reference):也称为幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,唯一的目的就是能在这个对象被收集器回收时收到一个系统通知 WTF? ==> PhantomReference类

生存还是死亡

在可达性分析算法中不可达的对象也不是“非死不可”,这个时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少需要经历两次标记过程。当一个对象被判定为不可达时,会先进行第一次标记并判断是否需要执行finalize()方法。当对象没有覆盖finalize()方法,或finalize()方法已经被调用过,则判定为不需要执行finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会,GC会将对象放置在F-Queue队列中,由低优先级的Finalizer线程去执行。虚拟机会触发finalize()方法,但不保证会等待它执行结束,因为finalize()方法可能执行缓慢或发生死循环。稍后GC会将F-Queue中的对象进行第二次标记,如果对象想要成功救赎自己就需要迅速的将自己与引用链上的任何一个对象建立关联,那么在第二次标记时它将会被移出即将回收的集合。

回收方法区

很多人认为方法区(HotSopt中的永久代)是没有垃圾收集的,java虚拟机规范中也没有要求需要对方法区实现垃圾收集。

永久代垃圾收集主要回收两部分:

  • 废弃常量

回收废弃常量与回收java堆中的对象非常相似。例如常量池中的“abc”字符串在当前系统中没有被任何一个String对象引用,也没有在其他地方被引用,而且必要的话,这个“abc”常量就会被系统清理出常量池。

  • 无用的类

类需要满足下面3个条件才能算是“无用类”。

  1. 该类的所有实例都已经被回收
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的Class对象没有在任何地方被引用,即无法通过反射获取该类的方法

虚拟机可以对无用类进行回收,但不是一定会回收

垃圾收集算法

标记-清除算法

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

主要不足有两个:

  • 效率,标记和清除两个过程的效率都不高。
  • 空间,标记清除后会产生大量不连续的内存碎片,导致以后需要分配较大对象时,无法找到足够的连续内存而不得提前触发垃圾收集动作。

《《深入理解java虚拟机》-垃圾收集器与内存分配策略》 “标记-清除”算法示意图

复制算法

为了解决效率问题,一种称为“复制”的收集算法出现了,它将可用内存分为大小相等的两块,每次只使用其中一块。当这块内存用完了,就将还存活的对象复制到另一块上,然后将已使用过的内存空间一次清理掉。这样就不需要考虑内存碎片等复杂情况,简单高效,是典型的空间换时间,内存消耗太大。

《《深入理解java虚拟机》-垃圾收集器与内存分配策略》 复制算法示意图

现在的商业虚拟机都采用这种收集算法来回收新生代,由于新生代中98%的对象是“朝生夕死”的,所以不需要按照1:1的比例来划分空间,而是把内存分为一块较大的Eden区和两块较小的Survivor区,每次使用Eden和其中一块Survivor。HotSpot虚拟机默认Eden:Survivor = 8:1。当然我们无法保证每次回收都只有不多于10%的对象存活,当Survivor空间不足是,需要依赖其他内存(这里是老年代)进行分配担保(Handle Promotion),多余的对象会直接通过分配担保进制进入老年代。

标记-整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会降低,而且还需要额外空间进行分配担保,以应对被使用内存中对象100%存活的极端情况,所以在老年代一般不能直接选择这种算法。标记-整理算法就是根据老年代的特点设计的。该算法第一步与标记-清除算法一样,第二步则是将所有存活对象都向一端移动,然后直接清理掉其他内存。

《《深入理解java虚拟机》-垃圾收集器与内存分配策略》 “标记-整理”算法示意图

分代收集算法

目前商业虚拟机垃圾收集都采用“分代收集”(Generational Collection)算法,其原理就是根据对象存活周期的不同将内存划分为几块,如新生代和老年代,然后根据各个年代的特点采用最适当的收集算法

GC算法实现基础

枚举根节点

在可达性分析中,GC必须先从GC Roots节点找引用链,然后逐个检查里面的引用,可作为GC Roots的节点主要在全局性引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中。另外,可达性分析必须在一个能确保一致性的快照中进行,这里的一致性是指在整个分析期间整个系统看起来就像被冻结在某个时间点上,不可以出现对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有java执行线程(Stop The World)的其中一个重要原因,即使在号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

安全点

由于程序在运行时导致引用变化的指令非常多,因此在程序执行时并非在所有地方都能停顿下来开始GC,只有到达特定的位置即安全点(Safepoint)才能暂停。

Safepoint的选定既不能太少以致于让GC等待时间太长,也不能太多以致于过分增大运行时的负荷,还有一个需要考虑的问题就是如何在GC发生时让所有线程都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:

  • 抢先式中断(Preemptive Suspension):不需要线程执行代码主动区配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
  • 主动式中断(Voluntary Suspension):当GC需要中断线程时,不直接对线程进行操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。

安全区域

对于Safepoint来说,有一个最大的缺点就是需要等待线程走到安全的地方去中断挂起,例如Sleep状态或Blocked状态。这个时候线程无法响应JVM的中断请求,显然JVM也不大可能等待线程重新被分配CPU事件。对于这个情况,就需要安全区域(Safe Region)来解决。安全区域是指在一段代码片段中,引用关系不会发生变化,在这个区域的任何地方开始GC都是安全的。当线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,这样,在这段时间内JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。当线程要离开Safe Region时,要检查系统是否完成了根节点枚举(或者整个GC过程),如果完成则继续执行,否则就必须等待直到可以安全离开Safe Region的信号为止。Safe Region可以看作是Safepoint的扩展。

垃圾收集器

这里以JDK 1.7 Update 14之后的HotSpot虚拟机为例

《《深入理解java虚拟机》-垃圾收集器与内存分配策略》 HotSpot虚拟机的垃圾收集器

图中展示了7中作用与不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所在区域表示它属于新生代收集器还是老年代收集器。

Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器,该收集器是一个单线程的收集器。这个单线程说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程(Stop The World),直到收集结束。

《《深入理解java虚拟机》-垃圾收集器与内存分配策略》 Serial收集器运行示意图

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本。

《《深入理解java虚拟机》-垃圾收集器与内存分配策略》 ParNew收集器运行示意图

从ParNew收集器开始,后面还会接触到几款并发和并行的收集器。在这之前有必要了解并发和并行:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户相似仍然处于等待状态
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续执行,而垃圾收集程序运行在另一个CPU上。

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,使用复制算法与并行多线程。它的特点在于关注点与其他收集器不同,目的是为了达到一个可控制的吞吐量(Throughput)。所谓的吞吐量 = 运行用户代码时间/(运行用户代码事件+垃圾收集时间),垃圾收集时间越短响应速度越快,吞吐量越大运算速度越快。对于Parallel Scavenge收集器来说还有一个重要参数-XX:+UseAdaptiveSizePolicy,当这个参数打开后,就不需要手动指定新生代的大小、Eden与Survivor区的比例、晋升老年代对象年龄等细节参数了,虚拟机会自动根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应调节策略(GC Ergonomics),这是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,使用单线程和标记-整理算法

《《深入理解java虚拟机》-垃圾收集器与内存分配策略》 Serial Old收集器运行示意图

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法

《《深入理解java虚拟机》-垃圾收集器与内存分配策略》 Parallel Old收集器运行示意图

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,使用多线程和标记-清除算法,对于重视响应速度的应用来说十分适合。整个运作过程分为4步:

  • 初始标记(CMS initial mark):仅仅只标记GC Roots能直接关联到的对象
  • 并发标记(CMS concurrent mark):进行GC追踪(GC Roots Tracing)的过程
  • 重新标记(CMS remark):修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段停顿时间一般会比初始标记阶段稍长一点,但远比并发标记的时间短
  • 并发清除(CMS concurrent sweep):对标记对象进行清除

《《深入理解java虚拟机》-垃圾收集器与内存分配策略》 Concurrent Mark Sweep收集器运行示意图

CMS收集器有3个明显的缺点:

  1. 对CPU资源非常敏感:在并发阶段,会占用一部分CPU资源,从而导致应用程序变慢,总吞吐量降低
  2. 无法处理浮动垃圾(Floating Garbage):浮动垃圾是指在并发清理阶段用核线程还在运行并产生新的垃圾,这部分垃圾出现在标记之后,CMS无法在当此处理掉它们,只好等待下一次GC。这意味着CMS收集器必须预留一部分空间提供并发收集的程序使用,会导致GC频率增加。
  3. 基于标记-清除算法

G1收集器

G1收集器的优点

  • 并行与并发
  • 分代收集
  • 空间整合
  • 可预测的停顿

在G1之前的其他收集器进行收集时,都是对整个新生代或者老年代进行回收,而G1收集器不再是这样。G1收集器将java堆分为多个独立区域(Region),每个区域之间相互独立,多个Region共同组成Eden、Survivor和老年代。这样G1收集器在进行垃圾收集时,就可以以Region为单位进行,而不需要对整个java堆进行全区域的垃圾收集。此时还有两个问题:

  1. 对象在所在Region死亡时,并不意味着对于java堆中其他的Region也是死亡的。难道还需要扫描这个java堆才能保证准确性?
  • 对于这个问题,虚拟机时采用Remembered Set来避免全堆扫描的。每个Region都有相对应的Remembered Set,用于记录其他Region中的引用信息,这样就可以避免全堆扫描。
  1. 对象大小超过Region最大容量
  • 虚拟机中定义了巨无霸区域(Humongous regions)来存储大对象,但是对于大对象的回收貌似没有什么好办法。

ps:在java8中,持久代也移动到了普通的堆内存中,改为元空间。

《《深入理解java虚拟机》-垃圾收集器与内存分配策略》 G1收集器运行示意图

对于具体的运作流程,可以看看这两篇文章G1垃圾收集器入门深入理解 Java G1 垃圾收集器

其他的一些策略

  • 对象优先在Eden分配
  • 大对象直接进入老年代
  • 长期存活对象将进入老年代
  • 动态对象年龄判定
  • 空间分配担保

参考文章

    原文作者:xiedacon
    原文地址: https://www.jianshu.com/p/21a97549d336
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞