垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,及时经过如此长时间的发展,Java 的垃圾收集机制仍然在不断的演进中,不同大小的设备,不同特征的应用场景,对垃圾收集提出了新的挑战。
第一,Java 常见的垃圾收集器有哪些
垃圾收集器(GC,Garbage Collector)是和具体 JVM 实现精密相关的,不同厂商(IBM,Oracle),不同版本的 JVM,提供的选择也不同,接下来,我们看看主流的 Oracle JDK。
1,Serial GC,它是最古老的垃圾收集器,它体现在其收集工作是单线程的,并且在进行垃圾收集过程中,会进入臭名昭著的 “Stop-The-World” 状态。
2,ParNew GC,是 Serial GC 的多线程版本,最常见的应用场景是配合老年代的 CMS GC 工作。
3,CMS GC,基于标记 - 清除算法,设计目标是尽量减少停顿时间,这一点对于 Web 等反应时间敏感的应用非常重要。但是它的算法存在着内存碎片化的问题,所以难以避免在长时间运行等情况下发生 full GC,导致恶劣的停顿。另外,它会占用更多的 CPU 资源,并和用户线程争抢。
4,Parallel GC,在早期 JDK 8 等版本中,它是 server 模式 JVM 的默认 GC 选择,也被称作是吞吐量优先的 GC。另外,它引入了开发者友好的配置项,我们可以直接设置暂停时间或吞吐量等目标,JVM 会自动进行适应性调整。
5,G1 GC 这是一种兼顾吞吐量和停顿时间的 GC 实现,是 Oracle JDK 9 以后默认的 GC 选项。G1 GC 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。它的内存结构并不是简单的条带式划分,而是类似棋盘的一个个 region。Region 之间是复制算法,但整体上实际可看作是标记-整理算法,可以有效地避免内存碎片,尤其是当 Java 堆非常大的时候,G1 的优势更加明显。G1 吞吐量和停顿表现都非常不错,并且任然在不断地完善,与此同时 CMS 已经在 JDK 9 中被标记为废弃,所以 G1 GC 值得你深入掌握。
第二,垃圾收集的原理和基础概念
1,自动垃圾收集的前提是清楚那些内存可以被释放。主要就是两方面,最主要的部分就是对象实例,都是存储在堆上的;还有就是方法区中的元数据等信息,例如类型不再使用,卸载该 Java 类似是很合理的。对于对象实例收集,主要是两种基本算法,引用计数和可达性分析。
引用技术算法,顾名思义就是为对象添加一个引用计数,用于记录对象被引用的情况,如果计数为 0,即表示对象可回收。例如,Python 同时支持引用计数和垃圾收集机制。Java 没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
Java 选择的可达性分析,Java 的各种引用关系,在某种程度上,将可达性问题还进一步复杂化,这种类型的垃圾收集通常叫做追踪性垃圾收集。其原理简单来说,就是将对象及其引用关系看做一个图,选定活动的对象作为 GC Roots,然后跟踪引用链条,如果一个对象和 GC Roots 之间不可达,也就是不存在引用链条,那么即可认为是可回收对象。JVM 会把虚拟机栈和本地方法栈中正在引用的对象,静态属性引用的对象和常量,作为 GC Roots。
2,常见的垃圾收集算法,主要分为三类。第一类,复制算法。新生代的 GC 基本都基于复制算法,是将活着的对象复制到 to 区域,拷贝过程中将对象顺序放置,就可以避免内存碎片化。这么做得代价是,既然要进行复制,既要提前预留内存空间,有一定的浪费;另外,对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,这个开销也不小,不管是内存占用或者时间开销。
第二类,标记 - 清除算法。首先进行标记工作,标识出所有要回收的对象,然后进行清除。这么做除了标记,清除过程效率有限,另外就是不可避免的出现碎片化问题,这就导致其不适合特别大的堆;否则,一旦出现 Full GC,暂停时间可能根本无法接受。
第三类,标记 - 整理算法。类似标记-清除,但是为了避免内存碎片化,它会在清理过程中将对象移动,以确保移动后的对象占用连续的内存空间。注意,这些只是基本的算法思路,实际 GC 实现过程要复杂的多,目前还在发展中的前沿 GC 都是复合算法,并且并行和并发兼备。参考《垃圾回收的算法与实现》一书。
第三,垃圾收集过程的理解
在垃圾收集的过程,对应到 Eden,Survivor,Tenured 等区域会发生什么变化?这实际上取决于具体的 GC 方式,先来熟悉一下通常的垃圾收集流程。
第一,Java 应用不断创建对象,通常都是分配在 Eden 区域,当其空间占用达到一定阈值时,触发 minor GC。没有被引用的被回收,仍然被引用的存活下来,被复制到 JVM 选择的 Survivor 区域,存活年龄加 1;
第二,经过一次 Minor GC,Eden 就会空闲下来,知道再次达到 Minor GC 触发条件,这时候,另外一个 Survivor 区域则会成为 to 区域,Eden 区域的存活对象和 From 区域对象,都会被复制到 to 区域,并且存活的年龄技术会被加 1;
第三,类似第二步的过程会发生很多次,直到有对象计数达到阈值,这时候就会发生所谓的晋升过程,超过阈值的对象会被晋升到老年代。后面就是老年代 GC,具体取决于选择的 GC 选项,对应不同的算法。
第四,谈谈 GC 的调优思路
网上很多资料对 G1 的介绍大多还停留在 JDK 7 或者更早期的实现,很多结论已经存在较大偏差,甚至有一些过去的 GC 选项已经不再推荐使用。所以,今天我们会选取最新版 JDK 中的默认 G1 GC 作为重点进行详解,并且我会从调优实践角度,分析典型场景和调优思路。
谈到调优,这一定是针对特定场景,特定目标的事情,对于 GC 调优来说,首先就需要清楚调优的目标是什么?从性能的角度看,通常关注三个方面,内存占用,延时和吞吐量,大多数情况下调优会侧重于其中一个或者两个方面的目标,很少情况可以兼顾三个不同的角度。当然,除了上面通常的三个方面,也可能需要考虑其他 GC 相关的场景,例如,OOM 也可能与不合理的 GC 先关参数有关;或者,应用启动速度方面的需求,GC 也会是个考虑的方面。
基本的调优思路可以总结为:
1,理解应用需求和问题,确定调优目标。假设,我们开发了一个应用服务,但发现偶尔会出现性能抖动,出现较长的服务停顿。评估用户可接受的相应时间和业务量,将目标简化为,希望 GC 暂停尽量控制在 200ms 以内,并且保证一定标准的吞吐量。
2,掌握 JVM 和 GC 的状态,定位具体问题,确定真的有 GC 调优的必要。具体有很多方法,比如,通过 jstat 等工具查看 GC 等相关状态,可以开启 GC 日志,或者是利用操作系统提供的诊断工具等。例如,通过追踪 GC 日志,就可以查找是不是 GC 在特定时间发生了长时间的暂停,进而导致了应用响应不及时。
3,这里需要思考,选择的 GC 类型是否符合我们的应用特征,如果是,具体问题表现在哪里,是 Minor GC 过长,还是 Mixed GC 等出现异常停顿情况;如果不是,考虑切换到什么类型,如 CMS 和 G1都是更侧重于低延迟的 GC 选项。
4,通过分析确定具体调整的参数或者软硬件配置。
5,验证是否达到调优目标,如果达到目标,既可以考虑结束调优;否则,重复完成分析,调整,验证这个过程。
网友评论