作者:钱文品(老钱),互联网分布式高并发技术十年老兵,目前任掌阅科技资深后端工程师。

来自:码洞(ID:codehole)

不得不说 SpringBoot 太复杂了,我本来只想研究一下 SpringBoot 最简单的 HelloWorld 程序是如何从 main 方法一步一步跑起来的,但是这却是一个相当深的坑。

你可以试着沿着调用栈代码一层一层的深入进去,如果你不打断点,你根本不知道接下来程序会往哪里流动。

这个不同于我研究过去的 Go 语言、Python 语言框架,它们通常都非常直接了当,设计上清晰易懂,代码写起来简单,里面的实现同样也很简单。但是 SpringBoot 不是,它的外表轻巧简单,但是它的里面就像一只巨大的怪兽,这只怪兽有千百只脚把自己缠绕在一起,把爱研究源码的读者绕的晕头转向。

但是这 Java 编程的世界 SpringBoot 就是老大哥,你却不得不服。即使你的心中有千万头草泥马在奔跑,但是它就是天下第一。如果你是一个学院派的程序员,看到这种现象你会怀疑人生,你不得不接受一个规则 —— 受市场最欢迎的未必就是设计的最好的,里面夹杂着太多其它的非理性因素。

图片

经过了一番痛苦的折磨,我还是把 SpringBoot 的运行原理摸清楚了,这里分享给大家。

Hello World

首先我们看看 SpringBoot 简单的 Hello World 代码,就两个文件 HelloControll.java 和 Application.java,运行 Application.java 就可以跑起来一个简单的 RESTFul Web 服务器了。

图片


图片


// HelloController.java

package hello;



import org.springframework.web.bind.annotation.RestController;

import org.springframework.web.bind.annotation.RequestMapping;



@RestController

public class HelloController{



    @RequestMapping("/")

    public String index() {

        return "Greetings from Spring Boot!";

    }



}



// Application.java

package hello;



import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;



@SpringBootApplication

public class Application{



    public static void main(String[] args) {

        SpringApplication.run(Application.class, args);

    }



}

当我打开浏览器看到服务器正常地将输出呈现在浏览器的时候,我不禁大呼 —— SpringBoot 真他妈太简单了。

图片

但是问题来了,在 Application 的 main 方法里我压根没有任何地方引用 HelloController 类,那么它的代码又是如何被服务器调用起来的呢?这就需要深入到 SpringApplication.run() 方法中看个究竟了。

不过即使不看代码,我们也很容易有这样的猜想,SpringBoot 肯定是在某个地方扫描了当前的 package,将带有 RestController 注解的类作为 MVC 层的 Controller 自动注册进了 Tomcat Server。

还有一个让人不爽的地方是 SpringBoot 启动太慢了,一个简单的 Hello World 启动居然还需要长达 5 秒,要是再复杂一些的项目这样龟漫的启动速度那真是不好想象了。

再抱怨一下,这个简单的 HelloWorld 虽然 pom 里只配置了一个 maven 依赖,但是传递下去,它一共依赖了 36 个 jar 包,其中以 spring 开头的 jar 包有 15 个。说这是依赖地狱真一点不为过。

图片

批评到这里就差不多了,下面就要正是进入主题了,看看 SpringBoot 的 main 方法到底是如何跑起来的。

SpringBoot 的堆栈

了解 SpringBoot 运行的最简单的方法就是看它的调用堆栈,下面这个启动调用堆栈还不是太深,我没什么可抱怨的。

图片


public class TomcatServer{



  @Override

  public void start() throws WebServerException{

  ...

  }



}

接下来再看看运行时堆栈,看看一个 HTTP 请求的调用栈有多深。不看不知道一看吓了一大跳!

图片


我通过将 IDE 窗口全屏化,并将其它的控制台窗口源码窗口统统最小化,总算勉强一个屏幕装下了整个调用堆栈。

不过转念一想,这也不怪 SpringBoot,绝大多数都是 Tomcat 的调用堆栈,跟 SpringBoot 相关的只有不到 10 层。

探索 ClassLoader

SpringBoot 还有一个特色的地方在于打包时它使用了 FatJar 技术将所有的依赖 jar 包一起放进了最终的 jar 包中的 BOOT-INF/lib 目录中,当前项目的 class 被统一放到了 BOOT-INF/classes 目录中。


<build>

    <plugins>

        <plugin>

            <groupId>org.springframework.boot</groupId>

            <artifactId>spring-boot-maven-plugin</artifactId>

        </plugin>

    </plugins>

</build>

这不同于我们平时经常使用的 maven shade 插件,将所有的依赖 jar 包中的 class 文件解包出来后再密密麻麻的塞进统一的 jar 包中。下面我们将 springboot 打包的 jar 包解压出来看看它的目录结构。


├── BOOT-INF

│   ├── classes

│   │   └── hello

│   └── lib

│       ├── classmate-1.3.4.jar

│       ├── hibernate-validator-6.0.12.Final.jar

│       ├── jackson-annotations-2.9.0.jar

│       ├── jackson-core-2.9.6.jar

│       ├── jackson-databind-2.9.6.jar

│       ├── jackson-datatype-jdk8-2.9.6.jar

│       ├── jackson-datatype-jsr310-2.9.6.jar

│       ├── jackson-module-parameter-names-2.9.6.jar

│       ├── javax.annotation-api-1.3.2.jar

│       ├── jboss-logging-3.3.2.Final.jar

│       ├── jul-to-slf4j-1.7.25.jar

│       ├── log4j-api-2.10.0.jar

│       ├── log4j-to-slf4j-2.10.0.jar

│       ├── logback-classic-1.2.3.jar

│       ├── logback-core-1.2.3.jar

│       ├── slf4j-api-1.7.25.jar

│       ├── snakeyaml-1.19.jar

│       ├── spring-aop-5.0.9.RELEASE.jar

│       ├── spring-beans-5.0.9.RELEASE.jar

│       ├── spring-boot-2.0.5.RELEASE.jar

│       ├── spring-boot-autoconfigure-2.0.5.RELEASE.jar

│       ├── spring-boot-starter-2.0.5.RELEASE.jar

│       ├── spring-boot-starter-json-2.0.5.RELEASE.jar

│       ├── spring-boot-starter-logging-2.0.5.RELEASE.jar

│       ├── spring-boot-starter-tomcat-2.0.5.RELEASE.jar

│       ├── spring-boot-starter-web-2.0.5.RELEASE.jar

│       ├── spring-context-5.0.9.RELEASE.jar

│       ├── spring-core-5.0.9.RELEASE.jar

│       ├── spring-expression-5.0.9.RELEASE.jar

│       ├── spring-jcl-5.0.9.RELEASE.jar

│       ├── spring-web-5.0.9.RELEASE.jar

│       ├── spring-webmvc-5.0.9.RELEASE.jar

│       ├── tomcat-embed-core-8.5.34.jar

│       ├── tomcat-embed-el-8.5.34.jar

│       ├── tomcat-embed-websocket-8.5.34.jar

│       └── validation-api-2.0.1.Final.jar

├── META-INF

│   ├── MANIFEST.MF

│   └── maven

│       └── org.springframework

└── org

    └── springframework

        └── boot

这种打包方式的优势在于最终的 jar 包结构很清晰,所有的依赖一目了然。如果使用 maven shade 会将所有的 class 文件混乱堆积在一起,是无法看清其中的依赖。而最终生成的 jar 包在体积上两也者几乎是相等的。

在运行机制上,使用 FatJar 技术运行程序是需要对 jar 包进行改造的,它还需要自定义自己的 ClassLoader 来加载 jar 包里面 lib 目录中嵌套的 jar 包中的类。我们可以对比一下两者的 MANIFEST 文件就可以看出明显差异


// Generated by Maven Shade Plugin

Manifest-Version: 1.0

Implementation-Title: gs-spring-boot

Implementation-Version: 0.1.0

Built-By: qianwp

Implementation-Vendor-Id: org.springframework

Created-By: Apache Maven 3.5.4

Build-Jdk: 1.8.0_191

Implementation-URL: https://projects.spring.io/spring-boot/#/spring-bo

 ot-starter-parent/gs-spring-boot

Main-Class: hello.Application



// Generated by SpringBootLoader Plugin

Manifest-Version: 1.0

Implementation-Title: gs-spring-boot

Implementation-Version: 0.1.0

Built-By: qianwp

Implementation-Vendor-Id: org.springframework

Spring-Boot-Version: 2.0.5.RELEASE

Main-Class: org.springframework.boot.loader.JarLauncher

Start-Class: hello.Application

Spring-Boot-Classes: BOOT-INF/classes/

Spring-Boot-Lib: BOOT-INF/lib/

Created-By: Apache Maven 3.5.4

Build-Jdk: 1.8.0_191

Implementation-URL: https://projects.spring.io/spring-boot/#/spring-bo

 ot-starter-parent/gs-spring-boot

SpringBoot 将 jar 包中的 Main-Class 进行了替换,换成了 JarLauncher。还增加了一个 Start-Class 参数,这个参数对应的类才是真正的业务 main 方法入口。我们再看看这个 JarLaucher 具体干了什么


public class JarLauncher{

    ...

  static void main(String[] args){

    new JarLauncher().launch(args);

  }



  protected void launch(String[] args){

    try {

      JarFile.registerUrlProtocolHandler();

      ClassLoader cl = createClassLoader(getClassPathArchives());

      launch(args, getMainClass(), cl);

    }

    catch (Exception ex) {

        ex.printStackTrace();

        System.exit(1);

    }

  }



  protected void launch(String[] args, String mcls, ClassLoader cl){

        Runnable runner = createMainMethodRunner(mcls, args, cl);

        Thread runnerThread = new Thread(runner);

        runnerThread.setContextClassLoader(classLoader);

        runnerThread.setName(Thread.currentThread().getName());

        runnerThread.start();

  }



}



class MainMethodRunner{

  @Override

  public void run(){

    try {

      Thread th = Thread.currentThread();

      ClassLoader cl = th.getContextClassLoader();

      Class<?> mc = cl.loadClass(this.mainClassName);

      Method mm = mc.getDeclaredMethod("main", String[].class);

      if (mm == null) {

        throw new IllegalStateException(this.mainClassName

                        + " does not have a main method");

      }

      mm.invoke(nullnew Object[] { this.args });

    } catch (Exception ex) {

      ex.printStackTrace();

      System.exit(1);

    }

  }

}

从源码中可以看出 JarLaucher 创建了一个特殊的 ClassLoader,然后由这个 ClassLoader 来另启一个单独的线程来加载 MainClass 并运行。

又一个问题来了,当 JVM 遇到一个不认识的类,BOOT-INF/lib 目录里又有那么多 jar 包,它是如何知道去哪个 jar 包里加载呢?我们继续看这个特别的 ClassLoader 的源码


class LaunchedURLClassLoader extends URLClassLoader{

  ...

  private Class<?> doLoadClass(String name) {

    if (this.rootClassLoader != null) {

      return this.rootClassLoader.loadClass(name);

    }



    findPackage(name);

    Class<?> cls = findClass(name);

    return cls;

  }



}

这里的 rootClassLoader 就是双亲委派模型里的 ExtensionClassLoader ,JVM 内置的类会优先使用它来加载。如果不是内置的就去查找这个类对应的 Package。


private void findPackage(final String name){

    int lastDot = name.lastIndexOf('.');

    if (lastDot != -1) {

        String packageName = name.substring(0, lastDot);

        if (getPackage(packageName) == null) {

            try {

                definePackage(name, packageName);

            } catch (Exception ex) {

                // Swallow and continue

            }

        }

    }

}



private final HashMap<String, Package> packages = new HashMap<>();



protected Package getPackage(String name){

    Package pkg;

    synchronized (packages) {

        pkg = packages.get(name);

    }

    if (pkg == null) {

        if (parent != null) {

            pkg = parent.getPackage(name);

        } else {

            pkg = Package.getSystemPackage(name);

        }

        if (pkg != null) {

            synchronized (packages) {

                Package pkg2 = packages.get(name);

                if (pkg2 == null) {

                    packages.put(name, pkg);

                } else {

                    pkg = pkg2;

                }

            }

        }

    }

    return pkg;

}



private void definePackage(String name, String packageName){

  String path = name.replace('.''/').concat(".class");

  for (URL url : getURLs()) {

    try {

      if (url.getContent() instanceof JarFile) {

        JarFile jf= (JarFile) url.getContent();

        if (jf.getJarEntryData(path) != null && jf.getManifest() != null) {

          definePackage(packageName, jf.getManifest(), url);

          return null;

        }

      }

    } catch (IOException ex) {

        // Ignore

    }

  }

  return null;

}

ClassLoader 会在本地缓存包名和 jar包路径的映射关系,如果缓存中找不到对应的包名,就必须去 jar 包中挨个遍历搜寻,这个就比较缓慢了。不过同一个包名只会搜寻一次,下一次就可以直接从缓存中得到对应的内嵌 jar 包路径。

深层 jar 包的内嵌 class 的 URL 路径长下面这样,使用感叹号 ! 分割


jar:file:/workspace/springboot-demo/target/application.jar!/BOOT-INF/lib/snakeyaml-1.19.jar!/org/yaml/snakeyaml/Yaml.class

不过这个定制的 ClassLoader 只会用于打包运行时,在 IDE 开发环境中 main 方法还是直接使用系统类加载器加载运行的。

不得不说,SpringbootLoader 的设计还是很有意思的,它本身很轻量级,代码逻辑很独立没有其它依赖,它也是 SpringBoot 值得欣赏的点之一。

HelloController 自动注册

还剩下最后一个问题,那就是 HelloController 没有被代码引用,它是如何注册到 Tomcat 服务中去的?它靠的是注解传递机制。

图片


SpringBoot 深度依赖注解来完成配置的自动装配工作,它自己发明了几十个注解,确实严重增加了开发者的心智负担,你需要仔细阅读文档才能知道它是用来干嘛的。Java 注解的形式和功能是分离的,它不同于 Python 的装饰器是功能性的,Java 的注解就好比代码注释,本身只有属性,没有逻辑,注解相应的功能由散落在其它地方的代码来完成,需要分析被注解的类结构才可以得到相应注解的属性。

那注解是又是如何传递的呢?


@SpringBootApplication

public class Application {

    public static void main(String[] args){

        SpringApplication.run(Application.class, args);

    }

}



@ComponentScan

public @interface SpringBootApplication {

...

}



public @interface ComponentScan {

    String[] basePackages() default{};

}

首先 main 方法可以看到的注解是 SpringBootApplication,这个注解又是由ComponentScan 注解来定义的,ComponentScan 注解会定义一个被扫描的包名称,如果没有显示定义那就是当前的包路径。SpringBoot 在遇到 ComponentScan 注解时会扫描对应包路径下面的所有 Class,根据这些 Class 上标注的其它注解继续进行后续处理。当它扫到 HelloController 类时发现它标注了 RestController 注解。


@RestController

public class HelloController{

...

}



@Controller

public @interface RestController {

}

而 RestController 注解又标注了 Controller 注解。SpringBoot 对 Controller 注解进行了特殊处理,它会将 Controller 注解的类当成 URL 处理器注册到 Servlet 的请求处理器中,在创建 Tomcat Server 时,会将请求处理器传递进去。HelloController 就是如此被自动装配进 Tomcat 的。

扫描处理注解是一个非常繁琐肮脏的活计,特别是这种用注解来注解注解(绕口)的高级使用方法,这种方法要少用慎用。SpringBoot 中有大量的注解相关代码,企图理解这些代码是乏味无趣的没有必要的,它只会把你的本来清醒的脑袋搞晕。SpringBoot 对于习惯使用的同学来说它是非常方便的,但是其内部实现代码不要轻易模仿,那绝对算不上模范 Java 代码。

图片

最后老钱表示自己真的很讨厌 SpringBoot 这只怪兽,但是很无奈,这个世界人人都在使用它。这就好比老人们常常告诫年轻人的那句话:如果你改变不了世界,那就先适应这个世界吧!

<完>

架构师小秘圈

聚集20万架构师的小圈子

长按二维码 ▲

关注「架构师小秘圈」公众号

SpringBoot 究竟是如何跑起来的?相关推荐

  1. SpringBoot究竟是如何跑起来的?

    摘要: 神奇的SpringBoot. 原文:SpringBoot 究竟是如何跑起来的? 作者:老钱 Fundebug经授权转载,版权归原作者所有. 不得不说 SpringBoot 太复杂了,我本来只想 ...

  2. 计算机究竟是怎么跑起来的

    不论你是计算机相关从业者,还是门外汉,你一定曾经或者正在疑惑,计算机究竟是怎么跑起来的.即使你看过计算机组成原理,知道冯诺依曼体系结构,了解操作系统的发展史,但你仍然有可能因为没有直观感受而总是在思考 ...

  3. 【做项目】基于SpringBoot从零开发的个人博客 —— 从技术选型到部署实战(附学习路线)

    文章目录 一.前言 1.1 背景介绍 1.2 寻找开源项目 1.3 技术选型 二.正式开发 2.1 看懂项目并模仿 2.2 正式动手敲之前,内化成自己的项目,对其设计并架构 2.3 动手编写 三.博客 ...

  4. 【SpringBoot】SpringBoot简介

    什么是SpringBoot? SpringBoot是为了尽快跑起来Spring项目并尽可能减少配置文件,默认配置了很多框架的使用方式. 为所有Spring 开发提供一个更快更广泛的入门体验. 零配置. ...

  5. 如何将SpringBoot项目部署到阿里云Linux服务器中

    在此之前,我写了一个如何购买阿里云Windows服务器,并将自己的SpringBoot项目部署到Windows云服务器的博客,链接如下:https://blog.csdn.net/MagicMHD/a ...

  6. java 定时任务怎么关闭_浅谈springboot项目中定时任务如何优雅退出

    在一个springboot项目中需要跑定时任务处理批数据时,突然有个Kill命令或者一个Ctrl+C的命令,此时我们需要当批数据处理完毕后才允许定时任务关闭,也就是当定时任务结束时才允许Kill命令生 ...

  7. Windows 消息循环(1) - 概览

    本文从消息循环是如何驱动程序的这个角度,对 Windows 消息循环进行概览性介绍. 使用 EN5 课件获得更好的阅读体验: [希沃白板5]课件分享 : <Windows培训 - 消息循环> ...

  8. 跟恶意插件的较量:手工清除Orzhz广告程序

        ***病毒,大家对之都是深恶痛绝的,我也是,所以平时只要遇到了就用360安全卫士进行查杀,一般都可以搞定了,如果不行就在安全模式下用360系统急救箱(原360***专杀大全)也能清除掉,可是前 ...

  9. Linux中的进程调度(六)

    从现在开始来分析和负载平衡有关的策略. /** This function gets called by the timer code, with HZ frequency.* We call it ...

最新文章

  1. UI设计培训怎么选择就业方向
  2. python 通过 实例方法 名字的字符串调用方法
  3. 排除网络故障课后习题参考答案
  4. Matplotlib - 柱状图、直方图、条形图 bar() barh() 所有用法详解
  5. [ACM_动态规划] hdu 1176 免费馅饼 [变形数塔问题]
  6. mysql binlog ignore db_MySQL binlog_ignore_db 参数最全解析
  7. 快速排序—三路快排 vs 双基准
  8. 基于matlab的倒立摆设计,基于MATLAB的倒立摆智能控制
  9. html5简单网页设计作品,30个以简约为主的网页设计作品
  10. Edison编译时显示No such file or directory
  11. 【力扣】714. 买卖股票的最好时机含手续费
  12. 墨刀如何导出html,墨刀元素如何快速拷贝到Axure里?
  13. win10下安装matlab r2018a破解版
  14. 人工智能与数据挖掘的关系
  15. java多线程详解及线程池创建
  16. 王学岗Kotlin协程(三)---协程的上下文与协程的异常处理
  17. C语言-- 输出大写英文字母
  18. 中科院计算所培训中心开启课程研发新征程
  19. 上海的“户口”和“居住证”到底有哪些不同?
  20. Python自定义排序规则:functools.cmp_to_key()

热门文章

  1. angular select设置默认选中_改进 Angular + Jest 项目中组件测试的调试
  2. 计算机专业西电和大工怎么选,放弃985大连理工,选择211西安电子科大,其实很多人都错了...
  3. Luogu P5556 圣剑护符(线性基,树链剖分,线段树)
  4. iis 无法连接mysql_远程无法连接SQL2000及MySQL的原因和解决办法
  5. jdbc mysql连接测试_JDBC测试计划-连接mysql
  6. Centos7 cdh5.14 安装
  7. LESS CSS 框架简介与使用
  8. 【Linux】查询文件中指定字符串的记录
  9. Node:非IO的异步API
  10. 解决目录和tgz文件等不显示相应颜色