垃圾回收算法手册读书笔记(四) 堆内存的划分

堆内存划分

到目前为止我们都假定垃圾回收以这样的方式进行:

  • 所有的对象是由相同的垃圾回收算法管理的,并且所有垃圾将在同一时间得到回收。

然而这个假设并不是必须的,在之前的介绍中我们也注意到,不同的垃圾回收算法在处理具有不同特性的对象时互有优势,如果我们将堆划分成多个分区,具有相同特性的对象分配在同一个分区,并且在不同分区上应用不同的垃圾回收算法,这样使得不同的垃圾算法能够尽可能的发挥出自己的优势,对于垃圾回收性能的提升显而易见。

根据什么分区

根据对象是否移动进行分区

当一个对象被传递给非托管代码时,回收器不能贸然移动这个对象。此时称该对象被”钉住”。对于不能被移动的对象,使用非移动式的标记-清扫算法显然是个好的选择

根据对象大小进行分区

在某些情况下移动对象可能是不合适的(但并非不可移动)。例如移动一个大对象的代价可能比其产生内存碎片的代价更大。通常将大对象分配到一个专门的大对象空间,并应用非移动式的回收器进行管理。

根据效益进行分区

一次垃圾回收的开销,很大程度上取决于回收空间中的存活对象数量,因为垃圾回收过程需要追踪遍历标记这些存活对象,并且在移动式回收器中还需要移动这个存活对象。因此当空间中存活对象占大多数时进行垃圾回收不仅开销大,而且回收不了多少内存,效益比较低。根据效益划分分区,增加效益高分区的回收频率,降低效益低分区的回收频率,使得整体效益得到提升。 如何划分出效益高的分区,这是我们需要讨论的一个问题。在Java程序中,大多数对象满足弱分代假说,即大多数对象在年轻时死亡。我们也可以这么理解,越年轻的对象成为垃圾的可能就越大。因此根据对象的存活时间进行划分,着重于回收年轻对象,从而提高整体的回收效益。

为缩短停顿时间进行分区

对于一些对停顿时间非常敏感的应用,我们可以通过限制回收器所需要回收的空间大小来达到目的。一次垃圾回收中,回收器需要进行回收的内存空间称为定罪空间,通过限制定罪空间的大小,我们就可以限定需要标记与清理的对象的数量,从而控制回收需要的时间。在G1回收算法中,这种策略的实现是:回收器将堆空间划分为很多足够小的内存分区,每次回收时根据最小停顿时间限制,选取部分效益高的分区进行回收。

如何分区

普通的做法是在物理上将堆内存分成互不重叠的地址段。一个分区拥有连续的内存地址段。

也可以将内存划分为多个足够小的连续内存地址段,每个地址段通过一个标识来确定属于哪个分区,这样一来,一个分区在内存上就是不连续的,而是由多个连续的内存地址段构成。例如G1垃圾回收器

更进一步,还可以通过在对象上添加标识,来确定一个对象属于哪个分区,这样一来就分区就仅仅是逻辑上的分区,而完全不在物理上进行分隔。

何时进行分区

分区一般可以在以下几个时刻完成:

  • 静态(编译期)完成
  • 一个对象被创建时
  • 垃圾回收时
  • 赋值器访问对象时

分区的决策可以静态编译时完成,通过对对象的类型,代码或者其他一些分析,某些相同类型的对象所属的空间可以被静态的划定。Jvm中的永久代就是其中一个例子(Java8中改为metaspace),作为存储类型信息的Class类,一旦被类加载器加载,很少情况下会被卸载,因此Class类对象会被直接分配进永久代。

分区的决策可以由赋值器完成,当新建一个对象时,根据对象的大小,超过一定阈值时,将对象分配进大对象空间

分区的决策也可以由回收器在垃圾回收时完成,作为我们最熟悉的分代垃圾回收,将对象按照年龄进行划分,每当一个对象在一次垃圾回收中存活下来,年龄加一,当回收器发现对象年龄到达阈值,就将其移动到老年代中。

需要考虑的问题

分区间的指针

在对某个分区进行垃圾回收前,回收器必须先确认分区的根,前面我们有提到,在对堆进行分区后,每次垃圾回收,并不一定会对所有的分区进行垃圾回收,而别的不需要垃圾回收的分区内的对象很有可能会引用当前需要垃圾回收的分区内对象,因此我们在确定根对象时,这些分区外的对象也必须作为根对象进行追踪,我们需要使用一些数据结构,来记录这些分区间的指针,以便在垃圾回收时找到这些其他分区的根。