> 文章列表 > 通过实战探究 GraalVM 静态编译

通过实战探究 GraalVM 静态编译

通过实战探究 GraalVM 静态编译

通过实战探究 GraalVM 静态编译

  • GraalVM 是什么
  • 什么是 Java 静态编译
  • GraalVM 静态编译优点
  • GraalVM 静态编译缺点
  • Substrate VM 是什么
  • native image 运行时的内存管理
    • Serial GC
    • G1 GC
    • Epsilon GC
  • 预执行目标应用程序
  • 静态编译目标应用流程
    • 命令行模式
    • 配置文件模式
    • Maven 模式
    • Gradle 模式
  • 静态编译 HelloWorld
  • 静态编译 SpringBoot3 项目

GraalVM 是什么

GraalVM 是 Oracle 推出的基于 Java 开发的开源高性能多语言运行时平台。

GraalVM 分为免费的社区版(Community Edition,CE)和收费的企业版(Enterprise Edition,EE),两者的基本功能是相同的,但是 EE 版的性能和安全性更高,并为用户提供不间断的 24 小时支持服务。Oracle 云服务的客户可以免费获得 EE 版。

什么是 Java 静态编译

Java 静态编译时指将 Java 程序的字节码在单独的离线阶段编译为汇编代码,其输入为 Java 的字节码,输出为 native image,即二进制 native 程序。

静态编译的基本原则时封闭性假设(closed world assumption),要求编译器在编译时必须掌握运行时所需的全部信息。

GraalVM 静态编译优点

GraalVM 实现了 Java 静态编译的编译器、编译框架和运行时等一整套完整的工具链。GraalVM 的 Java 静态编译器就是 GraalVM 底层的 GraalVM JIT Compiler,这意味着 GraalVM 统一了 JIT 和 AOT 编译器。

静态编译框架和运行时时由 Substrate VM 子项目实现的。

GraalVM 的静态编译方案的基本思路是由用户指定编译入口(比如 main 函数),然后编译框架从入口开始静态分析程序的可达范围,编译器将分析出的可达函数和 Substrate VM 中的运行时支持代码一起编译为一个被称为 native image 的二进制本地代码文件。

根据用户的参数设置,这个本地代码文件既可以时 ELF 可执行文件,也可以是动态共享库文件。

GraalVM 通过静态分析的指向分析(points-to-analysis)、控制流分析(control flow anylysis)以及调用图分析(call graph analysis)等技术找到可达的代码范围。

GraalVM 静态编译方案还实现了多种运行时优化,典型的有对 Java 静态初始化过程的优化。

GraalVM 静态编译缺点

静态分析是资源密集型计算,需要消耗大量的内存、CPU 和时间。

静态分析对反射的分析能力非常有限。

静态编译后程序的运行时性能低于传统 Java 经过 JIT 编译后的峰值性能。

Substrate VM 是什么

Substrate VM 提供了将 Java 程序静态编译为本地代码的编译工具链,包括了编译框架、静态分析工具、C++ 支持框架及运行时支持等。

但是 Substrate VM 中并没有编译器和链接器,因为其编译器是 GraalVM 编译器,而链接器则使用了 GCC(在 Linux 系统上)。

Substrate VM 由 native-image 启动器启动后,先对输入进行静态分析,找到其中的可达代码。由于静态分析本身特点,分析出的可达代码是全部代码的子集,实际执行代码的超集。然后,可达代码,也就是静态分析结果会被送人静态编译器中,最终编译得到 native image。

执行 native-image 文件以启动静态编译,会经过 Launcher(native-image)到 svm-driver.jar 再到 svm.jar 三层,最终开始执行静态编译。在这个三层结构中,native-image 文件是用户接口层,svm-driver.jar 是驱动层,svm.jar 是功能执行层。这样的三层结构是为用户屏蔽了启动过程中的复杂性,保持了用户入口的简洁和统一。

native image 运行时的内存管理

refer:https://www.graalvm.org/22.0/reference-manual/native-image/MemoryManagement/

native image 在运行时不会在 Java HotSpot VM 上运行,而是在 GraalVM 提供的运行时系统上运行。该运行时包括所有必要的组件,其中之一是内存管理。

native image 在运行时分配的 Java 对象驻留在称为 Java 堆(“Java heap”)的区域中。 Java 堆是在 native image 启动时创建的,在 native image 运行时可能会增大或减小大小。 当堆已满时,将触发垃圾回收以回收不再使用的对象的内存。

为了管理 Java 堆,native image 提供了不同的垃圾回收器 (GC) 实现:

  • Serial GC:顺序 GC 是 GraalVM 社区版和企业版中的默认 GC。 它针对低内存占用和较小的 Java 堆大小进行了优化。

  • G1 GC:(仅适用于 GraalVM 企业版)是一个多线程 GC,经过优化以减少 stop-the-world,从而改善延迟,同时实现高吞吐量。 若要启用 G1,请在 native image 生成时指定 --gc=G1 选项。 目前,G1 只能在基于 Linux 的 AMD64 构建的 native image 中使用。

  • Epsilon GC:(适用于 GraalVM 21.2 或更高版本)是一个无操作垃圾回收器,它不执行任何垃圾回收,因此从不释放任何分配的内存。 此 GC 的主要用例是运行时间非常短的应用程序,这些应用程序仅分配少量内存。 要启用 Epsilon GC,请在映像构建时指定 --gc=epsilon 选项。

执行 native image 时,可以使用以下选项来打印有关垃圾回收的一些信息。 详细打印哪些数据取决于所使用的 GC。

  • -XX:+PrintGC:打印每个垃圾回收的基本信息

  • -XX:+VerboseGC:可以添加以打印更多垃圾收集详细信息

# Execute a native image and print basic garbage collection information
./helloworld -XX:+PrintGC# Execute a native image and print detailed garbage collection information
./helloworld -XX:+PrintGC -XX:+VerboseGC

Serial GC

顺序 GC 针对低占用空间和较小的 Java 堆大小进行了优化。 如果未指定其他 GC,则串行 GC 将隐式用作 GraalVM 社区版和企业版的默认 GC。 也可以通过将 --gc=serial 选项传递给 native image 生成器来显式启用顺序 GC。

# Build a native image that uses the serial GC with default settings
native-image --gc=serial HelloWorld

通过实战探究 GraalVM 静态编译

顺序 GC 是一种无并发的单线程 “停止 - 复制” GC,具有低内存占用、高延迟和高吞吐量的特性。GC 进行内存分配和回收的基本单位是"块",内存对被划分为新生代和老生代,由若干个存放小对象的对齐块(AlignedChunk)和存放大对象的非对齐块(UnAlignedChunk)构成。对齐块的默认大小为 1MB,起始地址按 1MB 对齐,也就是起始地址的后 6 位都是 0,每个对齐块中可以放若干个小对象,直到放不下为止。小对象是指小于对齐块大小的八分之一(默认值,可配置)的对象;大对象则被存放到非对齐块中,一个非对齐块中只能存放一个对象。

Substate VM 采用顺序 GC 最主要的原因是实现简单。

native image 的内存堆设置与 JVM 保持了兼容。用户在启动 native image 程序时,可以在启动命令之后使用 -Xmx、-Xms 等参数设置对的内存大小

G1 GC

GraalVM Enterprise Edition 还提供了垃圾优先(G1)垃圾收集器,它基于Java HotSpot VM 的 G1 GC。 目前,G1 只能在基于 Linux 的 AMD64 构建的 native image 中使用。 要启用它,请将选项 --gc=G1 传递给 native image 生成器。

# Build a native image that uses the G1 GC with default settings
native-image --gc=G1 HelloWorld

通过实战探究 GraalVM 静态编译

注意:在 GraalVM 20.0、20.1 和 20.2 中,G1 GC 称为低延迟 GC,可以通过实验选项 -H:+UseLowLatencyGC 启用。

G1 是一个 generational, incremental, parallel, mostly concurrent, stop-the-world, evacuating 的 GC。 它旨在提供延迟和吞吐量之间的最佳平衡。

某些操作始终在 stop-the-world 暂停中执行,以提高吞吐量。 应用程序停止后需要更多时间的其他操作(如 global marking 等 whole-heap 操作)将与应用程序并行并发执行。 G1 GC 试图在更长的时间内以高概率满足设定的 pause-time 目标。 但是,给定的停顿没有绝对的确定性。

G1 将堆分区为一组大小相等的堆区域,每个堆区域都是一个连续的虚拟内存范围。 区域是用于内存分配和内存回收的 GC 内部单元。 在任何给定时间,这些区域中的每一个都可以为空,也可以分配给特定的一代。

如果未指定最大 Java 堆大小,那么使用 G1 GC 的 native image 会将其最大 Java 堆大小设置为物理内存大小的 25%。 例如,在具有 4GB RAM 的计算机上,最大 Java 堆大小将设置为 1GB。 如果在具有 32GB RAM 的计算机上执行相同的 native image,则最大 Java 堆大小将设置为 8GB。 要覆盖此缺省行为,请指定 -XX:MaxRAMPercentage 的值或显式设置最大 Java 堆大小。

G1 GC 是一个自适应垃圾回收器,其默认值使其无需修改即可高效工作。 但是,可以根据特定应用程序的性能需求对其进行调整。下面是在进行性能调整时可以指定的一小部分选项:

  • -H:G1HeapRegionSize:(只能在native image时指定) G1 区域的大小。

  • -XX:MaxRAMPercentage:如果未另行指定最大堆大小,则用作最大堆大小的物理内存大小的百分比。

  • -XX:MaxGCPauseMillis: 最长暂停时间的目标。

  • -XX:ParallelGCThreads:垃圾回收暂停期间用于并行工作的最大线程数。

  • -XX:ConcGCThreads:用于并发工作的最大线程数。

  • -XX:InitiatingHeapOccupancyPercent:触发标记周期的 Java 堆占用阈值。

  • -XX:G1HeapWastePercent:收集组候选项中允许的未回收空间。如果收集组候选项中的可用空间低于该阶段,G1 将停止空间回收阶段。

# Build and execute a native image that uses the G1 GC with a region size of 2MB and a maximum pause time goal of 100ms
native-image --gc=G1 -H:G1RegionSize=2m -R:MaxGCPauseMillis=100 HelloWorld
./helloworld# Execute the native image from above and override the maximum pause time goal
./helloworld -XX:MaxGCPauseMillis=50

Epsilon GC

与 OpenJDK 的无 GC 项目 Epsilon 类似,Substrate VM 也提供了无 GC 的运行模式,用参数 -R:+UseEpsilonGC 开启。在无 GC 模式下,运行时不会执行 GC 操作,从而降低了系统开销,提高了程序的性能,但是事先分配的内存耗尽,就会抛出 OOM 错误。这种模式适合内存消耗少、对响应时间要求高的场景。

预执行目标应用程序

Java 语言的动态特性违反了静态编译的封闭性假设。GraalVM 允许通过配置的形式将缺失的信息补充给静态编译器以满足封闭性,为此 GraalVM 设计了 reflect-config.json、jni-config.json、resource-config.json、proxy-config.json、serialization-config.json 和 predefined-classes-config.json 这 6 个 json 格式的配置文件,分别用于向静态编译提供反射目标信息、JNI 回调目标信息、资源文件信息、动态代理目标接口信息、序列化信息和提前定义的动态类信息。

预执行只需在目标应用程序原本的启动命令基础上,添加如下参数启动 Agent 即可。

java -agentlib:native-image-agent=config-output-dir=$CONFIG_ROOT/META-INF/native-image AppMain

我们基于一个 SpringBoot 3的项目,实际尝试一下,

通过实战探究 GraalVM 静态编译

启动 SpringBoot3 项目,访问其中的一个 API,然后停止这个 SpringBoot3 项目,我们可以看到 reflect-config.json、jni-config.json、resource-config.json、proxy-config.json、serialization-config.json 和 predefined-classes-config.json 这 6 个 json 格式的配置文件被创建出来了。

通过实战探究 GraalVM 静态编译

在编译时静态编译框架会自动从 classpath 的 META-INF/native-image 目录结构中识别出配置文件,此外,用户可以在启动 native-image 时传入多个参数以额外指定配置文件的位置。

  • -H:ConfigurationFileDirectories=:指定配置文件的直接目录,多个项目之间用逗号分隔。在该目录中按默认方式的命名的 json 配置文件都可以被自动识别。

  • -H:ConfigurationResourceRoots=:指定配置资源的根路径,多个项目之间用逗号分隔。配置文件不仅可以被当作外部文件读取,也可以被当作 resource 资源读取。这种方式适用于读取存放在 jar 文件中的配置文件。

  • -H:XXXConfigurationFiles=:指定某一种类型的配置文件,多个项目之间用逗号分隔。这里的 XXX 可以是 Reflection、DynamicProxy、Serialization、SerializationDeny、Resource、JNI 或 PredefinedClasses。

  • -H:XXXConfigurationResources=:指定某一种类型的配置资源的路径,多个项目之间用逗号分隔。这里的 XXX 可以是 Reflection、DynamicProxy、Serialization、SerializationDeny、Resource、JNI 或 PredefinedClasses。

静态编译目标应用流程

应用静态编译的基本流程是,首先获取 GraalVM JDK,然后获取目标应用程序及所需的依赖库,接下来 GraalVM JDK 预执行目标应用程序获取所有的动态特性配置,最后以目标应用程序、依赖库和动态特性配置作为输入,通过 GraalVM JDK 中的静态编译启动器 native-image 启动静态编译框架,编译出二进制本地可执行应用程序文件。

GraalVM 可以将 jar 包或者未打包的 class 文件编译为 ELF(Executable and Linkable Format)格式的二进制文件或者动态共享库文件。

目前 GraalVM 支持通过 4 种方式调用静态编译启动器,分别是命令行模式、配置文件模式、Maven 插件模式和 Gradle 插件模式。其中命令行模式是基础,其他 3 种都是对命令行模式的包装。

命令行模式

执行静态编译的基本命令格式是:

native-image -cp $CP $OPTS [app.Main]

或者

native-image $OPTS -jar [app.jar]

$OPTS 是编译时设置的选项,从使用的角度可以分为启动器选项、编译器选项和运行时选项三大项。

接下来详细说明几个常用选项。

1)启动器选项用于控制启动器行为,或通过启动器传递给 Substrate VM。

  1. -cp、–classpath、-jar、–version:虽然 native-mage 启动器并非 Java 程序,但是这些选项与 Java 的同名选项含义相同。
  2. –debug-attach[=<port>]:在指定端口开启远程调试,默认端口时 8000。
  3. –dry-run:启动器仅做参数解析包装工作,然后输入最终实际启动静态编译框架的所有参数,但不真正启动静态编译框架。其主要用于调试。
  4. –help、–help-extra、–expert-options-all:打印输出选项的帮助信息。
  5. 编译器参数:编译器参数用于控制静态编译器的行为,少部分常用选项以 -- 为前缀,可以通过 native-image --help 查看;更多的是以 -H: 为前缀(目前的 GraalVM EE Java 17 中有 887 个)的高级选项,这些选项可以通过执行 native-image --expert-options-all | grep "\\-H:" 查看。
  6. -J<Flag>:设置 native-image 编译框架本身的 JVM 参数。
  7. –no-fallback:从不进入 fallback 模式。当 Substate VM 发现使用了未被配置的动态特性时会默认回退到 JVM 模式。本选项会关闭回退行为,总是执行静态编译。
  8. –report-unsupported-elements-at-runtime:当发现应用程序中使用了静态编译不支持的特性时不立即报告并终止编译,而是继续完成编译,等到运行时第一次执行到不支持的特性再报告。
  9. –allow-incomplete-classpath:允许不完全的 classpath。
  10. –initialize-at-run-time:将指定的单个类或包中的所有类的初始化推迟到运行时。
  11. –initialize-at-build-time:将指定的单个类或包中的所有类的初始化提前到编译时。
  12. –shared:将程序编译为共享库文件,不加此项默认将应用程序编译为可执行文件。编译共享库文件时需用 CLibrary 的注解 @CEntryPoint 标识共享库暴露的 API 作为编译入口。
  13. -H:Name:指定编译产生的可执行文件的名字。如不指定,则默认以主函数所在类的全部小写的类全名(full qualified name)为文件名。
  14. -H:-DeleteLocalSymbols:禁止删除本地符号,本参数默认设置为打开,即会删除本地符号。为了减少编译文件的大小,编译器会将程序中的本地符号删除,但是缺少符号信息会在调试时难以定位代码。因此,如果有调试需求,可以关闭此选项。
  15. -H:+PreserveFramePointer:保留栈帧指针信息,本参数默认为关闭。同样是为了减少编译文件的大小,默认不会保留栈帧指针,这会导致在调试时无法显示调用栈名,而只能看到问号。因此,如有调试需求,可以将此参数设置为打开。
  16. -H:+ReportExceptionStackTraces:打印编译时异常的调用栈,本参数默认为关闭。打开后就可以在静态编译出错时输出完整的异常调用栈信息,帮助发现异常原因以便修复。

2)运行时参数:运行时参数用于控制可执行程序的运行时表现,以 -R: 开头(目前的 GraalVM EE Java 17 中有 671 个),这些选项可以通过执行 native-image --expert-options-all | grep "\\-R:" 查看。

配置文件模式

当静态编译使用编译参数较多时,GraalVM 官方推荐使用配置文件管理。

目前配置文件支持用户自行配置 3 个属性。

  • Args:设置各项参数。不同参数用空格分隔,换行用 \\

  • JavaArgs:设置静态编译框架本身的 JVM 参数,等同于命令行模式的 -J<Flag>

  • ImageName:设置编译生成的文件名,等同于命令行模式的 -H:Name。

配置文件的默认保存路径是静态编译时 classpath 下的 META-INF/native-image/native-image.properties。Substrate VM 会从 classpath 的文件目录结构或 classpath 上的 jar包中按上述路径寻找有效的配置文件。

Maven 模式

GraalVM 也支持通过 Maven 插件启动静态编译,示例配置信息如下,

<plugin><groupId>org.graalvm.buildtools</groupId><artifactId>native-maven-plugin</artifactId><version>${native.maven.plugin.version}</version><extensions>true</extensions><executions><execution><id>build-native</id><goals><goal>build</goal></goals><phase>package</phase></execution></executions><configuration><!-- Set this to true if you need to switch this off --><skip>false</skip><!-- The output name for the executable sh exe --><imageName>${artifactId}</imageName><mainClass>${app.main.class}</mainClass><buildArgs><!-- With Enterprise you can use the G1GC --><!--buildArg>- -gc=G1</buildArg--><buildArg>-Dgraal.CompilationFailureAction=Diagnose-Dgraal.ShowConfiguration=info-Dspring.native.verbose=true-Dspring.native.verify=false--verbose--allow-incomplete-classpath--report-unsupported-elements-at-runtime--diagnostics-mode--no-fallback-H:+ReportExceptionStackTraces-H:+PrintAOTCompilation-H:+PrintClassInitialization-H:+PrintFeatures-H:+PrintHeapHistogram-H:+PrintImageElementSizes-H:+PrintImageHeapPartitionSizes-H:+PrintJNIMethods-H:+PrintUniverse-H:+PrintMethodHistogram-H:+PrintRuntimeCompileMethods-H:Log=registerResource:3-H:+DynamicProxyTracing-H:+LogVerbose-H:+ProfileDeoptimization-H:TraceClassInitialization=true-H:+AddAllCharsets-H:+JNI-H:+DashboardAll-H:+DashboardPretty<!---H:+ReflectionPluginTracing--><!---H:+TraceLoggingFeature--><!--  -H:+TraceLocalizationFeature-H:+TraceSecurityServices-H:+TraceServiceLoaderFeature-H:+TraceVMOperations-->-H:DeadlockWatchdogInterval=10-H:+DeadlockWatchdogExitOnTimeout-H:+PrintAnalysisCallTree-H:+PrintAnalysisStatistics-H:+PrintCompilation</buildArg></buildArgs></configuration>
</plugin>

静态编译的信息在 <configuration> 项中配置。

  • <skip>:控制是否执行静态编译,true 表示不执行,false 表示执行。

  • <imageName>:配置编译的文件名。

  • <mainClass>:设置编译的入口主类名。

  • <buildArgs>:设置编译参数,多个参数之间用空格分隔。

Gradle 模式

静态编译 HelloWorld

创建一个 HelloWorld.java 文件,代码如下,

public class HelloWorld {public static void main(String[] args) {System.out.println("Hello World!");}
}

通过执行下列 2 行命令即可编译得到 HelloWorld 程序的二进制可执行文件 HelloWorld,

javac HelloWorld.java
native-image -cp ./ HelloWorld

运行 helloworld 程序,

./helloworld--- output
Hello World!
---

静态编译 SpringBoot3 项目

创建一个 SpringBoot3 项目,依赖里面勾选 “GraalVM Native Support”,
通过实战探究 GraalVM 静态编译

修改启动类的代码如下,

package com.oracle.springboot3nativeimage;import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;@Slf4j
@RestController
@SpringBootApplication
public class NativeApplication {@GetMapping("/")public String sayHello() {log.info("### In NativeApplication.sayHello() ###");return "Hello World!";}public static void main(String[] args) {SpringApplication.run(NativeApplication.class, args);}}

通过执行下列命令即可编译得到 SpringBoot3 程序的二进制可执行文件 nativeapplication,

./mvnw -Pnative native:compile

运行 nativeapplication 程序,

./target/nativeapplication

通过实战探究 GraalVM 静态编译

访问 localhost:8188

curl localhost:8188; echo--- output
Hello World!
---

通过执行下列命令即可构建得到包含 SpringBoot3 程序的二进制可执行文件的容器镜像,

./mvnw -Pnative spring-boot:build-image

从输出的日志中,我们能查看到整个构建工作中执行了 native-image 命令,

(略)
Executing native-image -H:+StaticExecutableWithDynamicLibC -H:Name=/layers/paketo-buildpacks_native-image/native-image/com.oracle.springboot3nativeimage.NativeApplication -cp /workspace:/workspace/BOOT-INF/classes:/workspace/BOOT-INF/lib/logback-classic-1.4.6.jar:/workspace/BOOT-INF/lib/logback-core-1.4.6.jar:/workspace/BOOT-INF/lib/log4j-to-slf4j-2.19.0.jar:/workspace/BOOT-INF/lib/log4j-api-2.19.0.jar:/workspace/BOOT-INF/lib/jul-to-slf4j-2.0.7.jar:/workspace/BOOT-INF/lib/jakarta.annotation-api-2.1.1.jar:/workspace/BOOT-INF/lib/snakeyaml-1.33.jar:/workspace/BOOT-INF/lib/jackson-databind-2.14.2.jar:/workspace/BOOT-INF/lib/jackson-annotations-2.14.2.jar:/workspace/BOOT-INF/lib/jackson-core-2.14.2.jar:/workspace/BOOT-INF/lib/jackson-datatype-jdk8-2.14.2.jar:/workspace/BOOT-INF/lib/jackson-datatype-jsr310-2.14.2.jar:/workspace/BOOT-INF/lib/jackson-module-parameter-names-2.14.2.jar:/workspace/BOOT-INF/lib/tomcat-embed-core-10.1.7.jar:/workspace/BOOT-INF/lib/tomcat-embed-el-10.1.7.jar:/workspace/BOOT-INF/lib/tomcat-embed-websocket-10.1.7.jar:/workspace/BOOT-INF/lib/spring-web-6.0.7.jar:/workspace/BOOT-INF/lib/spring-beans-6.0.7.jar:/workspace/BOOT-INF/lib/micrometer-observation-1.10.5.jar:/workspace/BOOT-INF/lib/micrometer-commons-1.10.5.jar:/workspace/BOOT-INF/lib/spring-webmvc-6.0.7.jar:/workspace/BOOT-INF/lib/spring-aop-6.0.7.jar:/workspace/BOOT-INF/lib/spring-context-6.0.7.jar:/workspace/BOOT-INF/lib/spring-expression-6.0.7.jar:/workspace/BOOT-INF/lib/spring-boot-3.0.5.jar:/workspace/BOOT-INF/lib/spring-boot-autoconfigure-3.0.5.jar:/workspace/BOOT-INF/lib/slf4j-api-2.0.7.jar:/workspace/BOOT-INF/lib/spring-core-6.0.7.jar:/workspace/BOOT-INF/lib/spring-jcl-6.0.7.jar:/workspace/BOOT-INF/lib/spring-boot-jarmode-layertools-3.0.5.jar com.oracle.springboot3nativeimage.NativeApplication
(略)

运行容器镜像,

docker run --rm -it -p 8188:8188 nativeapplication:0.0.1

通过实战探究 GraalVM 静态编译
访问 localhost:8188

curl localhost:8188; echo--- output
Hello World!
---

未完待续!