在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 。设计思路修改为:JVM启动时向OS请求最小的堆空间,并在运行时根据内存使用的情况逐步扩展,直到堆空间达到参数设置的最大堆空间。这样的设计在一定程度上解决了JVM启动慢、资源利用率低的问题,其本质是把应用启动时的内存资源初始化请求推迟到应用运行时,这可能导致应用运行性能受到内存资源扩展的影响。所以在一些应用中为了减少运行时内存扩展带来的影响,会在启动时把最小堆空间和最大堆空间设置成相同的值。
1.5节讨论垃圾回收工作范围时,提到垃圾回收不仅包含向OS请求内存,还包含向OS归还申请的内存。早期JVM设计主要考虑的是如何合理地向OS请求内存,很少考虑如何向OS归还内存。但这样的设计在一些场景中存在问题。例如,一个应用在运行过程中内存使用越来越多,在业务处理高峰时内存使用达到了最大堆空间,但当业务峰值下降之后,由于没有合理的内存归还机制,申请的内存一直被占用但没有再次使用,这实际上造成了资源浪费。这样的问题在云场景中表现得非常明显,在云场景中,用户按资源使用付费,不愿意也不应该为未使用的内存付费,所以最新的JVM都会考虑在什么情况下向OS归还内存。需要指出的是,向OS归还内存也是一个耗时的操作,不当的设计和实现会导致程序暂停时间过长。另外,归还时机和归还的内存数量不当,也可能导致内存归还后应用内存不足,会立即向OS再次请求内存,从而发生内存使用颠簸,这也会引起应用性能下降。针对这一问题,一个可能的设计是引入一个新的参数,假定参数记为SoftMaxHeapSize ,用于控制内存归还的边界。该参数满足条件:InitialHeapSize≤SoftMaxHeapSize ≤MaxHeapSize,这3个参数的作用如下:
根据这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)代际信息管理简单。通常为了高效地进行垃圾回收,可以使用引用集管理代际之间的引用,例如使用卡表。当边界固定时,卡表相关的写屏障处理简单,通过比较对象地址和边界的关系,非常容易判断对象是位于新生代中还是老生代中,从而减少写屏障的额外消耗。
固定新生代大小最大的缺点是内存管理的灵活性差,应用在启动时就需要确定新生代大小,这通常并不容易。当然垃圾回收算法可以增强,将固定边界优化为浮动边界,第5章介绍的并行回收、第6章介绍的G1都涉及这方面的设计和实现。
结合堆空间大小动态变化和边界固定的特点,将图3-1和图3-2组合后,应用堆空间的内存布局如图3-3所示。
图3-3 增加分代后的堆空间设计
在上文中提到,分代后针对不同的内存空间使用不同的垃圾回收算法。这需要进一步考虑两个代使用的场景,以及何时可以启动垃圾回收。
1)新生代的内存主要用于响应应用程序内存的分配请求,所以新生代的回收时机是在无法响应应用的内存分配请求时。
2)老生代的内存主要用于新生代垃圾回收以后对象的晋升,老生代GC对象晋升导致空间不足,所以老生代回收的时机一般是无法响应新生代回收中对象的晋升请求时。另外,在一些特殊情况下(如超大对象的分配),Mutator也可以直接在老生代中直接分配对象。
从两个内存代的使用场景来说,希望针对新生代的垃圾回收(称为Minor GC)触发更为频繁,针对老生代的垃圾回收(Major GC)触发次数少一些。通常两种GC工作方式如图3-4所示。
图3-4 Minor GC和Major GC理论触发模型
在JVM中通常使用Major GC指代老生代的回收,用Full GC指代整个堆空间的回收。上面提到串行回收上并不存在Major GC,当老生代无法响应Minor GC对象晋升时直接触发Full GC,具体原因在3.3节中讨论。