Spring 第一次启动执行慢,从 JVM 角度讲讲?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书
《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于
Spring AI + Spring Boot 3.x + JDK 21...,查看介绍《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/
新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍
截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
类加载机制理解:面试官想知道你是否清楚 Spring 启动过程中大量的类加载(
ClassLoader)行为,以及类加载的各个阶段开销在哪里。 -
JIT 编译原理:考察你是否了解解释执行和编译执行的区别,以及为什么"第一次"总是慢——因为 JIT 还没来得及预热(Warm-up)。
-
综合分析能力:能否把 Spring 框架行为和 JVM 底层机制串联起来,而不是孤立地背诵知识点。
核心答案
Spring 第一次启动慢,从 JVM 角度主要有 4 个原因:
| 原因 | 核心关键词 | 影响 |
|---|---|---|
| 类加载开销巨大 | ClassLoader、双亲委派、类的解析 |
首次需加载数千个类 |
| JIT 还没预热 | 解释执行、热点编译、分层编译 | 首次以解释执行为主,性能差 5-10 倍 |
| GC 压力大 | 元空间扩容、Young GC 频繁 | 大量临时对象触发频繁 GC |
| 内存分配冷启动 | 堆内存申请、TLAB 分配 | 首次分配无缓存,路径更长 |
深度解析
一、类加载开销——Spring 简直是类加载轰炸机
Spring 启动时要干的事情太多了:扫描包路径、解析注解(@Component、@Service、@Configuration...)、处理 BeanDefinition、生成代理类(CGLIB 或 JDK 动态代理)......每一步都意味着大量的类需要被加载到 JVM 中。
一个中等规模的 Spring Boot 项目,启动时加载 几千甚至上万个类 是很正常的。
上图展示了 Spring 启动时类加载的三个层次。重点说说 为什么"第一次"特别慢:
- 类加载的懒加载特性:JVM 规范允许类在"首次主动使用"时才加载。第一次启动 Spring,这些类全都是"首次使用",需要走完整的 加载 → 链接(验证、准备、解析) → 初始化 流程。而第二次启动时,如果你用了类的缓存或者运行在已经预热过的 JVM 中(比如热部署场景),很多类已经在内存中了。
- 验证阶段的开销:类加载的验证阶段要做字节码校验,确保类文件格式正确、符号引用能解析。Spring 那么多类,光是验证就够喝一壶的。
- CGLIB 代理类的生成:
@Configuration类、AOP 切面等都需要 CGLIB 在运行时动态生成字节码。这些生成的类同样要被ClassLoader加载,而且生成字节码本身就耗时。
二、JIT 预热——"第一次慢"的灵魂解释
这是这道题最核心的答案。
JVM 有两种执行方式:解释执行和 编译执行(JIT)。说白了就是——
- 解释执行:逐条读取字节码,翻译成机器码执行。快不起来,但"即拿即用"。
- JIT 编译执行:把热点代码(被频繁调用的方法)一次性编译成本地机器码缓存起来,后续直接执行机器码,速度飞快。
Spring 第一次启动时,所有方法都是"第一次调用"。BeanFactory 的创建、BeanDefinition 的解析、依赖注入、BeanPostProcessor 的回调链......这些核心方法都在解释执行,性能自然差。
举个直观的例子:Spring 启动过程中 AbstractBeanFactory.getBean() 可能被调用几千次。第一次启动时,这个方法还没被 JIT 编译,全靠解释器一行行翻译。等启动完了,JIT 终于把它编译成机器码了——但启动过程已经结束了。
而如果 JVM 不重启(比如热部署场景),第二次部署时这些热点方法已经是编译好的机器码了,速度自然快得多。
三、GC 压力——临时对象满天飞
Spring 启动过程中会创建大量临时对象:
BeanDefinition对象、BeanWrapper对象- 解析注解时产生的反射对象(
Method、Field、Annotation) - CGLIB 生成代理类时产生的字节码临时数据
BeanFactory处理依赖关系时的临时集合
这些对象大部分朝生夕死,会迅速填满 Eden 区,触发 Young GC。第一次启动时,JVM 的内存布局还没有稳定下来(Eden/Survivor 的比例还在自适应调整),GC 的效率不是最优的。
另外,如果加载的类特别多,元空间(Metaspace) 也会不断扩容。元空间默认没有上限(受限于宿主机内存),JVM 需要反复向操作系统申请内存来存放类的元数据。这个扩容过程本身就慢,而且会触发元空间的 GC(Full GC 的一部分)。
四、内存分配冷启动
第一次启动时,JVM 的堆内存是"冷"的:
- TLAB(Thread Local Allocation Buffer) 还没建立好,线程首次分配对象要走更慢的全局分配路径
- JIT 编译后的代码缓存(Code Cache) 是空的,编译后的机器码还没放进去
- JVM 内部的各种缓存(比如内联缓存 Inline Cache、方法计数器)都是空的
第二次启动时(在不重启 JVM 的前提下),这些缓存已经有了,分配路径更短。
五、怎么优化?
面试官如果追问"怎么优化",你可以抛出几个思路:
# 1. 开启分层编译(JDK 8+ 默认开启)
-XX:+TieredCompilation
# 2. 调大元空间初始值,避免反复扩容
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m
# 3. 调大初始堆内存,减少堆扩容
-Xms512m -Xmx512m
# 4. 使用 AppCDS(Application Class Data Sharing)
# 把类加载的元数据缓存起来,第二次启动直接映射
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar # 第一次:生成归档
java -XX:SharedArchiveFile=app.jsa -jar app.jar # 第二次:使用归档
# 5. Spring Boot 3.x + GraalVM Native Image
# 直接编译成本地可执行文件,彻底跳过 JIT 预热
# 启动时间从秒级降到毫秒级
其中 AppCDS 特别值得说。它是 JVM 提供的类共享机制,原理是把第一次启动时加载的类的元数据 dump 到一个归档文件中,后续启动时直接内存映射(mmap)这个文件,省去类的加载和解析过程。Spring Boot 对 AppCDS 有专门的支持,效果很明显。
而 GraalVM Native Image 则是另一个维度的解决方案——直接把 Spring 应用 AOT 编译成原生可执行文件,连 JVM 都不需要了,自然也就不存在 JIT 预热的问题。Spring Boot 3.x 对这个支持得很好。
面试高频追问
-
追问一:JIT 编译的触发条件是什么?
基于方法调用计数器和回边计数器(循环计数)。当一个方法的调用次数超过阈值(Client 模式默认 1500 次,Server 模式默认 10000 次),就会触发 JIT 编译。分层编译下阈值更低,更快触发 C1 编译。
-
追问二:为什么第二次启动就快了?是 JVM 没重启吗?
对,这里要分场景。如果 JVM 重启了,JIT 编译缓存会丢失(除非用了 JEP 代码缓存持久化)。但如果 JVM 没重启(比如热部署、开发环境的 DevTools 重启),类和 JIT 编译产物都还在,自然快。另外 AppCDS 也能在 JVM 重启后保留类加载的缓存。
-
追问三:Spring Boot 3.x 的 GraalVM Native Image 是怎么解决这个问题的?
Native Image 在构建期通过 AOT(Ahead-Of-Time)编译,把 Spring Bean 的注册、代理类的生成、配置的绑定全部在编译阶段完成了。运行时直接执行本地机器码,不需要类加载、不需要 JIT 预热、不需要反射,启动时间从秒级降到毫秒级。代价是构建时间变长,且有一些反射和动态代理的限制。
常见面试变体
- "为什么 Java 应用刚启动时性能差,跑一会就好了?"
- "JIT 编译器的工作原理是什么?解释执行和编译执行有什么区别?"
- "什么是 JVM 的预热(Warm-up)问题?怎么解决?"
- "AppCDS 是什么?能解决什么问题?"
记忆口诀
一加载二解释三编译:第一次启动 = 大量类加载 + 全程解释执行 + JIT 还没编译 + GC 频繁。预热之后 = 类已加载 + 机器码直接跑 + GC 趋于稳定。
总结
Spring 第一次启动慢,本质上是 JVM 冷启动问题在 Spring 这个重量级框架上的放大。核心就是两条:类加载太多太慢,JIT 还没预热只能解释执行。理解了这两点,再搭配 AppCDS、Native Image 这些优化手段,面试官基本满意了。
