AOT、Native 与检查点恢复:构建时冻结了什么
同一个Spring应用可以普通JVM启动、先做Spring AOT再在JVM运行,或者编译成平台专用Native可执行文件。AOT不是把正在运行的JVM保存下来;CRaC的checkpoint/restore才处理运行中JVM状态,它们的收益与失效边界不同。本页沿用Boot4.0.9/Framework7.0.10,应用源target Java21;普通/AOT JVM用Liberica21.0.11+11,Native专用工具链用GraalVM Community25 Innovation4(JDK25.0.4.1.1,macOS arm64)。工具链来源、sha256和实际构建见实验入口。
旧机制基线Framework6.1.12/Boot3.3.3保留。不是把“旧版本没有AOT”当成迁移差异:两条线均已有AOT/Native与6.1起的checkpoint集成;本次主要对照运行模式和Framework7的语言/空安全变化。选择版本应核对相应官方文档及构建工具,不从LTS或Native名字推断维护期限。
三种启动产物,三个不同的承诺
| 模式 | 先产出什么 | 运行环境和限制 |
|---|---|---|
| 普通JVM | class/JAR,运行期加载配置并发现Bean定义 | 需要兼容JVM;可在正常启动时按Profile改变Bean图 |
| Spring AOT JVM | 生成Bean注册/初始化Java、代理字节码、RuntimeHints,再打JAR | 仍需要JVM;启用生成产物后Bean图已按构建条件冻结;不是本机指令可执行文件 |
| GraalVM Native | 分析可达代码和元数据、平台链接,产出本机可执行文件 | 目标OS/架构/工具链匹配;动态反射/资源/代理等需可达性描述,不支持任意运行期加类 |
Native不是“绝不使用反射”,而是反射需要编译期已知的可达性元数据。Native也不是“绝不GC”,只是执行器和内存模型不再等同HotSpot;本页不把功能性启动日志当性能基准。CRaC是另一维:保留JVM运行状态,包括可能已接触的配置/秘密,恢复还依赖兼容的OS/JVM与资源处理。
AOT引擎:生成定义,不执行业务预热
固定Framework v7.0.10官方AOT章节说明入口ApplicationContextAotGenerator接收可分析的GenericApplicationContext与GenerationContext:先refreshForAotProcessing准备Bean定义和相关处理器,再执行BeanFactoryInitializationAotProcessor贡献;BeanRegistrationAotProcessor对候选BeanDefinition补生成代码和RuntimeHints。
普通业务Bean不应在此跑真实业务;但AOT处理器自身及其依赖可能被初始化,不能宣称构建过程永不接触网络/凭据。registerSingleton(existingInstance)、运行期lambda instance supplier等不能凭现成实例反推出可生成的注册过程;改用可分析的Bean定义、明确工厂返回类型,必要时提供AOT处理器。返回Object再靠运行期猜真实类型会丢失代理/注解信息。
实验使用@SpringBootApplication(proxyBeanMethods=false)、静态可分析@Bean、清晰返回类型;process-aot选择blue Profile,没有runner或SmartLifecycle.start观察行。产物位于app/target/spring-aot/,其中包含App__ApplicationContextInitializer和BeanDefinitions,产物应与构建输入版本绑定,不能从另一个JAR随便拷一份initializer补启动。
启用生成产物:开关不是编译器
原码:Framework v7.0.10 AotDetector,AOT在Native必需,在JVM可由spring.aot.enabled触发:
/**
* Determine whether AOT optimizations must be considered at runtime. This
* is mandatory in a native image but can be triggered on the JVM using
* the {@value #AOT_ENABLED} Spring property.
* @return whether AOT optimizations must be considered
*/
public static boolean useGeneratedArtifacts() {
return (inNativeImage || SpringProperties.getFlag(AOT_ENABLED));
}
}Boot v4.0.9在启动阶段寻找对应初始化器,固定源码节选:
private void addAotGeneratedInitializerIfNecessary(List<ApplicationContextInitializer<?>> initializers) {
if (AotDetector.useGeneratedArtifacts()) {
List<ApplicationContextInitializer<?>> aotInitializers = new ArrayList<>(
initializers.stream().filter(AotApplicationContextInitializer.class::isInstance).toList());
if (aotInitializers.isEmpty()) {
Assert.state(this.mainApplicationClass != null, "No application main class found");
String initializerClassName = this.mainApplicationClass.getName() + "__ApplicationContextInitializer";
if (!ClassUtils.isPresent(initializerClassName, getClassLoader())) {
throw new AotInitializerNotFoundException(this.mainApplicationClass, initializerClassName);
}
aotInitializers.add(AotApplicationContextInitializer.forInitializerClasses(initializerClassName));
}
initializers.removeAll(aotInitializers);
initializers.addAll(0, aotInitializers);
}
if (NativeDetector.inNativeImage()) {
NativeImageRequirementsException.throwIfNotMet();
}
}类名由mainApplicationClass加__ApplicationContextInitializer确定;没有对应生成类抛AotInitializerNotFoundException。Boot的失败分析器可能只打印可操作描述,不打印这个异常类名;实验以非零退出和initializer could not be found描述断言。给普通JAR加-Dspring.aot.enabled=true不会现场生成这些类。正确恢复是重新按固定输入process-aot并编译打包,或者明确回到普通JVM模式。
公开入口先clean package得到普通JAR并验证这个负例,再用mvn -pl app -Pnative package执行父POM的process-aot。这里只生成Spring AOT产物;native:compile是后续另一阶段,两个阶段不能用同一“编译完成”描述混过去。
Profile、条件与属性:图冻结,值仍有作用域
实验有两个互斥Bean:
record Choice(String value) { }
@Bean @Profile("blue") Choice blue() { return new Choice("blue"); }
@Bean @Profile("red") Choice red() { return new Choice("red"); }
@Bean ApplicationRunner runner(Choice choice, Environment env) {
return args -> System.out.println(choice.value()+":"+env.getProperty("runtime.value"));
}构建process-aot明确选择blue,普通JVM用red启动得到red;相同AOT JAR使用-Dspring.aot.enabled=true再传运行期red,实际Bean仍blue,而--runtime.value=changed仍读到changed。Native同样得到blue/changed。结论不是“所有属性在构建时固定”,而是决定Bean有无/类型的条件必须在构建时选定;已存在Bean读取的运行期值还有其配置语义,须逐项确认。若生产确需不同Bean图,分别构建对应产物,或保留普通JVM;不要用修改Profile掩盖产物身份。
构建环境的ConfigData、Profiles、条件和运行时依赖要可审计;把生产凭据放进构建环境是额外暴露面。AOT产物也要像代码一样审查,确认没有把构建路径/秘密带进可发布资源。
反射、资源和代理:说明可达性
实验把插件类名放在系统属性里,由Class.forName、getDeclaredMethod动态调用。这种输入不是任意来自用户的表达式:只用于本机合成插件的边界验证。对已知实现显式注册:
class Hints implements RuntimeHintsRegistrar {
public void registerHints(RuntimeHints hints, ClassLoader loader) {
hints.reflection().registerType(NativeGreeting.class,
MemberCategory.INVOKE_DECLARED_METHODS);
hints.resources().registerPattern("u28/greeting.txt");
}
}@ImportRuntimeHints(Hints.class)将注册器接入AOT。反射注册并不等于运行时调用方法;资源pattern也不等于文件内容已验证,所以实验在Native真机读取资源并断言resource-ok、反射得到hello:u28。JDK代理需描述接口组,序列化类型需相应hint;Spring能分析的代理在构建时生成,但应用自建代理或运行期拼接类型不能因此豁免。
未注册的UnregisteredGreeting在JVM中动态加载可成功,Native相同选择失败。本例只验证这一条动态边界;并非所有反射都必须手填hint,Graal/Spring会推导某些静态可分析调用。可用tracing agent辅助采集元数据,但只覆盖实际执行路径:只跑正常登录得不到错误/撤销路径的完整可达性。正式发布仍需Native下独立验证失败、序列化、资源与代理路径。
本机原生构建:固定工具链和资源上限
工具链登记固定官方非prerelease资产及sha256,下载只解压到任务缓存,不改全局JAVA_HOME。应用普通编译target21;Native编译进程改用GraalVM JDK25,不冒充两者同一个JVM。Native Build Tools0.11.5由Boot父POM管理。专用插件配置--no-fallback、--parallelism=4、-J-Xmx3g,无容器镜像/生产访问/监听端口。
JAVA_HOME=$(/usr/libexec/java_home -v21) U28_GRAALVM_HOME=/path/to/verified-graalvm/Contents/Home python3 labs/aot-native/run.py完整入口构建普通JAR→缺失AOT负例→AOT JAR→JVM对照→Native编译运行→未注册反射负例,并校验语言互操作。CPU、内存、链接器、签名/分发与CI制品矩阵都是成本;macOS arm64可执行文件不能直接放到Linux amd64。Buildpacks/Gradle是另一个构建入口(Gradle完整对照归U62),不因为本机Maven通过就声明它们或所有第三方库兼容。
package study.aot;
import java.nio.charset.StandardCharsets;
import org.springframework.aot.AotDetector;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
@SpringBootApplication(proxyBeanMethods=false)
@ImportRuntimeHints(App.Hints.class)
public class App {
public static void main(String[] args) {
try (var context = SpringApplication.run(App.class,args)) { }
}
record Choice(String value) { }
@Bean @Profile("blue") Choice blue() { return new Choice("blue"); }
@Bean @Profile("red") Choice red() { return new Choice("red"); }
@Bean Probe probe() { return new Probe(); }
@Bean ApplicationRunner runner(Choice choice, Environment env) {
return args -> {
Class<?> plugin=Class.forName(System.getProperty("u28.plugin","study.aot.NativeGreeting"));
String reflected=(String)plugin.getDeclaredMethod("greet",String.class).invoke(null,"u28");
try(var resource=App.class.getResourceAsStream("/u28/greeting.txt")) {
if(resource==null)throw new IllegalStateException("missing resource");
System.out.println("U28OBSERVE runner aot="+AotDetector.useGeneratedArtifacts()+" bean="+choice.value()+" value="+env.getProperty("runtime.value","default")+" reflection="+reflected+" resource="+new String(resource.readAllBytes(),StandardCharsets.UTF_8).trim());
}
};
}
static class Hints implements RuntimeHintsRegistrar {
@Override public void registerHints(RuntimeHints hints, ClassLoader loader) {
hints.reflection().registerType(NativeGreeting.class,MemberCategory.INVOKE_DECLARED_METHODS);
hints.resources().registerPattern("u28/greeting.txt");
}
}
static class Probe implements SmartLifecycle {
private boolean running;
Probe() { System.out.println("U28OBSERVE bean constructed"); }
@Override public void start() { running=true;System.out.println("U28OBSERVE lifecycle start"); }
@Override public void stop() { running=false;System.out.println("U28OBSERVE lifecycle stop"); }
@Override public boolean isRunning() { return running; }
@EventListener public void refreshed(ContextRefreshedEvent event) { System.out.println("U28OBSERVE context refreshed"); }
}
}CRaC:恢复的是状态,资源必须重新取得
固定Framework v7.0.10checkpoint文档:真实功能目前要求Linux、支持checkpoint/restore的JVM、org.crac:crac至少1.4.0以及相应JVM参数。Boot4.0.9 BOM解析crac1.5.0不等于普通JVM支持CRaC,本实验没有把这个jar装上就冒称可恢复。
按需checkpoint可用jcmd <自有pid> JDK.checkpoint请求,JVM配置-XX:CRaCCheckpointTo=<独有目录>;恢复用-XX:CRaCRestoreFrom=<该目录>。这是Linux专用使用示例,本机macOS未运行,不能拿onRefresh退出或Java对象序列化替代快照证据。
运行中checkpoint前,Spring停止运行的Lifecycle Bean,让其关闭线程、socket、文件与连接;恢复后再start重新取得资源。非Spring资源需org.crac.Resource注册beforeCheckpoint/afterRestore处理。连接不能沿用旧租约/认证或假定远端仍认旧session;恢复后查凭据与租约、重新建连接、重新探测readiness。恢复进程有可能保留旧时间/缓存/任务意图,应重新验证时效和幂等;fixedRate可能追赶停顿期间错过的任务,不能把所有定时任务恢复当成“从现在算下一次”。
onRefresh:区分构造、启动、ready和退出
固定v7.0.10 DefaultLifecycleProcessor#onRefresh:
@Override
public void onRefresh() {
if (checkpointOnRefresh) {
checkpointOnRefresh = false;
new CracDelegate().checkpointRestore();
}
if (exitOnRefresh) {
Runtime.getRuntime().halt(0);
}
this.stoppedBeans = null;
try {
startBeans(true);
}
catch (ApplicationContextException ex) {
// Some bean failed to auto-start within context refresh:
// stop already started beans on context refresh failure.
stopBeans(false);
throw ex;
}
this.running = true;
}spring.context.checkpoint=onRefresh在生命周期start之前请求checkpoint;spring.context.exit=onRefresh则直接Runtime.halt(0),之后的start不会发生。后者用于无需Linux/CRaC的生命周期检查,不是恢复模拟器,而且halt不会执行普通finally/shutdown hook清理;只能在无外部资源的自有探针进程使用。
实验Probe构造打印bean constructed、start打印lifecycle start、ContextRefreshedEvent与runner分别打印。普通运行四类观察都有,关闭context有stop;exit=onRefresh只有构造观察,未到start/refreshed/runner;普通JVM启用checkpoint=onRefresh会按预期失败,明确需要CRaC。这些实际验证生命周期时点和缺能力失败,真实checkpoint文件/恢复没有在本机执行。 ready仍晚于runner等启动环节,见Boot启动课,不能把Bean已经构造称为业务就绪。
本例未引入org.crac库,失败首先发生在DefaultLifecycleProcessor构造器的classpath检查,日志要求CRaC JVM与org.crac:crac;它不证明“已装库后JVM调用失败”的另一条分支。两项前提均须满足,真实恢复仍需受支持Linux环境独立验证。
Framework6到7:空安全不是多一个运行时检查
Framework6.1.12主要使用org.springframework.lang的Nullable、NonNull、包级NonNullApi/NonNullFields;旧文档明确不支持泛型参数/数组元素等细粒度声明。Framework7转用JSpecify,旧注解自7起deprecated而不是立刻删除。@NullMarked把范围内未标注的类型使用解释为非null,@Nullable允许对应类型位置为空;泛型/数组元素和容器本身需分别声明。
@NullMarked
package study.nulls;
// 另一个类文件里:
public static String required(String value) { return value.toUpperCase(); }
public static @Nullable String optional(boolean found) { return found ? "yes" : null; }这个片段的完整imports、package-info、Java/Kotlin文件在实验中。Java javac不会仅因JSpecify拒绝required(null),也不会自动插入运行时requireNonNull;实验该调用能编译、运行产生NPE并被负例捕获。需要静态工具如NullAway或IDE检查,输入边界仍要实际校验,不把注解视为HTTP参数验证。
迁移数组时尤其小心:旧Spring @Nullable Object[]对应数组整体可空,在JSpecify中应写Object @Nullable [];不要机械换import导致改成“元素可空”。选定的官方迁移章节就近给出这个例子。
同在@NullMarked作用域,List<String>的容器和元素均非null,List<@Nullable String>允许元素为空,@Nullable List<String>允许容器为空而元素非null。注解位置是契约的一部分;这组数组/泛型例子是固定文档语义核查,没有在本实验加入完整泛型工具兼容矩阵。
Kotlin、协程与Groovy:同在JVM不代表同一边界
Kotlin2.2.21、JSpecify1.0.1、Groovy5.0.8与协程依赖由Boot BOM管理。Kotlin编译开启-Xjspecify-annotations=strict:正常调用和nullable安全调用通过;把null传给required、把optional结果赋给非空String,两项编译负例失败,删除自有Bad.kt后clean compile恢复。Java仍可编译同一个null调用,显示契约由实际工具消费,不能用Kotlin编译通过宣布所有Java调用安全。
Framework7的Kotlin运行要求为Kotlin2.2+、kotlin-stdlib与kotlin-reflect;Jackson处理Kotlin DTO还需要与所用Jackson主版本匹配的Kotlin模块,不能只加编译器就认为JSON构造参数与默认值已受支持。本实验仅调用Java API和协程桥接,使用stdlib;未测试反射式Bean参数发现或Kotlin JSON端点,不据此省略真实Spring Kotlin应用的reflect/JSON依赖。
Kotlin类/方法默认final,Spring代理配置要考虑open、kotlin-spring/all-open插件或无需代理的设计;proxyBeanMethods=false只免除配置类Bean方法代理,并不让任意final服务方法可被AOP。参数名/可空性反射有其kotlin-reflect要求,不把纯Java接口或!!当成可空契约。扩展函数是编译期语法,不是给Java类真的新增可动态反射方法。
协程suspend表达可挂起调用,不保证函数体自动非阻塞。实验将Flow转asFlux,awaitSingle桥接Mono;显式ReactorContext中的traceId在挂起调用可见,withTimeoutOrNull取消awaitSingle后上游doOnCancel=1。不能把runBlocking放进Netty handler,也不能把任意ThreadLocal当作跨挂起身份;响应式事务/安全的资源与授权仍按U27订阅Context及U31处理。协程不是另一套不受数据库连接/取消结果限制的事务。
Groovy支持以GroovyBeanDefinitionReader读取受信Bean DSL,实验在普通JVM注册String Bean并取到groovy-ok。DSL具有代码执行能力,不能把用户输入当配置;动态加载Groovy脚本的能力也不能直接带入Native闭世界模型。AOT定义应转成可分析注册,若业务本质需要频繁运行期脚本加载,保留JVM并隔离信任边界是合理选项。
package study.nulls
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.reactor.ReactorContext
import kotlinx.coroutines.reactor.asFlux
import kotlinx.coroutines.reactor.awaitSingle
import reactor.core.publisher.Mono
import reactor.util.context.Context
import java.util.concurrent.atomic.AtomicInteger
fun main() = runBlocking {
check(Names.required("ok") == "OK")
check(Names.optional(false)?.length == null)
println("U28OBSERVE kotlin strict accepted=nonnull nullableSafeCall=true")
check(flowOf("a", "b").asFlux().collectList().awaitSingle() == listOf("a", "b"))
val carried = withContext(ReactorContext(Context.of("traceId", "u28"))) {
Mono.deferContextual { Mono.just(it.get<String>("traceId")) }.awaitSingle()
}
check(carried == "u28")
val cancelled = AtomicInteger()
val result = withTimeoutOrNull(100) {
Mono.never<String>().doOnCancel { cancelled.incrementAndGet() }.awaitSingle()
}
check(result == null && cancelled.get() == 1)
println("U28OBSERVE kotlin coroutine flow=[a,b] reactorContext=u28 awaitCancel=1")
}条件变化后的选择与恢复
假设一组短任务每小时启动600次,初始化2秒,每次实际工作300ms。仅模型计算,初始化总和1200秒/小时,工作180秒/小时,启动值得优化;但这不是本实验测得性能。Native若每次构建需数分钟、需多平台产物且脚本/动态依赖多,构建和回滚成本可能更高;长期稳定高吞吐服务还要测热身后行为,不能用启动快推断稳态吞吐高。
两个合理路径:保持普通JVM、复用长进程/连接并优化初始化,保留运行期条件与动态语言弹性;或者冻结配置图,给每个目标平台构建Native,以构建产物身份、反射/资源失败测试和回滚制品管理降低风险。Linux/受控JVM下CRaC可保留预热状态,但快照是额外有时效/秘密/平台约束的制品,恢复负责人必须重建外部资源、校验租约和业务不变量、确认readiness后才接流量。
演进触发应来自同工作负载下的启动/内存/延迟预算与部署频度。测量至少拆开构建耗时、冷启动到可服务、稳态窗口、制品大小和恢复失败率;本票只报告功能运行和退出状态,没有用一次本机日志制造P99/容量结论。
官方来源与复现限制
- Framework7 AOT目录、Boot4 Native、Boot系统要求:固定tag,不以浮动current断言兼容。
- Framework7语言目录、6.1.12空安全、7.0.10空安全:正式API、deprecated与编译器行为分开。
- 实验README和运行JSON分别记录普通JVM/AOT/Native、语言编译正负与CRaC未运行。构建Native只证明本平台和被执行路径,不证明所有依赖、生产性能或CRaC恢复;范围与复核见
docs/audits/java-depth-u28-scope.md、docs/audits/java-depth-u28-verification.md。
闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。