浅谈 JAVA 中的垃圾回收机制
在现代编程语言中,垃圾回收机制(Garbage Collection)扮演着至关重要的角色,尤其在 Java 语言中更是如此。Java 作为一门广泛应用于企业级开发���编程语言,通过自动化的内存管理,极大地简化了开发者的工作。然而,垃圾回收机制的复杂性也带来了许多挑战和性能优化的需求。本文将深入探讨 Java 中的垃圾回收机制,从基础原理到具体收集器的工作方式,再到如何进行垃圾回收调优,帮助开发者更好地理解和应用这一关键技术。
GC 要解决的问题本质是什么
这里没有必要再和各位读者讨论什么是垃圾以及什么是垃圾回收;也相信绝大多数的 Java 开发者也都多少背过一些面试的八股文,比如如何识别垃圾 ?当你提到引用计数法
的时候,可能面试官已经预测到你要说 可达性分析法
了。不过这里笔者还是将 Oracle Java 官方文档关于 GC 的一段描述放在这里:
Automatic garbage collection is the process of looking at heap memory, identifying which objects are in use and which are not, and deleting the unused objects. An in use object, or a referenced object, means that some part of your program still maintains a pointer to that object. An unused object, or unreferenced object, is no longer referenced by any part of your program. So the memory used by an unreferenced object can be reclaimed.
自动垃圾收集是查看堆内存、识别哪些对象正在使用、哪些未使用,并删除未使用的对象的过程。正在使用的对象或引用的对象意味着程序的某些部分仍然维护着指向该对象的指针。程序的任何部分都不再引用未使用的对象或未引用的对象。因此,未被引用的对象所使用的内存可以被回收。
从这段描述中,可以这样理解:GC 的作用是收集无效的对象(未使用的对象或未引用的对象),进而释放掉这些无效对象所占用的内存空间。那换个思路:如果物理内存空间是无限大的,那么是不是可以不需要去关注这些无效的对象?这显然是理论上的可能性。因此 GC 要解决的问题的本质是 因内存资源的稀缺性带来的如保障有足够空间来分配对象、保障不会因过多无效对象影响计算机缓存结构的访问效率以及硬件成本等问题。
另外,不得不提一下,GC 技术并不是 Java 所特有的,也不是 Java 语言开发者所提出的。GC 技术早在 Java 语言问世前 30 多年就已经发展和成熟起来了, Java 语言所做的不过是把这项神奇的技术带到了广大程序员身边而已。笔者查阅了相关资料,1960 年前后诞生于 MIT 的 Lisp 语言是第一种高度依赖于动态内存分配技术的语言, Lisp 中几乎所有数据都以*“表”的形式出现,而“表”*所占用的空间则是在堆中动态分配得到的。 Lisp 语言先天就具有的动态内存管理特性,这也就意味着要求 Lisp 语言的设计者必须解决堆中每一个内存块的自动释放问题,这也就直接促使了 GC 技术初始雏形。
好了,到这里,我想先抛出这篇文章所要阐述的第一个问题,即不管是 Java 还是 Lisp 语言都提及的动态内存分配技术,那么为什么动态内存分配技术必须要使用 GC 机制来兜底呢?
内存分配机制
这里简单回顾一下内存分配的主要方式。我们知道,大多数主流的语言或运行环境都支持三种最基本的内存分配方式,它们分别是:
-
静态分配( Static Allocation ):在程序编译时确定内存的分配和大小,并且在程序整个生命周期内这部分内存不会改变。静态分配就像你在家中固定安装的一个书架,用来存放特定数量的书籍。这个书架的大小和位置在你安装好之后就不会再改变。
-
自动分配( Automatic Allocation ):由编译器在函数调用时自动分配和释放内存。通常用于函数内部的局部变量,内存分配在函数调用时进行,在函数返回时释放。如:你在家中进行烹饪时,临时使用一个菜板切菜。使用完后,你会立即清洗并收好,菜板的使用是临时的,且与烹饪过程直接相关。
-
动态分配( Dynamic Allocation ):在程序运行时根据需要动态分配内存,并且需要显式地释放内存。通常使用内存管理函数如
malloc
、free
等。如:你在家中举办派对,根据派对人数临时租用桌椅。派对结束后,你需要联系租赁公司将桌椅归还。这种情况下,桌椅的数量和使用时间都是灵活的。
静态分配和自动分配不需要垃圾回收(GC)的原因在于其内存生命周期固定且由编译器自动管理。静态分配在程序启动时确定,内存始终有效,直到程序结束;自动分配用于函数内部的局部变量,函数调用时分配,结束时自动释放。这两种分配方式内存管理简单且确定,不存在内存泄漏或悬挂指针问题,因此自然而安就无需 GC 介入。而动态分配是在运行时进行内存分配,通常这些内存分配的动作是在代码中通过 malloc/free
、new/delete
等关键字进行申请和释放。
这就取决于编写代码的程序员的技术能力,还有就是对于代码整体的掌控能力;如果处理不当,则可能会引发下面的一些问题:
- 内存泄漏:当程序员分配内存但忘记释放内存时,就会发生这种情况。随着系统运行时间的拉长,这些未释放的内存块会慢慢累积,导致可用内存逐渐减少,最终可能会使应用程序或系统崩溃。
- 悬空指针:仍在使用或稍后将使用的内存会过早释放,访问此类内存空间可能会导致未定义的行为,从而导致应用程序崩溃或不可预知的结果。
- 双重释放:程序员试图取消分配已释放空间的问题,可能会损坏内存并导致不稳定的行为。
为了省去上述手动内存管理的麻烦,大佬们钻研开发出了 GC ,即如果把内存管理交给计算机,程序员就不用去主动释放内存了。在手动内存管理中,程序员要判断哪些是不用的内存空间(垃圾)和时刻留意内存空间的寿命。通过引入 GC 机制来解决程序员在手动内存管理上所带来的一系列问题,从而大大减轻程序员的负担和编程的复杂度,让程序员告别繁琐的内存管理,把精力集中在更本质的编程工作上。这就是动态内存分配技术需要使用 GC 机制来兜底的原因。
JAVA 中的 GC 机制
前面介绍了GC 要解决的问题本质是什么以及动态内存分配和GC 的关系,本小节主要来看看 JAVA 中的 GC 机制。Java 中垃圾回收由 Java 虚拟机 (JVM) 负责。要了解 Java 中的垃圾回收工作原理,首先必须了解 Java 内存模型的结构。
Java 的内存管理实际上就是对象的管理,其中包括对象的分配和释放,而对象的分配一般是在堆上,因此 GC 的主要工作区域是堆。
关于可达性分析和三色标记法
这里主要是讨论 JVM 判断对象存活的算法,常见的是引用计数法和可达性分析算法
- 引用计数法:给对象添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;计数器为 0 的对象就是不可能再被使用的对象。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
- 可达性分析算法:这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
当前主流的 JVM 实现中,已经摒弃了引用计数法,主要以可达性分析算法的实现为主,但是基本的可达性分析算法也同样存在问题,主要是 STW 时间长。
可达性分析的整个过程都需要 STW,以避免对象的状态发生改变,这就导致GC停顿时长很长,大大影响应用的整体性能。为了解决上面这些问题,就引入了三色标记算法
。
三色标记算法(Tricolor Marking Algorithm)是一种基于可达性分析的垃圾回收算法。它将对象状态分为三种:白色、灰色、黑色,其中:
-
白色(White):表示对象未被访问过,即还未进行可达性分析。
-
灰色(Gray):表示对象已被访问过,但其引用的对象尚未进行可达性分析。
-
黑色(Black):表示对象已被访问过,并且其引用的对象也已进行了可达性分析。
同时将标记过程可以分为三个阶段:初始标记(Initial Marking)、并发标记(Concurrent Marking)和重新标记(Remark)。三个标记阶段中,初始标记和重新标记是需要 STW ,而并发标记是不需要 STW。其中最耗时的其实就是并发标记的这个阶段,因为这个阶段需要遍历整个对象树,而三色标记把这个阶段做到了和应用线程并发执行,也就大大降低了 GC 的停顿时长。
分代 GC 的基本过程
目前 Java 中主要使用的分代垃圾回收机制,即使是 G1 以 region 概念进行的分区管理方式,也仍旧保留了分代的概念。这里主要依据是分代假说理论。
分代假说主要思想:在大多数情况下,大部分对象的生命周期很短暂,而少部分对象的生命周期较长。
基于分代假说,GC 算法通常会针对不同代使用不同的策略。例如,年轻代常使用复制算法(Copying Algorithm)或标记-复制算法(Mark-Compact Algorithm)进行垃圾回收,而老年代则可能使用标记-清除算法(Mark and Sweep Algorithm)或标记-整理算法(Mark-Compact Algorithm)。这种分代的思想是基于实践观察和优化策略而提出的,旨在通过不同的策略和算法,针对对象的生命周期特点,提高垃圾回收的效率和性能。下面是分代垃圾回收的基本过程。
- 1、任何新对象都会被分配到 eden 空间,两个 survior 空间一开始都是空的。
- 2、当 eden 空间填满时,将触发 minor GC。
- 3、引用的对象将移动到第一个 survior 空间。清除 eden 空间时,将删除未引用的对象。
- 4、在下一个minor GC 中,eden 空间也会发生同样的事情。删除未引用的对象,并将引用的对象移动到 survior 空间。但是,在这种情况下,它们被移动到第二个 survior 空间 (S1)。此外,第一个 survior 空间 (S0) 上最后一个 minor GC 中的对象的年龄会递增并移动到 S1。将所有幸存的对象移动到 S1 后,S0 和 eden 都会被清除。此时在 survior 空间中有不同年龄的对象。
- 5、在下一个minor GC 中,重复相同的过程。然而,这一次,survior 空间发生了变化。引用的对象将移动到 S0。幸存的对象已经老化。Eden 和 S1 被清除。
- 6、在 minor GC 之后,当老化对象达到某个年龄阈值时,它们将从年轻一代提升到老一代。
- 7、随着 minor GC 的继续发生,对象将继续被提升到老年代空间。
- 8、最终,将对老年代进行 major GC 处理,以清理和压缩该空间。
GC 调优的目标是什么?
最后笔者希望和各位读者来探讨下 GC 调优的目标。在过往的经历中,或者面试者提供的简历中,关于 GC 调优的问题背景差不多可以归结如下几类:
- 系统运行时出现了 OOM
- 尖刺问题
- 其他非堆区问题的 GC ,如 metaspace 不够,栈溢出等
确实,这些问题有的会导致我们在线业务受损,有的则可能直接导致系统崩溃。我们的调优往往基于这些已经发生的现象进行的调整,因为 GC 调优本身就没有标准的范式,它取决于你的软硬件环境、你的业务属性、你的流量分布情况等等。这里笔者就引出本小节希望探讨的另一个话题----调优的目的是什么?
这个问题很简单,一句话就是保障系统可用性,这个是终极目标;再细一点则是在有限的内存资源条件下,通过 GC 调优使得系统能够稳定长期运行。那再细一点呢?
笔者认为, GC 调优的目标是更合理的使用堆内存空间,尽量减少应该 GC 带来的业务影响;进一步展开就是:在有限的内存资源限定的条件下,通过 GC 调优来提高应用程序或系统对请求的数据做出响应的速度(响应能力)以及最大化应用程序在特定时间段内的工作量(吞吐量),以使得系统能够以一种较优的姿态保持稳定长期运行。
GC 调优的一些基本思路
从前一小节可以知道,在进行 GC 调优时,我们的目标是优化内存资源使用、提升响应速度、增加吞吐量,并确保系统能够稳定长期运行。为了实现这些目标,我们需要关注和调整一些关键因素,包括堆大小、吞吐量、停顿时间。
内存资源(堆内存空间)
堆内存空间是 GC 调优的基础,合理设置堆大小能够平衡内存使用和 GC 频率,避免内存不足或过度分配带来的性能问题。在实际的应用中,一般会关注初始堆大小(-Xms)和最大堆大小(-Xmx),通过这两个参数可以控制应用程序在启动和运行期间的内存使用;如果你的系统对响应性能要求比较高,可以将这两个值设置为相同,以避免动态调整带来的开销和性能波动。如果是 G1,除了调整堆大小之外,region size 可能也是一个值得关注的因素。
响应速度
提升应用的响应速度,从调优过程角度则是要降低 GC 带来的延迟,而这个延迟主要取决于GC 产生的停顿时间。这里可以考虑选择响应时间优先的GC 算法,如 G1;G1 中提出了一种模型叫做 Pause Prediction Model (停顿预测模型),与 CMS 最大的不同是,用户可以设定整个GC过程的期望停顿时间,参数-XX:MaxGCPauseMillis 指定一个G1收集过程目标停顿时间(默认值200ms,不是硬性条件,只是期望值)。G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的Region数量,从而尽量满足用户设定的目标停顿时间。 停顿预测模型是以衰减标准偏差为理论基础实现的:
// share/vm/gc_implementation/g1/g1CollectorPolicy.hpp
double get_new_prediction(TruncatedSeq* seq) {
return MAX2(seq->davg() + sigma() * seq->dsd(),
seq->davg() * confidence_factor(seq->num()));
}
这里不展开,有兴趣的读者可以自行研究一下 Pause Prediction Model 。
吞吐量
吞吐量指的是应用在单位时间内能够处理的任务数量,优化吞吐量可以提升系统的整体效率。这里的吞吐量指的是 CPU 用于运行用户代码的时间与CPU 总消耗时间的比值,即
吞吐量 = 运行用户代码时间/(运行用户代码时间+ 垃圾收集时间)。
比如如果应用程序总运行时间为100秒,其中90秒用于处理业务逻辑,10秒用于垃圾回收,那么吞吐量为90%(90秒 / 100秒)。
当我们说 GC 吞吐量优先时,意味着我们的目标是最大化应用程序的有效工作时间,最小化垃圾回收带来的开销;一般情况下吞吐量优先的场景主要是批处理系统或者后台服务,这类服务更关注整体处理能力而非单次操作的响应时间。如果你的系统关注吞吐量,可以选择并行 GC 或者其他高吞吐量的 GC 算法,如 Parallel GC。在 GC 参数的调整上,可以 增大年轻代大小,通过增加年轻代(-Xmn)的大小,减少对象晋升到老年代的频率,降低 Full GC 的次数;此外还可以 调整并行 GC 线程数,通过增加并行 GC 的线程数来提高并行垃圾回收的效率。
JVM GC 机制的发展展望
这张图是 2019 年时 JAVA 版本的使用情况分布,还是以 JAVA 8 为主。
下面这张图是 State of Java 2023 中关于 JAVA 目前版本使用情况的统计,从这张图可以看出,版本任你发,我用Java 8 的声音可能会慢慢的消失在历史的长河中。
从 2019 年到 2023 年,短短几年的时间,越来越多的用户选择了更好版本的 LTS 版本,这也意味着高版本中提供的各种语言特性或者机制(包括 GC)能够给用户带去更多的收益价值。从官方纰漏的 报告 中指出,从 JDK 8 到 JDK 18,经历了 10 个版本,Java 垃圾回收也经历了十次进化,包括了 2000+ 个增强功能。从这些演进中可以基本的出的结论是,Java 一直在为寻求更低的延迟、更高效的内存利用和更大的吞吐而努力;这不是三者平衡和折中的过程,而是将 GC 性能指标三角形的张力扩大的过程。
总结
本文主要针对 JAVA 中的垃圾回收机制进行了探讨,和其他文章不同的是,笔者没有将关注点放在某一个垃圾回收算法上,也不是针对某一个具体垃圾收集器进行展开;笔者期望从一个更加宏观的角度去解释什么需要 GC 以及 GC 的目标是什么。此外本文也对 GC 的基本过程、调优思路以及 GC 的未来发展进行了阐述,期望读者可以更加全面的理解 GC 机制。
参考
- blogs.oracle.com/javamagazin…
- www.oracle.com/webfolder/t…
- www.azul.com/wp-content/…
- 《垃圾回收的算法与实现》-- 中村成洋 相川光
转载自:https://juejin.cn/post/7384617995874943013