一些可以显著提高大型 Java 项目启动速度的尝试

一些可以显著提高大型 Java 项目启动速度的尝试

我们线上的业务 jar 包基本上普遍比较庞大,动不动一个 jar 包上百 M,启动时间在分钟级,拖慢了我们在故障时快速扩容的响应。于是做了一些分析,看看 Java 程序启动慢到底慢在哪里,如何去优化,目前的效果是大部分大型应用启动时间可以缩短 30%~50%

主要有下面这些内容

  • 修改 async-profiler 源码,只抓取启动阶段 main 线程的 wall 时间火焰图( )
  • 重新实现 JarIndex( )
  • 结合 JarIndex 重新自定义类加载器,启动提速 30%+( )
  • SpringBean 加载耗时 timeline 可视化分析( )
  • SpringBean 的可视化依赖分析( )
  • 基于依赖拓扑的 SpringBean 的异步加载( )

无观测不优化

秉承着无观测不优化的想法,首先我们要知道启动慢到底慢在了哪里。我之前分享过很多次关于火焰图的使用,结果很多人遇到问题就开始考虑火焰图,但是一个启动慢其实是一个时序问题,不是一个 hot CPU 热点问题。很多时候慢,不一定是 cpu 占用过高,很有可能是等锁、等 IO 或者傻傻的 sleep。

在 Linux 中有一个杀手级的工具 bootchart 来分析 linux 内核启动的问题,它把启动过程中所有的 IO、CPU 占用情况都做了详细的划分,我们可以很清楚地看到各个时间段,时间耗在了哪里,基于这个 chart,你就可以看看哪些过程可以延后处理、异步处理等。

在 Java 中,暂时没有类似的工具,但是又想知道时间到底耗在了哪里要怎么做呢,至少大概知道耗在了什么地方。在生成热点调用火焰图的时候,我们通过 arthas 的几个简单的命令就可以生成,它底层用的是 async-profiler 这个开源项目,它的作者 apangin 做过一系列关于 jvm profiling 相关的分享,感兴趣的同学可以去看看。

async-profiler 底层原理简介

async-profiler 是一个非常强大的工具,使用 jvmti 技术来实现。它的 NB 之处在于它利用了 libjvm.so 中 JVM 内部的 API AsyncGetCallTrace 来获取 Java 函数堆栈,精简后的伪代码如下:

static bool vm_init(JavaVM *vm) { std::cout << "vm_init" << std::endl; // 从 libjvm.so 中获取 AsyncGetCallTrace 的函数指针句柄 void *libjvm = dlopen("libjvm.so", RTLD_LAZY); _asyncGetCallTrace = (AsyncGetCallTrace) dlsym(libjvm, "AsyncGetCallTrace");}// 事件回调void recordSample(void *ucontext, uint64_t counter, jint event_type, Event *event) { std::cout << "Profiler::recordSample: " << std::endl; ASGCT_CallFrame frames[maxFramesToCapture]; ASGCT_CallTrace trace; trace.frames = frames; trace.env = getJNIEnv(g_jvm); // 调用 AsyncGetCallTrace 获取堆栈 _asyncGetCallTrace(&trace, maxFramesToCapture, ucontext);}

你可能要说获取个堆栈还需要搞这么复杂,jstack 等工具不是实现得很好了吗?其实不然。

jstack 等工具获取函数堆栈需要 jvm 进入到 safepoint,对于采样非常频繁的场景,会严重的影响 jvm 的性能,具体的原理不是本次内容的重点这里先不展开。

async-profiler 除了可以生成热点调用的火焰图,它还提供了 Wall-clock profiling 的功能,这个功能其实就是固定时间采样所有的线程(不管线程当前是 Running、Sleeping 还是 Blocked),它在文档中也提到了,这种方式的 profiling 适合用来分析应用的启动过程,我们姑且用这个不太精确的方式来粗略测量启动阶段耗时在了哪些函数里。

但是这个工具会抓取所有的线程的堆栈,按这样的方式抓取的 wall-clock 火焰图没法看,不信你看。

就算你找到了 main 线程,在函数耗时算占比的时候也不太方便,我们关心的其实只是 main 线程(也就是加载 jar 包,执行 spring 初始化的线程),于是我做了一些简单的修改,让 async-profiler 只取抓取 main 线程的堆栈。

重新编译运行

java -agentpath:/path/to/libasyncProfiler.so=start,event=wall,interval=1ms,threads,file=profile.html-jar xxx.jar

这样生成的火焰图就清爽多了,这样就知道时间耗在了什么函数上。

接下来就是分析这个 wall-clock 的火焰图,点开几个调用栈仔细分析,发现很多时间花费在类和资源文件查找和加载(挺失望的,java 连这部分都做不好)

继续分析代码看看类加载在做什么。

Java 垃圾般实现的类查找加载

Java 地类加载不出意外最终都走到了 java.net.URLClassLoader#findClass 这里。

这里的 ucp 指的是 URLClassPath,也就是 classpath 路径的集合。对于 SpringBoot 的应用来说,classpath 已经在 META-INF 里写清楚了。

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

此次测试的程序 BOOT-INF/lib/ 有 300 多个依赖的 jar 包,当加载某个类时,除了 BOOT-INF/classes/ 之外 Java 居然要遍历那 300 个 jar 包去查看这些 jar 包中是否包含某个类。

我在 loader.getResource 上注入了一下打印,看看这些函数调用了多少次。

可以看到太丧心病狂了,加载一个类,居然要调用 loader.getResource 去 jar 包中尝试几百次。我就按二分之一 150 来算,如果加载一万个类,要调用这个函数 150W 次。

请忽略源码中的 LookupCache 特性,这个特性看起来是为了加速 jar 包查找的,但是这个特性看源码是一个 oracle 商业版的才有的特性,在目前的 jdk 中是无法启用的。(推测,如果理解不对请告知我)

于是有了一些粗浅的想法,为何不告诉 java 这个类在那个 jar 里?做索引这么天然的想法为什么不实现。

以下面为例,项目依赖三个 jar 包,foo.jar、bar.jar、baz.jar,其中分别包含了特定包名的类,理想情况下我们可以生成一个索引文件,如下所示。

foo.jarcom/foo1com/foo2bar.jarcom/barcom/bar/barbarbaz.jarcom/baz

这就是我们接下来要介绍的 JarIndex 技术。

JarIndex 技术

其实 Jar 在文件格式上是支持索引技术的,称为 JarIndex,通过 jar -i 就可以在 META-INF/ 目录下生成 INDEX.LIST 文件。别高兴的太早,这个 JarIndex 目前无法真正起到作用,有下面几个原因:

  • INDEX.LIST 文件生成不正确,尤其是目前最流行的 fatjar 中包含 jar 列表的情况
  • classloader 不支持(那不是白忙活吗)

首先来看 INDEX.LIST 文件生成不正确的问题,随便拿一个 jar 文件,使用 jar -i 生成一下试试。

JarIndex-Version: 1.0encloud-api_origin.jarBOOT-INFBOOT-INF/classesBOOT-INF/classes/comBOOT-INF/classes/com/encloud….META-INFMETA-INF/mavenMETA-INF/maven/com.encloudMETA-INF/maven/com.encloud/encloud-apiBOOT-INF/liborgorg/springframeworkorg/springframework/bootorg/springframework/boot/loaderorg/springframework/boot/loader/jarorg/springframework/boot/loader/dataorg/springframework/boot/loader/archiveorg/springframework/boot/loader/util

可以看到在 BOOT-INF/lib 目录中的类索引并没有在这里生成,这里面可是有 300 多个 jar 包。

同时生成不对的地方还有,org 目录下只有文件夹并没有 class 文件,org 这一行不应该在 INDEX.LIST 文件中。

第二个缺陷才是最致命的,目前的 classloader 不支持 JarIndex 这个特性。

所以我们要做两个事情,生成正确的 JarIndex,同时修改 SpringBoot 的 classloader 让其支持 JarIndex。

生成正确的 JarIndex

这个简单,就是遍历 jar 包里的类,将其所在的包名抽取出来。SpringBoot 应用有三个地方存放了 class:

  • BOOT-INF/classes
  • BOOT-INF/lib
  • jar 包根目录下 org/springframework/boot/loader

生成的时候需要考虑到上面的情况,剩下的就简单了。遍历这些目录,将所有的包含 class 文件的包名过滤过来就行。

大概生成的结果是:

JarIndex-Version: 1.0encloud-api.jar/BOOT-INF/classescom/encloudcom/encloud/app/controllercom/encloud/app/controller/v2/org/springframework/boot/loaderorg/springframework/boot/loader/archiveorg/springframework/boot/loader/dataorg/springframework/boot/loader/jarorg/springframework/boot/loader/util/BOOT-INF/lib/spring-core-4.3.9.RELEASE.jarorg/springframework/asmorg/springframework/cgliborg/springframework/cglib/beansorg/springframework/cglib/core/BOOT-INF/lib/guava-19.0.jarcom/google/common/annotationscom/google/common/basecom/google/common/base/internalcom/google/common/cache… other jar …

除了加载类需要查找,其实还有不少资源文件需要查找,比如 spi 等扫描过程中需要,顺带把资源文件的索引也生成一下写入到 RES_INDEX.LIST 中,原理类似,这里展开。

自定义 classloder

生成了 INDEX.LIST 文件,接下来就是要实现了一个 classloader 能支持一步到位通过索引文件去对应的 jar 包中去加载 class,核心的代码如下:

public class JarIndexLaunchedURLClassLoader extends URLClassLoader { public JarIndexLaunchedURLClassLoader(boolean exploded, Archive rootArchive, URL[] urls, ClassLoader parent) { super(urls, parent); initJarIndex(urls); // 根据 INDEX.LIST 创建包名到 jar 文件的映射关系 } @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { Class loadedClass = findLoadedClass(name); if (loadedClass != null) return loadedClass; // 如果是 loader 相关的类,则直接加载,不用找了,就在 jar 包的根目录下 if (name.startsWith(“org.springframework.boot.loader.”) || name.startsWith(“com.seewo.psd.bootx.loader.”)) { Class result = loadClassInLaunchedClassLoader(name); if (resolve) { resolveClass(result); } return result; } // skip java.*, org.w3c.dom.* com.sun.* ,这些包交给 java 默认的 classloader 去处理 if (!name.startsWith(“java”) && !name.contains(“org.w3c.dom.”) && !name.contains(“xml”) && !name.startsWith(“com.sun”)) { int lastDot = name.lastIndexOf(‘.’); if (lastDot >= 0) { String packageName = name.substring(0, lastDot); String packageEntryName = packageName.replace(‘.’, ‘/’); String path = name.replace(‘.’, ‘/’).concat(“.class”); // 通过 packageName 找到对应的 jar 包 List loaders = package2LoaderMap.get(packageEntryName); if (loaders != null) { for (JarFileResourceLoader loader : loaders) { ClassSpec classSpec = loader.getClassSpec(path); // 从 jar 包中读取文件 if (classSpec == null) { continue; } // 文件存在,则加载这个 class Class definedClass = defineClass(name, classSpec.getBytes(), 0, classSpec.getBytes().length, classSpec.getCodeSource()); definePackageIfNecessary(name); return definedClass; } } } } // 执行到这里,说明需要父类加载器来加载类(兜底) definePackageIfNecessary(name); return super.loadClass(name, resolve); }}

到这里我们基本上就实现了一个支持 JarIndex 的类加载器,这里的改动经实测效果已经效果非常明显。

除此之外,我还发现查找一个已加载的类是一个非常高频执行的操作,于是可以在 JarIndexLaunchedURLClassLoader 之前再加一层缓存(思想来自 sofa-boot)

public class CachedLaunchedURLClassLoader extends JarIndexLaunchedURLClassLoader { private final Map classCache = new ConcurrentHashMap(3000); @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { return loadClassWithCache(name, resolve); } private Class loadClassWithCache(String name, boolean resolve) throws ClassNotFoundException { LoadClassResult result = classCache.get(name); if (result != null) { if (result.getEx() != null) { throw result.getEx(); } return result.getClazz(); } try { Class clazz = super.findLoadedClass(name); if (clazz == null) { clazz = super.loadClass(name, resolve); } if (clazz == null) { classCache.put(name, LoadClassResult.NOT_FOUND); } return clazz; } catch (ClassNotFoundException exception) { classCache.put(name, new LoadClassResult(exception)); throw exception; }}

注意:这里为了简单示例直接用 ConcurrentHashMap 来缓存 class,更好的做法是用 guava-cache 等可以带过期淘汰的 map,避免类被永久缓存。

如何不动 SpringBoot 的代码实现 classloader 的替换

接下的一个问题是如何不修改 SpringBoot 的情况下,把 SpringBoot 的 Classloader 替换为我们写的呢?

大家都知道,SpringBoot 的 jar 包启动类其实并不是我们项目中写的 main 函数,其实是

org.springframework.boot.loader.JarLauncher,这个类才是真正的 jar 包的入口。

package org.springframework.boot.loader;public class JarLauncher extends ExecutableArchiveLauncher {public static void main(String[] args) throws Exception {new JarLauncher().launch(args);}}

那我们只要替换这个入口类就可以接管后面的流程了。如果只是替换那很简单,修改生成好的 jar 包就可以了,但是这样后面维护的成本比较高,如果在打包的时候就替换就好了。SpringBoot 的打包是用 spring-boot-maven-plugin 插件

org.springframework.boot spring-boot-maven-plugin

最终生成的 META-INF/MANIFEST.MF 文件如下

$ cat META-INF/MANIFEST.MFManifest-Version: 1.0Implementation-Title: encloud-apiImplementation-Version: 2.0.0-SNAPSHOTArchiver-Version: Plexus ArchiverBuilt-By: arthurImplementation-Vendor-Id: com.encloudSpring-Boot-Version: 1.5.4.RELEASEImplementation-Vendor: Pivotal Software, Inc.Main-Class: org.springframework.boot.loader.JarLauncherStart-Class: com.encloud.APIBootSpring-Boot-Classes: BOOT-INF/classes/Spring-Boot-Lib: BOOT-INF/lib/Created-By: Apache Maven 3.8.5Build-Jdk: 1.8.0_332Implementation-URL: http://projects.spring.io/spring-boot/parent/enclo ud-api/

为了实现我们的需求,就要看 spring-boot-maven-plugin 这个插件到底是如何写入 Main-Class 这个类的,经过漫长的 maven 插件源码的调试,发现这个插件居然提供了扩展点,可以支持修改 Main-Class,它提供了一个 layoutFactory 可以自定义

org.springframework.boot spring-boot-maven-plugin repackage com.seewo.psd.bootx bootx-loader-tools 0.1.1

实现这个

package com.seewo.psd.bootx.loader.tools;import org.springframework.boot.loader.tools.*;import java.io.File;import java.io.IOException;import java.util.Locale;public class MyLayoutFactory implements LayoutFactory { private static final String NESTED_LOADER_JAR = “META-INF/loader/spring-boot-loader.jar”; private static final String NESTED_LOADER_JAR_BOOTX = “META-INF/loader/bootx-loader.jar”; public static class Jar implements RepackagingLayout, CustomLoaderLayout { @Override public void writeLoadedClasses(LoaderClassesWriter writer) throws IOException { // 拷贝 springboot loader 相关的文件到 jar 根目录 writer.writeLoaderClasses(NESTED_LOADER_JAR); // 拷贝 bootx loader 相关的文件到 jar 根目录 writer.writeLoaderClasses(NESTED_LOADER_JAR_BOOTX); } @Override public String getLauncherClassName() { // 替换为我们自己的 JarLauncher return “com.seewo.psd.bootx.loader.JarLauncher”; } }}

接下来实现我们自己的 JarLauncher

package com.seewo.psd.bootx.loader;import java.net.URL;public class JarLauncher extends org.springframework.boot.loader.JarLauncher { @Override protected ClassLoader createClassLoader(URL[] urls) throws Exception { return new CachedLaunchedURLClassLoader(urls, getClass().getClassLoader()); } public static void main(String[] args) throws Exception { new JarLauncher().launch(args); }}

重新编译就可以实现替换

$ cat META-INF/MANIFEST.MFManifest-Version: 1.0…Main-Class: com.seewo.psd.bootx.loader.JarLauncher…

到这里,我们就基本完成所有的工作,不用改一行业务代码,只用改几行 maven 打包脚本,就可以实现支持 JarIndex 的类加载实现。

优化效果

我们来看下实际的效果,项目 1 稍微小型一点,启动耗时从 70s 降低到 46s

第二个 jar 包更大一点,效果更明显,启动耗时从 220s 减少到 123s

未完待续

其实优化到这里,还远远没有达到我想要的目标,为什么启动需要这么长时间,解决了类查找的问题,那我们来深挖一下 Spring 的初始化。

Spring bean 的初始化是串行进行的,于是我先来做一个可视化 timeline,看看到底是哪些 Bean 耗时很长。

Spring Bean 初始化时序可视化

因为不会写前端,这里偷一下懒,利用 APM 的工具,把数据上报到 jaeger,这样我们就可以得到一个包含调用关系的timeline 的界面了。jaeger 的网址在这里:www.jaegertracing.io/

首先我们继承 DefaultListableBeanFactory 来对 createBean 的过程做记录。

public class BeanLoadTimeCostBeanFactory extends DefaultListableBeanFactory { private static ThreadLocal parentStackThreadLocal = new ThreadLocal(); @Override protected Object createBean(String beanName, RootBeanDefinition rbd, Object[] args) throws BeanCreationException { // 记录 bean 初始化开始 Object object = super.createBean(beanName, rbd, args); // 记录 bean 初始化结束 return object; }

接下来我们实现 ApplicationContextInitializer,在 initialize 方法中替换 beanFactory 为我们自己写的。

public class BeanLoadTimeCostApplicationContextInitializer implements ApplicationContextInitializer, Ordered { public BeanLoadCostApplicationContextInitializer() { System.out.println(“in BeanLoadCostApplicationContextInitializer()”); } @Override public void initialize(ConfigurableApplicationContext applicationContext) { if (applicationContext instanceof GenericApplicationContext) { System.out.println(“BeanLoadCostApplicationContextInitializer run”); BeanLoadTimeCostBeanFactory beanFactory = new BeanLoadTimeCostBeanFactory(); Field field = GenericApplicationContext.class.getDeclaredField(“beanFactory”); field.setAccessible(true); field.set(applicationContext, beanFactory); } }}

接下来将记录的状态上报到 jaeger 中,实现可视化堆栈显示。

public void reportBeanCreateResult(BeanCreateResult beanCreateResult) { Span span = GlobalTracer.get().buildSpan(beanCreateResult.getBeanClassName()).withStartTimestamp(beanCreateResult.getBeanStartTime() * 1000).start(); try (Scope ignore = GlobalTracer.get().scopeManager().activate(span)) { for (BeanCreateResult item : beanCreateResult.getChildren()) { Span childSpan = GlobalTracer.get().buildSpan(item.getBeanClassName()).withStartTimestamp(item.getBeanStartTime() * 1000).start(); try (Scope ignore2 = GlobalTracer.get().scopeManager().activate(childSpan)) { printBeanStat(item); } finally { childSpan.finish(item.getBeanEndTime() * 1000); } } } finally { span.finish(beanCreateResult.getBeanEndTime() * 1000); }}

通过这种方式,我们可以很轻松的看到 spring 启动阶段 bean 加载的 timeline,生成的图如下所示。

这对我们进一步优化 bean 的加载提供了思路,可以看到 bean 的依赖关系和加载耗时具体耗在了哪个 bean。通过这种方式可以在 SpringBean 串行加载的前提下,把 bean 的加载尽可能的优化。

SpringBean 的依赖分析

更好一点的方案是基于 SpringBean 的依赖关系做并行加载。这个特性 2011 年前就有人提给了 Spring,具体看这个 issue:github.com/spring-proj…

就在去年,还有人去这个 issue 下去恭祝这个 issue 10 周年快乐。

做并行加载确实有一些难度,真实项目的 Spring Bean 依赖关系非常复杂,我把 Spring Bean 的依赖关系导入到 neo4j 图数据库,然后进行查询

MATCH (n)RETURN n;

得到的图如下所示。一方面 Bean 的数量特别多,还有复杂的依赖关系,以及循环依赖。

基于此依赖关系,我们是有机会去做 SpringBean 的并行加载的,这部分还没实现,希望后面有机会可以完整的实现这块的逻辑,个人感觉可以做到 10s 内启动完一个超大的项目。

Java 启动优化的其它技术

Java 启动的其它技术还有 Heap Archive、CDS,以及 GraalVM 的 AOT 编译,不过这几个技术目前都有各自的缺陷,还无法完全解决目前我们遇到的问题。

后记

这篇文章中用到的技术只是目前比较粗浅的尝试,如果大家有更好的优化,可以跟我交流,非常感谢。

作者:挖坑的张师傅链接:https://juejin.cn/post/7117815437559070734

郑重声明:本文内容及图片均整理自互联网,不代表本站立场,版权归原作者所有,如有侵权请联系管理员(admin#wlmqw.com)删除。
(0)
用户投稿
上一篇 2022年7月11日
下一篇 2022年7月11日

相关推荐

  • 警惕这种母子关系,可能正在“养废”孩子,未来多半成为无用之人

    家里的婶婶近日有些抑郁,她一向”引以为傲“的儿子,因为被公司裁员而变得一蹶不振,成天窝在家里打游戏,不愿意出门找工作。 叔叔和婶婶只要一说起这个话题,儿子立马变脸,直接夺门而去,跟…

    2022年8月25日
  • 比丰田赛那还大,便宜5万多!比亚迪豪华MPV下月开卖,看好吗?

    近日,比亚迪旗下的子品牌腾势发布了全新MPV的官图,并且正式定名为D9。有意思的是,D9这个定名直接将这台MPV拉到一个最高级别的高度,并且腾势D9作为一台尺寸接近于大型MPV的车…

    2022年7月26日
  • 地铁末班车(地铁末班车时间是从首站出发的吗)

    今天小编给各位分享地铁末班车的知识,其中也会对地铁末班车时间是从首站出发的吗进行解释,如果能碰巧解决你现在面临的问题,别忘了关注本站,现在开始吧! 北京地铁末班车几点 北京地铁末班…

    2022年11月4日
  • 皮肤美容护肤法

    每个人的肤质是天生的,但无论何种肤质都要日常护理得当才无损于容颜的美丽。合理的生活习惯、科学的护理方法、良好的护肤心态,都是护理皮肤的重要因素。那么我们应该怎样掌握正确的美容方法呢…

    2022年6月4日
  • 智能扫地机器人哪个牌子好,选哪个不用交智商税

    智能扫地机器人哪个牌子好,冰尊智能扫地机器人是艳压群芳的存在,聪明度也就是智商水平是扫地机器人能否最大程度的帮助人们解放双手的基本要素, 那么智能扫地机器人哪个牌子好?下文开始介绍…

    2022年8月15日
  • 五年的时间对一个人的变化到底有多大

    五年的时候对一个人的变化到底有多大 04-15 22:42阅读 0 2022年的马莹菲加油,2018年的木唯柒你好呀突然有点伤感,终是弄丢了2018年的自己, 五年后的今天我重新回…

    2022年4月26日
  • 本人想上铁路专业,哪个专业好?

    让一个铁路从业者简单回答一下,铁路局做为运营单位,按专业划分主要有车务、电务、机务、供电、工务、车辆等主要专业,这些专业分工网上资料很多,各专业各有利弊,各有特点。 但就你想上铁路…

    2022年6月3日
  • 牛仔裤下面少搭配运动鞋,今年流行这几双“低跟鞋”,适合小个子

    牛仔裤可以说是人手一件的时尚单品了,而且在每个季节几乎都离不开它的存在,不过很多小姐妹在搭配牛仔裤的时候,都喜欢配运动鞋。因为在大家的固有思维里,牛仔裤就是休闲单品,所以很难将它和…

    2022年7月6日
  • 镇魂街361:太欺负人了,雪象玄马虐爆贪字营

    镇魂街更新。 上集贪字营有多嚣张,这集就有多狼狈。 自从雪象不再装,使出那罗延天凯形态后,贪字营的众人就惨咯。 大金刚这一拳,二十年功力,直接就把霍去病给轰飞。还没等霍去病站稳,大…

    2022年8月2日
  • WebGL vs. WebGPU

    你可能使用过 WebGL,如果没有,那么一定使用过 Three.js,不是吗?本文将向你介绍 WebGL 和后起之秀 WebGPU。 1、WebGL 的起源 说起起源,就不得不说O…

    2022年7月3日

联系我们

联系邮箱:admin#wlmqw.com
工作时间:周一至周五,10:30-18:30,节假日休息