0%

本文翻译自:Garbage-First Garbage Collector Tuning

关于G1的一般性建议

一般情况下,建议使用G1的默认设置,如果有其他需求,再设置期望停顿时间,并且通过-Xmx设置最大Java堆。

与其它回收器相比,G1默认设置下的平衡策略有所不同。在G1的默认配置中,它的目标既不是最大化吞吐量,也不是最小化停顿时间,而是在高吞吐量的同时提供相对较低且一贯的停顿时间。然而,G1用于增量回收的机制和对于停顿时间的控制,都在应用程序的线程和空间回收的效率方面都产生了一些开销。

如果更倾向于高吞吐量,请使用 -XX:MaxGCPauseMillis 放宽期望停顿时间或提供更大的堆。而如果主诉求是低延迟,则调整期望停顿时间。要避免使用 -Xmn-XX:NewRatio 等选项将年轻代、限定为固定值,因为(动态调整)年轻代大小是 G1满足停顿时间的主要手段。而将年轻代年轻代固定实际上会禁用停顿时间的控制。

从其他回收器迁移到 G1

通常情况下,当从其他垃圾回收器(尤其是并发标记清除(CMS)回收器)迁移到 G1 时,首先要移除所有影响垃圾回收的参数,仅需要设置期望停顿时间,作为总堆大小的-Xmx, 以及可选参数 -Xms

在特定方式下,许多参数在其他回收器中很有用,但是到了G1中,有的根本没有效果,有的甚至会降低吞吐量和或降低G1实现预期停顿的概率。例如设置年轻代大小会完全禁用 G1动态调整年轻代大小,而这是G1实现停顿时间特性的重要手段。

提高 G1 性能

G1 旨在提供良好的整体性能,而无需设定其他参数。但是,在某些情况下,G1的默认启发式配置或默认配置无法提供最佳性能。本节提供了在这些情况下进行诊断和改进的操作指南。本指南仅描述 G1 在给定应用下,给定指标中提高垃圾回收器性能的可能性。根据具体的场景,应用级的优化可能比尝试调优 VM更有效。例如可以可以减少短命对象的使用来规避某些异常的场景。

为了方便诊断,G1 提供了全面的日志记录。一个好的开始是使用 -Xlog:gc*=debug 参数,然后在必要时优化输出。该日志提供了垃圾回收活动的暂停期和非暂停期的详细概述。也包括垃圾回收的类型以及每个阶段耗时的细分统计。

以下小节会探讨一些常见的性能问题。

观察完整的垃圾回收

全堆垃圾回收(Full GC)通常非常耗时。老年代堆占用过高导致的Full GC可以通过在日志中查找Pause Full (Allocation Failure)关键字来确定。Full GC 之前通常会有转移失败导致的垃圾回收,这次垃圾回收包含to-space exhausted 标签(tags)。

发生 Full GC 的原因是应用程序分配了太多对象,并且这些对象不能被足够快地回收。这种情况下,并发标记不能及时完成以启动Space-reclamation阶段。如果此时分配了许多大对象,那么导致Full GC 的可能性可能会更加复杂。鉴于这些对象按照 G1 的方式分配,它们可能会占用比预期更多的内存。

确保并发标记按时完成是避免Full GC的目标。可以通过降低老年代的分配速率来实现,或者给并发标记更多的时间。

G1 提供了几个参数来更好地处理这种情况:

  • 可以使用 gc+heap=info 日志记录确定 Java 堆上大对象占用的Region数量。日志Humongous regions: X->YY 为提供大对象占用Region的数量。如果这个值高于老年代Region的数量,最佳选择是尝试减少该大对象的分配。(也)可以尝试使用 -XX:G1HeapRegionSize 参数增加Region的大小。当前堆的Region大小显示在日志的开头。
  • 增加 Java 堆的大小。这通常(也)会增加(并发)标记完成的时间。
  • 显式设置 -XX:ConcGCThreads 来增加并发标记线程的数量。
  • 强制 G1 提前开始并发标记。 G1 根据前面应用的行为自动推断初始堆占用百分比(Initiating Heap Occupancy Percent (IHOP))阈值。如果应用程序行为发生变化,那这些预测就可能是错误的。这是有两个选择: 通过修改 -XX:G1ReservePercent 增加自适应 IHOP 计算时使用的缓冲区,从而降低决定开启Space-reclamation的目标占用率;使用 -XX:-G1UseAdaptiveIHOP-XX:InitiatingHeapOccupancyPercent 手动设置来禁用 IHOP 的自适应计算。

除了分配失败(Allocation Failure )导致Full GC,通常也会有应用程序或某些外部工具导致Full GC。如果是由于调用了 System.gc(),并且无法修改应用的代码,可以使用 -XX:+ExplicitGCInvokesConcurrent 缓解 Full GC 的影响,或者通过设置 -XX:+DisableExplicitGC 让 VM 完全无视System.gc()。外部工具可能仍会强制Full GC,这时只能寄希望于别主动触发了。

大对象碎片

由于需要为大对象找到一组连续的Region的原因,因此可能会在 Java 堆内存耗尽之前发生 Full GC。在这种情况下,候选方案是通过参数:-XX:G1HeapRegionSize 增加堆Region大小以减少大对象的数量,或者增加堆的大小。在极端情况下,可能没有足够的连续空间可供 G1 分配对象。如果 Full GC 也无法回收出足够的连续空间,这将导致 VM 退出。因此,除了如前所述减少大对象的分配或增加堆内存外,没有其他选择。

停顿调优

本节讨论在常见停顿问题(即停顿时间太长)的情况下改进 G1 行为的建议。

异常的SystemReal 占用

对于每次垃圾回收暂停,gc+cpu=info 日志会输出一行,其中包含有关暂停时间耗时的OS信息。例如: User=0.19s Sys=0.00s Real=0.01s

User时间是在 VM 代码中的耗时,Sys时间是在OS中的耗时,而Real为垃圾回收暂停的绝对时间量。如果Sys时间相对较高,那么大多数情况下是环境造成的。

已知常见的高Sys时间包括:

  • VM 从操作系统内存分配或归还内存可能会导致不必要的延迟。通过使用选项 -Xms-Xmx 将最小和最大堆设置为相同的值来避免该延迟,并使用 -XX:+AlwaysPreTouch 将VM预先分配所有内存的工作移至启动阶段。
  • 特别是在 Linux 中,通过透明大页(*Transparent Huge Pages (THP)*)功能将小页面合并为大页面往往会随机停滞进程,这不仅限定在GC暂停期间。由于 VM 分配和维护了大量内存,因此 VM 被选为长时间停滞的进程的风险比通常高。关于如何禁用透明大页的信息,请参阅操作系统相关文档。
  • 由于日志的后台任务间歇性地占用硬盘的所有 I/O 带宽,写入日志可能会导致停顿(stall)。可以考虑在独立的磁盘或其他存储设备上记录日志,例如内存支持(memory-backed)的文件系统来避免这种情况。

另一种需要注意的情况是Real比其它时间的总和大得多,这说明 VM 在可能过载的机器上运行,并且没有获得足够的 CPU 时间。

对象引用处理耗时过长

关于处理对象应用耗时的信息显示在Reference Processing阶段。在Reference Processing阶段,G1 根据特定类型引用对象(Reference Object)的要求更新其引用的对象。默认情况下,G1 尝试使用如下启发式的方式将Reference Processing阶段并行化为多个子阶段:在-XX:ParallelGCThreads范围内,对于每 -XX:ReferencesPerThread 个引用对象启动一个线程。可以通过将 -XX:ReferencesPerThread 设置为 0 禁用该启发式设置,而启动所有可用线程,或者通过 -XX:-ParallelRefProcEnabled 完全禁用并行化。

Young-Only阶段中的Young GC耗时过长

一般来说,普通的Young GC的耗时大致与年轻代的大小成正比。或者更确切地说,是和CSet中需要复制的存活对象的数量成正比。如果 Evacuate Collection Set 阶段耗时过长,特别是 Object Copy 子阶段,则可以降低 -XX:G1NewSizePercent。这可以减少年轻代的最小值,允许更短的停顿。

如果应用的执行突然剧烈波动 (尤其是当大量的对象活过了一次GC) ,则可能导致年轻代调整相关的另一个问题:可能会导致垃圾收集停顿时间出现毛刺。通过降低-XX:G1MaxNewSizePercent 来减小年轻代的最大值可能会有所帮助。这会限制年轻代的最大值,因此限制了停顿期间需要处理的对象数量。

混合回收耗时过长

混合回收用于回收老年代的空间。混合回收的集合(collection set)包含年轻代和老年代Region。可以通过启用 gc+ergo+cset=trace 日志输出来获取年轻代或老年代Region对于停顿时间的占用信息。可以分别查看年轻代和老年代的 predicted young region时间和predicted old region`时间。

如果predicted young region时间太长,查看“Young-Only阶段中的Young GC耗时过长”一节中的参数配置。否则,那就要减少老年代Region对于停顿时间的占用,G1提供了三个参数:

  • 增加 -XX:G1MixedGCCountTarget 将老年代Region的回收分散到更多次的垃圾回收(译注:指的是混合回收)中。
  • 通过设置 -XX:G1MixedGCLiveThresholdPercent 避免那些需要大量时间进行回收的Region加入到候选回收Region中。在大多数情况下,高使用率的Region一般需要花费更多的回收时间。
  • 提前停止老年代的空间回收,这样 G1 就不会回收那么多高使用率的Region。在这种情况下,可以增加 -XX:G1HeapWastePercent

请注意,最后两个参数减少了当前Space-reclamation阶段中候选回收Region的数量。这可能意味着 G1 可能无法在老年代回收足够的空间来持续运行(for sustained operation)。但是后续的space-reclamation阶段可能能够对它们进行垃圾回收。

更新RS和扫描RS时间过长

为了使 G1 能够转移某个老年代Region,G1需要跟踪跨Region间的引用关系,即从一个Region指向另一个Region的引用。指向本Region的所有引用集合称为该Region的记忆集(remembered set)。当移动Region的对象(Content)时必须更新维护记忆集,这个操作大多是并发的。出于性能考虑,当应用在跨Region的两个对象之间设置引用关系时,G1 并不会立即更新记忆集。这项操作会被延迟和合批处理以提高效率。

G1 需要完整的记忆集才能进行垃圾回收,因此Update RS阶段会处理未完成的记忆集更新请求。Scan RS阶段搜索记忆集的对象引用,移动Region对象(Content),然后将这些对象的引用更新到新的位置。根据应用的不同,这两个阶段可能需要大量时间。

使用选项 -XX:G1HeapRegionSize 调整堆Region的大小会影响跨Region引用的数量以及记忆集的大小。处理Region的记忆集是垃圾回收的重要部分,因此这对最大期望停顿时间的实现有直接的影响。较大的Region往往具有较少的跨Region引用,因此处理它们所花费的相对工作量会减少。与之相反的是,较大的Region可能意味着每个Region有更多的存活对象需要转移,从而增加了其他阶段的时间。

G1 会尝试调度记忆集更新的并发处理,使得Update RS阶段的耗时和最大期望停顿时间的占比大致满足: -XX:G1RSetUpdatingPauseTimePercent 。可以减小这个值,这样G1 通常会更加并发地执行记忆集的更新工作。
当应用程序分配较大对象(large objects)时,会产生虚假的Update RS高耗时,这往往是由于如下优化工作导致的:试图将Update RS合批处理以减少并发的操作。如果应用程序的合批处理发生在垃圾回收之前,那么G1要在停顿时间内处理完所有的Update RS操作。可以使用-XX:-ReduceInitialCardMarks禁用该行为以及该行为发生的可能性。

为了让RS的大小保持在一个较低的水平,G1需要执行压缩操作,这个压缩量也决定了扫描RS的耗时。RS在内存中被压缩得越紧凑,那就需要花费越多的时间来检索内存中的值。G1 会自动执行这种被称为记忆集粗化(remembered set coarsening)的压缩操作,并根据Region当前记忆集的大小进行更新。尤其是在最高压缩级别下,检索实际数据可能非常缓慢。通过参数:-XX:G1SummarizeRSetStatsPeriod并结合gc+remset=trace级别的日志可以查看这种记忆集粗化行为是否产生。如果答案为是,参数:-XX:G1RSetRegionEntries可以显著降低这种行为。要避免在生产环境中开启这种详细的记忆集日志,因为收集这些数据可能需要大量时间。

吞吐量调优

G1 的默认策略是试图在吞吐量和延迟之间保持平衡;但是在某些情况下,我们也可能需要更高的吞吐量。除了如前几节所述减少整体期望停顿时间外,也可以通过减少停顿的频率达成这项目标。其实主要思想就是通过 -XX:MaxGCPauseMillis 来增加最大期望停顿时间。启发式的分代大小调整机制会自动调整年轻代的大小,这直接决定了垃圾回收停顿的频率。如果这没有达到预期吞吐量的目标,特别是在Space Reclamation阶段,通过-XX:G1NewSizePercent 增加最小年轻代大小将迫使 G1 增大吞吐量。

在某些情况下,-XX:G1MaxNewSizePercent (最大年轻代大小) 会限制年轻代容量的上限,进而限制了G1的吞吐量。可以通过查看gc+heap=info的Region输出进行诊断。这种情况下,Eden 区和 Survivor 区的百分比之和( combined percentage)接近所有Region总数的百分之 -XX:G1MaxNewSizePercent。此时就可以考虑增加-XX:G1MaxNewSizePercent

增加吞吐量的另一个选择是尝试减少并发工作量,特别是并发记忆集更新通常需要大量 CPU 资源。增加-XX:G1RSetUpdatingPauseTimePercent可以将这些耗时操作从并发操作期间往GC停顿期间转移。在最激进的(worst )情况下,可以通过-XX:-G1UseAdaptiveConcRefinement-XX:G1ConcRefinementGreenZone=2G-XX:G1ConcRefinementThreads=0禁用记忆集更新的并发操作。这些操作除了禁用该并发机制,还会将所有的集合更新工作移至下一次垃圾收集暂停中。

通过 -XX:+UseLargePages 启用大页面也可以提高吞吐量。有关如何设置大页面的信息,请参阅操作系统文档。

可以将参数-Xms-Xmx设置为相同的值来禁用堆大小动态调整。您可以使用 -XX:+AlwaysPreTouch 将操作系统分配物理内存到虚拟内存的操作移动到 VM 启动时。为了使暂停时间更加一致(consistent),这两项措施都是特别可取的。

堆大小调优

与其他收集器一样,G1 旨在管理(size)堆内存,使得垃圾回收所停顿的时间低于 -XX:GCTimeRatio 选项指定的比率。调整此选项以使 G1 满足您的要求。

可调整的默认值

本节介绍参数默认值及附加信息。

Table 10-1 Tunable Defaults G1 GC
参数及默认值 描述
-XX:+G1UseAdaptiveConcRefinement

-XX:G1ConcRefinementGreenZone=<ergo>

-XX:G1ConcRefinementYellowZone=<ergo>

-XX:G1ConcRefinementRedZone=<ergo>

-XX:G1ConcRefinementThreads=<ergo>

记忆集并发更新(refinement)使用这些参数控制并发refinement线程的工作分配。 G1 会为这些参数进行自动推断,以使得在垃圾回收暂停中花费 -XX:G1RSetUpdatingPauseTimePercent 时间处理剩余的工作,并根据需要自适应调整。请谨慎更改,因为这可能会导致长时间的停顿。
-XX:+ReduceInitialCardMarks 将初始对象分配的记忆集并发更新(refinement)进行合批处理。
-XX:ReferencesPerThread=1000

-XX:+ParallelRefProcEnabled

-XX:ReferencesPerThread 决定并行化程度:每N个对象引用分配1个线程,参与到Reference Processing的子阶段(线程数受-XX:ParallelGCThreads限制)。设置为 0 表示将始终使用 -XX:ParallelGCThreads 所指示的最大线程数。
-XX:+ParallelRefProcEnabled决定了 java.lang.Ref.* 实例的处理是否应该由多个线程并行完成。
-XX:G1RSetUpdatingPauseTimePercent=10 决定了G1在更新 RS 阶段的耗时占用总垃圾收集耗时的百分比。 G1 使用该值控制并发记忆集更新的数量。
-XX:G1SummarizeRSetStatsPeriod=0 这表示每隔多少个回收周期G1生成记忆集摘要报告。将此设置为0表示禁用。生成记忆集摘要报告是一项代价高昂的操作,因此仅应在必要时使用并且具有相当高的价值时,使用 gc+remset=trace 进行输出。
-XX:GCTimeRatio=12 该值代表消耗在垃圾回收上的目标时间比率公式的除数。该时间比率公式为: 1 / (1 + GCTimeRatio)。此默认值表示大约有 8% 的时间用于垃圾收集。

注意: <ergo>表示实际值根据自动推断设定。

本文翻译自:Garbage-First Garbage Collector

G1垃圾回收器简介(Introduction to Garbage-First Garbage Collector)

G1(Garbage-First) 垃圾回收器针对于具有大内存的多CPU计算机。它的目标是在较高概率下满足垃圾回收的暂停时间,同时在几乎不需要配置的情况下实现垃圾回收的高吞吐量。 G1 旨在如下应用和环境下提供延迟(latency)和吞吐量(throughput)之间的最佳平衡:

  • 堆内存高达 10 GB 或更大,同时超过 50% 的 Java 堆空间被占用。
  • 随着时间的推移,对象分配和晋升的速率可能会发生显著变化。
  • 堆中存在大量碎片。
  • 可预测的暂停时间目标不超过几百毫秒,避免长时间的垃圾收集暂停。

G1取代了并发标记清除(CMS)收集器。它也是默认收集器(译注:本文隶属于JDK 11)。

G1收集器实现了高性能,并试图通过以下几节中描述的几种方式实现暂停时间目标。

开启G1

G1垃圾回收器是默认的回收器,因此通常不必执行任何操作。也可以在命令行中显式启用它:-XX:+UseG1GC

基本概念

G1是一个分代的(generational)、增量的(incremental)、并行的(parallel)、大部分时间并发的(mostly concurrent)、STW(stop-the-world)和转移(evacuating )的垃圾收集器,它监视每个STW时的暂停时间。与其他回收器类似,G1将堆内存拆分为(虚拟的)年轻代和老年代。内存回收(Space-reclamation)工作主要集中在年轻代(具有较高的效率),偶尔会伴随着老年代。

G1的某些操作总是在 STW暂停时执行以提高吞吐量。而其他需要更多STW暂停时间的操作,诸如全局标记等堆上操作,则与应用程序并行(parallel)且并发(concurrently)执行。为了降低内存回收的 STW 停顿时间,G1会增量的、逐步的且并行的进行内存回收。 G1 通过跟踪应用程序的历史行为和垃圾收集暂停的信息构建了一个垃圾回收成本模型,从而实现了(STW的)可预测性。G1使用模型的信息来确定在某次暂停时的工作量。例如,G1会回收性价比最高的区域(即大部分被垃圾填满的区域,这也是垃圾优先的由来)。

G1主要通过转移回收内存空间:待回收内存区域中存活对象被复制到新的内存区域,在此过程中还会进行内存压缩(compacting )。当转移完成后,应用程序将可以在存活对象之前占用的内存上重新分配对象。

G1并不是实时的垃圾回收器(译注:然而G1属于软实时的垃圾回收器,可见:[Garbage First Garbage Collector Tuning](Garbage First Garbage Collector Tuning (oracle.com)) 或中村成洋《深入Java虚拟机 JVM G1GC的算法与实现》)。它试图在较长的时间内以较高可能性满足用户给定的暂停时间,但是G1并不能绝对保证STW暂停时间都在给定的暂停时间目标内。

堆内存分布

G1 将堆内存划分为一系列大小相等的区域(Region),每个区域都是连续的内存区域,如图 9-1 所示。区域是(G1中)内存分配和内存回收的单位。在任何给定时间,任意区域中都可以是空闲的(浅灰色),或者指定为特定分代:年轻代或老年代。当有内存分配请求时,内存管理器会拿出空闲的区域。内存管理器将空闲区域分配为分代内存(译注:指的是伊甸园分代),然后将它们作为空闲的内存空间返回给应用程序,后者就可以在其中分配内存。

图 9-1 G1垃圾回收器的堆内存分布

该网格图由 10×10 的单元格组成。大多数单元格为灰色。有19个单元格被涂成深蓝色(译注:从上往下,每行数量分别为:5 + 1 + 4 + 1 + 3 + 5 = 19)。这些深蓝色的单元格随机分布在上面6行中。其中有2个深蓝单元格包含红色块。有2个大单元格被涂成深蓝色并标记为“H”。有8个单元格为浅蓝色并包含红色块。其中两个被标记为“S”。这些带有红色块的浅蓝色单元格随机分布,其中大部分位于网格的上半部分。

年轻代包含伊甸园区(红色)和幸存者区(红色带“S”标记)。这些区域与其它回收器相应的连续空间具有相同的功能。不同之处在于:在G1中,这些区域通常不是连续分布在内存中。老年代区域(深蓝色1)构成了老年代。某些老年代区域可能巨大的(深蓝色2,带“H”),因为里面的对象需要多个单区域才能容纳。

原文使用的是light color,但是根据上图发现并非浅蓝色而是深蓝色,推测是原文笔误

应用程序总是在年轻代也即伊甸园区中分配内存。但是有一个例外:巨大对象直接老年代中。

G1 的垃圾回收暂停可以回收整个年轻代,还可以捎带这一些老年代区进行回收。在暂停期间,G1 将回收集合中的对象复制到其他一个或多个区。对象被复制的目标区域取决于该对象的源区域:所有的年轻代被复制到幸存者区或老年代区,老年代中的对象根据分代计数被复制到其他不同的老年代区中。

垃圾回收循环周期

从一个高层的角度上来看,G1 回收器在两个阶段之间循环交替。 Young-only阶段(The young-only phase)中的垃圾回收会逐渐在老年代中填充可用内存。Space-reclamation阶段(The space-reclamation phase) 除了回收年轻代外,还会增量的回收老年代中的空间。之后循环周期再次以纯年轻阶段开始。

图 9-2 给出了关于这个循环周期的概貌,并举例说明了可能发生的垃圾收集暂停的顺序:

图 9-2 垃圾回收周期概貌

该图显示了 G1 循环周期以及期间会发生的暂停。每个实心圆圈代表一个垃圾回收暂停:蓝色代表Young GC暂停,橙色代表标记暂停,红色代表Mix GC暂停。所有暂停被“穿”在两个箭头上并组成一个圆:一半代表在Young-only阶段发生的暂停,另一个半代表混合收集阶段。 Young-only 阶段从一些由蓝色小圆圈表示的 Young GC开始。当老年代的对象占用达到InitiatingHeapOccupancyPercent定义的阈值后,下一次垃圾回收暂停将是初始标记垃圾回收暂停,也即较大的蓝色圆圈。除了和其他Young GC暂停相同的工作外,它还做一些并发标记的准备工作。

在 G1进行并发标记的同时,可能伴随着其他Young GC暂停,直到 重新标记(Remark )暂停(第一个大橙色圆圈),G1完成并发标记。在清理(CleanUp)暂停之前,也可能会发生Young GC垃圾回收。在清理暂停之后,将有一个最终的Young GC完成Young-only阶段。在Space-reclamation阶段,将发生一系列由用红色实心圆圈表示的混合收集。由于 G1 努力使空间回收尽可能高效,通常混合垃圾收集暂停比 Young-only 阶段的Young GC暂停更少。

以下详细描述了 G1 垃圾收集周期的阶段、它们的暂停和阶段之间的转换:

  1. Young-only 阶段:这个阶段从一些普通的年轻代GC开始,这些GC将存活对象晋升到老年代。当老年代占用率达到某个阈值,即 Initiating Heap Occupancy 阈值时,Young-only 阶段和Space-reclamation阶段之间的转换就开始了。这时,G1 会安排一个包含并发标记开始Young GC,而不是再是正常的Young GC。

    • 并发开始(Concurrent Start):这种类型的GC除了执行正常的Young GC外,还会启动(并发)标记。并发标记确定了当前老年代中所有的可达的(存活的)对象,这些对象会在接下来的Space-reclamation阶段中得以生存。在并发标记尚未完成之前,可能会碰上普通的Young GC。并发标记结束前会有两个特殊的暂停:重新标记和清理。
    • 重新标记(Remark):这次暂停会完成并发标记,全局引用处理和类卸载,回收完全空的区域并且清理内部数据结构。在重新标记和清理之间,G1会统计信息,以便稍后能够回收选定老年代中的可用空间。
    • 清理:这次暂停决定了Space-reclamation阶段是否会紧随其后。如果答案为是,则 Young-only 阶段以一个Prepare Mixed Young GC结束。
  2. Space-reclamation阶段:该阶段由多个混合回收(Mixed collections)组成,除了年轻代外,还会转移某些老年代中的存活对象。当 G1 认为继续转移的老年代并不会产生足够的可用空间,Space-reclamation阶段就结束了。

    在Space-reclamation之后,循环周期以另一个 Young-only 阶段重新开始。作为兜底方案,如果应用程序在G1手机存活信息时消耗光了内存,G1 会像其他回收器一样就地执行STW的全堆GC(Full GC)。

G1内部细节

本节介绍垃圾优先 (G1) 垃圾回收器的一些重要细节。

确定初始堆占用

初始堆占用率(Initiating Heap Occupancy Percent(IHOP)) 是触发初始标记( Initial Mark )收集的阈值,它被定义为老年代使用量占整堆大小的百分比(译注:在JDK 8b12之前,该值为整堆使用量占整堆大小的百分百,详情参考:关于G1参数InitiatingHeapOccupancyPercent的正确认识

默认情况下,G1 通过观察统计并发标记的耗时,以及并发标记期间老年代的内存分配量自动确定最佳 IHOP。该特性被称为自适应 IHOPAdaptive IHOP)。如果该特性处于激活状态,当观察统计的数据不足以预测初始堆占用时,那么选项:-XX:InitiatingHeapOccupancyPercent决定了当下老年代初始百分百。可以使用选项-XX:-G1UseAdaptiveIHOP 关闭 G1 的自适应IHOP行为。那么,-XX:InitiatingHeapOccupancyPercent`的值就始终为初始堆占用的阈值。

在内部实现上,自适应 IHOP 会尝试设置初始堆占用率。当老年代占用 = 老年代最大占用 - 额外缓冲区(由-XX:G1HeapReservePercent定义)时,Space-reclamation阶段的第一次混合垃圾收集开始执行。

并发标记

G1 的并发标记使用一种称为初始快照( Snapshot-At-The-Beginning(SATB))的算法。它在初始标记暂停(Initial Mark pause)时给堆做一个虚拟快照:所有在标记开始时处于存活的对象,在后续的标记时都被认定为存活状态。这意味着在标记期间变成垃圾(不能通过根可达访问)的对象也仍然被认为是存活的,便于(后续)space-reclamation(也有一些例外)。与其它回收器相比,这可能会导致某些对象被错误地标记保存(译注:也即浮动垃圾)。而对于重新标记(Remark pause)期间的暂停,SATB提供了更低延迟的可能性。在标记期间由于“保守”而被保存的浮动对象将在下一次标记期间被回收。有关并发标记的更多信息,请参阅:G1垃圾回收器调优

内存紧张的情况

当应用存活对象占用大量内存以至于转移操作没有足够空间复制时,就会导致转移失败(evacuation failure )。G1为了完成本次垃圾回收,会保留已经移动的对象在新位置上不动,而不再拷贝和移动剩下的对象,仅调整对象之间的引用。转移失败可能会产生一些额外的开销,但通常与其他Young GC一样快。当含有转移失败的GC执行完毕后,G1 和正常情况一样恢复应用程序,不做额外处理。 G1会假设转移失败发生在接近垃圾回收结束时。也就是说,大多数对象已经被移动,并且有足够的空间继续运行应用程序,直到并发标记完成且Space-reclamation阶段开始。

如果这个假设没有成立,G1最终会发起一次Full GC。这种GC会对整个堆做压缩,整个过程会非常慢。

有关内存分配失败或 Full GC 前的信号的更多信息,请参阅 G1垃圾回收器调优

大对象

所谓大对象,就是那些大于等于1/2个Region大小的对象。如果没有使用 -XX:G1HeapRegionSize 选项进行设置,Region的大小按照G1 GC中的自动推断配置一节中的说明自动设定(determined ergonomically)。

这些大对象有时会以特殊方式处理:

  • 每个大对象都被(直接)分配在老年代中的若干连续Region上。大对象的初始位置始终位于第一个Region上,最后一个Region中的剩余空间都不再分配对象,直到整个大对象被回收。
  • 一般情况下,大对象只在清理暂停(Cleanup pause)结束时或者在Full GC期间回收。但是,对于原始类型数组(例如 bool、各种整数和浮点值)的大对象有一个特殊规定:在任何暂停期间,当大对象没有被大量引用,G1 会“投机性的”地尝试回收。这个操作默认是开启的,但可以通过 -XX:G1EagerReclaimHumongousObjects 将其禁用。
  • 大对象的分配可能会导致垃圾回收暂停过早发生。 G1 会在每次分配大对象时检查初始堆占用阈值( Initiating Heap Occupancy threshold),如果当前占用超过该阈值,则会强制开启包含初始标记(initial mark)的Young GC。
  • 大对象一直不会移动,即使在 Full GC 时也是一样。这可能会导致Full GC提前触发,也会导致空闲内存还剩很多,但还是会出现内存不足的情况。

Young-Only阶段

在Young-only阶段,待回收的Region集合(collection set,简称CSet)仅由年轻代Region组成。 G1 总是在普通的Young GC结束时为后续应用程序的运行调整年轻代的大小。这样,G1 可以根据实际停顿的长期观察,满足 -XX:MaxGCPauseTimeMillis-XX:PauseTimeIntervalMillis 设定的暂停目标。它会衡量转移年轻代Region需要消耗多长时间。包括在回收过程中必须复制多少对象以及这些对象之间的相互联系的信息。

如果没有其他约束,则 G1 会将年轻代Region调整在 -XX:G1NewSizePercent-XX:G1MaxNewSizePercent 之间以满足停顿时间。关于有关如何解决长时间停顿的更多信息,参阅 G1垃圾回收器调优

Space-Reclamation 阶段

在Space-Reclamation阶段,G1 尝试在每次垃圾回收中最大化地选择老年代的Region。年轻代则被设置为被允许条件下的最小值,通常由 -XX:G1NewSizePercent 确定。任何老年代Region都会被添加(到CSet),直到 G1 任务再添加将超过暂停目标。在特定的暂停期间,G1按回收效率、最高优先顺序和剩余可用停顿时间的顺序添加老年代Region,获得最终的CSet。

每次垃圾回收中老年代Region的数量在下限为:候选待回收老年代Region数量 / -XX:G1MixedGCCountTarget( Space-reclamation phase长度)。在Space-Reclamation阶段开始时,存活对象占有率低于 -XX:G1MixedGCLiveThresholdPercent 的老年代Region为候选待回收老年代Region。

当候选待回收老年代Region中可回收的剩余空间量小于 -XX:G1HeapWastePercent 设置的百分比时,Space-Reclamation阶段结束。

有关 G1中 使用多少个老年代Region以及如何避免长时间混合GC的更多信息,参阅 G1垃圾回收器调优

G1 GC中的自动推断配置

本主题概述了特定于 G1 的最重要的默认配置及其默认值。它们粗略地概述了在没有附加选项的情况下,使用 G1 的预期行为和资源使用情况。

表 9-1 G1 Gc中的自动推断配置
选项和默认值 描述
-XX:MaxGCPauseMillis=200 最大暂停时间。
-XX:GCPauseTimeInterval=<ergo> 最大暂停时间的间隔。默认情况下,G1 不设置任何值,允许 G1 在极端情况下连续执行垃圾回收。
-XX:ParallelGCThreads=<ergo> 垃圾回收暂停期间用于并行工作的最大线程数。这是从运行 VM 的计算机的可用线程数得出的,方法如下:如果进程可用的 CPU 线程数小于或等于 8,则使用CPU线程数。否则,计算公式为:**8 + ((N - 8) * 5/8)**。
在每次暂停开始时,使用的最大线程数进一步受到最大总堆大小的限制:G1 使用的线程数不会超过每-XX:HeapSizePerGCThread 容量一个线程的数量。
-XX:ConcGCThreads=<ergo> 用于并发工作的最大线程数。默认情况下,该值为 -XX:ParallelGCThreads / 4。
-XX:+G1UseAdaptiveIHOP

-XX:InitiatingHeapOccupancyPercent=45

控制初始堆占用率的默认值,标志着自适应IHOP已打开,并且在前几个收集周期中,G1 将使用 45% 的老年代占用率作为并发标记开始阈值。
-XX:G1HeapRegionSize=<ergo> Region大小基于初始和最大堆的大小。堆包含大致2048个Region。Region的大小可以从1到32 MB不等,并且必须是2的幂。
-XX:G1NewSizePercent=5

-XX:G1MaxNewSizePercent=60

年轻代的总大小,按照当前Java 堆的使用百分比,在这两个值之间变化。
-XX:G1HeapWastePercent=5 CSet中允许的未回收空间百分比。如果CSet的可用空间低于此值,则 G1 停止Space-reclamation阶段。
-XX:G1MixedGCCountTarget=8 Space-reclamation 阶段中触发回收的次数。
-XX:G1MixedGCLiveThresholdPercent=85 在 Space-reclamation阶段中,存活对象占用率高于此百分比的老年代Region不会加入到CSet中。

注意:<ergo> 表示实际值根据自动推断设定。

与其他回收器的比较

这是G1和其他回收器之间主要区别的总结:

  • Parallel GC只能作为一个整体来压缩和回收老年代的空间。 G1 将这项工作逐步分布在多个更短的回收中。这大大缩短了暂停时间,但可能会降低吞吐量。
  • 与 CMS 类似,G1 并发执行部分老年代空间回收。但是,CMS 无法对老年代堆进行碎片整理,最终会导致长时间的 Full GC。
  • G1 可能表现出比其他回收器更高的性能开销,由于其并发特性也会影响吞吐量。

基于G1的工作原理,它具有一些独特的机制来提高垃圾收集效率:

  • G1 可以在任何回收期间可以回收那些完全空的,大区域的老年代。这样可以避免许多其他不必要的垃圾回收,而无需花费太多精力即可释放大量空间。
  • G1 可以选择同时尝试对 Java 堆上的字符串进行去重回收。

从老年代回收空的、大对象默认总是启用的。可以使用选项 -XX:-G1EagerReclaimHumongousObjects 禁用此功能。默认情况下去重回收字符串是关闭的。可以使用选项 -XX:+G1EnableStringDeduplication 启用它。

spring的启动流程如下所示:

对于该流程图,解释如下:

  • 1-SpringApplication(ResourceLoader, Class<?>[])
  • 2-读取classpath下所有jar包内META-INF/spring.factories文件
  • 3-实例化:Bootstrapper、ApplicationContextInitializer、ApplicationListener
  • 5-这一步的最佳实践是:调用入参applicationContext的addBeanFactoryPostProcessor函数,也即注册自定义的BeanFactoryPostProcessor
  • 6-这一步中,会遍历AbstractApplicationContext内的列表:beanFactoryPostProcessors,并且当且仅当某一个BeanFactoryPostProcessor同时还是BeanDefinitionRegistryPostProcessor时,才会执行BeanDefinitionRegistryPostProcessor#postProcessBeanDefinitionRegistry,这两个接口的继承关系如图所示(beanFactoryPostProcessors列表的添加时机可以看上面第5步):

  • 7-先调用所有的ApplicationRunner,再调用所有的CommandLineRunner

spring bean生命周期(来源:spring docs):

  1. BeanNameAware’s setBeanName
  2. BeanClassLoaderAware’s setBeanClassLoader
  3. BeanFactoryAware’s setBeanFactory
  4. EnvironmentAware’s setEnvironment
  5. EmbeddedValueResolverAware’s setEmbeddedValueResolver
  6. ResourceLoaderAware’s setResourceLoader (only applicable when running in an application context)
  7. ApplicationEventPublisherAware’s setApplicationEventPublisher (only applicable when running in an application context)
  8. MessageSourceAware’s setMessageSource (only applicable when running in an application context)
  9. ApplicationContextAware’s setApplicationContext (only applicable when running in an application context)
  10. ServletContextAware’s setServletContext (only applicable when running in a web application context)
  11. postProcessBeforeInitialization methods of BeanPostProcessors
  12. InitializingBean’s afterPropertiesSet
  13. a custom init-method definition
  14. postProcessAfterInitialization methods of BeanPostProcessors

On shutdown of a bean factory, the following lifecycle methods apply:

  1. postProcessBeforeDestruction methods of DestructionAwareBeanPostProcessors
  2. DisposableBean’s destroy
  3. a custom destroy-method definition

  • 给出了画出干净的曲线轮廓的方法:不管是垂直方向还是水平方向上的像素,其过渡是渐进的,例如一个干净的倒角的曲线中(如本文中第4个对话气泡中的曲线),垂直方向的像素个数分别是:4、3、2、1、2、3、4,另外一个曲线举例(如本文中第5个对话气泡中的曲线):5、4、4、3、2、2、1、1、1、1、1,也就是说前后两个像素不能出现大的跳跃(来源:RHLPixels的像素画教程)。

  • 对于IsoMetric风格正方体上方的菱形,顶部和底部的角使用3个像素,这样的情况下,两个角之间的棱线就可以画在中间像素上了,详情可以参考本文的第4段描述(来源:RHLPixels的像素画教程

  • Isometric风格人物的其他的Isometric风格的原则一样(来源:RHLPixels的像素画教程):

    • 水平线和垂直线保持2:1像素的坡度
    • 有3个面可以被看到,因此头顶、肩膀都要画出厚度
  • 颜色渐变技巧:一个分成5个色带,左右两侧为分别为纯色A和纯色B,中间色带为A和B混合后的纯色X,左侧第2个色带为A和X的抖动颜色,右侧第2个色带为B和X的抖动颜色(来源:RHLPixels的像素画教程

本文翻译自:Pixels and voxels, the long answer


今年早些时候,有人在Quora上问:

像素和体素有什么区别?

这个问题很让我抓狂,以至于我一直没有给出直接答案,而是写了一篇有关该主题的整篇文章。

我明白你的问题了。你会看到一些像素电影海报之类的东西,这会让你很困惑。这是一个像素?是体素?它是一只鸟?它是一架飞机吗?这会把你弄得很狼狈。

我仍然无法决定是因为我对怀旧游戏的热爱,(导致)我应该去看这部电影还是绝对*不*看这部电影。

别担心,你现在安全了。当你读完这篇文章的时候,你就会知道关于像素和体素以及两者之间的一切。不着急,吃块饼干先。

(这是一篇很长的文章,但我向你保证,你正在阅读《Retronator》杂志,这意味着图片多于文字。)


我先给你们讲一点背景知识,这样你们就能了解全貌。在计算机上表示图形有两种主要的方法:矢量和位图。

矢量图形的数学精度(左)和栅格图的离散性(右)。

矢量图形通过数学方程式描述图像,通常用直线,曲线和形状等形式表示。相反,位图将图像描述为颜色值的数组,这些颜色值一个接一个地定位在网格图案中。

在计算机图形学第二个区别是表示2D和3D空间。包括矢量与位图划分,一共得到四个象限:

所有人都喜欢象限

矢量图形

在2D矢量图形中,直线或形状上的每个点都用一个有两个分量(x和y)的矢量来描述。这就是为什么它是2D的(两个分量-二维)。

这就是二维向量描述二维向量图形中所有点的方式。

下面是一个被称为低多边形二维矢量图像的例子。

Uluru the Mighty Dreamer, Anh Tran, 2015

它完全由2D多边形(在本例中为三角形)构成。术语“低多边形”是指用于制作图像的多边形数量相对较小,较低。这使得三角形很容易注意到。

让我们添加一个维度。在3D矢量图形中情况是相同的,但是每个矢量使用三个分量(x, y和z)。

让我们看一下3D低多边形图稿。

Racetrack iOS Game Concept, Timothy J. Reynolds, 2013

上面的艾尔斯岩(Ayers Rock)的2D图像与此处的3D赛道之间的最大区别是:我们可以从所需的任何位置查看赛道。

Racetrack iOS Game Concept, Timothy J. Reynolds, 2013

为了在屏幕(2D表面)上显示跑道,我们必须选择一个特定的视点并将3D几何体从该视点投影到2D。

从3D到2D的转换称为投影

这就是我们获取一张特定2D图像的方式。

但我们可以使用一个技巧来展示3D几何的体积性质,甚至在2d中——我们可以制作一个动画,在对象周围移动视点(或保留视点,同时旋转对象本身,如下所示)。

Wagon, Timothy J. Reynolds, 2013

耶,我们可以看到它确实是3D,不需要眼镜!

位图图形

这只是热身。让我们从向量继续,看看位图图形如何处理2D和3D。

在2D位图图形中,图像被分成若干大小相等的行和列:

Turbo Esprit Sprite, Matej ‘Retro’ Jan, 2014

每个单元格称为像素(来自于图像中的一个元素)。除了在网格(x,y)内的2D坐标外,它的主要属性是放置在该坐标上的颜色。

我们已经看到了低多边形矢量作品如何使用引人注目的大多边形。如果我们在位图图形中做同样的事情(使用引人注目的大像素),我们将获得像素作品。

Turbo Esprit Sprite, Matej ‘Retro’ Jan, 2014

对于2D像素图像,即使它们试图表示三维对象(Lotus Esprit或X-wing),它们也会直接绘制到2D像素网格上。你不能像上面的3D旅行车那样旋转这个图像。同样,文章开头的艾尔斯岩(Ayers Rock)图像也不能旋转。尽管它是由多边形构成的,但它们不是被放置在3D空间,而是直接放置在2D空间。


到目前为止,我们已经介绍了2D和3D矢量图形以及2D位图图像。最后一步是3D位图图形。

前方就是激动人心的时刻!

在3D位图图形中,体块被均匀地划分为行和列,覆盖所有三个不同的方向(上下、左右、内外)。这将三维空间划分为立方体,也称为体素(体积元素或体积像素)。每个体素由一个3D坐标和该坐标上的颜色定义。

就像像素作品一样(这是一种精心放置像素的艺术),我们现在有了体素作品,每个立方体都经过了仔细的考虑。

星际大战场景,@Sir_Carma,2015年

这很像乐高积木,你不觉得吗?

注意,因为我们在3D空间,体素也可以从任何角度观察。下面是对体素Tatooine的另一种观察:

星球大战场景(另一种视角),@Sir_Carma,2015

我们甚至可以做成动画!以下是 @Sir Carma的一个体素动画角色:

骑士奔跑,@Sir_Carma,2015年

与2D像素角色进行比较:

Final Element中的精灵,Glauber Kotaki,2015年

你可以看到在体素作品中,动画如何改变小立方体(体素)的存在(颜色),而在像素作品中,颜色的变化发生在小方块(像素)上。

现在你知道像素和体素之间的区别了(其实还有更多……哈哈,抱歉)。

但是现在还不是停下来的时候。瞧,我之所以解释了矢量/位图,2D/3D性质的原因是,在我们的现代显示器上,每种图形类型最终都最终显示为2D栅格图像。

我们在像素作品杂志中讨论这一点原因是,我们可以使用非像素风格的资源进行一些类型的变换,从而创建现代风格的像素作品。

“我可以用体素或3D模型制作像素艺术?”当然可以!巧妙的着色和渲染技术使我们能够创建独特的视觉风格,将像素艺术带入未来。

矢量显示和投影

ChannelId的那些事儿

ChannelId组成

ChannelId默认由io.netty.channel.DefaultChannelId实现,最终结果存储在一个data的byte数组中,data数组内由5个字段组成,从低位到高位分别是:6或8字节的MACHINE_ID,4字节的PROCESS_ID,4字节的SEQUENCE,8字节的TIMESTAMP和4字节的RANDOM,下表以8字节的`MACHINE_ID举例:

字段 RANDOM TIMESTAMP SEQUENCE PROCESS_ID MACHINE_ID
字节 [27-24](4字节) [23-16](8字节) [15-12](4字节) [11-8](4字节) [7-0](6或8字节)
说明 随机 自定义 初值为0,每次递增1 优先从系统属性io.netty.processId获取,类型为int 优先从系统属性io.netty.machineId获取,类型为String,长度为17或23

在DefaultChannelId的实现中,**TIMESTAMP**字段并没有直接使用系统的时间戳,例如:System.nanoTime(),而是自定义了一个时间戳生成方法:Long.reverse(System.nanoTime()) ^ System.currentTimeMillis(),举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final long nanoTime = System.nanoTime();
final long timeMillis = System.currentTimeMillis();
final long xorValue = nanoTime ^ timeMillis;
final long reverse = Long.reverse(xor);

log.info("{}", String.format("%64s", Long.toBinaryString(nanoTime)));
log.info("{}", String.format("%64s", Long.toBinaryString(timeMillis)));
log.info("{}", String.format("%64s", Long.toBinaryString(xorValue)));
log.info("{}", String.format("%64s", Long.toBinaryString(reverse)));

/*
输出如下所示
: 1111111100000110011110111010001111000001000000 -nanoTime
: 10111100001101101100000001000101100110110 -timeMillis
: 1111101011100111110011011010000111101101110110 -xorValue
: 110111011011110000101101100111110011101011111000000000000000000 -reverse,也即将xorValue换成2进制后,头尾互掉后的值
*/

至于Netty为什么这么设计,只是在源码中有一句简单的注释:// timestamp (kind of),翻译过来就是一种(Netty式的)时间戳,可能就是想这么实现吧。

ChannelId的表示

ChannelId有两个函数:asShortText()asLongText()

  • asShortText()使用懒加载的方式,输出**RANDOM字段转换成16进制并存储到String shortValue,例如:60fb0e5f**

  • asLongText()也采用懒加载的方式,将data数组中的5个字段转换成16进制以后,从**MACHINE_ID开始,到RANDOM结束,用“-”连起来,最后存储到String longValue中,例如:00000000000000e0-00003738-00000000-3a5715eac38e517e-60fb0e5f**

bind那些事儿

在上述的1. initChannel(Channel)中,执行了了一个重要的操作:new了一个匿名的ChannelInitializer并且addLast到传入的ChannelChannelPipeline中,该ChannelInitializer主要执行了两件事:

  • ChannelInitializer.initChannel回调被调用时,将ServerBootstrapChannelHandler addList到Channel.pipeline(),而ServerBootstrapChannelHanlder可以在构造时通过handler(ChannelHandler)传入,例如传入一个new LoggingHandler(LogLevel.INFO),从而实现log输出,有两点需要注意:
    • ChannelInitializer.initChannel回调时,发生在ServerBootstrap.group中(也即大家常用的BossGroup),此时还没有注册到ServerBootstrap.childGroup中(也即大家常用的WorkerGroup)。
    • 该ChannelHandler全局只有一个,会被所有的EventLoop共享包括ServerBootstrap.groupServerBootstrap.childGroup在内的所有EventLoop
  • ChannelInitializer.initChannel回调被调用时,调用了ChannelEventLoop(该EventLoop隶属于父线程池)并执行了一个异步任务:向Channel.pipeline()中addLast了一个new ServerBootstrapAcceptorServerBootstrapAcceptor执行了两件重要的事情:
    • ServerBootstrap.childHandler addLast到Channel.pipeline()
    • 调用ServerBootstrap.childGroup.register(Channel)Channel注册到子线程池(所以AbstractChannel声明成员变量eventLoop时使用了volatile关键字修饰)

GitHubGitHub tag (latest by date)Maven CentralGitHub Workflow Status

gamedo.persistence

gamedo.persistence是gamedo游戏服务器框架的持久化模块。它底层依赖于spring-data-mongodb,致力于构建一个 高性能、简单易用、易于维护 的游戏服务器持久化模块。同时,gamedo.persistence吸收了ECS设计模式的思想,也即:“组合优于继承”(这也是开发中的gamedo.ecs模块的一大个特性)。通过对底层的设计,将游戏持久化对象数据约束为Entity-Components(也即一个实体由多个组件组合而成)的形式,从而统一团队成员对于游戏对象数据的规范化设计和使用。

开始使用

Maven配置

增加Maven依赖:

1
2
3
4
5
<dependency>
<groupId>org.gamedo</groupId>
<artifactId>persistence</artifactId>
<version>1.2.1</version>
</dependency>

使用说明

  1. 定义游戏对象数据使之继承自EntityDbData,并使用 @Document注解来指定该持久化对象数据要持久化到MongoDB的哪一个Document中,一般情况下,该类内不再增加新的成员变量,因为数据应该存储在ComponentDbData的子类中,例如定义个玩家类:
1
2
3
4
5
6
@Document("player")
public class EntityDbPlayer extends EntityDbData {
public EntityDbPlayer(String id, Map<String, ComponentDbData> componentDbDataMap) {
super(id, componentDbDataMap);
}
}
  1. 根据开发需求,定义不同的组件数据类,也即是 ComponentDbData的子类,并使用和EntityDbPlayer相同的 @Document注解,确保被持久化到同一个Document中,例如定义一个背包类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
@Data
@Document("player")
public class ComponentDbPlayerInfo extends ComponentDbData
{
private String name;
private int level;
}

@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
@Data
@Document("player")
public class ComponentDbBag extends ComponentDbData
{
final List<Integer> itemList = new ArrayList<>();
}
  1. 定义EntityDbPlayer的转换器(属于样板代码,不需要实现逻辑),由于gamedo.persistence在设计过程中,使用了使用了自定义转换器对EntityDbData类进行序列化和反序列化,因此其子类也需要继承 AbstractEntityDbDataReadingConverterAbstractEntityDbDataWritingConverter并加上**@Component**注解,目的可以正确加载到spring的IOC容器中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
@ReadingConverter
public class EntityDbPlayerReadingConverter extends AbstractEntityDbDataReadingConverter<EntityDbPlayer> {
public EntityDbPlayerReadingConverter(MongoConfiguration configuration) {
super(configuration);
}
}

@Component
@WritingConverter
public class EntityDbPlayerWriterConverter extends AbstractEntityDbDataWritingConverter<EntityDbPlayer> {
public EntityDbPlayerWriterConverter(MongoConfiguration configuration) {
super(configuration);
}
}
  1. 搞定!接下来就是使用gamedo.persistence了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Slf4j
@SpringBootApplication(scanBasePackages = {"org.gamedo", "org.gamedo.persistence"})
public class Application {
public static void main(String[] args) {
final ConfigurableApplicationContext applicationContext = SpringApplication.run(Application.class, args);

//1、从容器中获取DbDataMongoTemplate(不能通过new操作符获取DbDataMongoTemplate,否则不具有异步持久化能力)
final DbDataMongoTemplate dataMongoTemplate = applicationContext.getBean(DbDataMongoTemplate.class);
//2、创建一个玩家持久化对象类:EntityDbPlayer
final EntityDbPlayer entityDbPlayer = new EntityDbPlayer(new ObjectId().toString(), new HashMap<>());

//3、增加一个组件数据:ComponentDbData
entityDbPlayer.addComponentDbData(new ComponentDbBag(new ArrayList<>()));
entityDbPlayer.addComponentDbData(new ComponentDbPlayerInfo("testName", 1));

//4、调用同步save函数,将完整的EntityDbPlayer持久化到MongoDB中
dataMongoTemplate.save(entityDbPlayer);
//4.1、同步方式实现异步调用save函数,该方法会把entityDbPlayer安全地发布到db线程后,就直接返回。
dataMongoTemplate.saveAsync(entityDbPlayer);

//接下来是gamedo.persistence提供的线程安全的异步增量更新的功能---------------------------------------------------

//应用层
//5、获取组件数据
final ComponentDbBag componentDbData = entityDbPlayer.getComponentDbData(ComponentDbBag.class);
//6、修改数据
componentDbData.getItemList().add(1);
//7、对修改的变量进行标脏
componentDbData.setDirty("itemList", componentDbData.getItemList());

//存储层
//8、进行异步更新,并通过CompletableFuture检查执行结果
if(componentDbData.isDirty())
{
final CompletableFuture<UpdateResult> future = dataMongoTemplate.updateFirstAsync(componentDbData);
//9、可以检查执行结果
future.whenCompleteAsync((result, t) -> {
if (t != null) {
log.error("exception caught.", t);
} else {
log.info("update async finish, result:{}", result);
}

applicationContext.close();
});

log.info("application run finish.");
}
}
}

当执行完第4步或第4.1步后,MongoDB中数据为:

1
2
3
4
5
6
7
8
9
10
11
12
13
{ 
"_id" : ObjectId("6058279e4cff6830d4ff1fd1"),
"_class" : "org.gamedo.db.EntityDbPlayer",
"ComponentDbBag" : {
"itemList" : [],
"_class" : "org.gamedo.db.ComponentDbBag"
},
"ComponentDbPlayerInfo" : {
"name" : "testName",
"level" : NumberInt(1),
"_class" : "org.gamedo.db.ComponentDbPlayerInfo"
}
}

第59步骤属于gamedo.persistence提供的异步局部增量更新的特性,在团队协作开发中,一般会把第89步封装到一个单独的模块中,进行统一的持久化操作,比如每隔5秒检查 **DbData.isDirty()**,并进行异步局部增量更新,而对于上层使用者只需要进行标脏操作即可。

当程序运行结束后,MongoDB中数据为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{ 
"_id" : ObjectId("6058279e4cff6830d4ff1fd1"),
"_class" : "org.gamedo.db.EntityDbPlayer",
"ComponentDbBag" : {
"itemList" : [
NumberInt(1)
],
"_class" : "org.gamedo.db.ComponentDbBag"
},
"ComponentDbPlayerInfo" : {
"name" : "testName",
"level" : NumberInt(1),
"_class" : "org.gamedo.db.ComponentDbPlayerInfo"
}
}

通过对比可知,文档中仅仅ComponentDbBag.itemList里增加了一个NumberInt(1),而控制台日志输出为:

1
2
2021-03-16 15:21:05.265  INFO 36044 --- [           main] org.gamedo.Application                   : application run finish.
2021-03-16 15:21:05.275 INFO 36044 --- [nPool-worker-19] org.gamedo.Application : updateFirstAsync finish, result:AcknowledgedUpdateResult{matchedCount=1, modifiedCount=1, upsertedId=null}

通过日志可知:

  1. 持久化发生在nPool-worker-19线程,而非main主线程
  2. 持久化日志在主线程日志之后打印,也即不会阻塞main主线程的业务逻辑

设计思想

ECS:组合优于继承

在gamedo.persistence中,游戏持久化对象数据被定义为:EntityDbData。它对应于ECS中的E(Entity),并且和ECS中的Entity相同的是:EntityDbData仅仅只是一个数据容器(当然,如果非得在EntityDbData的子类内增加成员变量也是可以的),真正要存储的数据都放在ComponentDbData中,对应于ECS中的C(Component),同时,EntityDbData和ComponentDbData都实现了DbData接口,通过观察DbData接口的定义,可以知道:

  • 它包含一个String类型的Id,映射到MongoDB的Document的_id字段
  • 它包含一个更新器,代表着这个DbData内的属性都可以通过$set操作符进标脏

在gamedo.persistence的实现中,EntityDbData中含有一个Map<String, ComponentDbData>的成员变量,其中Key为ComponentDbData所代表的java.lang.Class的getSimpleName()返回值,Value为ComponentDbData的子类实现。DbData、EntityDbData、ComponentDbData的继承关系如图所示:

gamedo.persistence.DbData-UML.png

高性能:化整为零

在游戏服务器框架的持久化业务场景中,一般情况都是游戏逻辑线程负责对游戏持久化对象数据进行修改,而在另外一个线程(以下简称为db线程)对该持久化数据进行读操作,并将之持久化到db中。这样读写分离的操作是为了带来更好的性能,假设对于持久化数据的修改和持久化操作都在业务线程中,这将会极大地影响业务线程。而读写操作不在同一个线程也带来了java编程中最常见的多线程问题:内存可见性和并发竞争。总而言之,就是要解决如何将游戏持久化对象数据安全地发布到db线程中,一种经典的做法是先将持久化对象数据序列化成中间状态,发布到db线程后再反序列化为原来状态后执行持久化操作。而这种操作带来了一个缺点,就是:带来了无谓的性能损耗。当持久化对象数据非常大时,这种性能损耗将会更加明显,即使仅修改一个简单的成员变量,也要执行全局序列化/反序列化。

由于gamedo.persistence模块的底层数据库是MongoDB,而后者提供了 “$set”操作符,这允许用户可以对MongoDB中文档(甚至是内嵌的文档)的字段进行局部更新,gamedo.persistence正是利用了这个特性,设计了一个线程安全的更新器(Updater),并为每个Entity和Component配备了一个独立的Updater, 并通过一系列的封装和设计,使之具有简单易用的特性。此外Updater内部使用了spring-data-mongodb的Update,当安全地发布到db线程后,可以直接执行持久化操作,而不会带来额外的性能反序列化性能开销。这种化整为零的拆分思想保证了只有需要更新的字段才会进行持久化操作,而无需进行无谓的全局序列化/反序列化。

  1. 一句话概括:把托管在github上的代码发布到sonatype nexus仓库,后者通过自动机制同步到maven中央仓库,可以参考官方的帮助文档:OSSRH Guide

  2. 创建sonatype jira账号,创建一个jira issue,如果是groupId是自有域名需要证明,例如增加TXT记录

  3. 由于执行maven的deploy phase时,需要将代码推送到sonatype的仓库,因此需要配置用户名和密码,方法就是在.m2/setting.xml里配置server字段,配置内容为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
    https://maven.apache.org/xsd/settings-1.0.0.xsd">
    <localRepository/>
    <interactiveMode/>
    <offline/>
    <pluginGroups/>
    <servers>
    <server>
    <id>ossrh</id>
    <username>sonatype jira's user name</username>
    <password>sonatype jira's password</password>
    </server>
    </servers>
    <mirrors/>
    <proxies/>
    <profiles>
    <profile>
    <id>nexus-release</id>
    <properties>
    <gpg.passphrase>gpg's passphrase</gpg.passphrase>
    </properties>
    </profile>
    </profiles>
    <activeProfiles>
    <activeProfile>nexus-release</activeProfile>
    </activeProfiles>
    </settings>

    如上所示,需要配置servers.server节点,至于profiles.profile节点,后面会用到

  4. 当执行到maven的verify phase时,需要调用maven-gpg-plugin插件的sign这个goal,目的是对推送到仓库的所有文件进行签名,而这个goal又会默认调用gpg这个系统命令,因此需要提前安装gpg,如果是在windows,需要下载windows下的gpg4win,安装后生成gpg key:gpg --generate-key,并将公钥分发到远程服务器:gpg --keyserver hkp://pool.sks-keyservers.net --send-keys B87AC4F5C8D3C23B,也可以使用gpg4win附带的可视化工具Kleopatra对gpg paire进行备份、生成吊销证书、分发到远程服务器。

  5. 创建开源项目,并托管到github,保证pom.xml内必备字段都完备,比如:GAV、licenses、developers、scm、issueManagement,如果是非pom打包,还要求有doc的jar包和源码的jar包,因此需要配置:maven-source-pluginmaven-javadoc-plugin插件,详情可以参考sonatype官方文档说明,详情参考的gamedo项目的配置,配置过程中需要注意:

    1. snapshotRepository.id需要保持和setting.xml中的server.id一致,maven通过pom.xml和setting.xml实现配置分离和继承,公共数据配置在pom.xml中,私密数据配置在setting.xml中
    2. maven-gpg-plugin插件中的configuration.gpgArguments内的两个参数的目的是让gpg插件自动输入密码,该配置参考自该链接
  6. 进入发布循环流程(参考文档):

    1. 开发,开发,开发

    2. 发布snapshots版本:mvn clean deploy -P nexus-release(需要保证版本号以 -SNAPSHOT 结尾)

    3. 版本稳定,准备发布release版本,设置新版本:export newVersion=1.0.0 && mvn versions:set -DnewVersion=${newVersion}(该操作是去掉 -SNAPSHOT 后缀)

    4. 发布新版本到nexus:mvn clean deploy -P nexus-release,由于nexus-staging-maven-plugin插件中的autoReleaseAfterClose设置为false,发布后会驻留在staging库,不会自动同步到release库

    5. 如果发现版本有问题,可以从staging库删除:mvn nexus-staging:drop,当确认无误后,可以发布到release库:mvn nexus-staging:release,nexus会在十分钟后自动同步到maven中央库,并且会在jira里自动评论:OSSRH-65516

    6. 提交新的tag到git中

      1
      2
      3
      git commit -m "Release ${newVersion}"
      git tag ${newVersion}
      git push --tags
    7. 开启下一次版本迭代,修改新的版本号export newVersion=1.1.0-SNAPSHOT && mvn versions:set -DnewVersion=${newVersion} && git commit -m "new feature version"

  1. spring-data-mongodb向mongodb-driver执行find指令时,后者解析后返回一个BsonDocument,BsonDocument继承自BsonValue,并且implements了Map<String, Bsonvalue>接口(内部通过LinkedHashMap实现),其成员如下,可以参考mongodb API

    1
    2
    3
    4
    5
    6
    waitedMS		--> BsonInt64
    cursor --> BsonDocument
    firstBatch --> BsonArrayWrapper //第一批读取到的document列表(ArrayList<Document>),当decode后被wrapper到BsonArrayWrapper
    id --> BsonInt64 //游标的Id,
    ns --> BsonString //NameSpace的缩写,组成为:数据库名.文档名,例如:test.Bag
    ok --> bsonDouble //标明命令是否执行成功:1:成功;0:失败
  2. spring-data-mongodb的基础事件:org.springframework.data.mongodb.core.mapping.event.MongoMappingEvent,可以继承AbstractMongoEventListener,当执行document在进行删除前、删除后、存储前、存储后、转换前、转换后、加载后时,可以执行自定义操作。

  3. MongoDb生成唯一Id最佳实践(来源):

    • 定义生成唯一Id的java类:

      1
      2
      3
      4
      5
      6
      7
      @Document(collection = "database_sequences")
      @Data
      public class DatabaseSequence {
      @Id
      private String id;
      private long seq;
      }
    • 定义生成唯一Id的函数:

      1
      2
      3
      4
      5
      6
      public long generateSequence(String seqName) {
      DatabaseSequence counter = mongoOperations.findAndModify(query(where("_id").is(seqName)),
      new Update().inc("seq",1), options().returnNew(true).upsert(true),
      DatabaseSequence.class);
      return !Objects.isNull(counter) ? counter.getSeq() : 1;
      }
    • 可以使用AbstractMongoEventListener实现自动化设置,例如:

      1
      2
      3
      User user = new User();
      user.setId(sequenceGenerator.generateSequence(User.SEQUENCE_NAME)); //User.SEQUENCE_NAME为static string类型
      user.setEmail("john.doe@example.com");
  1. spring-data-mongodb实例化entity的算法如下所示(原文地址):

    • 如果包含无参构造参数,则使用该构造函数,其他有参构造函数被忽视
    • 如果只有一个含有参数的构造函数,将该构造函数将会被使用
    • 如果有多个有参构造函数,那么含有@PersistenceConstructor注解的构造函数将会被使用。
  2. 在加载数据并创建领域对象时,Spring Data会在运行时生成对象工厂(继承自接口:ObjectInstantiator),并且调用领域对象的构造函数,而并没有使用反射,这提升了10%性能,但是如果要是用该特性必须满足以下条件(原文地址):

    • 不能是私有类
    • 不是是非静态的内部类
    • 不能是CGLib代理类
    • 被Spring Data选中的构造函数不能是私有的
  3. Spring Data给予的一般性建议((原文地址)[https://docs.spring.io/spring-data/mongodb/docs/3.1.6/reference/html/#mapping.general-recommendations]):

    • 尽量使用不可变实体对象(immutable objects
    • 如果无法将实体设计为不可变对象,那么提供一个全参构造函数,如此一来Spring Data可以将对象属性一次性赋值成功,从而跳过属性设置,最终获得最佳性能。
    • 全参构造函数可以提供最佳的性能,所以要尽量避免由于出现多个重载构造函数而使用*@PersistenceConstructor* 注解,而要防止多个重载的构造函数,那就尽量使用静态的工厂方法
    • 确保Spring Data的对象工厂(instantiator )和属性访问器(property accessor classes)可以正常被使用,也即第5条的4个条件都要被满足
    • 对于标识符字段(也即将会被序列化为_id字段的属性),声明为final,并且优先使用全参构造函数,其次使用with…方法
    • 使用Lombok 来避免样板代码,并且使用*@AllArgsConstructor*来生成全参构造函数
  4. Spring Data在映射层处理_id字段的规则

    • 如果类的成员变量被增加了@Idorg.springframework.data.annotation.Id)注解,那么将会被映射为MongoDB的_id字段
    • 如果类的成员变量没有@Id注解,但是被命名为id,那么也会被解析映射为_id字段
    • 作为标识符字段的默认类成员变量名_id,并且可以被@Field字段注解

  1. Doug Lea在他的论文中写道:

    • JUC同步器框架的主要性能目标是实现可伸缩性,也即:当发生锁竞争时,可预测地保持效率。在理想的情况下,无论多少线程尝试通过同步点,所需的开销都是常量。(更确切的讲)其中一个主要目标是:某一线程被允许通过同步点但还没有通过的情况下,使其耗费的总时间最少。
    • 实现同步器的上述目标包含了两种不同的使用类型。大部分应用程序是最大化其总的吞吐量,容错性,并且最好保证尽量减少饥饿的情况(非公平锁)。然而,对于那些控制资源分配的程序来说,更重要是去维持多线程读取的公平性,可以接受较差的总吞吐量(公平锁)。没有任何框架可以代表用户去决定应该选择哪一个方式,因此,应该提供不同的公平策略。
  2. AQS队列的head node很特殊,它属于一个占位node,其成员变量pre、next、thread都为null,也就是说队列中第2个node才是真正排队的队首。

  3. 对于AbstractQueuedSyncchronizer的入队函数:enq,当第一次调用(也即初始化队列)时,内部的for循环至少循环2次,第一次设置head、第二次设置tail。

  4. AQS的公平锁实现FairSync中,获取锁的流程为(假定请求锁的线程名为t2):

    • 首先尝试获取锁(FairSync::tryAcquire):
      • 如果发现state == 0(没有人拿到锁),并且有资格拿锁(也即hasQueuedPredecessors返回false,从字面意思讲,这个函数代表是否有线程在当前线程前面排队中,返回true的条件需要同时满足两个:1、队列已经初始化,2、队列中只有一个占位node(前面有一个node在enq一半时会出现这种情况)或者首个排队node的线程不是本线程),则尝试CAS设置state为0,如果设置成功,则获取锁成功并返回。
      • 如果发现state != 0,并且当前线程获取了锁,则增加锁的数量,并且获取锁成功并返回。
    • 如果没有获取到锁:先CAS入队(AbstractQueuedSynchronizer::addWaiter,重点1),其流程为:
      • 检查队列是否已经初始化(tail != null),如果已经初始化则CAS设置当前node为队尾
      • 如果队列已经初始化或者上一步CAS设置队尾失败,则自旋进行入队操作(AbstractQueuedSynchronizer::enq)。
    • 入队后,开始自旋(AbstractQueuedSynchronizer::acquireQueued),其流程为:
      • 先再一次检测能否获取锁(设计思路是:t2入队后,可能前面的线程正好释放了锁,此时t2虽然在队中,但是可能就是队首(也即是队列的第2个node),那就不需要排队)
      • 如果还是拿不到;检测是否需要park,检测条件为:pre.waitState == 0(并且前前一个node的waitState是由当前节点修改为0的,为什么呢?因为前一个node已经睡眠中,无法修改自己,有意思!),如果需要(第一次自旋时必然不成立,乐观锁的天性:尽量不park),则调用LockSupport.park阻塞当前线程。
      • 如果拿到锁直接返回
  5. 一个线程尝试获取ReentrantLock公平锁时,最多会尝试3次获取锁,发生的场景是锁已经被其他线程占有,但是该请求线程是队列的队首,该请求首先会尝试获取,获取失败后,在入队park前会自旋2次尝试获取。最少会1次尝试锁,发生的场景是锁已经被其他线程占有,且该请求线程入队后也不是队首。