java的垃圾回收浅谈

 并发标记问题

三色算法问题

浮动垃圾问题

漏标问题

cms的解决方式

g1的解决方式

跨代(区)引用

CMS垃圾回收日志

G1垃圾回收日志


垃圾回收过程其实都包含两步:标记+回收。

标记算法:

  • 引用计数:每个对象都有一个计数器,被别的对象引用时计数器加1,当技术器变成1的时候,这个对象就是垃圾了。这个实现 简单且高效,但是存在循环引用不好处理的问题。
  • 根可达算法:有一个根列表(对于java,比如运行栈、常量区、jni栈等都是根),通过根去遍历,能够遍历的就是存活的对象、不能遍历到的就是垃圾了。

回收算法:

  • 清理:只是清理垃圾对象,不作内存整理。所以这种回收方式会产生内存随便,对应的内存管理也就只能使用链表方式,且回收后对象的内存地址是不变的。
  • 整理:将垃圾对象回收后,会整理内存。正式因为整理过程,停顿时间就比较长。这种方式可以使用指针碰撞方式来管理内存
  • 复制:将内存一分为二,回收的时候是将存活的对象copy到另外一块内存,然后释放原来那块内存整体释放。这种方式没有内存碎片、效率也比整理要高,但是内存的使用率就变低了,有一半的内存都是浪费的,并且当内存很大的时候、存活对象很多的时候,copy过程就会很长,进而停顿时间就会变长

综合来看,复制算法适合在垃圾回收执行时存活对象比较少的场景;整理算法适合在垃圾回收时存活对象较多但是不能有内存碎片的场景;而清理算法因为会产品内存碎片,实际的场景其实并不多,CMS使用该算法,但也诟病比较多。

对于一个java进程中的对象,经过统计发现,大部分对象生命期都是比较短的,活不过一次垃圾回收,而少部分生命期比较长,多次gc后依然存在,甚至和java进程生命期相同。针对这种情况,就对java的内存进行了分区,不同分区使用不同的回收算法,来达到最大的收集效果。

所以在垃圾回收器的发展历史中,出现了两个分区方式(根据分区的粒度不同)

  • 分代内存布局:整个java进程的内存空间分成量大块:新生代和老年代。每一个分代中的内存地址是连续的。新生代里存放生命期端的对象,对应的垃圾回收的时候就用复制算法;而老年代里存放生命期比较长的对象,对应的垃圾回收的时候就使用整理算法。因为新生代使用复制算法,随意新生代需要进一步划分三部分,来满足复制算法:eden区、surviver from区、suviver to区。
  • 分区内存布局:将java进程的内存分成n个region,回收的时候也按region回收。

这是从内存布局上和回收算法上看gc发展的历史,主要就是分这么两个阶段。

从并行的角度看垃圾回收的发展。

  • 单gc线程回收:这个时期,gc线程只有一个,当启动垃圾回收的时候,暂停所有业务线程,然后gc线程开始工作,经过标记、回收后,业务线程才能继续运行。这个垃圾回收过程业务线程是完全暂停的(STW:stop the world)。早期因为java进程内存都比较小,业务也并不复杂,所以垃圾回收都是单线程的,停顿时间也是可接受的,早期的Serial、Serial Old都是单线程的垃圾回收器

  • 多gc线程回收:随着java进程的内存越来越大,单线程回收效率变的很慢了,所以这个时候自然的一个想法就是将gc线程变成多线程的,多线程来并行的标记-回收内存,会更快一些。但是在垃圾回收的过程中,业务线程还是完全暂停的。

  • gc线程和业务线程并行回收:随着技术发展,java的内存需求越来越大、对于业务线程的停顿时间要求也越来越高。要进一步缩短停顿时间,那就是将gc过程进一步系分,在最必要的时候才STW,其他步骤业务线程和gc线程并行运行,从而减少STW时间。

所以到了现在的垃圾回收器,就是多种回收算法的组合、多gc线程、以及gc过程中某些阶段gc线程和业务线程可并行运行的有机结合,来提高gc的性能的。比如CMS、G1,以及后来的ZGC。

jdk支持的垃圾回收器(连线表示可组合使用):

并发标记问题

当业务线程和gc线程并行运行的时候,就让标记变得非常复杂了,比如gc线程标记到某个对象不是垃圾、但是业务线程下一秒就断开了到该对象的引用、那么这个对象本次gc就不会回收;另外gc线程没有标记认为是垃圾、但是在回收前业务线程又有引用指向了这个对象,这种情况就会更严重,将不是垃圾的对象给回收了,程序就会出现问题。
为了解决这种并发标记的错标、漏标的问题,发明了三色着色算法来解决这个问题,其核心思想就是在标记的过程中,给对象的标记情况进行着色区分:

  • 黑色:自己和其字段都被标记,认为是标记结束了的对象,后续的标记都不会再去遍历黑色对象了。而黑色对象也认为是活跃对象,不能进行回收的。
  • 灰色:自己被标记了,但是其成员字段没有被标记。这种属于标记过程中的,所以后续标记处理会继续利用可达性算法来标记改对象的所有字段。
  • 白色:通过根可达算法,遍历不到的对象。这种对象其实就是垃圾,gc过程回收的也就是白对象。但这里需要强调一点:白对象可能是标记结束了确实不可达、也有可能是标记还没有结束,因为还没有标记到所以是白对象。

ps:

  1. 这里给对象着色是在gc的标记阶段发生的。标记阶段的目的就是识别出哪些对象是垃圾、哪些对象还在继续使用,然后在清楚阶段直接使用标记阶段标记的结果,决定是回收对应内存、还是需要将对象保留并进行一定的整理。
    着色过程:就是标记过程,标记过程结束,对象要么是黑色(存活对象)、要么是白色(垃圾),灰色只是一个过程中间态;清除过程是使用着色的结果
    所以说对象的颜色是gc过程中使用的。当没有发生gc的时候,对象是没有颜色的。
  2.  当一个对象被标记成灰对象,所谓的后面标记,是指因为操作系统调度,gc线程被暂停了,重新获得cpu时间片后,接着继续标记。这里千万不要和CMS、G1收集器的remark阶段混淆了,这些收集器设计remark阶段就是为了解决着色算法的漏标、错标问题的。所以这里讲的着色算法时的垃圾回收就两个阶段:标记+回收,标记不分阶段哈,不要和remark混为一谈,否则比较不好理解。

三色算法问题

浮动垃圾问题

当gc线程执行标记时,遇到了如下情况时,gc线程的cpu时间片耗尽,业务线程开始执行。

业务线程将B指向C的引用给解除了:

当gc线程再次获得cpu时间片的时候,再去标记B的字段的时候,已经通过B字段找不到C了,即C已经成为垃圾,但本轮gc就有可能回收不到C。不过下轮gc的时候,C就会被回收了,这种因为业务线程和gc线程并行执行过程中产生的、而本轮gc又不能回收的垃圾称之为浮动垃圾。

浮动垃圾的影响只是会多占用一段时间的内存,并不会导致错误。但是对于不是垃圾而又没有被标记到的,被清理了就会出现问题

为了解决浮动垃圾占用内存的问题,gc的触发时机就不会等到分区(分代)内存全部用光了,才会触发gc,而是对应分区(分代)内存占用达到一定比例的时候,就会处罚gc。

漏标问题

gc线程执行到如下情况,cpu时间片消耗尽了:

但是业务线程运行的时候,让A持有了C的引用,但是断开了B对C的引用。这个时候gc线程获得cpu时间片继续运行时,因为A已经是黑色对象,所以gc线程不会再去遍历其字段引用了哪些对象了、而B是灰色对象,但是通过B的字段遍历,已经标记不到C了,那么就会误认为C是一个垃圾,如果本轮gc将C给回收了,那么A对象去访问C的时候,就会出问题。

综上:三色标记法漏标问题的产生有两个充要条件:

1. 业务线程使得一个黑色对象引用指向了白色对象

2. 原本指向这个白色对象引用的灰色对象,被业务线程删除了

cms的解决三色标记的漏标问题(increment update)

cms解决三色标记漏标的问题,就是破坏第一个条件:业务线程使得一个黑色对象引用指向了白色对象。具体的做大就是当黑对象引用别的对象发生变化的时候,就将黑对象变成灰对象(写屏障),那么gc线程再次运行的时候,就会继续标记了。

但是对于并行的gc标记来说,还是会有问题,比如当gc线程开始标记A对象,所以A对象是灰对象

当gc线程正在通过A对象的A.b字段去标记B对象的时候,这个时候cpu时间片耗尽,业务线程开始运行,这个时候业务线程将B对象对C的引用断开,但是让A对象A.c引用指向了C对象。在写屏障中会将A变成灰对象(本身已经是灰对象了):

当gc线程再次获得cpu时间片的时候,会接着上次没有标记完的地方开始继续标记。当标记完成后,C其实是没有被标记到的,认为是垃圾,就会被回收,但实际上A却是有引用指向了C的,所以C是不能回收的。

在remark阶段,暂停业务线程,来重新扫描所有灰对象(其实是从gc root开始)的所有字段。这样就会将C给标记上了,从而避免问题。所以我们看CMS垃圾回收stw的时候,有的时候remark的时间是比较长的。

当然CMS还有其他的问题,比如CMS是不具内存整理能力的,当老年代内存使用率达到指定的比例的时候,就开始一次老年代gc,而这次gc只是回收了垃圾对象占用内存,但是并不会整理内存,当有对象需要分配在老年代(不管是晋升还是大对象直接分配),没有足够的连续内存分配的时候,就会触发一次内存整理,这个内存整理过程会SWT(和serial old过程一样),那这次gc的STW就会比较旧。

为什么清除算法有碎片问题,但是CMS还是使用了清除算法呢?

就是为了回收阶段不暂停业务线程。因为对于整理算法和复制算法,在回收的时候,对象的内存地址其实是要变化的,所以在回收过程也是需要暂停业务线程的。但是清除算法因为不会改变对象的内存地址,就可以做到业务线程和gc线程线程并行运行。

总结起来CMS量大问题:

1. 解决漏标问题效率太低了,即使remark的时候重新扫描

2. 因为清除算法会有碎片的问题,可能导致回退到serial old收集器来gc,所以可能导致某次耗时特别的长。这就会导致系统的不稳定

参考这个博客R大的回复:

https://hllvm-group.iteye.com/group/topic/44381?page=2

G1的解决方式(SATB:Snapshot-At-Beginning)

G1的解决思路就是破坏第二个条件:原本指向这个白色对象引用的灰色对象,被业务线程删除了。基本思路就是在灰色指向其他对象引用断开时,记录下断开的引用。然后在重新标记的时候,来看是否还有对象指向了断开引用指向的对象。

如上图,G1会记录下B-->C这个断开的引用,在重新标记的时候,就会去看是否还有对应应用了C,如果有,就会标记C。这样就会不会把C漏标了。

G1是如何知道B-->C的引用断开了呢?答案就是SATB,简单理解就是在gc开始的时候记录下了引用的快照,和快照相比,发现断开了B-->C的引用,于是将B-->C的这个引用会放到gc线程的运行栈中去,然后gc再次运行的时候,就会去看是否还有其他对象引用C,如果有,就会将C标记,避免漏标。

所以下一个问题就是SATB这个快照是如何创建的?难道真的是将当前堆里所有对象的引用关系都copy了一份?答案肯定不是的。这里其实可以引申一下,mysql事务开启的时候,也会创建一个快照视图(它肯定是不可能copy整个数据库的数据的,否则会疯的);ES的scroll扫描大量数据的时候,也是在初始化scroll的时候创建了一个快照(它肯定也不是copy整个索引的)。背后的基本思想个人总结就是:记录的其实是一个id水位。比如mysql,事务开启的时候,其快照记录的其实是:当前有哪些正在执行中的事务,且事务id是递增的,所以小于这个集合中最小id的事务一定是已经提交的、大于这个集合最大的id的事务一定是当前事务启动后创建的,所以利用这个水位关系来实现了快照;ES中的道理也大同小异,再回到这里的SATB,其实也是利用了类似的思想来记录快照的。

所以总结起来看:解决并发标记错标、漏标的问题都是有一个STW的重新标记(remark)阶段,只是说CMS和G1重新标记的范围不一样,从而导致效率不一样

  • CMS解决三色标记漏标是从新增的那个引用入手:只要有新增的引用(A->B),那么就会将A变成灰色对象,remark阶段的时候,就会再次从gc root开始去遍历所有的对象。只是说这个时候比直接遍历要快,因为经过前面的并发标记后,这次遍历会跳过黑对象,只看灰对象了。当当堆特别大的,这个地方的STW也会不小。
  • G1解决三色标记漏标问题是从消失的那个引用入手的:gc开始记录下了引用快照,当某个应用消失的时候,就记录下这个消失的引用。在remark阶段就只是会扫描这些消失的引用(A->B),从被引用的角度出发,看是否有新的对象执行了B。

ps:之所以会有错标、漏标的问题是因为并发标记阶段gc线程和业务线程在并行运行的,就可能出现一个对象被gc线程给标记后,业务线程修改了引用关系。所以用一个STW的remark阶段来解决,有了STW的remark阶段,标记阶段倒是不会漏标、错标了。但是后续清理的时候,在并发清理的时候,业务线程和gc线程并发运行,岂不是还是有可能、业务线程修改了引用关系,导致标记阶段标记的结果就不准了,如果后续gc线程依然按照标记阶段标记的结果来清理,岂不是就可能出错?

所以会发现,G1/ZGC在清除阶段,上来首先就是一个STW的初始清楚阶段,我理解在这个阶段只要先处理掉gc root上的垃圾。因为只是处理gc root上,就会比较快。如果一个对象通过gc root不可达,那么业务线程已经没有任何方式可以访问到这个对象了,所以后续并发清除阶段,就一定不会存在业务线程新增引用指向垃圾的情况了。但是浮动垃圾,是不可避免的,但是浮动垃圾不会导致不正确,相对可接受。ps:但是根据掌握的CMS资料,没有看到有这个STW的初始清除阶段,那他怎么解决的呢?

除此之外,G1和CMS在最后回收阶段有个不同:

  • CMS采用的是清除算法,所以在最后的回收阶段,是可以和业务线程并行执行的。
  • 但是G1采用的是复制算法(这里不绝对,也可能是标记整理算法),但不管是哪种,都会该表对象的内存地址,所以G1在回收阶段是会STW的。但是G1回收并不会回收整个堆,而是根据用户设置的stw期望停顿时间,来选择收益最高的region。
  • 另外,使用CMS和G1的堆内存布局是完全不一样的。CMS是分代式的内存管理;G1是分区式的内存管理(逻辑分代)。从这个角度看,jmm设计了内存模型,其最主要的目的就是服务于GC。

 ZGC

为 Java 开疆扩土的 ZGC

跨代(区)引用

这里主要的问题就是:不回收的那个分区引用了当前回收的区域中的对象的时候,会有问题。比如yong gc的时候回收年轻代,但是有老年代的对象引用了年轻代的对象,年轻代的这个对象就不能被回收。

反过来,只是回收老年代的时候,有年轻代对象引用了老年代对象的情况,也是一样的。

解决这方式:全量扫描一遍非收集区,比如yong gc的时候,全量扫描一遍老年代,看看有没有老年代对象引用新生代对象。

对于old区的回收,这么搞是ok的,因为这个时候可以先触发一次yong gc,然后年轻代存活对象也不多了,所以这么扫描不会耗时太久;但是反过来,yong gc的时候,其目的就是只回收年轻代,这个时候去全量扫描老年代,就失去了分区gc的意义了。

对于CMS来说,解决yong gc过程,有old区对象引用年轻代对象的情况,在年轻代中引入了卡表(card table),卡表中记录了有哪些老年代对象引用了年轻代的对象,在引用变更的时候,写屏障中会来更新卡表。

这样在yong gc的时候,不需要全量扫描老年代的对象,看是否引用了新生代对应,只需要扫描年轻代的卡表就可以了。而对于回收old区的时候,会先触发一次yong gc,然后回收old区的时候,扫描yong区就好了(其实这也是cms垃圾回收的一个耗时点)

对于G1来说,分代在G1中,分代只是一个逻辑概念了,其真实的内存布局已经变成了分区(Region)的,gc回收的时候,也是按照Region来回收的,所以这个问题就转换成了跨Region引用的问题了。

G1的解决方式就是每个Region都维护了一个Rset(Remember Set)来记录了其他Region引用了当前Region的对象,在回收对应Region的时候,扫描Rset就可以了。

gc日志

打印gc日志
-XX:+PrintGCDateStamps
​​​​​​-XX:+PrintGCTimeStamps
-XX:+PrintGCDetails 打印gc的详细日志
-XX:+PrintGCCause   打印产生gc的原因
// 如下是指定gc日志输出的方式
-Xloggc:/Users/george/gclog/gc-%t.log 
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100M

另外,-Xmx10M,配置成10M,更容易观察到full gc。

CMS垃圾回收日志

parNew和CMS的组合:

-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=30
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=1

parNew收集年轻代日志:

GC Allocation Failure就是造成本次gc的原因。Allocation Failure表示的就是新建对象分配内存失败导致的gc。常见的gc cause参考美团的技术博客:Java中9种常见的CMS GC问题分析与解决

cms收集老年代老年代:

  •  CMS Initial Mark(初始标记 STW):
  • CMS-concurrent-mark(并发标记):
  • CMS-concurrent-preclean(并发预清理)
  • CMS-concurrent-abortable(并发可中断预清理)
  • CMS Final Remark(最终标记STW)
    • YG occupancy: 348 K (3072 K) -- 表示年轻代占用空间为 348 K,年轻代总空间为 3072K
    • Rescan (parallel) , 0.0001908 secs -- 老年代重新扫描耗时 0.0001908 秒
    • weak refs processing, 0.0000335 secs -- 弱引用处理耗时
    • class unloading, 0.0002128 secs -- 类卸载处理0.0002128秒
    • scrub symbol table, 0.0002739 secs -- 符号表处理耗时0.0002739秒
    • scrub string table, 0.0001446 secs -- 字符表处理耗时0.0001446秒
    • CMS-remark: 2631K(6848K)] 2980K(389920K), 0.0009399 secs [Times: user=0.00 sys=0.00, real=0.00 secs]  -- CMS 重新标记后,老年代占用 2631K,老年代总空间 6848K,堆占用空间 2980K,堆总空间 389920K,以及最终标记的总耗时为0.0009399秒
  • CMS-concurrent-sweep(并发清除)

  • CMS-concurrent-reset(并发重置)

G1垃圾回收日志

-XX:+UseG1GC

G1相关的参数

-XX:G1HeapRegionSize=n

Region的大小。但是这不是最终值,Region大小会根据实际情况自动调整的

-XX:MaxGCPauseMillis

一次gc回收期望的STW时间,默认为200ms。G1会尽量在指定的这个时间内完成gc。

-XX:G1NewSizePercent

新生代最小值,默认值5%

-XX:G1MaxNewSizePercent

新生代最大值,默认值60%

-XX:ParallelGCThreads

STW期间,并行GC线程数

-XX:ConcGCThreads=n

并发标记阶段,并行执行的线程数

-XX:InitiatingHeapOccupancyPercent

设置触发标记周期的 Java 堆占用率阈值。默认值是45%。这里的java堆占比指的是non_young_capacity_bytes,包old+humongous

ps:查看java的参数:

  • java -X     会打印出hotspot的可选参数。
  • java -XX:+PrintFlagsFinal -version     会打印出当前jvm版本所有-XX开头的那些参数

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值