作者 | 波哥
审校 | 重楼
Java虚拟机(JVM)的自动内存管理是Java开发者的福音,它通过垃圾收集(GC)机制自动回收不再使用的对象,极大地简化了内存管理。然而,不恰当的GC配置或不理想的垃圾收集器选择可能会对应用性能产生负面影响。为了优化Java应用的性能,深入理解GC的原理和策略是至关重要的。本文笔者将详细探讨JVM的垃圾收集机制,包括内存模型、GC算法、各种垃圾收集器的特点及其调优策略。
一、JVM内存模型深入解析
JVM的内存模型是理解GC机制的基础。JVM将内存分为多个区域,主要包括堆(Heap)、方法区(Method Area)、程序计数器(Program Counter Register)、虚拟机栈(VM Stack)和本地方法栈(Native Method Stack)。
1.堆(Heap)
堆内存是Java虚拟机(JVM)管理的最大一块内存区域,它被所有线程共享,主要用于存放对象实例和数组。从垃圾收集的角度,堆内存进一步细分为新生代(Young Generation)、老年代(Old Generation)以及元空间(Metaspace,在Java 8之后取代了永久代PermGen)。
(1)新生代(Young Generation)
新生代是大多数新创建的对象的诞生地。由于对象的生存周期大多数较短,新生代的垃圾收集(Minor GC)发生频繁但速度快。新生代进一步分为三个区域:
- Eden区:几乎所有新生成的对象首先都是在Eden区分配。
- 两个Survivor区(S0和S1):用于存放从Eden区和Survivor区经过一次Minor GC后仍然存活的对象。在每次Minor GC后,存活的对象会被移动到一个Survivor区,而另一个空闲的Survivor区将用于下一轮的存活对象移动。
(2)老年代(Old Generation)
随着时间的推移,一些在新生代中经历了多次GC依然存活的对象会被移动到老年代。老年代用于存放应用中生命周期长的对象。相较于新生代,老年代的空间更大,GC发生的频率更低,但每次GC的时间更长。
对象进入老年代(Old Generation)通常是基于它们的存活周期。JVM采用分代垃圾收集策略,其中对象首先在新生代(Young Generation)分配。随着垃圾收集的进行,只有存活下来的对象才会逐步晋升到老年代。具体而言,有几种情况下对象会进入到老年代:
(3)经历多次Minor GC后仍然存活的对象
新生代中的对象在经历了一定数量的Minor GC(垃圾收集只针对新生代的收集称为Minor GC)后,如果仍然存活,它们会被移动到老年代。JVM中有一个年龄计数器,每当对象在Minor GC后仍然存活,它的年龄就会增加。当对象的年龄增加到一定阈值(默认为15,但可以通过JVM参数-XX:MaxTenuringThreshold进行调整)时,这个对象就会被晋升到老年代。
(4)大对象直接分配到老年代
所谓的大对象是指需要大量连续内存空间的Java对象,例如那些很大的数组和长字符串。如果新生代中的Eden区无法容纳一个新创建的对象,JVM就会直接将这个对象分配到老年代。这样做是为了避免在新生代中为大对象分配内存后,进行Minor GC时发生大量的内存复制操作(因为新生代使用的是复制算法)。通过JVM参数-XX:PretenureSizeThreshold可以设置大对象的大小阈值。
(5)动态年龄判断
在新生代的两个Survivor区之间,对象每经过一次Minor GC就会年龄增加。如果在Survivor空间中相同年龄所有对象的大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到-XX:MaxTenuringThreshold设置的年龄。
(6)空间分配担保
在进行Minor GC前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果这个条件不能满足,虚拟机会提前将新生代中的部分对象转移到老年代中,这个过程称为“空间分配担保”。目的是确保Minor GC可以顺利完成,不会因为老年代空间不足而触发更耗时的Full GC。
(7)元空间(Metaspace)
元空间用于存放类的元数据信息,如类的定义信息、常量、静态变量等,并使用本地内存(而非JVM堆内存)。在Java 8之前,这部分数据被存放在永久代中。元空间的引入是为了避免永久代容易发生的内存溢出问题,并提供更灵活的内存管理。
2.方法区(Method Area)
方法区(Method Area)是堆的一部分,也被称为非堆(Non-Heap),它被所有线程共享。方法区主要用于存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
在Java 8及之后的版本中,传统的永久代(PermGen)被元空间(Metaspace)所取代。与永久代不同,元空间并不在虚拟机内存中,而是使用本地内存,因此,元空间的大小只受本地内存限制。
方法区的特点
- 静态存储:方法区存储的信息相对静态,包括类的结构(如运行时常量池、字段和方法数据)以及方法和构造函数的代码。
- 全局共享:方法区被所有线程共享,这意味着它不像堆那样频繁地进行垃圾收集。实际上,方法区的垃圾收集主要针对常量池的回收和对类型的卸载。
- 动态扩展:虽然方法区的初始大小有限,但它可以在运行时动态扩展,也可以设置最大空间大小,以防止其过度消耗内存。
方法区的垃圾收集
方法区的垃圾收集比较少见且难以执行,主要涉及两部分工作:废弃常量的回收和无用类的卸载。无用类的卸载条件相对严格,需要同时满足以下三个条件:
- 该类所有的实例都已经被回收,也就是说Java堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
二、GC算法的探究
GC算法是实现垃圾收集的具体方法。主要的GC算法包括标记-清除(Mark-Sweep)、复制(Copying)和标记-整理(Mark-Compact),下面笔者将详细介绍这三种算法的工作原理以及它们的优缺点。
1.标记-清除算法
(1)工作原理
- 标记阶段:从一组根对象(通常是活跃线程的局部变量和输入参数、静态字段等)开始遍历,标记所有从这些根对象可达的对象。
- 清除阶段:扫描整个堆空间,回收所有未被标记的对象所占用的内存。
(2)优点
- 简单直接,实现相对容易。
- 不需要额外移动对象,减少了额外的开销。
(3)缺点
- 执行过程中会产生内存碎片,导致后续可能无法为大对象分配连续内存空间。
- 需要暂停应用程序执行(Stop-The-World),可能会导致应用响应时间变长。
2.复制算法
(1)工作原理
- 将可用内存划分为大小相等的两块,每次只使用其中一块。
- 当这一块的内存快用完时,将存活的对象复制到另一块空闲区域。
- 清空已使用的内存块,交换两个内存区域的角色。
(2)优点
- 解决了标记-清除算法中的内存碎片问题。
适合存活对象较少的场景,如新生代的垃圾收集。
(3)缺点
- 需要将存活的对象复制到另一块内存区域,增加了复制成本,特别是当存活对象较多时。
内存使用效率低,因为任何时候只有一半的内存区域被使用。
3.标记-整理算法
(1)工作原理
- 标记阶段:与标记-清除算法相同,从根集合出发标记所有可达的对象。
- 整理阶段:将所有存活的对象压缩到堆的一端,然后清理掉边界以外的内存。
(2)优点
- 解决了内存碎片问题,为大对象的分配提供了连续的内存空间。
- 避免了复制算法中的高成本复制操作,更适合老年代的垃圾收集。
(3)缺点
- 需要移动对象,并更新对象引用的位置,增加了额外的开销。
- 同样需要暂停应用程序执行,可能会影响应用的响应时间。
现代JVM实现通常采用以上基本GC算法的变体或组合,以达到更高的垃圾收集效率和更低的停顿时间。例如:G1收集器就是将堆划分为多个区域(Region),并根据每个区域的垃圾回收价值进行增量收集,旨在平衡吞吐量和停顿时间。ZGC和Shenandoah收集器则采用了基于Region的复制算法,实现了几乎全程并发的垃圾收集,极大地减少了停顿时间。
JVM提供了多种垃圾收集器,下面我们大概介绍下目前主流的几种垃圾回收器及每种收集器的适用场景。
- Serial收集器Serial收集器是最简单的GC实现,它使用单线程进行垃圾收集。在进行GC时,需要暂停其他所有工作线程("Stop The World"),因此不适合多处理器环境或要求低延迟的应用。
- Parallel(并行)收集器Parallel收集器类似于Serial收集器,但它使用多线程进行垃圾收集,可以显著减少GC的停顿时间。它主要关注达到一个可接受的吞吐量(应用时间与GC时间的比率)。
- Concurrent Mark Sweep(CMS)收集器CMS收集器的目标是尽可能减少应用停顿时间。它通过并发标记和并发清除实现了这一点,但是CMS收集器可能会产生较多的内存碎片。
- G1收集器G1收集器是一种服务器端的垃圾收集器,旨在替代CMS收集器,它通过将堆划分为多个区域(Region)并并行处理这些区域来减少停顿时间,同时提供了更细粒度的GC控制。
- ZGC和Shenandoah收集器ZGC和Shenandoah是实验性的低延迟垃圾收集器,旨在实现几乎不停顿的垃圾收集。它们通过使用读写屏障和并发线程来实现这一目标,适用于需要极低停顿时间的应用。
三、垃圾收集器的调优实践
以上我们详细介绍了垃圾回收算法和主流的垃圾回收器,接下来我们详细介绍下在实际应用中,该如何根据具体应用特性进行调优。以下是一些调优的通用策略:
- 选择合适的垃圾收集器根据应用的需求(如响应时间要求、吞吐量要求等)和资源限制(如CPU、内存大小等),选择最适合的垃圾收集器。
- 堆大小调整适当地调整堆大小可以平衡GC的频率和停顿时间。一般而言,增大堆大小会减少GC的频率,但可能增加GC的停顿时间。
- 监控和分析GC日志通过开启GC日志,可以获得垃圾收集的详细信息,如各阶段的耗时、回收量等。分析这些数据可以帮助识别性能瓶颈和调优方向。
- 细化GC参数设置
JVM提供了丰富的GC相关参数,通过调整这些参数(如新生代与老年代的比例、触发Full GC的阈值等),可以微调垃圾收集的行为,优化性能。
深入理解JVM的垃圾收集机制和各种垃圾收集器的特点是进行有效性能调优的前提。通过选择合适的垃圾收集器并适当调优,可以显著提升Java应用的性能,满足不同场景下对响应时间和吞吐量的需求。记住,没有一劳永逸的解决方案,性能优化是一个持续的过程,需要不断地监控、评估和调整。
作者介绍
波哥,互联行业从业10余年,先后担任项目总监及架构师。目前专攻技术,喜欢研究技术原理。技术全面,主攻Java,精通JVM底层机制及Spring全家桶底层框架原理,熟练掌握当前主流的中间件、服务网格等技术原理。