0%

垃圾回收

垃圾回收

程序计数器、虚拟机栈、本地方法栈三个区域都是随线程而生,随线程而灭,因此这几个区域的内存分配和回收都是跟随线程的生命周期的,不需要过多的考虑,垃圾回收主要是回收堆空间和方法区的内存

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾

GC主要作用于堆和方法区,其中大多数是在堆中进行

如何判断一个对象是否应该被回收(垃圾标记算法)

既然要进行回收,那么就需要判断一个对象是否是垃圾,是否应该被回收,判断对象是否存活一般有两种方式:引用计数法可达性分析

引用计数法

引用计数法是对每个对象都保存一个整型的引用计数器属性,用于记录对象被引用的情况,对于一个对象,有其他任何一个对象引用它,该对象的引用计数器就加一,当引用失效时,就减一,只要该对象的引用计数器的值为0,则表示该对象已经不再被使用了

优点
  • 实现简单,垃圾对象便于辨识
  • 判断效率高,回收没有延迟性
缺点
  • 需要额外的字段进行存储计数器,需要额外的存储空间开销
  • 每次赋值都需要更新计数器,增加了时间开销
  • 无法处理循环引用的情况,由于这种问题导致java垃圾回收器没有使用该算法

可达性分析

可达性分析是以根对象集合(GC Roots)为起始点,按照从上往下的方式搜索被根对象集合所连接的目标对象是否可达,内存中存活的对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链,如果目标对象没有任何引用链相连,则是不可达的,意味着该对象已经死亡

可达性分析可以解决引用计数法中无法处理循环引用的问题

哪些可以作为GC Roots

由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,它就是一个Root

  • 虚拟机栈中所引用的对象,如各个线程被调用的方法堆栈中使用到的参数、局部变量等
  • 本地方法栈内JNI引用的对象
  • 方法区中类静态属性引用的对象,如类的引用类型静态变量
  • 方法区中常量引用的对象,如字符串常量池里的引用
  • 所有被同步锁synchronized持有的对象
  • java虚拟机内部的引用,如基本数据类型对象的Class对象,一些常驻的异常对象,系统类加载器
  • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

由于GC Roots中并不包含有堆中对象所引用的对象,这样就不会有循环引用的问题了

finalization机制

在Object类中有一个finalize方法

1
protected void finalize() throws Throwable { }

如果对象在可达性分析中没有与GC Root的引用链,此时会被标记并且进行一次筛选,筛选的条件是是否有必要执行finalize()方法。当对象没有重写finalize()方法或者已被虚拟机调用过,则认为是没有必要的。如果该对象有必要执行finalize()方法,该对象会被放在F-Queue的队列中,然后慢慢调用finalize()方法

该方法可以被子类进行重写,用于在对象被垃圾回收时进行资源释放,该方法会在垃圾回收该对象之前调用,该方法不要主动的调用

finalize方法只可以被调用一次

虚拟机对象中的状态
  • 可触及的 可达的对象
  • 可复活的 对象的所有引用都被释放,但是对象有可能在finalize方法中复活
  • 不可触及的 finalize方法已经被调用,并且没有复活
判断是否可回收的过程

判断对象是否可回收,至少要经历两次标记过程

  • 如果该对象到GC Roots没有引用链,则进行第一次标记
  • 判断该对象是不是有必要执行finalize方法
    • 如果对象没有重写finalize方法,或者已经被虚拟机调用过,则视为没有必要执行,该对象被判定为不可触及的
    • 如果对象重写了finalize方法,且还没有被调用过,该对象会被插入到F-Queue队列中,由Flializer线程触发其finalize方法
    • finalize方法是对象逃脱被回收的最后机会,GC会对F-Queue队列中的对象进行第二次标记,如果在finalize方法中对象与引用链上的任何一个对象建立了联系,那么在进行第二次标记时,该对象会被移出即将回收的集合。等待下一次没有被引用的时候,但是finalize方法只可以被调用一次

判断一个类是否可以被回收?

需要同时满足三个条件

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

垃圾回收算法

  • 标记-清除

  • 复制

  • 标记-整理

  • 分代收集

标记-清除算法

标记-清除算法(Mark-Sweep)在堆中的有效内存空间被耗尽的时候就会停止整个程序,然后进行两个阶段,标记清除

  • 标记 Collector从引用根节点GC Root开始遍历,标记所有被引用的对象,在对象头中记录为可达对象
  • 清除 Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其头部中没有标记为可达对象,则将其回收
优点
  • 实现简单
  • 不会移动对象,所以与其他算法结合也比较简单
缺点
  • 效率较低,标记和清除两个过程的效率都不高,都需要遍历空闲链表,但是比标记-整理算法效率高
  • 在进行GC的时候,需要停止整个应用程序,导致用户体验差
  • 清理完之后空闲内存不是连续的,产生内存碎片,造成空间不连续,导致之后需要分配大对象时,无法找到足够的连续内存而触发下一次垃圾回收,需要维护一个空闲列表

复制算法

复制算法(Copying)将内存空间分成两块,每次使用其中一块,在垃圾回收时将真正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块内的所有对象,交换两个内存的角色,完成垃圾回收

优点
  • 运行高效
  • 复制过去以后保证空间的连续性,不会出现碎片问题
缺点
  • 需要两倍的内存空间,浪费空间
  • GC需要维护region之间的对象引用关系,不管内存占用或者时间开销都不小

该算法适用于存活对象比较少、垃圾比较多的情况下,所以可以用于新生代中

标记-整理算法(标记-压缩算法)

标记-整理算法(Mark-Compact)也是分为了两个阶段,标记整理,这种方式用于老年代的垃圾收集

  • 标记 该阶段和标记-清除算法一样,从根节点开始标记所有被引用的对象
  • 整理 将所有对象整理到内存的另一端,按顺序排放,然后清理边界外的所有空间
优点
  • 消除了标记-清除算法中内存碎片的缺点,在需要给新对象分配内存时,只需要持有一个内存的起始地址就可以了,不需要维护空闲列表
  • 消除了复制算法中需要两倍内存的缺点
缺点
  • 效率低,比复制算法的效率低,比标记-清除算法效率还低
  • 移动对象的同时,如果对象被其他对象引用,则需要调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序

分代收集算法

由于没有一个完美的算法,所以根据不同的场景来采用不同的垃圾回收算法,HotSpot根据不同的内存区域采用不同的垃圾回收算法,也就是分代收集算法

在新生代中,由于新生代中对象生命周期短,存活率低,回收频繁,且新生代中存在两个survivor,所以在新生代使用复制算法,回收速度快

在老年代中,由于老年代区域大,对象生命周期长,存活率高,肯定不可以使用复制算法,所以使用的是标记-清除或者标记-清除和标记-整理混合实现

分区算法

分代算法是按照对象的生命周期长短划分为两个部分,分区算法是将整个堆空间划分为连续的不同小区间,每个小区间独立使用,独立回收,可以控制一次回收多少小区间,这样每次回收若干个小区间,而不是整个堆空间,可以减少一次GC所产生的停顿

STW事件(stop the world)

STW事件是指GC发生过程中,会产生应用程序的停顿,停顿产生时整个应用程序线程会被暂停,没有任何响应

在可达性分析算法中枚举根节点(GC Roots)就会导致所有java线程停顿(停顿是由于分析工作必须在一个能确保一致性的快照中进行)

垃圾回收器

有七款经典的垃圾回收器

按照线程数分类

  • 串行回收器:Serial、Serial Old 使用单线程进行垃圾回收的回收器
  • 并行回收器:ParNew、Parallel Scavenge、Parallel Old
  • 并发回收期:CMS、G1

按照分代分类

  • 新生代收集器:Serial、ParNew、Parallel Scavenge
  • 老年代收集器:Serial Old、Parallel Old、CMS
  • 整堆收集器:G1

可以使用-XX:+PrintCommandLineFlags查看命令行相关参数

也可以使用 jinfo -flag 相关垃圾回收器参数 pid

Serial收集器

Serial收集器是最基本、历史最悠久的收集器,在jdk3之前回收新生代的唯一选择,采用复制算法、单线程串行回收和STW机制的方式执行内存回收,Serial Old收集器是用来执行老年代垃圾收集的收集器,Serial Old收集器采用了标记-压缩算法、串行回收和STW机制的方式执行内存回收

Serial收集器是HotSpot中Client模式下的默认新生代垃圾收集器

Serial Old收集器是HotSpot中Client模式下的默认老年代垃圾收集器

Serial Old收集器在Server模式下的用途

  • 与新生代Parallel Scavenge配合使用
  • 作为老年代CMS收集器的后备垃圾收集方案
优点

简单高效

可以使用-XX:+UseSerialGC参数来指定年轻代使用Serial收集器,老年代使用Serial Old收集器

一般在单核cpu时使用,目前已经不使用串行回收了

ParNew收集器

ParNew(Par是Parallel,New是指新生代)收集器是新生代中的多线程版本,除了使用多线程并行回收外,其他与Serial没有什么区别,也是采用复制算法和STW机制,由于在新生代回收次数较多,所以使用的是并行的方式,而老年代回收次数较少,可以使用串行方式来节省资源,减少了线程切换的消耗,追求降低用户停顿时间,适合交互式应用。所以老年代可以使用Serial Old收集器来进行回收

可以使用-XX:+UseParNewGC来指定新生代使用ParNew来进行内存回收,老年代使用串行回收器

同时可以使用 -XX:+UseConcMarkSweepGC来指定老年代使用CMS收集器

使用-XX:ParallelGCThreads来指定线程数量,默认是CPU核数

Parallel Scavenge收集器

Parallel Scavenge收集器采用复制算法、多线程并行回收和STW机制的方式执行内存回收,看上去与ParNew收集器是一样的,但是Parallel Scavenge收集器是一个吞吐量优先的收集器,可以进行自适应调节策略,可以高效的利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务,如批量处理、订单处理等,吞吐量优先的场景下使用Parallel Scavenge收集器和Parallel Old收集器进行组合(JDK8中默认的组合)

吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,主要使用在后台运算而不需要太多交互的任务

在jdk6时老年代的默认垃圾收集器由Serial Old收集器变为了Parallel Old收集器

Parallel Old收集器采用了标记-压缩算法、并行回收和STW机制的方式执行内存回收

参数配置
  • -XX:+UseParallelGC 指定新生代使用Parallel并行收集器
  • -XX:+UseParallelOldGC 指定老年代使用并行收集器,开启新生代也会激活老年代,开启老年代也会激活新生代,互相激活
  • -XX:ParallelGCThreads 设置新生代并行收集器的线程数,一般与CPU核数相等,默认情况下,如果CPU核数小于8,则为CPU核数;如果CPU核数大于8,则为3+(5*CPU核数/8)
  • -XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(STW时间),单位毫秒,与-XX:GCTimeRatio互斥
  • -XX:GCTimeRatio 吞吐量大小,设置垃圾收集时间占总时间的比例(1/(N+1)) 取值范围为(0,100),默认99
  • -XX:+UseAdaptiveSizePolicy 设置是否开启自适应调节策略,默认开启,如果开启,新生代大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间的平衡点

注意:这里说明一下,吞吐量和低延迟是两个相互竞争的指标,如果选择吞吐量优先,那么就需要降低内存回收的执行频率,这将会导致GC需要更长的暂停时间来执行垃圾回收;如果选择低延迟,那么就必须降低每次执行内存回收时的暂停时间,而必须频繁的进行内存回收,从而引起吞吐量的下降

举例:在60s的JVM运行时间中,同样的代码由于占用的内存其实是一样的,那么如果是20s进行一次垃圾回收,那么60s内就执行了3次,每次耗时100ms,一共会有300ms用于垃圾回收

现在我们为了低延时,将执行频率变成了10s一次,那么60s内就执行了6次,每次GC耗时80ms的话,那么就耗时480ms,很显然吞吐量下降了,但是每次的时延降低了

Serial Old收集器

Serial Old是Serial收集器的老年代版本,采用了标记-整理算法、串行回收和STW机制的方式执行内存回收,可以与Parallel Scavenge收集器搭配使用

Parallel Old收集器

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

CMS收集器

全称为Concurrent Mark Sweep,jdk5中推出的老年代的并发收集器,尽可能缩短垃圾回收时用户线程的停顿时间,初衷是减少Serial和Parallel收集器在FullGC周期的长时间停顿,CMS在Minor GC时会暂停所有的应用线程,并以多线程的方式进行垃圾回收,而在Full GC时不再暂停应用线程,而是使用若干个后台线程定期地对老年代空间进行扫描,及时回收其中不再使用的对象,应用线程只在Minor GC和后台线程扫描老年代时发生短暂的停顿,重视服务的响应速度,低延迟,采用的是标记-清除算法和STW机制,该垃圾回收器不能与Parallel Scavenge收集器组合,只能与ParNew或者Serial收集器组合

分为初始标记(会STW)、并发标记(可以与用户线程一起工作,不会产生STW)、重新标记(会STW)、并发清理(可以与用户线程一起工作,不会产生STW)、重置线程(可以与用户线程一起工作,不会产生STW)五个步骤

只有ParNew来和CMS进行配合工作

过程
  • 初始标记:该阶段标记从GCRoots直接可达的对象,该过程是单线程的,会导致STW
  • 并发标记:由上一阶段标记过的对象,标记所有可达的对象,垃圾回收线程和用户线程同时运行,会导致有些对象会从新生代晋升到老年代、有些老年代对象引用可能会改变、有些对象会直接分配到老年代,这些受到影响的老年代对象所在的card会被标记为dirty,用于重新标记阶段的扫描
  • 重新标记:重新扫描堆中的对象,进行可达性分析,标记活着的对象,会导致STW
  • 并发清理:用户线程被重新激活,同时将那些未被标记为存活对象标记为不可达
  • 重置线程:CMS内部重置回收器状态,准备下一个回收周期
优点
  • 并发收集
  • 低延迟
缺点
  • 由于使用的是标记-清除算法,所以会产生内存碎片,可以设置-XX:+UseCMSCompactAtFullCollection-XX:CMSFullGCsBeforeCompaction 来进行内存碎片整理
  • CMS收集器对CPU资源非常敏感
  • CMS收集器无法处理浮动垃圾,在并发清理阶段产生的新的垃圾,无法进行标记,所以新产生的垃圾不会回收,只能在下一次GC时进行释放
  • 在执行过程中,会存在上一次垃圾回收还没有执行完,然后垃圾回收又被触发的情况,在无法分配大对象时,(concurrent mode failure)CMS就会蜕化到Serial收集器的行为,暂停所有应用线程,STW,使用单线程回收、整理老年代空间,之后在恢复到CMS
参数配置
  • -XX:+UseConcMarkSweepGC 指定使用CMS收集器,自动触发-XX:+UseParNewGC,使用的组合为ParNew(新生代)+CMS(老年代)+Serial Old(老年代)
  • -XX:CMSInitiatingOccupanyFraction 设置堆内存使用率的阈值,达到该阈值,触发Full GC,如果内存增长缓慢,可以设置稍大的值,降低CMS的触发频率,减少老年代回收的次数;如果内存增长迅速,则应该降低该阈值,避免频繁触发Serial Old,有效降低Full GC的执行次数
  • -XX:+UseCMSCompactAtFullCollection 指定在执行Full GC后对内存进行压缩整理,会带来STW
  • -XX:CMSFullGCsBeforeCompaction 设置执行多少次Full GC后对内存空间进行压缩整理
  • -XX:ParallelCMSThreads 设置CMS线程数量,默认为 (ParallelGCThreads + 3) /4,ParallelGCThreads是新生代并行手机器的线程数
  • -XX:+CMSScavengeBeforeRemark 在CMS GC前启动一次minor gc,目的是在于减少老年代对年轻代的引用,降低CMS GC在标记阶段的开销

jdk9中废弃,jdk14中删除

G1收集器

分区回收

jdk7引入G1收集器,G1是一个并发收集器,初衷是为了尽量缩短处理超大堆(大于4G)时产生的停顿,将堆内存分割为很多不相关的区域(region),使用不同的region来表示Eden、Survivor0、Survivor1、老年代等,避免在整个堆中进行全区域的垃圾收集,跟踪各个Region里垃圾堆积的价值大小(回收所获得的空间大小比上回收所需的时间),维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region

面向服务端应用,针对大内存、多处理器的机器使用G1收集器

垃圾回收过程
  • 新生代GC(Young GC)

    应用程序分配内存,当新生代的Eden区用尽时开始新生代回收过程,G1新生代收集阶段是一个并行的独占式收集器,新生代回收期,G1会暂停所有应用程序线程,启动多线程执行新生代回收,然后从新生代移动存活对象到Survivor区间或者老年区间,新生代垃圾回收只会回收Eden区和Survivor区,会触发STW

  • 老年代并发标记(Concurrent Marking)

    当堆内存达到一定值时(默认45%,-XX:InitiatingHeapOccupancyParcent),开始老年代并发标记过程

  • 混合回收(Mixed GC)

    标记完成之后立即开始混合回收过程,G1从老年区间移动存活对象到空闲区间,这些空闲区间就变成可老年代的一部分,和新生代不同,老年代的G1回收器不需要整个老年代回收,一次只需扫描一小部分老年代的region,同时,这个老年代的region是和新生代一起被回收的

  • Full GC(单线程、独占式、高强度的Full GC 作为保底方案)

优点
  • 并行与并发 可以多个GC线程同时工作,不过会导致STW;可以与应用程序交替执行,部分工作可以与应用程序同时执行
  • 分代收集 G1依然属于分代回收,不过不要求Eden、新生代或者老年代是年需的,而是将堆空间分为若干个region,这些region中包含了逻辑上的新生代和老年代
  • 空间整合 G1将内存划分为一个个的region,内存回收是以region为基本单位的,region之间是复制算法,整体上是标记-整理算法,可以避免内存碎片
  • 可预测的停顿时间模型 避免在整个堆中进行全区域的垃圾收集,跟踪各个Region里垃圾堆积的价值大小(回收所获得的空间大小比上回收所需的时间),维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,保证了G1收集器在有限时间内可以获取尽可能高的收集效率
缺点

G1需要更高的内存占用,所以在小内存的应用上CMS的表现会优于G1,而在大内存的应用上G1可以有很大的优势(6~8G)

参数配置
  • -XX:+UseG1GC 指定使用G1收集器
  • -XX:G1HeapRegionSize 设置每个region的大小,值是2的次幂,范围是1M到32M之前,目标是根据最小的堆大小划分出2048个region,默认是堆内存的1/2000
  • -XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标(STW时间),不能保证一定可以达到,但是会尽量保证,默认200ms
  • -XX:ParallelGCThread 设置并行垃圾回收线程数,最大为8
  • -XX:ConcGCThreads 设置并发标记的线程数,ParallelGCThread`的1/4左右
  • -XX:InitiatingHeapOccupancyParcent 触发并发GC周期的堆占用率阈值,超过则会触发GC,默认45

jdk9中为默认垃圾收集器

在使用G1垃圾收集器时,尽量避免使用-Xmn-XX:NewRatio等选项显式设置年轻代大小,固定的年轻代大小会覆盖暂停时间目标

评估GC的性能指标

  • ※吞吐量:运行用户代码的时间占总运行时间的比例(高吞吐量可以高效的利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务)
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例
  • ※暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间(STW时间),越小越好(暂停时间短适合需要和用户交互的程序,良好的响应速度能提升用户体验)
  • 收集频率:相对于应用程序的执行,收集操作发生的频率
  • 内存占用:java堆区所占用的内存大小
  • 快速:一个对象从诞生到被回收所经历的时间

几种垃圾收集器对比

垃圾收集器对比

  • 如果每个请求都需要很快的响应,那么选择响应速度优先的合适
  • 如果平均响应时间比最大响应时间更重要,那么选择吞吐量优先的合适

分代收集

Java垃圾回收机制最基本的做法是分代收集。内存中的区域被分为不同的年代,对象根据其存活的时间被保存在对应年代的区域内。

分为3个年代:新生代、老年代和元空间。

内存的分配发生在新生代中,当对象存活时间足够长时,会被复制到老年代。

垃圾回收根据回收区域不同分为两种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

部分收集Partial GC

部分收集不是完整收集整个堆的垃圾回收

  • Minor GC新生代收集 :也称为Young GC,只对新生代进行垃圾回收,Eden满的话会触发Minor GC,Survivor不会去触发,只不过Minor GC也会去清理Survivor
  • Major GC老年代收集 :也称为Old GC,只对老年代进行垃圾回收(只有CMS GC会单独对老年代进行垃圾回收)
  • Mixed GC混合收集:对整个新生代以及部分老年代进行垃圾回收(只有G1 GC会存在Mixed GC)
整堆收集Full GC

整堆收集会收集整个java堆和方法区的垃圾收集,当准备触发一次Minor GC时,如果发现老年代的剩余空间比以往晋升的空间小,则不会触发Minor GC,转而触发Full GC,因为JVM认为,之前大空间的时候已经发生过对象晋升了,现在剩余空间更小了,大概率也会发生对象晋升,所以直接进行Full GC;另外在方法区如果没有足够的空间的话,也会触发Full GC

GC日志显式参数配置

  • -XX:+PrintGC 输出GC日志
  • -XX:+PrintGCDetails 输出GC的详细日志
  • -XX:+PrintGCTimeStamps 输出GC的时间戳
  • -XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式)
  • -XX:+PrintHeapAtGC 在进行GC前后打印出堆的信息
  • -XX:+PrintGCApplicationConcurrentTime 打印GC过程中用户线程并发时间
  • -XX:+PrintGCApplicationStoppedTime 打印GC过程中用户线程停顿时间
  • -XX:+PrintTenuringDistribution GC收集后剩余对象的年龄分布信息
  • -Xloggc:../logs/gc.log 日志文件输出路径,可以使用gc分析工具,如gcViewer、gcEasy(gceasy.io)

欢迎关注我的其它发布渠道