关于垃圾收集器的相关内容。
运行时数据区包括程序计数器、本地方法栈、Java 虚拟机栈、堆、方法区,其中程序计数器、虚拟机栈、本地方法栈随着线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈的操作。
为什么我们还要去了解GC和内存分配呢?
答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
这个算法的基本思路就是通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。如下图所示,对象 object 5、object 6、object 7 虽然互相有关联,但是它们到 GC Roots 是不可达的,所以它们将会被判定为是可回收的对象。
在 Java 中,可以作为 GC Roots 的对象包括以下几种:
在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是“无用的类”:
在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
关于 垃圾回收算法
Serial 收集器是最基本、发展历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是虚拟机新生代收集的唯一选择。它是一个单线程的收集器,在它进行垃圾收集时必须暂停其他所有的工作线程,直到它收集结束。这种情况也成为 “Stop The World”,对于很多用户来说是难以接受的。
Serial / Serial Old 收集器运行示意图如下:
从 JDK 1.3 开始,一直到 JDK 1.7,HotSpot 虚拟机开发团队为消除或者减少工作线程因内存回收而导致停顿的努力一直在进行着,从 Serial 收集器到 Parallel 收集器,再到 Concurrent Mark Sweep(CMS)乃至 GC 收集器的最前沿成果 Garbage First(G1)收集器,我们看到了一个个越来越优秀(也越来越复杂)的收集器的出现,用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除(这里暂不包括RTSJ中的收集器)。寻找更优秀的垃圾收集器的工作仍在继续!
虽然 Serial 收集器看上去如此鸡肋,但到目前为止,塔仍是虚拟机运行在 Client 模式下的默认新生代收集器。它相比其他收集器的优势在于简单高效。对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
ParNew 收集器是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集之外,与 Serial 收集器完全一样。
ParNew 收集器运行示意图如下:
ParNew 收集器虽然与 Serial 收集器相比没有太多创新之处,但是它在许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。ParNew 收集器也是使用 -XX:+UseConcMarkSweepGC 选项后的默认新生代收集器,也可以使用 -XX:+UseParNewGC 选项来强制指定它。
随着垃圾收集器技术的不断改进,更先进的 G1 收集器带着 CMS 继承者和替代者的光环登场。 G1 收集器是一个面向全堆的收集器,不再需要其他新生代收集器配合工作。 从 JDK 1.9 开始,ParNew + CMS 收集器的组合不再是官方推荐的服务端模式下的收集器解决方案了,官方希望其被 G1 收集器取代。
ParNew 收集器在单 CPU 的环境中不仅没有 Serial 收集器高效,还会产生多余的线程开销。 它默认开启的收集线程数与 CPU 的数量相同,在 CPU 很多情况下,可以使用 -XX:ParllelGCThreads 参数来限制垃圾收集器的线程数。
从ParNew收集器开始,后面还会接触到几款并发和并行的收集器。在大家可能产生疑惑之前,有必要先解释两个名词:并发和并行。 并发:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。 并行:指用户线程与垃圾收集器线程同时执行(但不一定并行,可能会交替执行),用户程序在继续运行,而垃圾收集器程序运行在另一个 CPU 上。
Parallel Scavenge 收集器是一个新生代收集器,使用复制算法进行回收,同时是一个并行的多线程收集器。
CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。
所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数。
MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。不过大家不要认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。 GCTimeRatio参数的值应当是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1/(1+19)),默认值为99,就是允许最大1%(即1/(1+99))的垃圾收集时间。
Parallel Scavenge 收集器也经常称为“吞吐量优先”收集器。
如果对于收集器运作原来不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如 -Xmx 设置最大堆),然后使用 MaxGCPauseMillis 参数(更关注最大停顿时间)或GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。
Serial Old 收集器是 Serial 收集器的老年代版本,也是一个单线程的收集器,使用的是“标记-整理”算法。
它的主要用途:
Serial / Serial Old 收集器运行示意图如下:
Parallel Scavenge 收集器架构中本身有 PS MarkSweep 收集器来进行老年代收集,并非直接使用了 Serial Old 收集器,但是这个 PS MarkSweep 收集器与 Serial Old 的实现非常接近,所以在官方的许多资料中都是直接以 Serial Old 代替 PS MarkSweep 进行讲解。
Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法,在 JDK 1.6 之后才开始提供的。
Parallel Scavenge/Parallel Old 收集器运行示意图如下:
CMS 收集器全称是 Concurrent Mark Sweep 收集器,是一种以获取最短回收停顿时间为目标的收集器。
目前大部分 Java 应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端,这类应用较为关注服务的响应速度,希望系统停顿时间尽量短,以给用户更好的交互体验,CMS 收集器非常适合该类用户的需求。
CMS 收集器是基于“标记-清除”算法实现的,它的过程相比之前的几种收集器要复杂一些,分为四个步骤:
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。
由于整个收集过程中,耗时较长的并发标记和并发清除阶段,收集收集器的线程可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。
CMS 收集器运行示意图:
CMS 是一款优秀的收集器,优点是:
但是它还未达到完美的程度,存在以下几个缺点:
Garbage First(简称 G1)收集器是一款面向服务端应用的垃圾收集器。
G1 收集器不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每个 Region 都可以根据需要扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。
虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。
G1 收集器之所以能建议可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 的整数倍,这样可以有计划的避免整个 Java 堆中进行全区域的垃圾收集。更具体的思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后后台维护一个优先级表,每次根据用户设定允许的收集停顿时间(使用参数 -XX:MaxGCPauseMillis 指定,默认值是200毫秒),优先处理回收价值最大的 Region,这也是 “Garbage First” 名字的由来。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。
这个阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确的再可用的 Region 中分配新对象。
这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这个阶段耗时较长,但可以与用户线程并发执行。当对象图扫描完成后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧的 Region 的全部空间。这里面的操作涉及存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成。
G1 收集器运行示意图:
G1 收集器优点:
与 CMS 收集器的“标记-清除”算法不同,G1 收集器从总体上看起来是基于“标记-整理”算法实现的,但从局部看又是基于“标记-复制”算法实现,
相比 CMS,G1存在的缺点有: 在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载,都要比 CMS 高。
总结一下各个垃圾收集器的参数。
参数 | 描述 |
---|---|
UseSerialGC | 虚拟机运行在 Client 模式下的默认值,打开此开关后,使用 Serial + Serial Old 的组合进行内存回收 |
UseparNewGC | 打开此开关后,使用 ParNew + Serial Old 的组合进行内存回收 |
UseConcMarkSweepGC | 打开此开关后,使用 ParNew + CMS + Serial Old 的收集器组合进行内存回收。Serial Old 收集器将作为在 CMS 收集器出现 Concurrent Mode Failure 失败后的后备收集器使用 |
UserParallelGC | 虚拟机运行 Server 模式下的默认值,打开此开关后,使用 Parallel Scavenge + Serial Old 的组合进行内存回收 |
UserParallelOldGC | 打开此开关后,使用 Parallel Scavenge + Parallel Old 的组合进行内存回收 |
SurvivorRatio | 新生代中 Eden 区域与 Survivor 区域的容量比值,默认为8,代表 Eden : Survivor = 8 : 1 |
PretenureSizeThreshold | 直接晋升到老年代的对象大小,设置后,大于这个参数的对象将直接在老年代分配 |
MaxTenuringThreshold | 晋升到老年代的对象年龄。对象每经过一次 Minor GC 后,年龄就增加 1,当超过设置的年龄就进入老年代 |
UseAdaptiveSizePolicy | 动态调整 Java 堆中各个区域的大小以及进入老年代的年龄 |
HandlePromotionFailure | 是否允许分配担保失败,即老年代剩余空间不足以应付新生代的整个 Eden 和 Survivor 区所有对象都存活的极端情况 |
ParallelGCThreads | 设置并行 GC 进行内存回收的线程数 |
GCTimeRatio | GC 时间占总时间的比例,默认值 99,即允许 1% 的 GC时间。仅在使用 Parallel Scavenge 收集器时生效 |
MaxGCPauseMillis | 设置 GC 的最大停顿时间。仅在使用 Parallel Scavenge 收集器时生效 |
CMSSInitatingOccupancyFraction | 设置 CMS 收集器在老年代空间被使用多少后触发垃圾收集。默认值 68%,仅在使用 CMS 收集器时生效 |
UseCMSCompactAtFullCollection | 设置 CMS 收集器在完成垃圾收集后是否进行一次内存碎片整理。仅在使用 CMS 收集器时生效 |
CMSFullGCsBeforeCompaction | 设置 CMS 收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用 CMS 收集器时生效 |
主要有以下策略:
大多数情况,对象在新生代 Eden 区中分配。当 Eden 区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC。
新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次 Minor GC(并非绝对)。Major GC 的速度一般会比 Minor GC 慢10倍以上。
大对象是指需要大量连续内存空间的 Java 对象,例如很长的字符串以及数组等。
虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,大于这个参数值的对象直接在老年代分配。这样可以避免在 Eden 区以及两个 Survivor 区之间发生大量的内存复制。
PretenureSizeThreshold 这个参数只对 Serial 和 ParNew 两种收集器有效。
虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。
Copyright: 采用 知识共享署名4.0 国际许可协议进行许可 Links: https://lixj.fun/archives/垃圾收集器