都说 SpringBoot 启动慢 ,你知道慢在哪里吗?

一. 前言

前段时间体验了几个开源的开发框架 ,发现他们的亮点主要集中在启动快 ,内存低上面。

随之回想 SpringBoot ,发现自己并不能准确的说出 SpringBoot 启动慢的详细原因,所以才有了这篇文章。

来 ,让我们详细的理解一下 ,SpringBoot 启动这么慢 ,是做了什么?

二. 宏观路线

先来选择一个最简单的 MVC 项目 ,来看一下时间轴 :

  • 其中耗时最多的是我标注的 :prepareEnvironmentrefreshContext
  • 这段代码是 Spring 启动类里面的代码 ,这里简单列一下 :
java 复制代码
public void run(String... args) {
    // 3. 触发启动开始事件
    listeners.starting();

    // 4. 准备应用运行环境
    // 包括:加载配置、设置profile、系统环境变量等
    ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);

    // 7. 创建应用上下文
    // 根据应用类型(Web/普通)创建不同的上下文
    context = createApplicationContext();

    // 8. 准备应用上下文
    // 包括:设置环境、注册初始化器、加载source、处理前置处理器等
    prepareContext(context, environment, listeners, applicationArguments, printedBanner);

    // 9. 刷新应用上下文
    refreshContext(context);

    // 13. 触发上下文启动完成事件
    listeners.started(context);

    // 14. 执行所有注册的运行器(ApplicationRunner和CommandLineRunner)
    callRunners(context, applicationArguments);

    // 15. 触发应用运行事件
    listeners.running(context);
}

三. 看一下细节

3.1 各环节做了什么这么慢

prepareEnvironment 部分 :

  • 这里我专门做了一些简单的配置 ,可以看到这些对启动的影响微乎其微 (这里先不考虑 Cloud 取配置)
    • 其实可以理解 ,在不获取远程配置的情况下 ,整个过程中无非就是内存里面的处理
  • 来对着源码来看一下 :
java 复制代码
// 根据应用类型创建基础环境对象  
ConfigurableEnvironment environment = getOrCreateEnvironment();  

// 环境配置 : 设置profile和处理启动参数  
configureEnvironment(environment, applicationArguments.getSourceArgs());  

// 高级环境注入 : 增强属性源,提供更灵活的配置解析  
// - 特殊的环境绑定 : 驼峰 ,下划线等
// - 自动进行属性类型转换
// - 复杂的嵌套属性解析 , 占位符 $ 的变量替换
// - 各种优先级相关
ConfigurationPropertySources.attach(environment);  

// 触发环境准备事件,允许监听器处理  
listeners.environmentPrepared(environment);  

// 将环境配置绑定到SpringApplication  
bindToSpringApplication(environment);  

// 返回配置完成的环境对象  
return environment;  

refreshContext 部分 :

可以看到 ,refreshContext 部分才是大头 ,这里就是 Bean 加载创建最核心的流程 ,我们一般知道的 doGetBeanpopulateBean 就是在这个环节中进行的 :

  • 最耗时的三个地方 :
    • BeanFactory后置处理器 : 这个是由于 Spring 做Bean管理的时候 ,大量用到了这类对象
    • 初始化Web容器 : 这能理解 ,创建 Web 容器 ,Tomcat 等处理都是要耗时的
    • 结束初始化单例Bean : 这就是 Bean 创建的主流程 ,当然也会很慢
java 复制代码
// 前置刷新上下文环境  
this.prepareRefresh();

// 获取freshBeanFactory,初始化BeanFactory  
ConfigurableListableBeanFactory beanFactory = this.obtainFreshBeanFactory();  
    
// 配置BeanFactory的标准属性  
this.prepareBeanFactory(beanFactory);  


// 提供子类扩展BeanFactory的机会  
this.postProcessBeanFactory(beanFactory);  
        
// 执行BeanFactory后置处理器  
this.invokeBeanFactoryPostProcessors(beanFactory);  
        
// 注册Bean后置处理器  
this.registerBeanPostProcessors(beanFactory);  
        
// 初始化消息源  
this.initMessageSource();  
        
// 初始化应用事件广播器  
this.initApplicationEventMulticaster();  
        
// 提供子容器刷新的扩展点  
this.onRefresh();  
        
// 注册监听器  
this.registerListeners();  
        
// 初始化所有非懒加载单例Bean  
this.finishBeanFactoryInitialization(beanFactory);  
        
// 完成刷新,发布容器刷新事件  
this.finishRefresh();  

3.2 空项目如此 ,如果是加上各种依赖的生产级项目呢 ?

生产级项目的分析和上面的是完全不一样的 ,生产级的复杂度远超单一的小项目 ,这也是导致大家认为 : Spring 启动太慢了

我贴了一个我这边启动中规中矩的一个项目的处理栈 ,整个大概花了 35s , 来分析一下 :

场景一 : 多容器环境 (反复创建容器

  • 就像 SpringApplication.run() 方法 ,在主应用启动 和 Cloud BootStrap 启动的时候都会分别加载一次
  • 也就是说环境会被整个执行多次 ,那么创建不同环境的耗时是很大的

场景二 :配置侧的环境 Listener (资源文件的读取

  • 这里不能理解为我们通常用的某个Listener 的操作 ,监听个什么东西什么的
  • 这里的环境 Listener 是为了加载 BootStrap 等复杂流程
    • 或者你可以理解为这里是为了开启 Cloud 的配置加载 ,也就是初始 Bootstrap上下文 , 加载外部配置
  • ApplicationEnvironmentPreparedEvent 有很多很复杂的 Listener 在监听
    • ConfigFileApplicationListener : 用于加载外部文件
    • SpringBootServletInitializer : 用于 Servlet容器集成
    • BootstrapApplicationListener : 初始化 SpringCloud 到的配置

场景三 : 大量的 doRefrsh() Bean 的加载 (重复读资源

  • 一个10万行代码的小项目 ,反反复复的触发了40多次 refresh() ,要知道这个环节要执行近10项大型处理流程
  • 每一次循环都会触发大量的 Listner 和 PostProcessor 以及 Aware 操作

场景四 :各种 Bean 的加载 (大量 Bean

这个是一大根源 ,(由于统计时间的逻辑不够严谨,所以Bean处理的时间被分摊到 prepareRefresh 中了)

场景五 : 第三方组件 Client 的创建 (连接第三方

  • Redis 为例 ,创建 Client 的过程也消耗了大量的时间
  • 同理 MySQL , 因为创建 Client 的时候就会创建连接 ,有的甚至于会创建连接池

四. 贴一下统计的源码

大部分时间都用来写这了 ,感觉还行, 在 IDEA 里面进行 DEBUG 就行 :

  • S1 : 在需要记录的位置运行断点 Log :StopWatchExpand.start("Bean","开始-创建RedisClient")
  • S2 : 结束后执行 : StopWatchExpand.start("主线","全部完成"); StopWatchExpand.stop();
java 复制代码
package com.gang.start;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;

/**
 * 检测程序片段运行时间拓展
 */
public class StopWatchExpand {

    /**
     * 存储时间节点的静态集合
     */
    private static List<TaskEntry> taskEntries = new ArrayList<>();

    /**
     * 开启计时
     *
     * @param processLine 流程线
     * @param node        节点行为
     * @return 提示字符串
     */
    public static String start(String processLine, String node) {
        try {
            // 获取调用的类和方法信息
            StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
            // 注意: stackTrace[2]是调用start方法的方法
            String callerClass = stackTrace[2].getClassName().substring(
                    stackTrace[2].getClassName().lastIndexOf(".") + 1
            );
            String callerMethod = stackTrace[2].getMethodName();

            // 记录当前时间戳
            long currentTimeMillis = System.currentTimeMillis();

            // 将流程线、节点行为、调用类、调用方法和时间戳保存到集合
            taskEntries.add(new TaskEntry(processLine, node, callerClass, callerMethod, currentTimeMillis));

            return String.format("[ 流程: %s | 节点: %s | 调用类: %s | 调用方法: %s ] 监测到达时间: %s",
                    processLine, node, callerClass, callerMethod, formatTimestamp(currentTimeMillis));
        } catch (Exception e) {
            e.printStackTrace();
            return "执行异常: " + node;
        }
    }

    /**
     * 结束计时并记录统计
     */
    public static void stop() {
        logStatistics();
        // 清空记录,防止跨调用干扰
        taskEntries.clear();
    }

    /**
     * 打印所有任务的时间节点以及间隔时间和百分比
     */
    private static void logStatistics() {
        if (taskEntries.isEmpty()) {
            return;
        }

        // 1. 预处理:计算最大节点行为长度和最大流程行长度
        int maxNodeLength = taskEntries.stream()
                .map(entry -> entry.getNode().length())
                .max(Integer::compare)
                .orElse(30); // 默认最小宽度为30

        // 保证processLine长度为10
        int processLineWidth = 10;
        int classNameWidth = 30;
        int methodNameWidth = 30;

        // 2. 创建格式化模板,使用动态宽度
        String headerFormat = "%-" + (maxNodeLength + 10) + "s |%-" + processLineWidth + "s | %-" + classNameWidth + "s | %-" + methodNameWidth + "s | %-9s | %-12s | %-24s | %-30s\n";
        String rowFormat = "%-" + processLineWidth + "s |%-" + (classNameWidth+4) + "s | %-" + (methodNameWidth+4) + "s |%-11d ms | %-13.2f%% | %-31d ms | %-30d ms\n";

        // 3. 计算总时长:从第一个节点到最后一个节点的时间差
        long startTime = taskEntries.get(0).getTimeMillis();
        long endTime = taskEntries.get(taskEntries.size() - 1).getTimeMillis();
        long totalDuration = endTime - startTime;

        // 4. 构建日志字符串
        StringBuilder sb = new StringBuilder();
        sb.append("--------------------------------------------------------------------------------------------------\n");
        sb.append("时间节点统计:\n");
        sb.append("--------------------------------------------------------------------------------------------------\n");

        // 5. 使用动态宽度的格式化模板打印表头
        sb.append(String.format(headerFormat,
                "节点行为", "流程", "调用类", "调用方法", "节点耗时", "占比", "相对总初始节点时间戳",
                "相对当前流程初始节点时间戳"));
        sb.append("--------------------------------------------------------------------------------------------------\n");

        // 6. 遍历并格式化每一行
        for (int i = 0; i < taskEntries.size(); i++) {
            TaskEntry entry = taskEntries.get(i);

            // 填充processLine到固定长度
            String processLine = String.format("%-" + processLineWidth + "s",
                    entry.getProcessLine().length() > processLineWidth
                            ? entry.getProcessLine().substring(0, processLineWidth)
                            : entry.getProcessLine());

            // 截断或填充调用类和方法名
            String callerClass = padString(entry.getCallerClass(), classNameWidth);
            String callerMethod = padString(entry.getCallerMethod(), methodNameWidth);

            String node = padChineseNode(entry.getNode(), maxNodeLength + 10);
            long taskTimeMillis = entry.getTimeMillis();

            // 7. 计算时间相关指标
            long interval = (i == 0) ? 0 : (taskTimeMillis - taskEntries.get(i - 1).getTimeMillis());
            double intervalPercentage = totalDuration > 0 ? ((double) interval / totalDuration) * 100 : 0.0;

            long relativeToInitial = taskTimeMillis - startTime;
            long relativeToCurrentProcess = (i == 0) ? 0 : (taskTimeMillis - startTime);

            // 8. 使用动态宽度的格式化模板打印每一行
            sb.append("| ");
            sb.append(node);
            sb.append("   |");
            sb.append(String.format(rowFormat,
                    processLine,
                    callerClass,
                    callerMethod,
                    interval,
                    intervalPercentage,
                    relativeToInitial,
                    relativeToCurrentProcess));
        }

        sb.append("---------------------------------------------------------------\n");

        // 9. 输出到日志
        System.out.println(sb);
    }

    /**
     * 格式化时间戳为字符串
     *
     * @param timestamp 时间戳
     * @return 格式化后的字符串
     */
    private static String formatTimestamp(long timestamp) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
        return sdf.format(timestamp);
    }

    /**
     * 填充或截断字符串到指定长度
     */
    private static String padString(String input, int length) {
        if (input == null) input = "";
        if (input.length() > length) {
            return input.substring(0, length);
        }
        return String.format("%-" + length + "s", input);
    }

    /**
     * 处理中文节点的填充
     */
    private static String padChineseNode(String node, int maxNodeLength) {
        int width = 0;
        StringBuilder result = new StringBuilder();

        for (char c : node.toCharArray()) {
            int charWidth = (c >= 0x4E00 && c <= 0x9FA5) ||
                    (c >= 'a' && c <= 'z') ||
                    (c >= 'A' && c <= 'Z') ?
                    ((c >= 0x4E00 && c <= 0x9FA5) ? 2 : 1) : 0;

            if (width + charWidth > maxNodeLength) {
                break;
            }

            if (charWidth > 0) {
                result.append(c);
                width += charWidth;
            }
        }

        while (width < maxNodeLength) {
            result.append(" ");
            width++;
        }

        return result.toString();
    }

    /**
     * 任务条目类,增加调用类和调用方法字段
     */
    private static class TaskEntry {
        private String processLine;   // 流程线
        private String node;           // 节点行为
        private String callerClass;    // 调用类
        private String callerMethod;   // 调用方法
        private long timeMillis;       // 时间戳

        public TaskEntry(String processLine, String node, String callerClass, String callerMethod, long timeMillis) {
            this.processLine = processLine;
            this.node = node;
            this.callerClass = callerClass;
            this.callerMethod = callerMethod;
            this.timeMillis = timeMillis;
        }

        // 增加getter方法
        public String getProcessLine() { return processLine; }
        public String getNode() { return node; }
        public String getCallerClass() { return callerClass; }
        public String getCallerMethod() { return callerMethod; }
        public long getTimeMillis() { return timeMillis; }
    }

    // 可选的main方法,用于测试
    public static void main(String[] args) {
        // 测试用例
        start("测试流程", "开始处理");
        try {
            Thread.sleep(100); // 模拟处理时间
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        start("测试流程", "处理中");
        try {
            Thread.sleep(50); // 模拟处理时间
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        stop();
    }
}

总结

整个过程中 ,Client 端的连接是最耗时的 ,其次是配置读取 。 也就是外部资源的加载更耗时 。

所以后面看看新版本的时候 ,来看一下他们是怎么解决的 ,以及其他优秀的开源组件又是怎么解决的。

SpringBoot 本身是知道自己过于臃肿的 ,所以在后面的迭代中都有意识的为自己的代码进行瘦身。

先看懂了 SpringBoot2 的慢 ,后面会有一篇来感受一下SpringBoot3 干了什么 ,以及是否真的提升了加载的速度。

清明光顾着玩去了 ,实在没时间研究什么,最后半天感兴趣研究了下。

以前模模糊糊也知道这些 ,但是没具体的深入了解过 ,了解了原理框架搭建也就得心应手了❗❗

最后的最后 ❤️❤️❤️👇👇👇

相关推荐
摇滚侠7 分钟前
DBeaver 导入数据库 导入 SQL 文件 MySQL 备份恢复
java·数据库·mysql
keep one's resolveY30 分钟前
SpringBoot实现重试机制的四种方案
java·spring boot·后端
天空属于哈夫克31 小时前
企业微信API常见的错误和解决方案
java·数据库·企业微信
摇滚侠2 小时前
VMvare 虚拟机 Oracle19c 安装步骤,远程连接 Oracle19c,百度网盘安装包
java·oracle
梁萌2 小时前
idea报错找不到XX包的解决方法
java·intellij-idea·启动报错·缺少包
女生也可以敲代码2 小时前
AI时代下的50道前端开发面试题:从基础到大模型应用
前端·面试
Agent产品评测局2 小时前
生产排期与MES/ERP系统打通,实操方法详解 —— 2026企业级智能体自动化选型与实战指南
java·运维·人工智能·ai·chatgpt·自动化
阿丰资源2 小时前
基于Spring Boot的电影城管理系统(直接运行)
java·spring boot·后端
呱牛do it3 小时前
企业级门户网站设计与实现:基于SpringBoot + Vue3的全栈解决方案(Day 8)
java
消失的旧时光-19433 小时前
Spring Boot 工程化进阶:统一返回 + 全局异常 + AOP 通用工具包
java·spring boot·后端·aop·自定义注解