Spring 第一次启动执行慢,从 JVM 角度讲讲?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

面试官抛出这个问题,可不是想听你背 Spring 启动流程的。他的潜台词是:

  1. 是否理解 JVM 的运行时机制? 面试官想知道你是否熟悉 JVM 的类加载、JIT 编译、内存分配与 GC 这些基础概念,并能将它们与实际应用(Spring)的性能问题联系起来。
  2. 能否将框架知识与底层技术相结合? 考察你是否具备“全栈式”的技术视野,而不仅仅是一个只会用框架的 “API Caller”。能不能看到 Spring 优雅背后的 JVM 代价。
  3. 分析性能问题的思路是否清晰? 对于一个宏观的 “慢” 问题,你能不能从多个维度(CPU、内存、IO、编译)进行拆解,找到可能的瓶颈点。
  4. 是否有 JVM 调优的实战经验? 如果能顺带提出一些针对性的 JVM 参数调优建议,比如使用 -Xverify:none、调整 CodeCache、甚至提到 CDS(Class Data Sharing)或 AppCDS,那绝对能让面试官眼前一亮。

核心答案

从 JVM 的角度来看,Spring 应用第一次启动慢,本质上是 “类的生命周期管理”、“代码的执行方式” 以及 “内存的分配与回收” 这三个方面在启动初期协同工作时产生的“冷启动”开销。简单来说,就是 JVM 还没 “热” 起来,大量的资源消耗在了准备工作上。

技术深度解析

我们可以把这 “慢” 拆解成几个关键环节来看:

1. 类加载的开销:把 “食材” 搬进厨房

Spring 是一个非常 “重” 的框架,它自身以及你的业务代码,包含了成百上千个类。第一次启动时,JVM 的类加载子系统(ClassLoader)需要把这些类从磁盘(或 JAR 包)中读取进来,然后执行一套严格的 “安检” 流程:

  • 加载: 找到并读入类的二进制数据。
  • 验证: 确保 Class 文件的字节流符合 JVM 规范,不会危害 JVM 自身安全。这是一个比较耗时的操作。
  • 准备: 为类的静态变量分配内存,并赋予默认值。
  • 解析: 将常量池中的符号引用替换为直接引用(比如把类名 “com.example.UserService” 变成实际的内存地址)。
  • 初始化: 执行类的 <clinit> 方法,即静态代码块和静态变量的赋值。

这个过程对于任何一个类都是 “一次性” 的。Spring 启动时需要加载的类数量巨大,这部分的 CPU 和 IO 开销自然就成了启动时间的一部分。

2. 代码执行的预热:厨师刚开始 “手生”

Java 程序是 “解释 + 编译” 混合执行的。在启动初期,绝大部分代码都是解释执行的。解释执行的特点就是逐行翻译字节码并运行,速度相对较慢。

随着程序的运行,JVM 的即时编译器(JIT,Just-In-Time Compiler)会介入。它会统计出那些被频繁调用的“热点代码”(比如 Spring 核心的 getBean() 方法),然后花时间将它们编译成与当前操作系统、CPU 架构匹配的本地机器码。下次再执行这段代码时,就直接调用编译好的机器码,速度会快几个数量级。

第一次启动时,JIT 编译器还没来得及 “预热”,整个应用基本都处于 “慢速” 的解释执行模式。只有当应用运行一段时间后,性能才会逐渐达到最佳状态。

3. 对象创建与 GC 的干扰:边炒菜边刷碗

Spring 的 IoC 容器在启动阶段会进行密集的对象创建。它会扫描配置,为每个 Bean 生成 BeanDefinition,然后通过反射实例化对象,再处理依赖注入。这个过程会瞬间产生大量的 “临时” 对象和最终的 “单例” Bean。

这会给 JVM 的堆内存带来巨大压力:

  • 频繁的 Young GC: Eden 区很快被填满,触发 Minor GC。虽然 Minor GC 很快,但如果启动过程中发生了几十次甚至上百次,累积起来的暂停时间也相当可观。
  • 对象晋升: 一些生命周期较长的 Bean 或者创建过程中产生的大对象,可能过早地进入老年代,如果老年代空间不足,又会触发代价更高的 Full GC,导致明显的停顿。

4. 反射与动态代理的额外开销

Spring 大量使用反射(如依赖注入 @Autowired、属性赋值)和动态代理(AOP)。反射调用在首次执行时,需要进行一系列的安全检查和访问权限验证,比普通的方法调用要慢。而且,这些反射调用的代码在早期也很难被 JIT 编译器有效地优化,因为它的调用点(Call Site)是动态变化的。

最佳实践与调优思路

既然知道了原因,我们就能从 JVM 层面做一些针对性的优化:

  • 加速类加载: 在开发或预发环境,可以临时加上 -Xverify:none-noverify 参数,跳过字节码验证阶段,能有效缩短启动时间(但在生产环境建议保留,以保证安全性)。更高阶的可以使用 CDS(Class Data Sharing)AppCDS,将核心系统类甚至应用类提前 dump 到一个共享归档文件中,下次启动时直接内存映射加载,省去类加载的大量步骤。
  • 助力 JIT 编译:
    • 适当增大 CodeCache(存放 JIT 编译后的机器码),通过 -XX:ReservedCodeCacheSize 调整,避免因 CodeCache 满了导致编译停止,影响后续性能。
    • 可以使用 -XX:CompileThreshold 调整编译阈值,让 JIT 更早介入。但要注意,这可能会在启动阶段增加 CPU 开销。
  • 优化内存与 GC:
    • 合理设置初始堆大小(-Xms)和最大堆大小(-Xmx)。将初始堆设大一些,可以减少启动过程中的动态扩容和 GC 次数。
    • 根据机器配置和应用特点,选择合适的垃圾收集器,比如 G1 或 ZGC,并调整其目标停顿时间,尽量减少 GC 对启动过程的影响。
  • 应用层配合:
    • 使用 Spring 的懒加载 @Lazy 机制,让非必须的 Bean 在使用时才创建,将部分对象的创建压力分散到首次访问时,但要注意这可能会影响第一次访问的性能。
    • 减少不必要的组件扫描路径,精确指定扫描包。

总结

一句话概括:Spring 第一次启动慢,从 JVM 层面看,是海量类的 “加载验证”、大量代码的 “解释执行” 以及密集对象创建触发的 “GC活动” 这三者叠加导致的 “冷启动” 综合症。 理解这个过程,不仅能帮你通过面试,更能让你在实战中更有针对性地进行性能优化。