前言
使用Java作为开发语言时,我们并不需要像C、C++、早期的OC等语言一样需要自己管理内存(申请和释放)资源,在Java的世界里JVM会帮我们做这件事,默认配置的JVM虽足以应付大部分用户的要求,但在面对性能要求极端严苛的用户时,则略显不足。这时需要进行JVM调优。而JVM调优中,针对GC性能的调优是其中非常重要的一项。所以本文将简单介绍一下JVM的垃圾回收机制(Garbage Collection Mechanism) 相关一些概念 ,需要注意的是本文主要参考资料为Oracle官方的GC调优指南1,即所提及的JVM实现都是The Java HotSpot VM2。
本文是一篇对于GC的入门文章,旨在帮读者构建对于JVM GC和垃圾回收器、分代回收等重要概念的相对全面的基本认知。
垃圾回收机制
使用Java开发时,JVM提供了一种机制使得开发者不再需要关注繁琐的内存资源的申请(Memory Allocation)与释放(Garbage Collection)工作,这种机制被称为垃圾回收机制。
这机制的存在使得开发者能够专注于业务逻辑的开发,但这并不是说开发者就不需要了解JVM里的垃圾回收机制到底是如工作的。事实上,当JVM的垃圾回收机制成为应用的性能瓶颈的时候,了解垃圾回收机制的原理并基于其原理进行调优是很有必要的。
在了解垃圾回收机制之前笔者希望读者能够有比较清晰的对JVM的运行时数据分区的认知。垃圾回收机制的本质就是管理Heap上所有的实例并在他们不再被使用时,抹除其中不再被使用的数据。在JVM中实现这一机制的组件被叫做 自动化存储管理系统(automatic storage management system) ,也就是大名鼎鼎的垃圾回收器(Garbage Collector)。
补充:其他语言如Swift也有automatic storage management system,当然在Swift的世界里它不叫垃圾回收器,而是被称为ARC(Automatic Reference Counting)。
垃圾回收算法
我们知道垃圾回收器会帮我们抹除不再被使用的数据。那么它们是如何定位垃圾并回收的呢?
定位垃圾和并回收的算法被称为垃圾回收算法。任何垃圾回收算法都有两个基本能力。
- 其应该具有定位到无法抵达的对象(unreachable objects) 的能力,
- 其次其应该具有 回收第一步定位到的“垃圾”所占用Heap空间的能力。
一般我们熟知的垃圾回收算法基于其核心思想的不同,有引用计数和可达性分析两大分类。
垃圾回收算法分类 (En) | 优点 | 缺点 |
---|---|---|
引用计数(Reference Counting) | 实现简单,判断垃圾块速度快 | 需要增加引用计数字段并维护,无法解决循环引用的致命问题 |
可达性分析(Reachability analysis) | 能解决循环引用问题 | 实现复杂,速度相对较慢,GC时会有暂停应用线程的延迟 |
JVM的垃圾回收算法就是采用的基于可达性分析的垃圾回收算法。其主要有三种:
- 标记擦除算法(Mark and Sweep Algorithm)
- 标记压缩算法(Mark and Compact Algorithm)
- 标记复制算法(Mark and Copy Algorithm)
标记擦除算法(Mark and Sweep Algorithm)
标记擦除算法是非常基础的垃圾回收算法,其工作时分为标记和擦除两个阶段。
在标记阶段通过GC Roots树遍历的方式,来标记确定各对象是否可达。
在擦除阶段则是把前一阶段未标记的对象回收。因为JVM Heap是预先申请的内存,所以这里回收对象并不是在内存中消灭之,而是一段连续内存中的一块区域的对象标记擦除即可。可以理解为在一段连续内存里,你知道在第3个房间开始是一个对象,第3个房间里存储了对象的大小为5个房间的信息,那么你就能确定第3个房间开始到第8个房间为止是整个对象占用的空间。当你想回收这些空间时,并不需要亲自把这些房间的住户都赶出去(比特位置0),你只需要设置这些房间为无主之地,那么后来住户就可以直接赶走前任住户(比特位覆盖)。 例子中第3个房间就是所谓的对象头(Object Header),对象头是定位对象的重要信息,对象头信息丢失,对象占用的一段连续内存空间就变成毫无意义的01比特位。另一个例子就是文件的普通删除与恢复和文件的强力删除。普通文件删除并不会重置占用区间的比特位,通过技术手段是可恢复的。强力删除则会重置占用磁盘空间的比特位。
标记擦除算法实现起来呢非常简单,但有一个致命缺点,回收对象时,仅简单的擦除会导致内存碎片化(memory fragmentation) 的问题。这个问题会导致即是剩余内存空间总量是足够的,但确无法找到任何一段连续足够大的空间分配给新对象使用。
标记压缩算法(Mark and Compact Algorithm)
标记压缩算法也有翻译成标记整理算法的。其主要目的是为了解决标记擦除算法带来的内存碎片化问题的3。
After marking the live objects in the heap in the same fashion as the mark–sweep algorithm, the heap will often be fragmented. The goal of mark–compact algorithms is to shift the live objects in memory together so the fragmentation is eliminated.
通过移动剩余存活的对象到一块儿组成连续内存空间(通常是Heap分区的开始端)来解决内存碎片化的问题。GC之后紧凑的内存分布使得之后分配新对象内存的时候可以直接从边界开始分配,提高分配新对象内存的效率,反之分配对象内存就需要在碎片化的内存里找到一块大小合适的区域去分配。
但是这种算法在移动对象时需要维护指针的地址,实现起来较为复杂。移动对象和维护指针地址较为耗时。
标记复制算法(Mark and Copy Algorithm)
标记复制算法也被称为复制算法(Copying Algorithm),与标记压缩算法类似,其也是通过移动(relocate) 存活对象到一块儿组成连续内存空间来解决内存碎片化问题。不过其移动的方式是通过准备两块大小一样的空间分别为From分区和To分区。每次GC之后都会把存活对象从From分区拷贝到To分区,并清空From分区。一次GC完成之后,From分区和To分区的身份互换。下次GC时上一次的To分区就会变成From分区。每次分区完后,总有一块分区是空的。
标记复制算法的好处是在标记阶段我们就可以同时进行移动操作,能够提高GC的效率。而坏处就是空间需求达到恐怖的2倍之多。 比较有名的应用场景就是JVM分代垃圾回收里的对新生代YG的回收策略,其中就有用到标记复制算法的(大名鼎鼎的Survivor Space1和Survivor Space2就是上述的From、To子分区)。
分代垃圾回收(Generational Garbage Collection)
HotSpot JVM为其用户(Developer)提供了多种的垃圾回收器,其中除了ZGC之外,都使用了一种叫分代回收(Generational Collection) 的技术。其理论基础是通过研究观察表明大部分Java应用里被创建的实例存活时间都很短(weak generational hypothesis)。相比固定频率遍历整个Heap做垃圾回收的朴素算法与分区之后分频率遍历各分区做垃圾回收的算法,后者就要高效很多。
下图是Oracle官网文档关于对象生命周期的分布图。X轴代表时间轴,Y轴代表对应时间“死亡”的数据总量。可以在图里看到大部分的数据都会在创建后不久“死亡”变为回收对象,例如iterator对象通常的生命周期仅在循环执行期间。
分代(Generations)
虽然每个程序的情况不尽相同,但令人惊讶的是大部分程序的对象生命周期分布图都与上图类似,所以如果能够精准的分别以不同频率对年轻对象和老年对象做垃圾回收判定的话,可以预见能够极大幅度提高垃圾回收处理整体的效率。
因此就引入了分代的概念,通过把Heap分成新生代和老年代两个区域,分别存储年轻对象和老年对象。
- 新生代 (Young Generation) : 体量小,用于储存年轻对象,回收频繁,简称YG
- 老年代 (Old Generation) : 体量大,用于储存老年对象,回收频率低,简称OG
年轻对象是创建时间短,经历GC次数少的对象实例。
老年对象呢则是创建时间长,经历GC次数多的对象实例。
Heap被分为新生代、老年代两大块,而新生代分区下也有细分的子分区去承担不同的责任。如Eden子分区就是新生实例被存放的地方,而Survivor子分区呢就是经历过新生代GC留存下来的实例被存放的地方,这两个子分区也可以分别类比于小婴儿和青少年,而经历过多次GC的Survivor子分区里的数据就会被移到老年代分区去。数据在各区域间移动的时候通常伴随着去碎片化处理(Compaction),以提高内存资源的利用率。
笔者这里贴上Oracle官网的两张,串行GC的分区示意图(上) 和 并行GC的分区示意图(下)。可以看到串行GC和并行GC的分区是有细微差别的,如网上经常能看到的两个Survivor子分区(通常被称为Survivor Space1和Survivor Space2)的博文呢,就只是串行GC的分区示意图,而并行GC的分区里只有一个Survivor子分区。涉及到篇幅和查询资料的时间成本,笔者并不会在本文详细讨论各类GC的详细子分区。提一嘴只是希望读者们知道,分代只是一种分治的思想,而各分代分区下的子分区是如何划分的及各子分区的作用并不可一概而论。
Minor Collection ?Major Collection ?Full Collection ?
在分代里有两个重要的概念,分别是Minor Collection与 Major Collection。这是什么意思呢?
当新生代被装满的时候会触发Minor Collection,这是针对新生代的垃圾回收。经历过一定次数的新生代GC的对象会被移动到老年代里,这个被叫做Aging。
在新生代GC不断执行,最终当老年代被装满的时候呢,就会触发Major Collection。需要注意的是这不仅是针对老年代的GC,而是针对整个Heap的,因此Major Collection也被认为是Full Collection (or Full GC)。
令人遗憾的是,Major Collection (Major GC) 与 Full Collection (Full GC)并没有一个官方的定义或者说难以找到。因此也有的人认为Major Collection是老年代专属GC而 Full GC则是两个都执行,有的博文也会区分Major Collection和 Full Collection为不同的东西,笔者自身则更偏向于官方调优指南文章里所暗示的Major Collection就是Full Collection。
永生代(Permanent Generation)? 已过时!
互联网的发展已经很久,虽然已经过去多年,但我们依然能够搜索到以往过时的资料。在大部分公司都转移到JSE8以上版本的今天,我们需要注意的是,永生代的概念存在于JSE8之前的(~ JSE7)4,之后的版本为了提升GC效率和为了便于未来开发更为高效的新GC做准备,在Java8时已经被Metaspace替代,从Heap里被移除。永生代属于Heap,而Metaspace不属于Heap。
如果有兴趣读者可以去看看 JEP 122: Remove the Permanent Generation 和 PermGen elimination in JDK 8 - StackOverflow 的高赞回答。
垃圾回收器(Garbage Collector)
在Java里Heap上的实例是被一个叫自动化存储管理系统(automatic storage management system) 来管理的。而这个系统也就是大名鼎鼎的 垃圾回收器(Garbage Collector) ,简称为GC。
Java作为一个跨平台的语言,其应用场景从小型的桌面Applet到大型服务器上的Service程序,为了满足这些多样化的应用场景,HotSpot JVM为其用户提供了多种垃圾回收器,每一种都被设计用来满足不同的用户需求1。
HotSpot JVM提供过五种主要的垃圾回收器,本节将简单介绍HotSpot JVM提供的五种垃圾回收器的主要特性及其被设计来满足的主要应用场景。其中的CMS GC在Java9起被不推荐使用5,于Java14被移除6。
各LTS版本对5类垃圾回收器的支持
鉴于市场对JDK的选择主要集中在几种LTS之间,笔者调查了Oracle官网的现存LTS版本7对于各类垃圾回收器的支持,用下表做了一个总结。需要注意的是CMS GC的废除以及ZGC是在JSE11时被引用的试验性特性、在JSE15的时候被申明为生产就绪(Production Ready) 的状态8。
垃圾回收器 (En) | HotSpot VM Option | JSE89 | JSE1110 | JSE171 |
---|---|---|---|---|
串行GC (Serial Collector) | -XX:+UseSerialGC. | 〇 (Client-Class) | 〇 (Client-Class) | 〇 (Client-Class) |
并行GC (Parallel Collector) | -XX:+UseParallelGC | 〇 (Server-Class) | 〇 | 〇 |
G1GC | -XX:+UseG1GC | 〇 | 〇 (Server-Class) | 〇 (Server-Class) |
ZGC | -XX:+UseZGC | × | △ (Experimental) | 〇 |
CMS GC | -XX:+UseConcMarkSweepGC | 〇 | △ (Depreciated) | × |
JVM在选用默认GC的时候,会对运行的机器的硬件条件做评估,简单来说CPU数量超过1以及可用内存打到一定数量(2GB)的机器会被判定为Server-Class Machine11,反之被判定为Client-Class Machine12。具体判定条件参考os::is_server_class_machine() - OpenJDK Github Repo
1. 串行GC(Serial Collector)
Serial Collector,一般的翻译有串行收集器、串行GC等。
串行GC使用一个单独的线程去完成垃圾回收的工作,因为单线程没有线程间通信的开销,这使得其能够相对高效地完成其工作。
串行GC最佳的使用场景是运行在单处理器机器(Single processor machines) 上。因为这些机器不能享受到多处理器(Multiprocessor) 带来的好处。当然并不是说多处理器机器上运行的Java程序就不适用串行GC,当多处理器机器上的Java程序所涉及的数据规模很小(占用内存大约在100MB以下)时,选用串行GC也是非常合适的。
串行GC在硬件条件不达标被判定为Client-Class Machine时,JVM默认选用的GC。
2. 并行GC(Parallel Collector)
Paralle Collector也被称为Throughput collector,一般的翻译有并行GC、并行收集器或者翻译自Throughput collector的吞吐量GC、吞吐量收集器。
与串行GC一样,并行GC也是一个分代收集器(Generational collector)。其和串行GC的主要区别就是并行GC使用多个线程去做垃圾回收工作,以加速整个垃圾回收工作。
串行GC是针对内存占用小的应用程序,并行GC就是针对内存占用中至大的运行在多处理器机器上的应用程序。
并行压缩(Parallel compaction)是一个并行GC的特性。打开这个特性能够允许并行GC在做Major collection时也并行执行,否则Major collection只会用一个线程执行。当你使用 -XX:+UseParallelGC时,并行压缩是默认打开的。你可以通过添加 -XX:-UseParallelOldGC 来关闭并行压缩。需要注意关闭并行压缩会影响程序的扩展性(Scalability),难以高效完成大Heap下的GC任务。
3. G1GC (Garbage-First)
G1GC,全称Garbage-First Garbage Collector。译为垃圾优先回收器,但交流中一般直接称呼为G1GC。其也是一个分代收集器。
G1GC也是一个Mostly concurrent collector。Mostly concurrent collector是什么意思呢?就是他在完成工作量大的任务时是并发收集器(Concurrent collector),咋一看和并行收集器(Parallel Collector)似乎有些类似?但两者完全不同。并行收集器执行GC任务的时候会Stop-The-Word,也就是暂停所有应用程序线程(Java线程)的执行,整个JVM内部专注于GC工作,而并发收集器则不同,它并不会Stop-The-Word,在并发收集器执行GC任务的时候,并不会暂停应用程序线程的执行。而其他时候,G1GC则会和其他GC一样,触发Stop-The-Word。
因其并不是全程并发执行,所以被称为Mostly concurrent collector。
G1GC是被设计用来同时满足资源紧俏型机器到大型服务器(多处理器、大内存 etc…),其有满足低暂停时间和高吞吐量的能力。G1GC是大部分计算资源充足的机器(Server-Class Machine)的默认GC(JSE11~)。
4. ZGC
ZGC与其他几个垃圾回收器就不同,它并没有用到分代技术。根据Oracle官方的描述,ZGC是一个可扩展的低延迟的垃圾回收器(scalable low latency garbage collector)1,这到底是是什么呢?OpenJDK Wiki里有记载9
At its core, ZGC is a concurrent garbage collector, meaning all heavy lifting work is done while Java threads continue to execute. This greatly limits the impact garbage collection will have on your application’s response time.
所谓低延迟(low latency),就是ZGC在其工作时,并不会Stop-The-World(暂停应用程序线程的执行),而是和应用程序线程同时执行(Concurrently),这使得应用程序的响应时间得到大幅提升。
所谓可扩展(scalable),ZGC支持的Heap大小也从8MB到16TB,无论大型小型应用程序均可使用。
ZGC在JDK11时作为实验性特性被引入JDK,在JDK15时被声明为生产就绪(Production Ready)状态。
如果对ZGC和前面两款高性能GC的对比有兴趣可以移步延伸阅读1,如果对ZGC想更深入了解一点呢可以移步延伸阅读2。ZGC有恐怖的超短暂停时间(<10ms)的特性9,如果已经升级到JDK17的用户可以考虑使用ZGC来提升服务的响应时间。
延伸阅读:
5. CMS GC(Depreciated JDK9~,Dropped Support JDK14~ )
CMS GC,全称Concurrent Mark Sweep Collector。与G1GC一样,也是一个Mostly concurrent collector9。也会在其进行一部分昂贵开销的工作时,不Stop-The-Word,是高效GC的一种。这个收集器与G1GC主要区别在于其关注点在降低GC暂停时间,而G1GC的关注点则是在提高吞吐量。这个GC在Java9里已经不被推荐使用6,在Java14被完全移除5。
其他厂商JVM的垃圾回收器
本文主要对HotSpot VM的GC进行了简单介绍,但是还是要请读者们知道,JVM只是一个标准,其拥有许多供应商的实现(Implementation),如GraalVM、Eclipse OpenJ9等2,各大VM内部提供的GC不尽相同,如GraalVM就提供了Epsilon GC,这个GC并不被HotSpot VM支持,调优和学习时请以自己使用的JVM的供应商的官方调优指南为准。
垃圾收集器组合(GC Combination)
垃圾收集器组合,英:Garbage Collector Combination,这个东西在官方文档里甚至难以定位到其准确定义。笔者好一番寻找,才在JEP 173: Retire Some Rarely-Used GC Combinations和JEP 214: Remove GC Combinations Deprecated in JDK 8里找到一点与GC Combination有关的段落。也因其没有其官方定义,笔者只能结合经验做出推导。
GC Combination是分代技术里的概念。JVM通过分代技术,把Heap分为年轻代YG和老年代OG。
上一节的垃圾回收器我们主要描述的是针对整个Heap的垃圾回收器。但其实年轻代YG和老年代YG这两个子分区都分别有匹配其的垃圾回收器。这些个年轻代GC和老年代GC的组合则被称为垃圾收集器组合。
OpenJDK的项目组为了减小GC代码库的大小和维护成本,并不是任意两种年轻代和老年代的GC都能随意搭配,能被搭配的仅是上图所示的,并且在Java9及之后的版本,虚线所标示的组合将不再被HotSpot JVM支持。
HotSpot Garbage Collection Types
HotSpot里的垃圾回收类型有如下几种:
那么上一章的Heap GC和本章的子分区GC都有什么关联呢?可以参考下表。注意到Use前的+、-符号即可。+代表使用,-代表不使用。
Genrational GC | HotSpot JVM Options | 年轻代GC | 老年代GC |
---|---|---|---|
串行GC | -XX:+UseSerialGC. | Serial | Serial Old |
并行GC | -XX:+UseParallelGC | Parallel Scavenge | Parallel Old |
并行GC (关闭并行压缩) | -XX:+UseParallelGC -XX:-UseParallelOldGC | Parallel Scavenge | Serial Old |
G1GC | -XX:+UseG1GC | G1 | G1 |
CMS GC | -XX:+UseConcMarkSweepGC | ParNew | CMS |
CMS GC (Concurrent Old only) | -XX:+UseConcMarkSweepGC -XX:-UseParNewGC | Serial | CMS |
补充知识点
JVM 运行时数据分区(JVM Runtime Data Area)
根据JVM的定义13,jvm 运行时数据分区有如下几块。其中笔者只介绍与本文关系颇深的Heap数据分区。如果对其他分区的含义感兴趣的读者,请自行根据笔者标注的脚注自行阅读。
分区名 | Thread Local / Common Area |
---|---|
The pc Register | Thread Local |
Java Virtual Machine Stacks | Thread Local |
Native Method Stacks | Thread Local |
Heap | Common Area |
Method Area | Common Area |
Method Area / Run-Time Constant Pool | Common Area |
JVM 运行时数据分区之 堆(Heap)
JVM的Heap是一块能被所有JVM线程使用的公共数据分区。所有的类实例和数组的创建其内存资源都会由Heap分区提供,即所有类实例都会被Allocate在Heap分区。
Heap数据分区在JVM启动之时就被创建了,即JVM会在启动之时就会为Heap数据分区向操作系统申请足够量的内存资源。而这个量则是由JVM参数–Xms (initial heap size)来确定,该参数没有默认值。通常被设为物理内存的1/64,即如果你有2G内存,那么JVM启动时申请的Heap大小则是32MB。
Initial heap size of 1/64 of physical memory 1
Heap上的实例是被一个叫自动化存储管理系统(automatic storage management system) 也就是垃圾回收器(Garbage Collector) 来管理的。Heap数据分区的大小可以使固定长也可以是可扩展的。通过设置参数–Xms (initial heap size)与-Xmx (maximum heap size)。-Xms与-Xmx不相同时,在内存不足时JVM会扩展Heap数据分区,当不再需要超大Heap数据分区时,也会进行压缩。当Heap被占满并且无法再扩展时,JVM会抛出 OutOfMeoryError。
结语
内存管理一直都是现代高级编程语言的一个重要领域,理解内存管理调优的机制、知道如何管理内存或知道如何利用工具去管理内存提高程序运行效率是程序员修炼的一个必经之路。希望通过本文能让读者们大致理解JVM中的垃圾回收机制的相关概念。也希望读者们不要轻信知识的二道贩子,包括本人。笔者更希望读者们如果有时间和精力、尽量去啃啃罗列出的参考文献,其权威性是二次加工的知识无法比拟的。
本文是基于笔者对JVM的垃圾回收机制的理解和官方资料整理而来、如有错误请指出,如果您觉得本文还不错,收藏点赞和转发或者留言是对笔者最大的支持~谢谢♪(・ω・)ノ
参考
Java/JavaSE/17 - HotSpot Virtual Machine Garbage Collection Tuning Guide 17 - Oracle ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
JEP 363: Remove the Concurrent Mark Sweep (CMS) Garbage Collector - OpenJDK ↩︎ ↩︎
JEP 291: Deprecate the Concurrent Mark Sweep (CMS) Garbage Collector - OpenJDK ↩︎ ↩︎
Java/JavaSE/8 - HotSpot Virtual Machine Garbage Collection Tuning Guide 8 - Oracle ↩︎ ↩︎ ↩︎ ↩︎
Java/JavaSE/11 - HotSpot Virtual Machine Garbage Collection Tuning Guide 11 - Oracle ↩︎
Criteria for default garbage collector Hotspot JVM 11/17- StackOverflow ↩︎