一、JVM 中常见的垃圾回收器
在新生代中,每次垃圾回收时都发现有大批对象死去,只有少量存活,那就选用复制算法
,只需要付出少量存活对象的复制成本就可以完成回收。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清理
或者标记—整理
算法来进行回收。
请记住下图的垃圾收集器和之间的连线关系。Oracle 官 方 也 有 对 应 英 文 解 释Available Collectors (oracle.com)
1)、单线程垃圾回收器 - Serial/Serial Old
JVM 刚诞生就只有这种,最古老的,单线程,独占式,成熟,适合单 CPU,一般用在客户端模式下。
这种垃圾回收器只适合几十兆到一两百兆的堆空间进行垃圾回收(可以控制停顿时间再 100ms 左右),但是对于超过这个大小的内存回收速度很慢,所以对于现在来说这个垃圾回收器已经是一个鸡肋。
- 参数设置
-XX:+UseSerialGC 新生代和老年代都用串行收集器
2)、多线程并行垃圾回收器 - Stop The World(STW)
单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。这个暂停称之为“Stop The World”,但是这种 STW 带来了恶劣的用户体验,例如:
应用每运行一个小时就需要暂停响应 5 分。这个也是早期 JVM 和 java 被 C/C++语言诟病性能差的一个重要原因。所以 JVM 开发团队一直努力消除或降低 STW 的时间。
- Parallel Scavenge (ParallerGC )/Parallel Old(重点)
为了提高回收效率,从 JDK1.3 开始,JVM 使用了多线程的垃圾回收机制,关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%
该垃圾回收器适合回收堆空间上百兆~几个 G。
- 参数设置
开启参数
JDK1.8 默认就是以下组合 默认就是以下组合
-XX:+UseParallelGC 新生代使用 Parallel Scavenge ,老年代使用 Parallel Old。
收集器提供了两个参数用于精确控制吞吐量,分别控制的停顿时间的-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio 参数https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html- -XX:MaxGCPauseMillis
不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也直接导致垃圾收集发生得更频繁,原来 10秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、 每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。- -XX:GCTimeRatio
-XX:GCTimeRatio 参数的值则应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。
例如:把此参数设置为 19, 那允许的最大垃圾收集时占用总时间的 5% (即 1/(1+19)), 默认值为 99,即允许最大 1% (即 1/(1+99))的垃圾收集时间由于与吞吐量关系密切,ParallelScavenge 是“吞吐量优先垃圾回收器”。- -XX:+UseAdaptiveSizePolicy
-XX:+UseAdaptiveSizePolicy (默认开启)。这是一个开关参数, 当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden 与 Survivor区的比例(-XX:SurvivorRatio)、 晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
-
动态机制
JVM 的参数中 -Xms 和 -Xmx 设置的不一致,在初始化时只会初始 -Xms 大小的空间存储信息,每当空间不够用时再向操作系统申请,这样的话必然要进
行一次 GC。
另外,如果空间剩余很多时也会进行缩容操作,JVM 通过 -XX:MinHeapFreeRatio 和 -XX:MaxHeapFreeRatio 来控制扩容和缩容的比例,调节这两个值也可以控制伸缩的时机。
所以动态扩容会引发 GC,同时缩容的话 JVM 也要处理。
在 高 并 发 应 用 中 , 尽 量 将 成 对 出 现 的 空 间 大 小 配 置 参 数 设 置 成 固 定 的 , 如 -Xms 和 -Xmx , -XX:MaxNewSize 和 -XX:NewSize ,-XX:MetaSpaceSize 和 -XX:MaxMetaSpaceSize 等,保证 Java 虚拟机的堆是稳定的,确保 -Xms 和 -Xmx 设置的是一个值(即初始值和最大值一致),获得一个稳定的堆。
3)、ParNew
多线程垃圾回收器,与 CMS 进行配合,对于 CMS(CMS 只回收老年代),新生代垃圾回收器只有 Serial 与 ParNew 可以选。和 Serial 基本没区别,唯一的区别:多线程,多 CPU 的,停顿时间比 Serial 少。(在 JDK9 以后,把 ParNew 合并到了 CMS 了)大致了解下搭配关系即可,后续版本已经接近淘汰。
4)、并发垃圾垃圾回收器 - CMS(Concurrent Mark Sweep )
收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。
从名字(包含“Mark Sweep”)上就可以看出,CMS 收集器是基于标记—清除
算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,
整个过程分为 4 个步骤,包括:
初始标记
短暂,仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。并发标记
和用户的应用程序同时进行,进行 GC Roots 追踪的过程,标记从 GCRoots 开始关联的所有对象开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理(垃圾回收器线程和用户线程同时工作。重新标记
短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。并发清除
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。参数
-XX:+UseConcMarkSweepGC ,表示新生代使用 ParNew,老年代的用 CMS
-
预清理与并发可中断预清理
这两个处理都是并发的,所以如果是比较泛的讲的话,都可以说成并发标记阶段,如果是要抓细节,那么并发标记阶段后续还有这两个处理。
因为 CMS 的终极目标是降低垃圾回收时的暂停时间,所以在该阶段要尽最大的努力去处理,如果能够在并发阶段处理被应用线程更新的老年代对象,这样在暂停的重新标记阶段就可以少处理一些,暂停时间也会相应的降低。
- 预清理
主要做两件事情:1、在并发阶段(并发阶段是不暂停的),在 Eden 区中分配了一个 A 对象,A 对象引用了一个老年代对象 B(这个 B 之前没有被标记),在这个阶段就会标记对象 B 为活跃对象。
2、在并发标记阶段,如果老年代中有对象内部引用发生变化,会把所在的 Card 标记为 Dirty(其实这里并非使用CardTable,而是一个类似的数据结构,叫 ModUnionTalble)通过扫描这些 Table,重新标记那些在并发标记阶段引用被更新的对象。
- 并发可中断预清理
该阶段发生的前提是,新生代 Eden 区的内存使用量大于参数,CMSScheduleRemarkEdenSizeThreshold,默认是 2M,如果新生代的对象太少,就没有必要执行该阶段,直接执行重新标记阶段
在该阶段,主要循环的做两件事:1、处理 From 和 To 区的对象,标记可达的老年代对象,类似于预处理。
2、预清理的第二个阶段。
这个逻辑不会一直循环下去,打断这个循环的条件有三个(满足一个即可):
- 可以设置最多循环的次数CMSMaxAbortablePrecleanLoops,默认是 0,意思没有循环次数的限制。
- 如果执行这个逻辑的时间达到了阈值CMSMaxAbortablePrecleanTime,默认是 5s,会退出循环。
- 如果新生代 Eden 区的内存使用率达到了阈值CMSScheduleRemarkEdenPenetration,默认 50%,会退出循环。
- CMS 中的问题
- 1)、 CPU敏感
CMS 对处理器资源敏感,毕竟采用了并发的收集、当处理核心数不足 4 个时,CMS 对用户的影响较大。- 2)、浮动垃圾
由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。
由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。
在 1.8 的版本中老年代空间使用率阈值(92%) 备注:一个复杂的公式。不用管。
当然 CMS 还有参数可以控制触发回收的条件(堆空间达到多少比例触发):CMSInitiatingOccupancyFraction
CMSInitiatingOccupancyFraction的值,如果你没设置过就是虚拟机自己的默认值,默认-1,-1 就是按照 92%来算。
如果手动设置-XX:CMSInitiatingOccupancyFraction=70,那么就是按照手动的设置来算。- 3)、 内存碎片
标记 - 清除算法会导致产生不连续的空间碎片
碎片带来了两个问题:
1、空间分配效率较低
:如果是连续的空间 JVM 可以通过使用指针碰撞的方式来分配,而对于这种有大量碎片的空闲链表则需要逐个访问空闲列表中的项来访问,查找可以存放新建对象的地址。
2、空间利用效率变低
:新生代晋升的对象大小大于了连续空间的大小,即使整个 Old 区的容量是足够的,但由于其不连续,也无法存放新对象。就是内存碎片导致的 Promotion Failed,Young GC 以为 Old 有足够的空间,结果到分配时,晋级的大对象找不到连续的空间存放。
- CMS 总结
CMS 问题比较多,所以现在没有一个版本默认是 CMS,只能手工指定。但是它毕竟是第一个并发垃圾回收器,对于了解并发垃圾回收具有一定意义,所以我们必须了解。
为什么 CMS 采用标记-清除,在实现并发的垃圾回收时,如果采用标记整理算法,那么还涉及到对象的移动(对象的移动必定涉及到引用的变化,这个需要暂停业务线程来处理栈信息,这样使得并发收集的暂停时间更长),所以使用简单的标记-清除算法才可以降低 CMS 的 STW 的时间。
该垃圾回收器适合回收堆空间几个 G~ 20G 左右。
- CMS 日志查看
二、JVM 调优
-
1)、堆空间如何设置
在分代模型中,各分区的大小对 GC 的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。
活跃数据的大小:应用程序稳定运行时长期存活对象在堆中占用的空间大小,也就是 Full GC 后堆中老年代占用空间的大小。
可以通过 GC 日志中 Full GC 之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取 GC 数据,通过取平均值的方式计算活跃数据的大小。、
例如,根据 GC 日志获得老年代的活跃数据大小为 300M,那么各分区大小可以设为:
总堆:1200MB = 300MB × 4
新生代:450MB = 300MB × 1.5
老年代:750MB = 1200MB - 450MB
- 2)、扩容新生代能提高 GC 效率吗?
通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此可以通过增大新生代空间来降低 Minor GC 的频率。例如在相同的内存分配率的前提下,新生代中的 Eden 区增加一倍,Minor GC 的次数就会减少一半。扩容 Eden 区虽然可以减少 Minor GC 的次数,但会增加单次Minor GC 时间啊,单次时间增加了,是不是也白忙活了!!!
单次 Minor GC 时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到 Survivor 区)如下图:
扩容前:新生代容量为 R ,假设对象 A 的存活时间为 750ms,Minor GC 间隔 500ms,那么本次 Minor GC 时间= T1(扫描新生代 R)+T2(复制对象 A到 S)。
扩容后:新生代容量为 2R ,对象 A 的生命周期为 750ms,那么 Minor GC 间隔增加为 1000ms,此时 Minor GC 对象 A 已不再存活,不需要把它复制到Survivor 区,那么本次 GC 时间 = 2 × T1(扫描新生代 R),没有 T2 复制时间
可见,扩容后,Minor GC 时增加了 T1(扫描时间),但省去 T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小 区的大小。
所以当 JVM 服务中存在大量短期临时对象,扩容新生代空间后,Minor GC 频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC 频率自然也会降低。
但是如果堆中短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。
总结的经验就是:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。
- 3)、JVM 是如何避免 Minor GC 时扫描全堆的?
新生代 GC 和老年代的 GC 是各自分开独立进行的。
新生代对象持有老年代中对象的引用,老年代也可能持有新生代对象引用,这种情况称为“跨代引用”。
因它的存在,所以 Minor GC 时也必须扫描老年代。
JVM 是如何避免 Minor GC 时扫描全堆的?
经过统计信息显示,老年代持有新生代对象引用的情况不足 1%,根据这一特性 JVM 引入了卡表(card table)来实现这一目的。
卡表
的具体策略是将老年代的空间分成大小为 512B 的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表 3 被标记为脏,之后 Minor GC 时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。
三、JDK 的发展(7 开始)
- Java 7
Java 7 增加了以下新特性;
- try、catch 能够捕获多个异常
- 新增 try-with-resources 语法
- JSR341 脚本语言新规范
- JSR203 更多的 NIO 相关函数
- JSR292,课程中提到的 InvokeDynamic
- 支持 JDBC 4.1 规范
- 文件操作的 Path 接口、DirectoryStream、Files、WatchService
- jcmd 命令
- 多线程 fork/join 框架
- Java Mission Control
- Java 8
Java 8 是一个重要的版本,在语法层面上有更大的改动,支持 Lamda 表达式,影响堪比 Java 5 的泛型支持:
- 支持 Lamda 表达式
- 支持集合的 stream 操作
- 提升了 HashMaps 的性能(红黑树)
- 提供了一系列线程安全的日期处理类
- 完全去掉了 Perm 区
- Java 9
Java 9 增加了以下新特性:
- JSR376 Java 平台模块系统
- JEP261 模块系统
- jlink 精简 JDK 大小
- G1 成为默认垃圾回收器
- CMS 垃圾回收器进入废弃倒计时
- GC Log 参数完全改变,且不兼容
- JEP110 支持 HTTP2,同时改进 HttpClient 的 API,支持异步模式
- jshell 支持类似于 Python 的交互式模式
- Java 10
Java 10 增加了以下新特性:
- JEP304 垃圾回收器接口代码进行整改
- JEP307 G1 在 FullGC 时采用并行收集方式
- JEP313 移除 javah 命令
- JEP317 重磅 JIT 编译器 Graal 进入实验阶段
- Java 11
Java 11 增加了以下新特性:
- JEP318 引入了 Epsilon 垃圾回收器(这个回收器什么都不干,适合短期任务)
- JEP320 移除了 JavaEE 和 CORBA Modules,应该要走轻量级路线
- Flight Recorder 功能,类似 JMC 工具里的功能
- JEP321 内置 httpclient 功能,java.net.http 包
- JEP323 允许 lambda 表达式使用 var 变量
- 废弃了 -XX+AggressiveOpts 选项
- 引入了 ZGC,依然是实验性质
- Java 12
Java 12 增加了以下新特性:
- JEP189 先加入 ShenandoahGC
- JEP325 switch 可以使用表达式
- JEP344 优化 G1 达成预定目标
- 优化 ZGC
- Java 13
Java 13 增加了以下新特性:
- JEP354 yield 替代 break
- JEP355 加入了 Text Blocks,类似 Python 的多行文本
- ZGC 的最大 heap 大小增大到 16TB
- 废弃 rmic Tool 并准备移除
- Java 14
Java 14 增加了以下新特性:
- JEP343 打包工具引入
- JEP345 实现了 NUMA-aware 的内存分配,以提升 G1 在大型机器上的性能
- JEP359 引入了 preview 版本的 record 类型,可用于替换 lombok 的部分功能
- JEP364 之前的 ZGC 只能在 Linux 上使用,现在 Mac 和 Windows 上也能使用 ZGC 了
- JEP363 正式移除 CMS,CMS 涉及到的一些优化参数,在 14 版本普及之后,将不复存在
总结
每一个版本的发布,Java 都会对以下进行改进:
- 优化垃圾回收器,减少停顿,提高吞吐
- 语言语法层面的升级
- 结构调整,减少运行环境的大小,模块化
- 废弃掉一些承诺要废弃的模块
Java 9 之后,已经进入了快速发布阶段,大约每半年发布一次,Java 8
和 Java 11
是目前支持的 LTS 版本(Long Term Support:长期演进版)
网友评论