深入理解JVM(2) 垃圾回收机制原理

  • Post author:
  • Post category:其他




一、GC的基本思想

C++的内存回收机制是依靠程序员手动实现的,但是面对智能指针循环引用的情况也需要一些特殊机制才能解决,Java中循环引用的情况也相当复杂,所以垃圾回收机制的基本思路都是通过可达性分析实现的。

这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

在Java中,固定可被作为GC root的对象包括


  • 在方法区中类静态(static)属性引用的对象

    (它们的生命周期与类绑定,只有在类被卸载,类对象不再属于方法区后才可能被GC)

  • 在方法区中常量引用的对象

    ,譬如字符串常量池(String Table)里的引用
  • 本地方法栈JNI引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象,以及系统类加载器

  • 所有被synchronized关键字持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

GCroot还可以被临时加入,在进行Partial GC时,所限的内存区域对象可能被其他区域对象引用,此时就要添加引用它们的对象,构成完整的GC root。

JVM规范种不要求实现方法区的GC,其性价比比较低



二、垃圾回收算法理论



分代收集理论

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

上述假说确定了收集器应当将Java堆划分出不同区域,再根据年龄代进行不同收集策略。

GC种类

■ 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

■ 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。

■ 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。

■ 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。

·整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。



标记清除算法

最基础的算法

  1. 首先标记出所有不需要的对象
  2. 标记完成后,统一回收所有未被标记的对象

或者标记并回收需要回收者,这一般在GC强度不高的时候使用

问题:

  • 如果大量对象需要回收,标记和清除的施行时间将会很长
  • 标记清除会使得内存空间碎片化



标记复制算法

核心思想是半区赋值,将内存分配为2块,每次只使用其中的一块,当一块的内存用完了,就将

存活的对象

复制到另一块上,再把已使用过得内存空间清理掉,这对多数回收的情况比较友好。



Appel式回收

把新生代分为一块较大的Eden空间和两块较小的Survivor空间,

每次分配只使用Eden和一块Survivor空间


  • 进行一次Minor GC时,将Eden和Survivor的存活对象复制到另一个Survivor,这样Survivor就完成了一次替换。
  • 对象有年龄计数器(在对象头中,见上篇),每当对象成功在1次GC中存活,被分配到Survivor空间,其年龄计数器就会递增
  • HotSpot默认Eden:Survivor=8:1
  • 年龄达到一定程度(默认15?),对象就会在GC后进入老年代
  • 如果另一块Survivor没有足够的空间存放上一次新生代收集的存活对象,则无论对象的年龄为多少,年龄较高的对象就会进入老年代,直到一块Survivor能够容下剩下年龄较小的对象



标记整理算法

标记复制算法在对象存活率较高时效率就会降低,所以老年代不适用于这种算法。


标记整理的主要思想就是标记存活对象,之后让所有存活对象都向内存空间一段移动,然后直接清除边界以外的内存。

局限:在对象移动和用户程序不能并发执行时,对象移动操作会让用户程序暂停,所以移动对象会产生停顿;但用free-list维护空闲区域又会产生大量内存访问overhead。

因此,关注吞吐量的Parallel Scavenge基于标记-整理算法,而关注延迟的CMS基于标记清除算法


事实上,CMS面临空间碎片过多时,会首先标记清除,在碎片化程度影响对象分配时,用标记整理算法收集一次。



三、HotSpot算法细节实现

上述的算法原理是粗粒度的,实现细节需要更多打磨



根节点枚举

GC root会随着项目的增长而不断庞大,而在根节点枚举时,所有现行虚拟机都必须暂停用户线程。

可以通过将一部分根节点枚举的工作与用户线程

并发

(如最好是的查找引用链的过程)。但事实上,查找引用链的过程又应该看起来确实是让用户线程被冻结(也即保证consistency),GC root不应当在这个过程中还出现引用的变化,以保证准确性,所以根节点枚举

需要停顿



OopMap与准确GC

主流垃圾回收器进行准确GC,一般的思路是对对象进行逐个扫描,这样比较费事,并且需要判断一个变量是不是指针,这很费时。

JVM通过JIT的手段,在类加载完成后,把对象的(偏移量,类型)键值对全部计算出来并存在OopMap中,这样就知道了哪些是引用,引用链的溯源就更加高效。



安全点



OopMap维护的压缩

从上数的内容可以看出OopMap的变动可能会很频繁,显然不能为每条指令都维护OopMap。


HotSpot仅为安全点维护OopMap

,也因此,

用户程序只能在执行到安全点时才可以GC



因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。因此要寻找让程序长时间执行的位置,然而对单个执行时间很短的指令,程序执行时间长意味着方法调用,循环等情况,它们

是指令序列复用

,这些指令才可能产生安全点。



安全点的一致性

JVM需要定义能够却要用户线程能在需要GC时在安全点处停下。此处有两种策略

  • 抢先式中断(Preemptive Suspension)。

    很少使用

    ,因为要先全部中断,再放没到安全点的线程去执行,知道跑到安全点上。它的优点是不需要线程配合。
  • 主动式中断(Voluntary Suspension)。不直接操作,而让线程polling一个标志位,一旦发现标志位为真就在最近的安全点上主动挂起,polling标志的位置大致等于

    安全点



    Java在堆上分配内存行为代码处

    (检查GC是否有必要执行)的并集。

polling操作很密集,因而它被简化成只有一条汇编指令的程度,即test一个预先设为不可读的内存页,这会产生trap异常,然后在预先注册的异常处理器中挂起,从而实现等待。



安全区域

当用户线程长时间处在挂起或阻塞状态,它显然无法用主动式中断方法相应GC请求。此时用安全区域解决这种问题。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。

我们也可以把安全区域看作被扩展拉伸了的安全点



用户线程会在进入安全区域时进行标记,GC时JVM显然就不会管这些进入安全区域的线程。

显然,用户线程在离开安全区域时必须检查JVM是否完成了需要暂停自身的GC步骤,并一直等到可以离开的信号到达。



记忆集与卡表

所有设计隔代收集的GC行为都要处理跨代引用,记忆集(Remember Set)用于避免遍历整个其他代。

记忆集是一种用于记录

从非收集区域指向收集区域

的指针集合的抽象数据结构。

为了节省成本,需要用粗粒度的记忆集,而卡精度(每个记录精确到一块内存区域,该区域内有对象含有跨代指针)则与卡表对应。



卡表

HotSpot中,卡表是字节数组。其每个元素对应着内存区域中的一块固定大小内存块(卡页,card page)。所以,卡表的entry是

addr>>offset

,卡页大小就是



2

o

f

f

s

e

t

2^{offset}







2











o


f


f


s


e


t















只要卡页中有存在跨代引用,dirty位置位,则GC中对脏卡页进行扫描,随后找出跨代指针放入GC root。



写屏障(Write Barrier)

HotSpot中通过写屏障技术维护卡表状态。显然其他分代引用卡表区域对象时其就会变脏,但是需要在JIT编译其中找到机器码层面实现这一操作的手段。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作。

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。



伪共享

多个线程同享一个存储着一系列卡页的cache line会降低效率,应当先检查标记,当卡表元素不脏时,才改写为脏,减少写入。



并发可达性分析

使用三色标记作为工具进行辅助:


  • 白色

    :表示对象

    尚未被垃圾收集器访问过

    。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,

    若在分析结束的阶段,仍然是白色的对象,即代表不可达


  • 黑色

    :表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

  • 灰色

    :表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。


黑被误认为白的情况

当且仅当:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

两种解决方案



增量更新(CMS)

破坏第一个条件,当插入黑指向白时,记录这个引用,等并发扫描完,将有引用的黑节点为根二次扫描



原始快照(G1)

当灰色对象要删除指向白色对象的引用时,记录它们,之后再将所有记录的灰色节点为根二次扫描



四、经典垃圾回收器

在这里插入图片描述

上图中,如果两个收集器之间存在连线,就说明它们可以搭配使用



Serial

在这里插入图片描述



ParNew

是Serial的多线程并行版本

在这里插入图片描述



Serial Old

Serial的老年代版本,单线程,使用

标记整理

算法



Parallel Scavenge

与上述二者一样都是新生代收集器,同样基于

标记复制

算法,他的关注点是达到一个可控制的吞吐量:

在这里插入图片描述

主要参数有2个:

  • -XX:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值
  • -XX:GCTimeRatio参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。



Parallel Old

是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于

标记-整理

算法实现

Parallel Scavenge不能与CMS合作,所以如果选择它为新生代收集器,Java8最好的选择是Parallel Old,这样“吞吐量优先”收集器终于有了比较名副其实的搭配组合。


Parallel组合适用于注重吞吐量或处理资源稀缺的场合



CMS

它是一种以获取最短回收停顿时间为目标的收集器,

较好地适用于基于互联网和B/S系统的服务端上



CMS的

标记清除

算法运作过程分为4个步骤

  1. 初始标记(CMS initial mark)标记一下GC Roots能直接关联到的对象
  2. 并发标记(CMS concurrent mark)从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
  3. 重新标记(CMS remark)修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这一手段运用了增量更新的记录,

    这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短
  4. 并发清除(CMS concurrent sweep)清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

    在这里插入图片描述

    可以看出,

    初始标记



    重新标记

    依然需要Stop the World



缺点

  • 对处理器资源敏感,降低总吞吐量
  • 无法处理浮动垃圾(Floating Garbage)导致的并发失败、再次Full GC。要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了
  • 因此参数

    -XX:CMSInitiatingOccupancyFraction

    参数太低就太保守,太高容易导致大量并发失败
  • 需要在空间碎片很多时进行一次标记整理算法



G1

它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

G1是一款主要面向服务端应用的垃圾收集器

G1不在面向年代,而是面向堆内存任何部分来组成回收集(CSet)进行回收,衡量的标准是哪里存放的垃圾数量最多,回收效益最大,这就是G1的 Mixed GC模式

G1就将Java堆划分为多个大小相等的Region,每个Region根据需要扮演新生代的Eden空间,Survivor空间或者是老年代空间,收集器对扮演不同年代的Region进行不同处理。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数

-XX:G1HeapRegionSize

设 定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

G1收集器将跟踪各个Region中垃圾堆集的“价值”大小,在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(

-XX:MaxGCPauseMillis

指定,默认值是200毫秒),优先回收受益最大的Region。


所以G1整体上是标记整理,Region内是标记复制。



G1垃圾回收的步骤

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要 停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际 并没有额外的停顿。



垃圾收集器的选择

在这里插入图片描述



版权声明:本文为weixin_44662670原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。