JVM垃圾回收器:分代堆内存管理,堆设计+分代边界+回收设计思路

JVM垃圾回收器详解

垃圾回收器是JVM中最重要的组件之一,几乎每一个JDK的大版本都对垃圾回收进行重大的更新。另外,由于JDK发布策略的改变,在最近3年的版本发布中,每一个大版本都至少合入一个(甚至数个)关于垃圾回收的JEP。

垃圾回收的快速发展主要受两个方面的影响:一方面是现代计算机的配置越来越好,应用实际可使用的内存也越来越多(虽然微服务架构改变了这一现象,但是微服务拆分过多,将导致公共资源消耗过多,这是JDK的另外一个发展方向);另一方面是应用性能要求也越来越高,期望垃圾回收尽可能少的暂停。

这些诉求要求不断优化垃圾回收,甚至出现新的垃圾回收实现。根据JDK版本支持的策略,JDK 8、JDK 11和JDK 17是目前长期支持的版本。目前这3个版本共支持7个垃圾回收器,分别是串行回收(简称SerialGC)、并行回收(Parallel Scavenge,简称Parallel GC)、并发标记清除(Concurrent Mark Sweep,简称CMS)、垃圾优先(Garbage First,简称G1)、Shenandoah GC、ZGC、Epsilon(实验特性,仅支持分配不回收,实际场景中不会采用)。

由于垃圾回收技术发展很快,所以这3个版本中JDK支持的垃圾回收器并不完全相同,其中CMS仅在JDK 8和JDK 11中支持,ZGC在JDK11中为实验特性,在JDK 17中为正式产品,Shenandoah在JDK 17中为正式产品,Epsilon在JDK 11和JDK 17中为实验特性。

JVM实现的垃圾回收算法从不同的角度可以归属到不同的类别。通常会从执行角度和内存管理角度进行划分。

从执行角度可以分为以下3种:

串行执行:串行指的是当垃圾回收启动时,只有一个垃圾回收线程在工作,而Java应用程序则暂停执行。

并行执行:JVM中的并行指多个垃圾回收相关线程在OS之上并发地运行。这里的并行强调的是只有垃圾回收线程工作,Java应用程序暂停执行。

并发执行:JVM中的并发指垃圾回收相关的线程并发地运行(如果启动多个线程),且这些工作线程会与Java应用程序并发地运行。

从内存管理角度可以分为以下两种:

连续内存管理:JVM管理一块内存区域,在对象分配时会请求一块可容纳对象大小的内存块才能分配成功,在回收时会一次性回收整个内存区域。

分区内存管理:内存区域被划分成多个分区,在进行对象分配时,对象所需的内存可以由多个不连续的内存分区组成,在回收时一般也只回收部分分区。

按照执行和内存管理的角度,JVM支持的垃圾回收可以归纳如下:

在垃圾回收的实现中,可能会根据对象的生命周期管理实现分代,不同生命周期的对象放入不同的内存区域,不同的内存区域通常采用不同的回收算法。按照分代可以将垃圾回收器划分为单代内存回收器和两代内存回收器。单代内存回收器采用一种回收算法,两代内存回收器通常采用两种算法或者采用同一种算法但不同的回收策略。按照分代的划分,JVM支持的垃圾回收可以归纳如下:

串行回收

串行回收器是JVM中最早实现的垃圾回收器。从工程实现角度看,它是最简单的垃圾回收器,但目前串行回收器的使用场景已经非常有限,除了少部分特殊的场景以外几乎都不会考虑使用它。串行回收器是一款暂停应用执行的垃圾回收器,且在垃圾回收执行过程仅有一个GC工作线程执行垃圾回收的动作,它逻辑清晰、实现简单,是学习和研究垃圾回收器的首选。

虽然串行回收器是JVM中最简单的垃圾回收器,但它也包含了很多有意思的设计,而且这些设计和后面介绍的其他垃圾回收器有许多共同的地方。通常对于一款垃圾回收器的实现,需要回答以下问题:如何进行分代内存管理?新生代如何进行内存管理?老生代(或者整个堆空间)如何进行内存管理?新生代和老生代之间是否需要交互?怎样交互?这些问题在本章中都有回答。

分代堆内存管理概述

在2.3.4节介绍分代回收时,提到分代有一些问题需要回答,最简单的问题是分代边界是否固定?串行回收采用边界固定的分代方法,将整个堆空间划分为两个代:新生代和老生代。在内存管理方面,新生代采用复制算法进行垃圾回收,整个堆空间采用标记压缩算法进行垃圾回收,复制算法采用的是变异的Cheney复制算法。整个堆内存管理示意图如图3-1所示。

图3-1 串行回收堆空间管理示意图

串行回收的特点如下:

1)内存是连续的。

2)新生代和老生代边界固定,边界在JVM启动时确定

3)新生代空间划分为3个子空间,分别是Eden、From、To空间,并且Eden、From、To空间的大小在启动时确定

4)新生代空间的垃圾回收采用的是复制算法

5)整个堆空间的垃圾回收采用的是标记压缩算法。注意,标记压缩算法针对的是整个堆空间,串行回收中没有只回收老生代的算法,具体原因后文讨论。

堆设计

从应用程序运行的角度来说,应用所需的堆空间大小与应用程序中对象的分配速率和运行时间相关。由于应用对象分配速率和运行时间不同,且对于堆空间大小的需求不尽相同,因此应用启动时应该告诉JVM需要多少堆空间,常见的做法是在应用启动时通过参数设置堆空间大小。除了需要确定堆空间的大小以外,使用者还需要根据垃圾回收器堆的设计了解如何使用堆空间,才能充分利用堆空间。

JVM管理的堆空间是基于OS管理的内存之上的,应用在启动时向OS请求整个运行期所需要的全部内存。当然这样的设计并非完美,至少存在两个问题:其一,从OS直接请求内存是相对耗时的操作,请求运行时全部内存将导致JVM启动时间过长;其二,JVM启动时从OS请求了内存但并不会立即使用,实际上造成了资源浪费。JVM如此设计的原因在于:应用都是较长时间运行,期望通过启动初始化运行时所需的内存加快运行的效率。那么有没有比较好的方案既能保证应用的执行效率,又能兼顾应用启动速度和内存利用率呢?

JVM通过细化堆空间设计解决这个问题。JVM提供了两个参数:一个是最小的堆空间,另一个是最大的堆空间。假定这两个参数分别记为InitialHeapSize和MaxHeapSize[1]。设计思路修改为:JVM启动时向OS请求最小的堆空间,并在运行时根据内存使用的情况逐步扩展,直到堆空间达到参数设置的最大堆空间。这样的设计在一定程度上解决了JVM启动慢、资源利用率低的问题,其本质是把应用启动时的内存资源初始化请求推迟到应用运行时,这可能导致应用运行性能受到内存资源扩展的影响。所以在一些应用中为了减少运行时内存扩展带来的影响,会在启动时把最小堆空间和最大堆空间设置成相同的值。

1.5节讨论垃圾回收工作范围时,提到垃圾回收不仅包含向OS请求内存,还包含向OS归还申请的内存。早期JVM设计主要考虑的是如何合理地向OS请求内存,很少考虑如何向OS归还内存。但这样的设计在一些场景中存在问题。

例如,一个应用在运行过程中内存使用越来越多,在业务处理高峰时内存使用达到了最大堆空间,但当业务峰值下降之后,由于没有合理的内存归还机制,申请的内存一直被占用但没有再次使用,这实际上造成了资源浪费。这样的问题在云场景中表现得非常明显,在云场景中,用户按资源使用付费,不愿意也不应该为未使用的内存付费,所以最新的JVM都会考虑在什么情况下向OS归还内存。需要指出的是,向OS归还内存也是一个耗时的操作,不当的设计和实现会导致程序暂停时间过长。

另外,归还时机和归还的内存数量不当,也可能导致内存归还后应用内存不足,会立即向OS再次请求内存,从而发生内存使用颠簸,这也会引起应用性能下降。针对这一问题,一个可能的设计是引入一个新的参数,假定参数记为SoftMaxHeapSize[2],用于控制内存归还的边界。该参数满足条件:

InitialHeapSize≤SoftMaxHeapSize[3]≤MaxHeapSize,这3个参数的作用如下:

InitialHeapSize作为应用启动时最小的堆空间。

根据运行的需要,应用程序使用的内存可以扩展,但最大使用量不超过MaxHeapSize。

SoftMaxHeapSize作为控制参数,当内存使用超过该阈值到MaxHeapSize之间的部分,在满足一定条件的情况下,可以归还给OS。

对于不支持SoftMaxHeapSize的垃圾回收器,可以简单地认为SoftMaxHeapSize等于MaxHeapSize。

根据这3个参数的含义,堆空间的划分如图3-2所示。

图3-2 堆空间划分示意图

那么在实际工作中该如何设置这3个参数值?通常的原则如下:

1)MaxHeapSize是对应用程序最大内存量的估计。

2)SoftMaxHeapSize是对应用程序常见工作负载使用的内存量的估计。

3)InitialHeapSize一方面是对应用程序启动后所需最小内存使用量的估计(最小内存一般指应用满足最小工作负载时的内存使用量),另一方面是在启动速度和资源利用之间寻找一个平衡值(即在最小内存使用量和最大内存使用量之间寻找一个合适的值)。

在JVM的实现中,应用也可以不提供这3个参数值。如果应用启动时没有提供参数值,那么JVM会为参数提供一个默认值,然后根据系统的硬件配置启发式地为参数推导一个“合适”的值。例如,JVM运行在32位系统之上,MaxHeapSize的默认值是96MB;JVM运行在64位系统之上,MaxHeapSize的默认值是124.8MB。然后JVM进一步启发式地推导:

在小内存系统中使用50%的物理内存作为MaxHeapSize的上限(小内存指的是默认值大于50%的物理内存),否则使用25%的物理内存作为MaxHeapSize的上限,然后再通过其他参数加以调整(具体公式会在第9章详细介绍)。

JVM的设计者推荐Out-Of-Box(开箱即用)的使用方式,即JVM使用者无须进行任何参数配置即可较好地使用JVM。但是在实际工作中,对于堆空间这样重要的参数,使用者还是需要明确地设置,如明确设置MaxHeapSize等相关参数,既能确保资源没有浪费,又能保证资源充分利用。

分代边界

在固定边界的分代内存管理中,边界该如何确定?因为整个堆空间划分为新生代和老生代两个代,所以只要确定其中一个代的大小,另外一个代的大小也就确定下来,边界也就确定了。JVM通过确定新生代的大小来确定边界,假定新生代的大小记为MaxNewSize。从整体的堆空间中确定新生代空间大小常用的方法有以下两种:

1)绝对值划分:设置一个新生代的大小。

2)比例划分:设置一个比例,假定记为NewRatio,假定堆大小记为HeapSize,在JVM中新生代大小可以通过公式

计算得到。该参数的含义是:新生代和老生代的比例为1∶NewRatio

JVM同时支持两种设置方式,这意味着使用者既可以通过设置新生代大小(绝对值方式)确定边界,也可以通过设置新生代占用整个堆空间的比例来确定边界。由于JVM同时支持两种方式,而两种方式修改的是同一个参数,如果两种方式同时使用,则会造成参数设置冲突。而在实际工作中,笔者也遇到过一些用户对于参数不了解或者错误使用的情况,同时设置这两种参数,从而造成了参数冲突。在JVM实现中,为了防止误用,需要解决这样的冲突。

通常解决这类冲突的方法是对这两种参数的设置方式使用不同的优先级,当设置高优先级参数时,低优先级参数失效。在JVM中,绝对值参数设置方式优先级更高,即假设使用者同时设置了参数MaxNewSize和NewRatio,只有MaxNewSize有效,NewRatio无效。

笔者在实际工作中遇到过许多JVM使用者不知道或者忘记设置新生代大小的情况,新生代大小的设置实际上对应用的性能有较大的影响(新生代用于应用程序对象的分配,所以新生代的大小会直接影响应用的效率。参考2.3节垃圾回收的基础知识)。JVM中关于新生代大小参数设置的效果如表3-1所示。

表3-1 新生代大小参数设置效果

在讨论分代边界的时候,我们假定堆空间大小固定为HeapSize,并根据上面的方法计算新生代和老生代的大小,进而确定边界。但是在上一节的讨论中,使用的堆空间并不固定,存在最大堆空间和最小堆空间。那么边界是与最大堆空间相关,一直保持不变,还是与实际使用的堆空间相关,随着使用堆空间的大小变化而变化呢?其实这个问题并没有一个绝对的设计原则。

串行回收使用固定的边界,其好处如下:

1)新生代扩展处理简单。假设边界随着堆空间的实际使用量的变化而变化,在新生代需要扩展的时候该如何处理?根据图3-1所示的内存对象布局,为了保持新生代和老生代管理内存的连续性,只能把老生代管理的内存向后移动,移动出的空闲部分归新生代扩展使用。移动内存是非常耗时的操作,而使用固定边界可以避免内存移动,从而获得更高性能。

2)代际信息管理简单。通常为了高效地进行垃圾回收,可以使用引用集管理代际之间的引用,例如使用卡表。当边界固定时,卡表相关的写屏障处理简单,通过比较对象地址和边界的关系,非常容易判断对象是位于新生代中还是老生代中,从而减少写屏障的额外消耗。

固定新生代大小最大的缺点是内存管理的灵活性差,应用在启动时就需要确定新生代大小,这通常并不容易。当然垃圾回收算法可以增强,将固定边界优化为浮动边界。

结合堆空间大小动态变化和边界固定的特点,将图3-1和图3-2组合后,

应用堆空间的内存布局如图3-3所示。

图3-3 增加分代后的堆空间设计

回收设计思路

在上文中提到,分代后针对不同的内存空间使用不同的垃圾回收算法。

这需要进一步考虑两个代使用的场景,以及何时可以启动垃圾回收。

1)新生代的内存主要用于响应应用程序内存的分配请求,所以新生代的回收时机是在无法响应应用的内存分配请求时。

2)老生代的内存主要用于新生代垃圾回收以后对象的晋升,老生代GC对象晋升导致空间不足,所以老生代回收的时机一般是无法响应新生代回收中对象的晋升请求时。另外,在一些特殊情况下(如超大对象的分配),Mutator也可以直接在老生代中直接分配对象。

从两个内存代的使用场景来说,希望针对新生代的垃圾回收(称为MinorGC)触发更为频繁,针对老生代的垃圾回收(Major GC)触发次数少一些。

通常两种GC工作方式如图3-4所示。

图3-4 Minor GC和Major GC理论触发模型

在JVM中通常使用Major GC指代老生代的回收,用Full GC指代整个堆空间的回收。上面提到串行回收上并不存在Major GC,当老生代无法响应MinorGC对象晋升时直接触发Full GC。

本文给大家讲解的内容是JVM垃圾回收器详解:串行回收,分代堆内存管理概述

  1. 下篇文章给大家讲解的内容是JVM垃圾回收器详解:串行回收,新生代内存管理
  2. 感谢大家的支持!
展开阅读全文

页面更新:2024-05-27

标签:边界   垃圾   老生   新生代   算法   思路   大小   对象   内存   参数   空间

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top