《深入理解java虚拟机》学习笔记系列——垃圾收集器&内存分配策略

本文主要从GC(垃圾回收)的角度试着对jvm中的内存分配策略与相应的垃圾收集器做一个介绍。

注:还是老规矩,本着能画图就不BB原则,尽量将各知识点通过思维导图或者其他模型图的方式进行说明。文字仅记录额外的思考与心得,以及其他特殊情况

内存分配策略

本部分的回答主要围绕 哪些内存需要回收?什么时候回收?以及如何回收?这三个问题来进行介绍。

哪些内存需要回收?

一张图总结

补充说明

由上图可知,只有堆区和静态区,运行时才能知道创建的对象信息,所以垃圾收集器所需要关注的内存也就集中于这两个部分了。

什么时候回收?

堆区

回收依据

不可能再被任何途径使用(对象已死)

对象存活判定算法

主流对象存过判定算法分为如下两种:

  • 引用计数算法

  • 可达性分析算法

补充说明

Java 中引用分为强软弱虚四种形式,

  • 最常见的就是强引用,比如类似Object obj = new Object()这种。

  • 软引用通过 “SoftReference” 来实现

  • 弱引用通过 “WeakReference” 来实现

  • 弱引用通过 “PhantomReference” 来实现

方法区

在方法区中,垃圾收集远不像堆区那么频繁和高效。我们聚焦于两部分内容,废弃常量和无用的类。

补充介绍

  • 针对是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制

  • 针对类加载和卸载信息,可以使用 -verbose:class 以及 -XX:+TraceClassLoading-XX:TraceClassUnLoading

注:-verbose:class 以及 -XX:+TraceClassLoading 可以用在Product版的虚拟机中。-XX:+TraceClassUnLoading 参数需要 FastDebug 版的虚拟机支持。

如何回收?

其实如何回收也是具体的垃圾收集器该干的的事。但是各个平台的虚拟机操作内存的方法又各不相同。所以这部分先站在一个略宏观的角度讨论下关于垃圾回收的几种常见算法。

标记-清除算法

示意图

一张图总结

复制收集算法

示意图:

一张图总结

拓展说明

传统的复制算法由于将内存划分为了两半,导致同一时间内存的可用率只有50%,这显然是难以接受的。
所以也早就有了机智的前辈对此方法进行了改进,接下来就来介绍下 HotSpot 虚拟机中是如何改进的~

标记-整理算法

示意图

一张图总结

拓展说明

复制收集算法在对象存活率较高的时候,就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的控件,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。

所以针对老年代的特点,一般更倾向使用类似“标记-整理”而非“复制收集”这样的算法。

分代收集算法

一张图总结

HotSpot 的算法实现难点

前面从理论上介绍了对象存活的判定方法和垃圾收集算法的思想,但是具体实现的过程中,也才会发现一些在理论思考时不会注意的点。

枚举根节点

难点

解决方案

通过一组称为 OopMap 的数据结构来达到目的:

  • 在类加载完成的时候,HotSpot 将对象内数据类型及其偏移量记录下来

  • JIT 编译过程中也在特定的位置记录下栈和寄存器中哪些位置使引用

通过这种事前约定记录位置的方法,实现快速遍历根节点引用

安全

概念由来

安全点的由来本身也是为了解决一个难题而产生的:

位置选定的要点

如何进入安全点

安全区域

垃圾收集器

不同的厂商,不同版本的虚拟机所提供的垃圾收集器差别很大,为了方便讨论,这里以 JDK 1.7 update 14 为基础进行讨论。

一张图总结

上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。

Serial 收集器

运行示意图(新生代部分)

优缺点分析

ParNew 收集器

运行示意图(新生代部分)

优缺点分析

补充说明

ParNew 默认开启的垃圾收集器线程数就是CPU数量,可通过-XX:parallelGCThreads参数来限制收集器线程数

另:
从 ParNew 收集器开始,后续还有几款并发和并行收集器。这里解释一下这两个名词:并发和并行。这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。

  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

Parallel Scavenge 收集器

运行示意图(新生代部分)

优缺点分析

补充说明

提供了两个参数来精确控制吞吐量:

  1. 最大垃圾收集器停顿时间(-XX:MaxGCPauseMillis 大于0的毫秒数,停顿时间小了就要牺牲相应的吞吐量和新生代空间),

  2. 设置吞吐量大小(-XX:GCTimeRatio 大于0小于100的整数,默认99,也就是允许最大1%的垃圾回收时间)。
      

还有一个参数表示自适应调节策略(GC Ergonomics)(-XX:UseAdaptiveSizePolicy)。就不用手动设置新生代大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)晋升老年代对象大小(-XX:PretenureSizeThreshold),会根据当前系统的运行情况手机监控信息,动态调整停顿时间和吞吐量大小。也是其与PreNew收集器的一个重要区别,也是其无法与CMS收集器搭配使用的原因(CMS收集器尽可能地缩短垃圾收集时用户线程的停顿时间,以提升交互体验)。

另:所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。

虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那么吞吐量就是99%。

Serial Old 收集器

运行示意图(老年代部分)

优缺点分析

Parallel Old 收集器

运行示意图(老年代部分)

(图画错了,老年代应该是并行收集才对)

优缺点分析

CMS 收集器

运行示意图

优缺点分析

补充说明

CMS收集器是基于“标记-清除”算法实现的,整个收集过程大致分为4个步骤:
①.初始标记(CMS initial mark)
②.并发标记(CMS concurrenr mark)
③.重新标记(CMS remark)
④.并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤任然需要停顿其他用户线程(Stop The World)。
初始标记仅仅只是标记出 GC ROOTS 能直接关联到的对象,速度很快,并发标记阶段是进行 GC ROOTS 根搜索算法阶段,会判定对象是否存活。而重新标记阶段则是为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

关于CMS的三个缺点,这里有更详细的解释说明:

  1. CMS收集器对CPU资源非常敏感。在并发(并发标记、并发清除)阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致应用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4。收集器线程所占用的CPU数量为:(CPU+3)/4=0.25+3/(4*CPU)。因此这时垃圾收集器始终不会占用少于25%的CPU,因此当进行并发阶段时,虽然用户线程可以跑,但是很缓慢,特别是双核CPU的时候,已经占用了5/8的CPU,吞吐量会很低。为了解决这种情况,产生了“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)。就是采用抢占方式来模拟多任务机制,就是在并发(并发标记、并发清除)阶段,让GC线程、用户线程交替执行,尽量减少GC线程独占CPU,这样垃圾收集过程更长,但是对用户程序影响小一些。实际上i-CMS效果很一般,目前已经被声明为“deprecated”。

  2. CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure“,失败后而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。这一部分垃圾称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以降低内存回收次数提高性能。JDK1.6中,CMS收集器的启动阈值已经提升到92%。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置的过高将会很容易导致“Concurrent Mode Failure”失败,性能反而降低。

  3. 最后一个缺点,CMS是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。空间碎片太多时,将会给对象分配带来很多麻烦。比如说大对象,内存空间找不到连续的空间来分配不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个内存碎片的合并整理过程,但是内存整理过程是无法并发的,因此解决了空间碎片问题,却使停顿时间变长。还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后,跟着来一次碎片整理过程(默认值是0,表示每次进入Full GC时都进行碎片整理)。

G1 收集器

运行示意图

优缺点分析

补充说明

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内获可以获取尽可能高的收集效率。

但是,G1把内存“化整为零”的思路,理解起来似乎很容易理解,其中的实现细节却远远没有现象中简单,否则也不会从04年Sun实验室发表第一篇G1的论文拖至今将近8年时间都还没有开发出G1的商用版。笔者举个一个细节为例:把Java堆分为多个Region后,垃圾收集是否就真的能以Region为单位进行了?听起来顺理成章,再仔细想想就很容易发现问题所在:Region不可能是孤立的。一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系。那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个Java堆才能保障准确性?这个问题其实并非在G1中才有,只是在G1中更加突出了而已。在以前的分代收集中,新生代的规模一般都比老年代要小许多,新生代的收集也比老年代要频繁许多,那回收新生代中的对象也面临过相同的问题,如果回收新生代时也不得不同时扫描老年代的话,Minor GC的效率可能下降不少。。

在G1收集器中Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查引是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。

具体实现思路

忽略Remembered Set的维护,G1的运行步骤可简单描述为:

①.初始标记(Initial Marking)
②.并发标记(Concurrenr Marking)
③.最终标记(Final Marking)
④.筛选回收(Live Data Counting And Evacution)

1.初始标记:初始标记仅仅标记GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新的对象。这阶段需要停顿线程,不可并行执行,但是时间很短。
2.并发标记:此阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活对象,此阶段时间较长可与用户程序并发执行。
3.最终标记:此阶段是为了修正在并发标记期间因为用户线程继续运行而导致标记产生变动的那一份标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这段时间需要停顿线程,但是可并行执行。
4.筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来制定回收计划。

垃圾收集器参数总结

-XX:+<option> 启用选项
-XX:-<option> 不启用选项
-XX:<option>=<number>
-XX:<option>=<string>

Client、Server模式默认GC

Sun/Oracle JDK GC组合方式

总结

表面上看,Java 和 C 比起来,由于内存的动态分配与内存回收技术已经相对成熟,日常的代码中也不怎么需要关注内存的申请与释放。为什么我们还要关注这些问题呢?

笔者认为,一方面越是平常不会关注的东西,在关键的时候越珍贵,因为存在排查各种内存溢出、内存泄漏问题、又或者当垃圾收集称为系统达到更高并发量瓶颈时,对这些“自动化”功能细节的了解,为我们提供了更广阔的思路。另一方面,不同业务场景总有相似的一面,今天借鉴到的实现思想的细节,一直积累下去,或许未来的某天突然就豁然开朗了。

参考文章

联系作者

zhihu.com
segmentfault.com
oschina.net

赞 (0) 评论 分享 ()