JVM学习:CMS和G1收集器浅析

总框架

一、Java自动内存管理基础

1、运行时数据区

运行时数据区可分为线程隔离线程共享 两个维度,垃圾回收主要是针对堆内存进行回收

(1)线程隔离

  • 程序计数器
    • 虚拟机多线程是通过线程轮流切换、分配处理器执行时间来实现的。为了线程切换后能恢复到正确的执行位置,每个线程需要有一个独立的程序计数器
  • 虚拟机栈
    • 每个方法被执行都会创建一个栈帧
    • 栈深度大于虚拟机所允许的最大深度,抛出StackOverflowError异常
      栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常
  • 本地方法栈
    • 与虚拟机栈的作用相似,只是针对本地方法

(2)线程共享

    • 垃圾收集器管理的内存区域,存放对象实例,所有线程共享
  • 方法区
    • 用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
    • 如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常
    • 运行时常量池

2、对象的创建流程

  • 检查对象所对应的类是否已经被加载过,如果没有要先执行类加载过程
  • 为新生对象分配内存(把一块确认大小的内存从Java堆中划分出来)
    • 划分可用的空间
      • 堆是否规整由所采用的垃圾收集器是否带有空间压缩整理的能力决定
      • 指针碰撞(简单高效):规整内存,使用过的放在一边,没有使用过的放在另外一边
      • 空闲列表(复杂):不规整内存,维护一个列表,记录哪些内存块可用
    • 线程安全问题的解决
      • 时间维度:对分配内存空间的动作进行同步处理(实际上虚拟机采用了CAS配上失败重试方式保证更新操作的原子性)
      • 空间维度:把内存分配的动作按照线程划分在不同的空间中进行(给线程预分配隔离的内存)
  • 将分配到的内存空间初始化为零值(各个类型的默认值)
  • 对对象进行必要的信息设置(对象哈希码、GC分代信息等)
  • 执行构造函数,初始化对象属性

二、垃圾收集器与内存分配策略

1、如何判断对象已死

(1)引用计数法

给对象添加一个计数器,每当一个地方引用这个对象时,计数器+1,每当取消引用时,计数器-1,如果计数器为0就说明对象没有被引用

优点:实现简单,判断效率高

缺点:没办法解决循环引用问题

循环引用典型例子:

(2)可达性分析算法

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

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。·在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象"临时性"地加入,共同构成完整GC Roots集合。

可达性分析的扫描过程中,如果用户线程是冻结的,只有收集器线程在工作,那不会有任何问题,但如果是用户线程与收集器并发工作呢?

可能会有两种后果:

  • 一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的。只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。
  • 另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。

触发第二种后果的两个必要条件:

  • 并发收集过程中,插入了一条或多条从黑色对象到白色对象的新引用
  • 并发收集过程中,删除了全部从灰色对象到该白色对象的直接或间接引用

什么是三色标记:

  • 白色表示对象未被垃圾收集器扫描过
  • 黑色表示该对象已被扫描,且所有引用都扫描完毕,结果是安全存活
  • 灰色表示该对象已被扫描,但是至少存在一个引用还没被扫描过
    三色标记案例:

两种解决方案:

  • 增量更新
    • 破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次
  • 原始快照
    • 破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次

2、垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为"引用计数式垃圾收集 "​(ReferenceCounting GC)和"追踪式垃圾收集"​(Tracing GC)两大类,这两类也常被称作"直接垃圾收集"和"间接垃圾收集"​。

主流垃圾收集器都是后者,所以下面我们只针对后者展开分析。

(1)分代收集理论

弱分代假说:绝大多数对象都是朝生夕灭的

强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡

  • 这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储
  • 如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间
  • 如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域
  • 这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域------因而才有了"Minor GC"​"Major GC"​"Full GC"这样的回收类型的划分

也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法------因而发展出了"标记-复制算法"​"标记-清除算法"​"标记-整理算法"等针对性的垃圾收集算法

跨代引用假说:跨代引用相对于同代引用来说仅占极少数

  • 这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
  • 举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为"记忆集"​,Remembered Set)​,这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。

(2)标记-清除算法

两个阶段:

  • 标记阶段:首先标记出所有需要回收的对象(不需要回收)
  • 清除阶段:在标记完成后,统一回收掉所有被标记的对象(没有被标记)
    标记-清除算法是最基础的收集算法,因为后续收集算法大多都是以它为基础,对其缺点进行改进而得到的

缺点:

  • 第一是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

(3)标记-复制算法(半区复制)

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为"半区复制"​(Semispace Copying)的垃圾收集算法

原理:

  • 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
  • 当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉

优点:实现简单,运行高效

  • 对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,内存复制开销很小
  • 每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可

缺点:

  • 如果内存中多数对象都是存活 的,这种算法将会产生大量的内存间复制的开销
  • 这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点

(4)标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的"标记-整理"​(Mark-Compact)算法

原理:

  • 其中的标记过程仍然与"标记-清除"算法一样
  • 后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动
  • 最后直接清理掉边界以外的内存

抉择:

  • 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,会Stop The World
  • 如果不移动对象,会导致空间碎片化问题,需要依赖更为复杂的内存分配器和内存访问器来解决。内存访问是非常频繁的,如果增加额外负担会影响程序吞吐量

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂

从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿(关注延迟的CMS收集器就是基于标记-清除算法的)

但是从整个程序的吞吐量来看,移动对象会更划算,因为内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降(关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的)

3、经典垃圾收集器

(1)Serial收集器(新生代收集器、标记复制算法)

Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择

这个收集器是一个单线程工作 的收集器,但它的"单线程"的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束

(2)ParNew收集器(新生代收集器、标记复制算法)

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

ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器

其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作

(3)Parallel Scavenge收集器(新生代收集器、标记复制算法)

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量

吞吐量=运行用户代码时间/(运行用户代码时间+运行垃圾收集时间)。如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

抉择:

  • 停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验
  • 高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务

参数:

  • 控制最大垃圾收集停顿时间(-XX:MaxGCPauseMillis参数,默认值为空)。收集器将尽力保证内存回收花费的时间不超过用户设定值,但垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了
  • 设置吞吐量大小(-XX:GCTimeRatio参数,默认值为99)。表示允许的最大垃圾收集时间占总时间的1/(1+N),N为大于0小于100的整数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19))。默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间
  • 开启自适应的调节策略(-XX:+UseAdaptiveSizePolicy,默认为开启)。当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量

真实案例:

生产上有服务出现频繁fullGC的告警

分析:

我们生产上的Java版本是1.8,默认采用Parallel Scavenge收集器,且自适应调节策略是默认开启的,这就导致了为了保障吞吐量,JVM会自动把垃圾收集时间控制在1%以下,压缩了新生代空间(空间越小收集越快),最后频繁发生GC,频繁GC告警就是这么来的

(4)CMS收集器(老年代收集器、标记清除算法)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

从名字(包含"Mark Sweep"​)上就可以看出CMS收集器是基于标记-清除算法实现的,它的过程分为四个步骤:

  • 初始标记,标记GC Roots能直接关联到的对象(需要Stop The World)
  • 并发标记,从GC Roots的直接关联对象开始遍历整个对象图的过程(耗时长但是不需要停顿用户线程)
  • 重新标记,修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(增量更新,需要Stop The World)
  • 并发清除,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

优点:

  • 并发收集,效率高
  • 基于标记清除算法,低停顿

缺点:

  • CMS收集器对处理器资源非常敏感 (面向并发设计的程序都对处理器资源比较敏感)。
    • 在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,降低应用总吞吐量。
    • CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低
  • CMS收集器无法处理"浮动垃圾"​ (Floating Garbage),有可能出现"Con-current ModeFailure"失败进而导致另一次完全"Stop The World"的Full GC的产生
    • 浮动垃圾:在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉
    • 由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用
    • 要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次"并发失败"(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了
  • CMS是一款基于"标记-清除"算法实现的收集器,收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况

(5)G1收集器

Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

目标:

作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起**"停顿时间模型"​**(PausePrediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标 。翻译一下,就是保证延迟可控,且获得尽可能高的吞吐量

原理:

  • 跟之前的收集器只能面向某个区域进行垃圾回收不一样,G1可以面向堆内存任何部分来组成回收集 (Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大
  • G1把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。对扮演不同角色的 Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
  • 之所以能建立可预测的停顿时间模型,是因为G1将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集
  • 更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的"价值"大小 ,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是"Garbage First"名字的由来

细节问题:

  • 将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?
    • 使用记忆集避免全堆作为GC Roots扫描
    • 每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内
    • G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担(至少要耗费大约相当于Java堆容量10%至20%的额外内存)
  • 并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
    • 通过原始快照(SATB)算法来实现的
    • 与CMS中的"Concurrent Mode Failure"失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间"Stop The World"

步骤:

  • 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象(需要短暂Stop The World)
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行(耗时长但是不需要停顿用户线程)
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录(需要短暂Stop The World)
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间(需要Stop The World)

优点:

  • 创新性设计带来技术红利
    • 可以指定最大停顿时间
    • 分Region的内存布局
    • 按收益动态确定回收集
  • 与CMS的"标记-清除"算法不同,G1从整体来看是基于"标记-整理"算法实现的收集器,但从局部(两个Region之间)上看又是基于"标记-复制"算法实现。无论如何,G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存 。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集
    缺点:

G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高

经验参考:

  • 目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间
  • 随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜
相关推荐
阿杆13 分钟前
🤯我写了一套无敌的参数校验组件④ | 现已支持 i18n
java·spring
小样vvv14 分钟前
【微服务管理】注册中心:分布式系统的基石
java·数据库·微服务
amagi60017 分钟前
Java中的正则表达式(Regular Expression)
java
喵手24 分钟前
如何快速掌握 Java 反射之获取类的字段?
java·后端·java ee
AronTing27 分钟前
06- 服务网格实战:从 Istio 核心原理到微服务治理升级
java·后端·架构
奋进的小暄27 分钟前
贪心算法(18)(java)距离相等的条形码
java·开发语言·贪心算法
雷渊28 分钟前
Elasticsearch查询为什么这么快
java
struggle202530 分钟前
Trinity三位一体开源程序是可解释的 AI 分析工具和 3D 可视化
数据库·人工智能·学习·3d·开源·自动化
雷渊31 分钟前
RocketMQ和kafka一样有重平衡的问题吗?
java·后端·面试
码农周37 分钟前
Spring Boot 启动后自动执行 Service 方法终极指南
java·spring boot·后端