HotSpot 中的内存管理

2017-04-16

介绍垃圾回收概念、HotSpot中的垃圾回收等

Explicit vs. Automatic Memory Management

内存管理的过程就是找出不再使用的对象,释放(回收)该对象所使用的内存,使得在接下来的分配过程中,可以重新使用这部分内存。内存管理分为显式内存管理和自动内存管理。

显式内存管理即由程序员负责分配和回收内存。显式内存管理经常出现的一个问题是悬空指针(dangling references,也叫野指针),这种情况出现在我们回收了一个指针所指向的对象,但是如果还有其他指针也指向了该对象,那么这时其他指针就变成悬空指针了。如果我们尝试使用悬空指针访问该对象时,因为这时原对象所在的内存已经被分配给新的对象了,那么这时就会出现不可预测的结果。

显式内存管理另一个常见的问题是内存泄漏(space leaks)。这种情况出现在我们为对象分配了内存,但没有及时回收该内存。一个例子是如果我们想要释放掉一个线性表,但是我们可能只是释放了链表头,那么剩下的链表元素就不能再访问,也无法回收它们所占用的内存。如果出现了很多次内存泄漏,那么程序的可用内存就会减少,甚至出现内存不够的情况(out of memory)。

相比于显式内存管理,自动内存管理是将内存交给程序自己管理,一般这样的程序称为垃圾回收器(garbage collector)。相比于显式内存管理,自动垃圾回收减少了程序员的负担,还能避免野指针和内存泄漏的问题。

Garbage Collection Concepts

一般来说,不考虑循环引用的情况下,如果对象被其他对象引用(如,指针),那么这样的对象称为存活的(live);反之,如果对象没有被引用,那么这样的对象称为已死亡(dead),变成了垃圾,需要被清理。垃圾回收的过程就是找出这样已死亡的对象并释放它们所占用的内存。

除了垃圾回收之外,垃圾回收器的职责还包括为新创建的对象分配内存。一般的,内存都是从一大块内存池分配,这样的内存池称为堆(heap)。

垃圾回收的时间是由垃圾回收器决定的。通常,当堆的占用率达到某个阈值或者堆已经满了,就要开始回收了。回收可以对整个堆进行,也可以对一部分空间进行。

内存分配的难点在于找到一块未使用、满足大小要求的内存块。内存分配主要的问题是避免碎片(fragmentation)。

Desirable Garbage Collector Characteristics

理想的垃圾回收器应该具有以下特点:

  1. 安全
    • 存活的对象将保证永远不会被错误的释放,垃圾会在几个回收周期内被清除
  2. 高效
    • 理想的垃圾回收器在垃圾回收的过程中不会造成长时间的暂停。但是在现实世界中,垃圾回收器通常需要在时间,空间和频率上做折衷。举个例子,如果堆太小了,那么回收过程就会很快,但是堆很快又满了,所以需要频繁的回收。反过来说,如果堆比较大,这样要填满堆就要更长的时间了,所以回收频率就不大,但是每一次回收则需要更长的时间。
  3. 减少碎片
    • 垃圾回收器还应该减少内存碎片,一种可行的方法是进行压缩(compaction
  4. 可扩展性
    • 对于多线程应用或者多处理器系统,内存分配和垃圾回收不应该成为可扩展性的瓶颈

Design Choices

垃圾回收器的设计者通常面临以下设计选择(design choices):

Serial versus Parallel

串行回收的情况下,垃圾回收器使用单线程回收,无法发挥多核 CPU 的优势。如果使用并行回收,那么可以将回收过程分为几个子任务,然后在不同的 CPU 上同时开始回收。并行回收可以加快回收速度,但是会带来额外的复杂性和潜在的内存碎片。

Concurrent versus Stop-the-world

使用 stop-the-world 的方式回收,那么回收开始时会暂停应用程序的执行直到回收过程完成。Concurrent 的方式大部分是并发的去回收,也会采用小部分 stop-the-world 回收。一般来说,stop-the-world 回收过程比 concurrent 回收过程简单,因为应用程序没有在执行,不会改变对象之间的引用关系;缺点就是回收过程要停止应用程序的执行。对应地,使用 concurrent 回收方式回收时,暂停过程相对较短,但是回收过程会更小心,因为回收过程中应用程序可能会改变对象的引用,这会降低并发收集器的性能。

Compacting versus Non-compacting versus Copying

compact 过程是指回收器决定了存活对象和死亡对象后,将所有存活对象移动到一起并完全回收剩下的内存。压缩的好处在于可以很方便的分配内存。与 compacting 收集器相对的,是 non-compacting 收集器。这种收集器仅仅收回死亡对象的内存,而不需要移动所有的存活对象。好处在于可以更快的回收内存,但是无法解决内存碎片的问题。第三种方案是 copying,该类收集器将所有存活的对象复制到另一块内存区域,并清空当前内存区域。这种方案一般需要两块内存区域,两块交替成为使用区和空闲区。

Performance Metrics

通常用以下指标衡量垃圾收集器的性能:

  • Throughput - the percentage of total time not spent in garbage collection, considered over long periods of time.
  • Garbage collection overhead - the inverse of throughput, that is, the percentage of total time spent in garbage collection (the inverse of throughput)
  • Pause time - the length of time during which application execution is stopped while garbage collection is occurring.
  • Frequency of collection - how often collection occurs, relative to application execution.
  • Footprint - a measure of size, such as heap size.
  • Promptness - the time between when an object becomes garbage and when the memory becomes available.

之所以存在这么多指标,是因为不同的应用程序有不同的性能要求。交互式应用程序需要更少的 pause time,后台程序需要更高的 throughput,而实时应用既需要更少的 pause time,也需要更少的 garbage collection overhead。

Generational Collection

分代收集算法将内存划分为数代(generations),不同代的内存池保存着不同年龄的对象。如,使用最广泛的是将内存划分为两代:one for young objects and one for old objects。

分代收集算法基于以下现象,称为 weak generational hypothesis

  • Most allocated objects are not referenced (considered live) for long, that is, they die young.
  • Few references from older to younger objects exist.

在新生代中,垃圾回收发生相对频繁,因为新生代空间通常较小,并且新生代对象通常很快死亡。

如果对象在新生代中经历了一定次数的垃圾回收还没有死亡,那么它将会被 promoted 或者 tenured 到老年代中(old generation),如下图。通常,老年代比新生代要大,占用率增长也相对缓慢。所以,在老年代中,垃圾回收相对较少,但每一次回收则要用更长的时间。

Generational garbage collection

根据上面的性质,我们可以在新生代选择速度较快的垃圾回收算法,而在老年代选择能有效压缩空间的垃圾回收算法。

Garbage Collectors in the HotSpot JVM

HotSpot Generations

在 Java 8 之前,HotSpot 内存被分为了三代:新生代(young generation),老年代(old generation), 以及永久代(permanent generation)。而在 Java 8 之后,永久代被移除,并引入了元空间(MetaSpace)。

大多数对象初始都位于新生代中。老年代包含那些在新生代存活超过一定回收次数的,以及一些直接分配在老年代、占用较大内存的对象。永久代被移除后,原来存放在永久代的数据分为两类。其中存放 Class 和 Method 对象的方法区被移至元空间,而字符串常量则移至 Java 堆。

新生代进而被细分为三部分:Eden 区加上两块较小的 survivor 区,如下图所示

Young Generation Memory Areas

大多数对象初始都分配在 Eden 区(如果对象太大,Eden 区内存不够,那么就会直接分配在老年代)。Survivor 区包含那些至少经历过一次新生代垃圾回收的对象。在任意时刻,只有一个 survivor 区可用(在图中使用 From 标记),而另一个 survivor 区在下一次回收前都会保持空。

Garbage Collection Types

当新生代塞满了,就会启动一次 young generation collection(有时也称为 minor collection)。当老年代或者元空间塞满了,则会引发一次 full collection(有时也称为 major collection)。当发生 full collection,会对所有代都进行回收,通常先从新生代开始,之后再对老年代收集,对不同代收集时会使用不同的收集算法。

但是有时也会出现一个窘况,那就是首先回收新生代时,回收器需要将足够老的对象提升到老年代,但是这时老年代也满了,无法进行提升。在这样的情况下,就不会先运行新生代回收算法,而是对整个堆运用老年代回收算法。一个例外是使用 CMS 收集器时,回收器还是对新生代运用新生代回收算法,因为 CMS 老年代算法不能用于回收新生代。

Fast Allocation

对于带有压缩功能的回收器,通常会拥有一大块连续的内存空间,这时可以使用指针碰撞的(bump-the-pointer)方法来高效分配内存。回收器使用指针来追踪最近分配对象的结束位置,当需要分配新的内存时,首先检查剩余内存是否满足要求,如果满足,那么只需要移动指针就完成了内存分配。

对于多线程应用,需要保证分配操作是线程安全的。一种方法是使用全局锁,这种方法会降低系统性能。HotSpot 采用了另一种方法,称为 Thread-Local Allocation Buffers(TLABs)。基于这种方法,每一个线程都分配有线程独有的缓冲区(buffer,也属于代的一小部分),可以直接在这个缓冲区中使用指针碰撞的方式创建对象。只有当线程装满了自己的 TLAB,需要获得新的缓冲区时才需要同步。

Serial Collector

使用串行回收器,就是在新生代和老年代使用 stop-the-world 方式串行回收(单线程)。

Yong Generation Collection Using hte Serial Collector

下图展示了在新生代使用串行回收器回收的过程。

Serial Young Generation Collection

在 Eden 区存活的对象将被拷贝到空的 survivor 区(在图中用 To 标记),如果对象太大,To 区装不下,那么这样的对象就会直接被拷贝到老年代。在另一 survivor 区(在图中用 From 标记)存活的对象将分为两类拷贝:如果对象足够老(存活超过一定回收次数),将被拷贝到老年代;否则,对象也被拷贝到 To 区。注意:在拷贝过程中,如果 To 区装满了,那么在 Eden 和 From 区剩下的对象都将 tenured,拷贝到老年代,而不用考虑它们经历了多少次垃圾回收。拷贝完成后,那些在 Eden 或者 From 区没有被拷贝的对象成为垃圾,将被回收(在图中使用 X 标记的)。

当新生代回收完成后,Eden 和先前的 From survivor 区都变为空,而先前为空的 To survivor 区则包含存活的对象。于是,From 和 To 也相应改变,如下图所示:

Serial Young Generation Collection 2

Old Generation Collection Using the Serial Collector

使用串行收集器,老年代通过标记-清除-压缩(mark-sweep-compact)算法回收。在标记阶段,收集器要判断对象是否存活;清除阶段在老年代扫描,识别垃圾;压缩阶段进行滑动压缩(sliding compaction),将存活对象复制到老年代空间的开始部分,这样尾部就有了大片连续空闲空间,便于使用 bump-the-pointer 方法分配内存,如下图:

Compaction of the old generation

Parallel Collector

并行收集器(parallel collector),也称为 throughput collector,可以利用多个 CPU 多线程并行的进行垃圾回收。

Young Generation Collection Using the Parallel Collector

对新生代做垃圾回收时,并行收集器使用了串行收集器算法的并行版本。本质上,该回收算法还是个 stop-the-world 和 copying 收集器,但是可以使用多个 CPU 并行的回收,提高了回收效率。下图展示了用于新生代垃圾回收的串行收集器和并行收集器之间的区别:

Comparison between serial and parallel young generation collection

Old Generation Collection Using the Parallel Collector

同样的,并行收集器在老年代的算法原理也是 mark-sweep-compact,只不过算法是在并行执行。

Concurrent Mark-Sweep (CMS) Collector

CMS 收集器,也称为 low-latency collector,收集方式以并发为主,只有短暂的 STW。

Young Generation Collection Using the CMS Collector

CMS 算法不适用于新生代,所以在新生代主要使用并行收集器。

Old Generation Collection Using the CMS Collector

在老年代,CMS 收集器收集过程如下:

  1. 初始标记(initial mark),标识出能从应用代码直接到达的存活对象的初始集合。在这个阶段,应用程序会被暂停执行。
  2. 并发标记(concurrent marking),收集器标记所有能从初始集合间接到达的存活对象。因为在并发标记过程中应用程序还在运行,所以并发标记结束后,并不能保证所有存活对象都被标记。为了解决这个问题,需要引入重新标记。
  3. 重新标记(remark),在这一阶段,应用程序会被再次暂停,然后垃圾回收线程重新标记那些在上一阶段被修改过的对象。重新标记过程结束时,将保证堆中所有存活对象都被标记。
  4. 并发清除(concurrent sweep),这一阶段将进行最终的垃圾回收。

下图展示了在老年代使用串行 mark-sweep-compact 收集器和 CMS 收集器的区别:

Differences between old generation collection using the serial mark-sweep-compact collector and the CMS collection

注意,CMS 收集器是唯一一个使用 non-compacting 回收内存的收集器。即,当 CMS 收集器释放掉已死亡对象所占用的空间,它并不会将存活对象移动到老年代的一端,如下图:

CMS Sweeping

虽然这样的收集过程节省了时间,但是因为可用内存空间不再连续,收集器就不能再使用 bump-the-pointer 的方法分配内存了,只能使用 free lists。即,收集器创建一定数量的链表,将可用的内存空间连在一起。每次需要创建对象时,都需要搜索合适的链表以便找到一块足够大的内存区域。这种分配内存的方式比那些使用 bump-the-pointer 方式有更大的开销。另外,这也给新生代回收带来了额外的开销,因为在老年代出现的大部分内存分配都是新生代进行垃圾回收,将对象进行 promoted 的时候。

CMS 收集器另一个缺点是该收集器需要比其他收集器更大的堆。这是因为 CMS 收集器允许应用程序在标记过程继续执行,那么在这个过程,应用程序可以继续创建新的对象,可能导致老年代继续增长。另外,尽管收集器可以保证在标记阶段可以识别所有存活对象,但是在这个过程也会有一些对象变成垃圾,这样的对象称为浮动垃圾(floating garbage)。这些浮动垃圾只能在下一次老年代垃圾回收时才能被释放。

总的来说,与其他并行收集器相比,CMS 收集器减少了老年代暂停时间,代价是稍微增加了新生代暂停时间,减少了 throughput,并且需要更大的堆。

GC Root

不管是在新生代还是老年代回收垃圾,都需要判断对象是否存活。Hotspot 基于可达性分析判断对象是否是存活的。算法的基本思想是通过一系列称为”GC Roots”对象作为起始点,从这些节点开始遍历。遍历结束后,如果对象没有被访问过,那么就认为该对象已死亡,需要被回收。

能够作为 GC Roots 的对象有以下几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中 JNI 引用的对象

参考

  1. memory management whitepaper
  2. JEP 122: Remove the Permanent Generation
  3. 深入理解Java虚拟机