🥫🍞

Java HotSpot VM 笔记

2022-07-20

即时编译器

目前主流的两款商用 Java VM (HotSpot、OpenJ9)里,Java 程序最初都是通过解释器(Interperter)进行解释的。当 VM 发现某个方法或者代码块的运行特别频繁,就会把这些代码认定为 “热点代码”(Hot Spot Code),为了提到热点代码的执行效率,在运行时,VM 会将这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。

HotSpot 分代

针对 J2SE 5.0 HotSpot JVM:

Java HotSpot 虚拟机的内存被分为三代:young generation(年轻代)、old generation(老年代)、permanent generation(永久代)。大多数对象一开始的时候都是分配在年轻代。老年代的对象是那些已经在年轻代经历了几次回收且存货下来的对象,以及一些可以直接分配在老年代的大型对象。永久代存储描述类和方法的对象,以及类和方法本身。

年轻代包含一个叫 Eden 的区域,以及 2 个较小的 Survivor Spaces。大多数对象一开始都是被分配在 Eden。(之所以用”大多数”,因为少部分大型对象可能直接被分配在老年代)。Survivor Spaces 存放那些至少活过 1 次年轻代的回收,被给予了更多死亡的机会,但是依然存活下来,足够老,可以”晋升”到老年代。在任何给定的时刻,Survivor Spaces 中只有 1 个持有对象,另一个Survivor Space 是空的,且保留不使用,直到下一次回收,循环使用。

每次回收,将会交换幸存者空间的使用。

当年轻代满了的时候,年轻代会执行垃圾回收(有时候也叫 minor collection)。当老年代或者永久代满了,所谓的 full collection 将会执行(有时候也叫 major collection)。也就是说,所有的代都会回收垃圾。通常,年轻代首先被回收,使用特别为它设计的回收算法,因为这种算法通常是在年轻代识别垃圾最高效的算法。如果发生压缩,每个代也都是单独压缩的。

Serial Collector

Serial Collector 可以叫做串行回收器。使用串行回收器,年轻代和老年代都会在 stop-the-world 状态下,串行地进行回收工作(使用单 CPU)。也就是说,当回收工作进行时,应用执行会终止(即停止全部线程)。

使用串行回收器进行年轻代回收

下图描述了年轻代使用串行回收器回收的操作。Eden 中存活的对象将拷贝到一开始是空的幸存者空间(即To)。

除了那些太大,To 空间都放不下的对象,这样的对象会直接拷贝到老年代。

在被占用的幸存者空间(即From)中,存活下来的对象,而且相对比较年轻的(经历回收次数不多)会转移到另一个幸存者空间(To)。

相对比较老的(经历回收次数多)对象会拷贝到老年代。

另外注意:如果 To 空间满了,那些来自于 Eden 或者 From 空间的,存活着,但还没有拷贝的对象直接进入老年代,不管它们经历了几次存活。

在存活对象拷贝之后,任何还留在 Eden 或者 From 空间的对象,按定义来说,都不再存活,而且,它们也不必再检查了。

图中标记了 “X” 的对象都是垃圾对象,不过实际上,回收器也不会检查它们,或者标记它们,只是为了展示,一般直接回收掉。

在年轻代回收完毕之后,Eden 和之前被占用的幸存者空间(之前的From)都是空的,仅仅只有之前是空的幸存者空间(之前的To)包含了存活对象。这时候可以说,幸存者空间交换了角色(ToFrom 角色交换,如下图)。

Serial Old 回收器

Serial 回收器老年代版本,老年代和永久代通过 “标记-清理-压缩” 回收算法进行回收工作。在标记阶段,回收器识别哪些对象是存活的。清理阶段识别、清理老年代中垃圾。然后,回收器执行移动压缩,将存活的对象移动到老年代的一端(永久代也类似),使剩下的空闲区域形成一个连续的块位于另一端。压缩可以让以后分配到老年代和永久代的操作使用快速的 bump-the-pointer 技术。

串行回收器的使用场景

现在,串行回收器并非没有它的价值。对于运行在 Client 模式下的虚拟机,串行回收器是默认的新生代回收器。优势在于:简单高效、没有线程交互开销。

串行回收器的选择

在 J2SE 5.0 发行版,在非 server-class 的机器上,默认选择串行回收器。在其他机器,串行回收器需要使用 -XX:+UseSerialGC 命令行选项。

Parallel Collector

Parallel Collector 可以叫做并行回收器。

使用并行回收器进行年轻代回收

并行回收器使用的是串行回收器进行年轻代回收工作的算法的并行版本。它仍然是一个 stop-the-world 并进行拷贝的回收器,但是,它可以使用多 CPU,并行地执行年轻代的回收工作,减少了垃圾回收的开销,因此可以提高应用吞吐量。下图描述了串行回收器和并行回收器在年轻代工作时的区别。

使用并行回收器进行老年代回收

用并行回收器进行老年代垃圾回收工作,将会使用和串行回收器相同的串行 “标记-清理-压缩” 回收算法。

何时使用并行回收器?

运行在多处理器的机器上,并且,没有对暂停时间要求不高的应用程序将能从并行回收器获益。

因为有可能发生长时间的老年代回收,尽管比较少见。

比较合适使用并行回收器的应用,例如:批处理、账单服务、支付、科学计算等。

Parallel Compacting Collector

Parallel Compacting Collector,可以翻译为并行压缩回收器。

首先在 JDK 5 update 6 中提供, JDK 6 中实现性能显著改进。

参考文档

JDK

日志

  • JDK 6

默认情况不启用并行压缩,如果需要,添加选项 -XX:+UseParallelOldGC 到 Java 命令行。

注意:并行压缩不能与 CMS 回收器一起使用。它只能与年轻代的并行回收器一起使用。

使用 Parallel Compacting Collector 进行年轻代回收

对于 Parallel Compacting Collector 进行年轻代垃圾回收工作,会使用与 Parallel Collector 一样的算法。

使用 Parallel Compacting Collector 进行老年代回收

使用 Parallel Compacting Collector,老年代和永久代在 stop-the-world 下进行垃圾回收,通常伴随着滑动压缩。

与 Parallel 一样,都需要 stop-the-world

回收器使用三个阶段。(1) 每个代,从逻辑上被划分为固定大小的区(region)。在 marking 阶段,应用程序代码直接可达的存活对象的初始集合在垃圾回收线程被划分,然后并行地标记所有的存活对象。当识别出对象是存活时,对象所在 region 关于对象大小和位置的信息数据被更新。

(2) 汇总阶段是在 region 上操作,而不是对象。由于之前回收的压缩,通常,每个代左边的部分是密集的,包含了大多数存活对象。从这些密集的 region 中回收空间是不值得它们去压缩的。region 到那一点的左边被认为是 dense prefix,并且没有对象会移动到那些区域。那一点右边的区域会被压缩,消除所有死亡的空间。汇总阶段计算并存储下每个压缩 region 存活数据的第一个字节的新位置。注意:汇总阶段当前以串行实现,并行化是可能的,但是对于执行来说,不如标记和压缩阶段的并行化重要。

(3) 在压缩阶段,垃圾回收线程使用汇总的数据来识别需要填充的 region,并且线程可以独立把数据拷贝进 region。这样就产生了一个一端高密度的堆,另一端是一个巨大的空块。

何时使用 Parallel Compacting Collector

与并行回收器一样,并行压缩回收器对于运行在多核 CPU 的机器上的应用程序是有益的。此外,老年代回收的并行操作缩短了暂停时间,并且,对于有暂停时间约束的应用程序,并行压缩回收器比并行回收器更适合。

并行压缩回收器也许不太适合运行在大型的共享机器的应用程序,在这种情况下,单个应用程序不能长时间独自霸占多 CPU。在这样的机器上,要么考虑减少垃圾回收现成的数量(通过 -XX:ParaleelGCThreads=n 命令行选项),要么选择别的回收器。

选择并行压缩回收器

如果你想使用并行压缩回收器,你必须指定命令行选项 -XX:+UseParallelOldGC

Concurrent Mark-Sweep (CMS) Collector

并发标记清理回收器,可以简称为 CMS。

使用并发标记清理收集器进行老年代收集

使用 CMS 进行的老年代清理工作能够与应用程序并发执行。

对于 CMS 来说,1 个收集的周期从一个短暂的停止开始(叫做 initial mark,初始标记),识别出直接可达的存活对象,形成初始集合。然后,在并发标记阶段,收集器标记所有从这些集合中,间接可达的存活对象。因为在标记阶段的时候,应用程序也在运行,更新引用,所以不能保证在并发标记阶段结束的时候,所有存活的对象都能被标记到。

可能存在存活对象在并发阶段被认为是死亡对象,见对象消失。也可能存在浮动垃圾,但是不具有致命影响。

为了解决这个问题,应用程序再一次停止(叫做 remark),通过重访在并发标记阶段被修改的对象最终确定标记。因为 remark 阶段比 initial 标记工作量更大,多线程并行运行可以提高效率。

并发标记阶段必须再次停止用户线程,否则又会产生对象消失的问题。

在 remark 阶段结束的时候,所有堆中存活的对象都能保证被标记,因此,接下来的并发清理阶段会回收所有识别出来的垃圾。

由于一些任务,比如重访 remark 阶段的对象,增加了收集器的工作量,它的开销也相应增加。不过,对于大多数尝试减少停止时间的 GC 来说,这也是一种权衡。

CMS 是仅有的一个不压缩的收集器。也就是说,在释放了死亡对象的空间之后,它不会把存活的对象移动到老年代的一边。

这节约了时间,但是由于空闲空间不是连续的,收集器不再简单地使用一个指针指向下一个对象可分配的空间区域。相反,它现在需要使用空闲列表。也就是说,它会创建一些列表,连接着未分配的区域内存,每次对象需要分配时,基于内存需要,搜索适当的列表,找到足够大能够存储对象的一块区域。结果,分配进入老年代比使用简单的 bump-the-pointer 代价更大。这也会给年轻代的收集工作带来额外的开销,因为大多数老年代的分配工作都是因为对象在年轻代收集阶段晋升到老年代。

另一个 CMS 的缺陷是,需要比其他收集器更多的堆内存。考虑到应用程序可以在并发标记阶段运行,它可以继续分配内存,因此有可能继续增加老年代。另外,虽然 CMS 能够保证,识别在 GC 过程中所有存活的对象,但是一些对象可能在并发标记阶段变成了垃圾,它们得不到清理直到下一次老年代收集工作。这种垃圾叫做 floating garbage(浮动垃圾)。

与其他 GC 不同的是,CMS 不会在老年代满了的时候才开始进行老年代回收工作。相反,它会早一点开始回收工作,以便在老年代满之前完成清理。

因为 CMS 收集期间,老年代仍然可能增长。如果不早一点,CMS 可能会回退到使用并行和串行收集器,进行更费时的 stop-the-world 标记-清理-压缩算法。

为了避免上述可能,CMS 收集器会根据之前的回收时间和老年代占满的时间进行统计,得到合适的时间来提前开始。如果老年代的占用,超过了一个叫 initiating occupancy 的东西,CMS 回收器也会开始回收。

initiating occupancy 的值可以通过命令行选项 -XX:CMSInitiatingOccupancyFraction=n,其中 n 是一个老年代大小的百分数值。默认是 68。

增量模式

Incremental Mode,增量模式、增量 GC。通过慢慢地进行 GC 在缩短 mutator 最大暂停时间的一种手段。

CMS 回收器使用一种==并发阶段==增量进行的模式。这个模式通过阶段性的停止并发阶段来让出 CPU 给应用程序,以减少长并发阶段的影响。回收器的工作被划分成一个一个小块的时间。

当运行在一些处理器比较少的机器上时(比如只有 1 个或 2 个),应用程序又需要并发回收器提供低暂停时间,那么这个特性还是有用的。

什么时候使用 CMS 回收器?

如果 (1) 你的应用程序需要更短的垃圾回收暂停时间,(2) 而且,可以承担得起垃圾回收器与应用程序一起共享处理器资源,那么你可以选择 CMS 回收器。

由于并发性,CMS 回收器在回收周期内,会占用应用程序一定的 CPU 周期

通常,应用程序有相对大一点的老年代,并且运行在两个以上的处理器机器上时,更有利于该回收器。例如,Web 服务器。

对于那些需要低暂停时间的应用程序来说,可以考虑 CMS 回收器。对于老年代不太大,又运行在单单处理器上的交互式应用程序来说,CMS 回收器可能也有不错的效果。

Tags: JVM
使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏

扫描二维码,分享此文章