垃圾收集器

垃圾收集器

概要:此文章大体介绍了各种垃圾收集器,该文章为与一位大佬深入交流后整理各种资料并加入自己的理解所得,后续会继续更新自己的相关心得。

1. 垃圾收集器

前面提到过有很多,jVM把堆内存分为了新生代/老年代,对应的在不同内存分代上都有不同的垃圾收集器,具体的配置组合如下:
在这里插入图片描述

1.1 Serial 收集器

作用域 : 新生代
算法 : 标记-复制 算法
serial收集器,是最基础的,历史最悠久的收集器,从名字可以看出,他是一个遵守顺序的收集器,换句话来说就是,所有的用户线程都必要要等他执行结束,才能运行,同时他也是一个单线程的垃圾收集器
hotspot客户端模式下的默认新生代收集器
· 优点 : 简单高效,单线程的收集器,没有多线程的切换开销,对于内存较小的应用是性价比比较高的选择
· 缺点 : 如果应用程序内存占用比较高,那么stop the world 的时间将会特别的长
1.2 ParNew 收集器
作用域 : 新生代
算法 : 标记-复制 算法
ParNew实际上是Serial的多线程版本,收集算法,对象分配规则,回收策略都和Serial相同
· 优点 : 多线程版本的serial虽然提升寥寥无几,更主要的是在JDK8以后,是唯一一个能和CMS配合使用的新生代收集器
· 缺点 : 如果你的机器是单核的CPU,甚至超线程的伪双核CPU,其运行效率可能还不如Serial
1.3 Parallel Scavenge 收集器
作用域 : 新生代
算法 : 标记-复制 算法
名字也很直白,直意翻译就是并行捡破烂,也是和ParNew相似标记-复制 算法 加上 并行处理,那么他和ParNew的区别在哪里嗯?
CMS这样的垃圾回收器重点在于降低线程的停顿时间,而Parallel Scavenge则是把重点放在了可控吞吐量上面
__吞吐量 = 用户代码运行时间/(用户代码运行时间 + GC运行时间) __
如果 (用户代码运行时间 + GC运行时间) = 100分钟,其中GC运行时间 = 1分钟,那么吞吐量就是99%
Parallel Scavenge 可以通过参数去设置吞吐量等指标,然后GC自己调节各种基本参数(新生代大小,eden,s1,s2比例等),来达到全自动优化的目标
· 优点 : 有很多参数可以去控制吞吐量等指标,可以自适应调节策略,不需要去了解很多JVM知识就能达到GC调优,优化GC门槛大大降低了
· 缺点 : ParNew有的缺点他都有
1.4 Serial old 收集器
作用域 : 老年代
算法 : 标记-整理 算法
serial的老年代版本,最佳备胎,曾经可以配合所有的新生代收集器一起用(JDK8以后ParNew成了CMS专用),在CMS故障后也是Serial old
· 优点 : 同Serial,最佳备胎,可以配合大多数新生代收集器
· 缺点 : 同Serial
1.5 Parallel Old 收集器
作用域 : 老年代
算法 : 标记-整理 算法
Parallel Scavenge 在JDK6以前只能用Serial old,但是这是一个单线程的老年代收集器,在高性能CPU上面成了性能的短板,所以一个为了Parallel Scavenge的最佳伴侣出现了
· 优点 : Parallel Scavenge的最佳伴侣,多线程的垃圾收集器
· 缺点 : 只能配合Parallel Scavenge,单核CPU下性能堪忧
1.6 CMS(Concurrent Mark Sweep) 收集器
作用域 : 老年代
算法 : 标记-清除 算法
从名字就能看出来,他是可以并发标记清理的收集器,他的运行过程分为四个步骤

  1. 初始标记(initial mark) – 这个阶段是需要stop the world的,仅仅是标记一下能直接去GCRoots直接关联到的对象,速度很快
  2. 并发标记(concurrent mark) – 去从GCRoots开始扫描整个对象图,这个需要大量时间但是可以和用户线程同时进行
  3. 重新标记(remark) – 因为用户可能会在GC扫描对象的时候,去修改了对象的引用关系,所以需要重新标记,同时这个阶段也是需要stop the world,他的耗费时间比1阶段要长,但是远远小于2阶段的花费时间
  4. 并发清除(concurrent sweep) – 删除已经死亡的所有的所有对象,由于不需要去移动存活的对象,所以他是不需要停止用户线程的
    从上面可以看出,他耗时最长的是 2. 并发标记(concurrent mark)和 4. 并发清除(concurrent sweep),但是因为这2个阶段是可以和用户线程同时进行的,所以他是stop the world 最短的GC,需要高响应的系统(比如:B/S架构下的service)CMS是性价比非常高的系统
    · 优点 : 追求低停顿的收集器,可以并发的标记和清除对象.
    · 缺点 :
    o 对处理器资源非常的敏感,虽然他不会让用户线程停顿,但是因为占用了一部分的线程导致应用程序的处理能力变慢,降低了总的吞吐量,尤其是处理器核心非常少的情况下,线程的抢夺会变得非常的激烈.
    o 因为清除和用户线程 是并行执行的,那么就会存在在清除的同时,用户线程不停产生垃圾,所以要预留一定的空间给用户线程,别的GC可以等空间只有99%统一来一次大扫除,但是CMS可能需要90%(-XX:CMSInitiatingOccu-pancyFraction 来设置)的时候就要触发GC,这时候可能出现这预留的10%马上被用户线程用完了,就会出现一次并发失败(Concurrent Mode Failure),那么这时候就会出现备用方案 --> 触发了Full GC让serial old 来执行一遍,前面可以知道serial old 会暂停用户线程,这样停顿时间就很长了.
    o 同时,因为他是标记-清除 算法,会产生大量的内存碎片,当碎片严重到大对象已经无法进入的时候,也会触发一次Full GC让serial old 来执行一遍
    这里就有第一个 CMS的优化点 -XX:CMSInitiatingOccu-pancyFraction 不要设置的太高.
    1.7 G1(Garbage First) 收集器
    作用域 : 新生代/老年代
    算法 : 标记-整理/标记-复制 算法
    用于取代CMS的垃圾收集器,JDK8中基本补全了G1所有的功能,而JDK9中G1成了服务器端默认的GC,同时CMS被声明Deprecate
    G1 虽然遵循分代收集的理论,但是他不在以固定大小和固定数量去划分分代区域,而是把连续的JAVA堆划分成多个大小相等的独立区域(Region),每一个Region都可以根据实际需要扮演eden 空间/survivor空间/老年代,同时G1会把大小超过Region 一半的对象当做大对象,大对象会被放在Humongous Region区域里,对于超级大对象(超过Region大小),会存放在由多个Humongous Region组成的空间里,Humongous Region会被G1当做老年代来对待
    同时在每个Region 里面都有2个叫做TAMS的指针,在并发回收的时候用户新创建出来的对象就会在TAMS指针指向的地方,这样GC就不会当做垃圾把用户新创建出来的对象给回收了.
    同样他的运行过程也分为四个步骤
  5. 初始标记(initial mark) – 这个阶段是需要stop the world的,仅仅是标记一下能直接去GCRoots直接关联到的对象,和CMS不同的是,这里还需要去修改TAMS指针的值,速度很快
  6. 并发标记(concurrent mark) – 去从GCRoots开始扫描整个对象图,这个需要大量时间但是可以和用户线程同时进行
  7. 重新标记(remark) – 因为用户可能会在GC扫描对象的时候,去修改了对象的引用关系,所以需要重新标记,同时这个阶段也是需要stop the world,他的耗费时间比1阶段要长,但是远远小于2阶段的花费时间
  8. 筛选回收(Live Data Counting and Evacuation) – GC会对每个Region 进行回收成本和回收价值的排序,根据用户期望的停顿时间来制定回收计划来决定去处理哪一个Region,换一句话说就是,Region里有垃圾但是垃圾不多的话GC不会处理的,因为性价比太低.在清理Region的时候,会把Region里的存活对象全部拷贝出来到一个新的Region,然后删除老的Region,因为涉及到了存活对象的移动,所以会发生stop the world
    从上面可以看出,除了并发标记 其他阶段也都会stop the world,他不像CMS纯粹去追求延迟,而是在延迟和吞吐量上找到一个平衡点, 他比CMS比吞吐量高,比Parallel延迟低,是一个性能比较均衡的GC,而且用户可以去使用参数指定延迟时间(设置越低,对CPU负担越大,性能不足的CPU会降低吞吐量,并且让GC每次都选择很少的Region去清理,让内存很快被垃圾塞满,从而触发Full GC)
    · 优点 : 用于可以指定延迟时间,在不同的CPU上让延迟/吞吐量达到一个平衡点
    · 缺点 : 每个Region里面都要去维护一个记忆卡(解决跨代指针问题),可以会占整个堆空间的20%左右,一般大于8G内存的应用,G1会更有优势,否则CMS还是挺有竞争力
    1.8 Shenandoah 收集器
    作用域 : 整个堆
    算法 : 标记-整理/标记-复制 算法
    由redHat 开发的新型收集器,后来送给了OpenJdk,因不是一个爹生的,所以遭到了Oracle的排挤,在Oracle JDK12通过条件编译直接把这个收集器给排掉,但是在OpenJDK 12得以保留
    Shenandoah的目标是能在任何堆内存大小的环境下,都可以把垃圾收集器的的停顿时间限制在10毫秒以内,该目标意味着相比CMS/G1,Shenandoah``Shenandoah不仅要并发标记,还要实现并发后的整理动作.
    Shenandoah更像是G1的继承者,2者有着相似的堆内存布局,在初始标记,并发标记等多个阶段的处理思路上都高度一致,甚至还共享了一部分的实现代码,但是他和G1还是有很多不同的具体如下:
  9. Shenandoah 支持了并发整理对象,这是和G1最大的性能差距
  10. Shenandoah 默认不使用分代算法
  11. Shenandoah 用一个全局的连接矩阵来代替了维护在每个Region里的记忆卡,解决了G1的内存占用问题
    Shenandoah 收集器的运行过程大致可以分成9个阶段
  12. 初始标记(initial mark) – 和G1一样,这个阶段仍然是stop the world,但是停顿的时间和堆大小无关,和GCRoots的大小有关
  13. 并发标记(concurrent mark) – 和G1一样,这一阶段和用户线程一起并发进行
  14. 最终标记(final mark) – 和G1一样处理剩余的SATB扫描,并在这个阶段计算出高价值的Region,将这个些Region组成一个回收集
  15. 并发清理(Concurrent Cleanup) – 这个阶段清理整个区域内一个存活对象都没有的Region,可以和用户线程同时进行
  16. 并发回收(Concurrent Evacuation) – 这里其实和平常的收集器一样,把活的对象移动到一个完整的Region,然后删除老的Region,但是如果简单这么做的话和G1还有什么区别,为了能够不停止用户线程去移动活对象,Shenandoah这里只是把活对象复制到一个干净的Region里面,注意的是这里只是复制了对象其实并没有把老对象删除,所有的指针(GCRoots/堆里对象相互引用)都是指向了老对象
    在这里插入图片描述

由上图可以看出栈里有2个属性a/b 指向了堆里的A/B 2个对象,同时A对象里引用了B对象,在完成了并发清理后额外复制了A/B 2个对象,但是指向是没有改变的(不管是栈里的指向,还是对象之间的指向)

  1. 初始引用更新(Initial Update Refernce) – 这个阶段什么都不做,只是确定一下所有的GC线程已经完成了对象的复制工作,这个阶段会产生一个非常短暂的暂停
  2. 并发引用更新(Concurrent Update Reference) – 上面不是复制了活对象,对象和对象之间是有引用关系的,复制后的对象其实还是指向了老对象,这里是把对象和对象之间的引用改成新对象的,这里是和用户线程一起并发操作的.
    在这里插入图片描述

如上图所示,完成了并发引用更新 后新创建的对象的引用关系就修改了.

  1. 最终引用更新(Final Update Reference) – 第7步 解决了堆中引用的更新,然后就是要修改GCRoots 中引用的更新了,这一步涉及到了用户线程,会发生stop the world,同时也是最后一次暂停了
    在这里插入图片描述

  2. 并发清理(Concurrent Cleanup) – 最后把老的Region 清理掉就行了

下图是整个清理过程的总流程,其中绿色代表存活对象,黄色代表被选中的Region,蓝色代表用户线程可以分配的Region
在这里插入图片描述

看完上面的9步,其实是存在很多问题的
1.8.1 Brooks pointers
上面的过程粗略来看是非常简单的,但是细想后又有很多的问题,尤其是高并发学的好的同学,比如: 在第五步并发回收(Concurrent Evacuation) 的时候,复制出了一个新的对象,那么在引用更新之前,如果用户线程写操作到了老对象上面怎么办?
所以为了防止这种问题的出现,一个叫做Brooks 提出了使用转发指针也叫做Brooks pointers,其实也就是在每一个对象前面加一个Brooks pointers, 可以理解成Brooks pointers是一个代理,当只有一个对象的时候,Brooks pointers会指向自己.
所以我们再回过来看阶段五的那一张图,虽然复制了新对象后,指向还是老对象,但是所有的读写操作都会转发到新对象上面
但是这里是会有并发问题的,设想一下下面三件事并发进行的场景

  1. GC复制了新的对象副本
  2. 用户线程更新了某个字段
  3. GC修改Brooks pointers指向新对象
    如果发生了以上顺序,就会导致用户线程把值更新到了老的对象上面,所以要解决上面的问题,很简单上面几步操作上锁就行,这里Shenandoah 使用CAS来解决的.
    1.8 ZGC 收集器
    作用域 : 整个堆
    算法 : 标记-整理 算法
    JDK11加入的新垃圾收集器,同样使用了Region的堆内存布局,不设分代.使用了读屏障,染色指针,内存多重映射,等高大上的技术.
    1.8.1 染色指针
    以前,我们要在对象上面存一些额外的信息(hashcode,分代年龄,锁记录),这些信息都是存在了对象header里的,通常来说这样的方式没有什么问题,但是如果这个对象被移动了,但是我不知道他移动到了那里,我又想知道他的一些基本信息(比如我想看到他是否移动了?或者是被销毁了)就会有问题了,再或者某些对象我不想去访问他,但是我想获取他的基本信息,想要达成上面所描述的,那么染色指针 闪亮登场.
    染色指针是一种直接将少量少量额外信息储存在指针里的技术.因为在64位的操作系统里最大可以访问16EB(264)的内存,但是实际上X86架构CPU最大只支持到4PB(252),linux支持到了128TB(247)而windows只能支持到16TB(244)
    ZGC 将指针的高4位用来存储一些额外的对象信息
    在这里插入图片描述
    · Finalizable: 是否执行过finalize()方法
    · remapped: 对象是否被移动过
    · marked0/marked1: 三色标记的标志位, 2个标志位可以有4种可能性,刚好包括了 三种颜色
    由上面可以看出 前面还有18bits没有使用,因为操作系统的原因现在还没能利用起来,后面应该会使用起来,这样就能记录更多信息
    染色指针的优点 :
  4. 自愈功能: 可以使得某个Region存活对象被移走后,就能立即释放这个Region,而不用等所有指向该对象的引用全部修正后才能清理(具体可以看Shenandoah的5-9 步操作), 具体原理是在访问对象的时候经过读屏障(可以认为就是读对象的aop)时会通过染色指针中的remapped信息去判断这个对象是否被移动过,如果发现被移动了,那么就更新新的地址到指针中.
  5. 可以减少内存屏障的时候,尤其是写屏障是为了记录对象的变更信息,现在可以直接将这些信息记录在指针里.
    1.8.2 内存多重映射
    要能顺利使用 染色指针 有这么一个小问题: JVM作为一个普通的进程,是否能重新定义内存中某些指针的前几位? 所以 内存多重映射 出现了
    内存多重映射 是把多个不同的虚拟内存地址映射到同一个物理地址上面染色指针的4位额外信息,不管这4位如何变化,都会指向同一个物理内存地址
    1.8.3 ZGC的运行流程
  6. 并发标记(concurrent mark): 和G1/Shenandoah 一样,是遍历对象做可达性分析,前后也要经过类似G1的初始标记,最终标记的短暂停顿.与G1不同的是,ZGC的标记是记录在指针上面,而非对象上面
  7. 并发预备重分配(Concurrent Prepare for Relocate): 这个阶段去查询统计有哪些Region需要清理,将这些Region组成重分配集(Relocation Set)
  8. 并发重分配(Concurrent Relocate): 将重分配集 里的活对象都移动到一个新的Region上面,并且维护一个转发表Forward Table,用于维护新老对象的映射关系,上面提到染色指针的自愈功能就会从这个映射表去自动替换新对象.
  9. 并发重映射(Concurrent Remap): 修正整个堆中指向旧对象的所有引用,这个并不是一个迫切的任务,因为就算不修复,也会有自愈功能自动去修复.
    缺点: 上面提到很多ZGC的优点,但是还是有一些缺点的
    · 因为没有使用分代收集,会导致他能承受的对象的分配速度不会太高,假设 ZGC准备要收集一个很大的堆,全过程要持续10分钟以上,在这段时间里,用户线程分配对象的速度很快,每时每刻会创建大量的新对象,这些对象很难进入当次的收集标记范围,通常就当做活对象考虑了,尽管其中绝大多数都是转瞬即逝的,这样就产生了大量的浮动垃圾,如果这种高速分配持续进行,每一次完整的并发收集周期都会很长.
    1.9 Epsilon 收集器
    这个收集器算是比较奇特的一个,他并不做任何垃圾回收的工作.
    他的受众群体是那些可能只需要运行几分钟/几秒钟 的小程序,那么显然运行负载极小,没有任何回收行为的Epsilon是很恰当的选择

2 GC的优化

2.1 超大堆的管理
JVM的内存并不是越大越好,超大的堆内存会让一次Full GC的时间十分的长(16G内存,CPU-E3也会达到10S左右的停顿)
JVM超大堆的问题:
· 上面提到的会让Full GC的时间变得很长,但是现在可以用G1,Shenandoah,ZGC来缓解
· 超大的堆内存会让内存快照(dump)变得异常的大,如果发生了内存溢出很难定位到问题
超大堆其实可以使用 Parallel Scavenge/old 这样的收集器,不一定非要G1这样的收集器才能解决问题,但是就需要应用程序把Full GC控制在一定范围,或者就不发生Full GC,因为超大的内存,如果程序设计良好足够你老年代的使用,通过凌晨触发Full GC,定时重启来解决老年代的垃圾回收.
但是其实,是不推荐使用超大堆的,而是把一个超大的服务拆小,使用集群的方式来扩充内存,集群服务可以在一台机子,也可以在不同的机子上面,比如上面提到的16G内存的服务器,可以拆成4个4G的服务,然后负载均衡,现在的微服务也是这样的路子.
2.2 扩容优化
因为 在老年代满了之后,会发生Full GC 然后扩容,那么在扩容的时候,将会出现很多的Full GC 所以可以把-Xms 和 -XX:PermSize 设置的和 -Xms与 -XX:MaxPermSize 一样来防止扩容,来避免Full GC
2.3 屏蔽System.gc()
有的时候,可以看到老年代占用的内存很少(100MB),而老年代上限1G,但是还是发生了Full GC,那可能是有框架层面手动调用了System.gc(),导致的.可以使用参数-XX:+DisableExplicitGC来屏蔽System.gc()

致谢大佬:https://blog.csdn.net/u010928589

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值