优化背景
无论是本地调试,还是线上发布,应用启动所需的时间变得越来越久,逐渐变成了一种“煎熬”,而且每天都需要默默的忍受。不愿做”奴隶“的人们,又打响了反抗的第二枪(第一枪见《如何优化Spring应用镜像》)。如何减少应用的构建、启动时间已经成为了提高生产效率的”制胜法宝“。结合前期的镜像构建优化经验和方法,今天,我们从应用本身查找下优化方法。
技术选型
子曰:“工欲善其事,必先利其器。“
一款好的Profile工具是发现问题、解决问题的”金刚钻“。今天我选选择的是一款基于JavaAgent技术的国产的开源工具-”Spring Startup Ananlyzer“。虽然是一款个人开源的工具,star目前也不多(不足1K),但其使用体验、设计思想、加速效果,以及出于支持国产开源产品Buff加持,它值得我们试用。
工具介绍
关于
Spring Startup Analyzer 是一款基于Java-Agent技术的数据采集工具,采集Spring应用启动过程数据,生成交互式分析报告(HTML),用于分析Spring应用启动卡点,同时支持Spring Bean异步初始化,以优化Spring应用启动时间。
特性
- 启动报告 -Spring Bean初始化详情信息,支持初始化耗时beanName搜索、Spring Bean初始化时序图、方法调用次数及耗时统计(支持自定义方法)、应用未加载的jar包(帮助fatjar瘦身)及应用启动过程线程wall clock火焰图,帮助开发者快速分析定位应用启动卡点
- Demo展示
- 启动概览 - 基于Text-Storage服务分析数据
- Spring Bean初始化详情
- Spring Bean初始化时序图
- 方法调用次数、耗时统计(支持自定义方法)
- Jar包瘦身
- Thread wall clock火焰图(基于Async-profile采集,Windows 系统暂不支持)
- 总结
- 可以非常方便的帮助我们定位高耗时的Bean(尤其是业务Bean和第三方Bean),同时也非常直观观察整个Spring Context的构建,深入了解SpringApplication启动的内部原理和实现。
- 提供了依赖Jar的分析,可以方便的帮助我们实现Jar包瘦身,尤其是针对Fat Jar
- 总之,这就是我们定位Spring应用启动耗时分析需要的工具!
- 数据采集拓展
- 提供了一套拓展机制,可供用户实现自定义的指标采集
- 提供了监听器模式 EventListener
public interface EventListener extends Startable {
/**
* 应用启动时调用
*/
void start();
/**
* 应用启动完成后调用
*/
void stop();
/**
* 需要增强的类
* @param className 类全限定名, 如果为空, 默认返回为true
* @return true: 进行增强, false: 不进行增强
*/
boolean filter(String className);
/**
* 需要增强的方法(此方法会依赖filter(className), 只有filter(className)返回true时,才会执行到此方法)
* @param methodName 方法名
* @param methodTypes 方法参数列表
* @return true: 进行增强, false: 不进行增强
*/
default boolean filter(String methodName, String[] methodTypes) {
return true;
}
/**
* 事件响应处理逻辑
* @param event 触发的事件
*/
void onEvent(Event event);
/**
* 监听的事件
* @return 需要监听的事件列表
*/
List<Event.Type> listen();
}
- Demo
@MetaInfServices
public class FindResourceCounter implements EventListener {
private final AtomicLong COUNT = new AtomicLong(0);
@Override
public boolean filter(String className) {
return "java.net.URLClassLoader".equals(className);
}
@Override
public boolean filter(String methodName, String[] methodTypes) {
if (!"findResource".equals(methodName)) {
return false;
}
return methodTypes != null && methodTypes.length == 1 && "java.lang.String".equals(methodTypes[0]);
}
@Override
public void onEvent(Event event) {
if (event instanceof AtEnterEvent) {
// 开始进入findResource方法
} else if (event instanceof AtExitEvent) {
// findResource方法返回
}
// 统计调用次数
COUNT.incrementAndGet();
}
@Override
public List<Event.Type> listen() {
return Arrays.asList(Event.Type.AT_ENTER, Event.Type.AT_EXIT);
}
@Override
public void start() {
System.out.println("============== my extension start =============");
}
@Override
public void stop() {
System.out.println("============== my extension end =============");
System.out.println("findResource count: " + COUNT.get());
}
}
- 异步初始化 - 提供一个Spring Bean(主要是针对Root Bean的异步化)异步初始化jar包,针对初始化耗时比较长的bean,异步执行init和@PostConstruct方法提高应用启动速度。
核心技术
Java Agent
- 增强工具
- 基于ByteKit进行字节码增强
- 设计思想
- 参考jvm-sandbox的思想:任何一个Java方法的调用都可以分解为BEFORE、RETURN和THROWS三个环节
public class Interceptor {
// BEFORE
@AtEnter
public static void atEnter(@Binding.Class Class<?> clazz,
@Binding.This Object target,
@Binding.MethodName String methodName,
@Binding.MethodDesc String methodDesc,
@Binding.Args Object[] args) {
Bridge.atEnter(clazz, target, methodName, methodDesc, args);
}
// RETURN
@AtExit
public static void atExit(@Binding.Class Class<?> clazz,
@Binding.This Object target,
@Binding.MethodName String methodName,
@Binding.MethodDesc String methodDesc,
@Binding.Args Object[] args,
@Binding.Return Object returnObj) {
Bridge.atExit(clazz, target, methodName, methodDesc, args, returnObj);
}
// THROWS
@AtExceptionExit
public static void atExceptionExit(@Binding.Class Class<?> clazz,
@Binding.This Object target,
@Binding.MethodName String methodName,
@Binding.MethodDesc String methodDesc,
@Binding.Args Object[] args,
@Binding.Throwable Throwable throwable) {
Bridge.atExceptionExit(clazz, target, methodName, methodDesc, args, throwable);
}
}
- Jar包隔离 - 随着项目能力的增多,所依赖的三方包数量也在增加,容易与应用产生依赖冲突,导致应用启动失败或运行时异常。
- 自定义类加载器实现隔离
agentLoader = createAgentClassLoader();
Class<?> transFormer = agentLoader.loadClass("io.github.linyimin0812.profiler.core.enhance.ProfilerClassFileTransformer");
Constructor<?> constructor = transFormer.getConstructor(Instrumentation.class, List.class);
Object instance = constructor.newInstance(instrumentation, getManifests());
- 首先通过ProfilerAgentClassLoader加载spring-profiler-core的初始化类ProfilerClassFileTransformer;
- 通过反射调用的方式进行实例化实现本项目的初始化。
Async Bean Init
- 诟病已久
- 我们现在基于Spring开发的Java应用,启动时长受bean初始化影响非常大,之前对经手过的应用做了一个粗略的统计,大部分启动过程花了50%+的时间在做bean的初始化,更有甚者会占到80%以上(主要是启动时去拉取配置)。
- 优化思想
- Spring在启动过程中对于Bean的加载是顺序进行的(单线程完成),如果存在部分Bean加载耗时比较严重时,应用的启动时长会被严重拉长。Bean(业务Bean)的加载耗时主要在init-method方法或@PostConstruct标识的方法,容器中的Bean多数不存在依赖关系,如果可以将Bean的初始化方法异步化,可以大大降低启动耗时。
- 实现原理
- 主要依赖Spring提供一个BeanPostProcessor扩展点实现。BeanPostProcessor允许我们自定义bean的实例化和初始化过程。它是一个接口,定义了两个方法:
- postProcessBeforeInitialization(Object bean, String beanName):在bean初始化之前调用该方法,可以在初始化之前对bean对象进行任何自定义的修改或增强。
- postProcessAfterInitialization(Object bean, String beanName):在bean初始化之后调用该方法。可以在bean初始化后对其进行任何自定义的修改或增强。
- 实现过程
- 实现BeanPostProcessor扩展点
- 在postProcessBeforeInitialization中判断beanName是否是配置异步初始化Bean
- 如果需要异步化,查找init-method或者@PostConstruct修饰的方法
- 动态代理初始化方法,将初始化方法扔到线程池中执行,并返回Future
public class AsyncProxyBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if (!beanFactory.containsBeanDefinition(beanName)) {
return bean;
}
String methodName = AsyncInitBeanFinder.getAsyncInitMethodName(beanName, beanFactory.getBeanDefinition(beanName));
ProxyFactory proxyFactory = new ProxyFactory();
proxyFactory.setTargetClass(bean.getClass());
proxyFactory.setProxyTargetClass(true);
AsyncInitializeBeanMethodInvoker invoker = new AsyncInitializeBeanMethodInvoker(bean, beanName, methodName);
proxyFactory.addAdvice(invoker);
return proxyFactory.getProxy();
}
class AsyncInitializeBeanMethodInvoker implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
if (AsyncTaskExecutor.isFinished()) {
return invocation.getMethod().invoke(targetObject, invocation.getArguments());
}
Method method = invocation.getMethod();
String methodName = method.getName();
if (this.asyncMethodName.equals(methodName)) {
logger.info("async-init-bean, beanName: {}, async init method: {}", beanName, asyncMethodName);
AsyncTaskExecutor.submitTask(() -> {
invocation.getMethod().invoke(targetObject, invocation.getArguments());
});
return null;
}
return invocation.getMethod().invoke(targetObject, invocation.getArguments());
}
}
}
- 实现ApplicationListener
监听ContextRefreshedEvent事件,等待所有异步执行的init-method完成;
- 支持优先加载配置异步化的Bean
public class AsyncBeanPriorityLoadPostProcessor extends InstantiationAwareBeanPostProcessorAdapter implements BeanFactoryAware {
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
List<String> asyncBeans = AsyncConfig.getInstance().getAsyncBeanProperties().getBeanNames();
for (String beanName : asyncBeans) {
if (beanFactory instanceof DefaultListableBeanFactory && !((DefaultListableBeanFactory) beanFactory).containsBeanDefinition(beanName)) {
continue;
}
beanFactory.getBean(beanName);
}
}
}
Async-Profiler
- Java应用程序CPU和内存高级分析工具
- 特点
- 支持CPU和堆内存分析:async-profiler可以分析Java应用程序的CPU和堆内存使用情况,帮助开发人员确定性能瓶颈和内存泄漏。
- 低开销且高效:async-profiler的代理只需要很少的CPU时间和内存来运行,不会对应用程序的性能产生显著影响。
- 多种输出格式:async-profiler可以将收集的数据以多种格式输出,包括文本、SVG、火焰图等。
- 火焰图支持:async-profiler可以生成火焰图,以可视化分析结果,更容易地识别性能瓶颈。
- 支持多种操作系统和JVM:async-profiler可以在各种操作系统(包括Linux、macOS、FreeBSD等)和JVM(包括HotSpot、OpenJDK等)上运行。
Spring Boot Startup Report
该工具的数据采集展示使用了Spring Boot Startup Report,一个开源工具,生成一个启动报告,展示了Spring Boot应用程序的各个组件和依赖项以及它们对应用程序启动时间的组成。
使用方法
安装jar包
提供了手动安装和一键脚本安装两种安装方式
- 手动安装
- 点击realease下载最新版tar.gz包
- 新建文件夹,并解压
mkdir -p ${HOME}/spring-startup-analyzer
cd 下载路径
tar -zxvf spring-startup-analyzer.tar.gz -C ${HOME}/spring-startup-analyzer
- 脚本安装
curl -sS https://raw.githubusercontent.com/linyimin0812/spring-startup-analyzer/main/bin/install.sh | sh
配置项
- 配置示例
# app health check timeout, unit is minute
spring-startup-analyzer.app.health.check.timeout=20
# health check endpoint, support multiple endpoints, separated by comma.
spring-startup-analyzer.app.health.check.endpoints=http://localhost:8080/actuator/health
spring-startup-analyzer.admin.http.server.port=8065
# support configuring multiple methods, separated by | between methods
spring-startup-analyzer.invoke.count.methods=org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(java.lang.String,org.springframework.beans.factory.support.RootBeanDefinition,java.lang.Object[])\
|java.net.URLClassLoader.findResource(java.lang.String)
spring-startup-analyzer.async.profiler.sample.thread.names=main
spring-startup-analyzer.async.profiler.interval.millis=5
- 配置项说明
配置项 | 说明 | 默认值 |
---|---|---|
spring-startup-analyzer.app.health.check.timeout | 应用启动健康检查超时时间,单位为分钟 | 20 |
spring-startup-analyzer.app.health.check.endpoints | 应用启动成功检查url,可配置多个,以","分隔 | http://127.0.0.1:7002/actuator/health |
spring-startup-analyzer.admin.http.server.port | 管理端口 | 8065 |
spring-startup-analyzer.async.profiler.sample.thread.names | async profiler采集的线程名称,支持配置多个,以","进行分隔 | main |
spring-startup-analyzer.async.profiler.interval.millis | async profiler采集间隔时间(ms) | 5 |
spring-startup-analyzer.linux.and.mac.profiler | 指定linux/mac下火焰图采样器:async_profiler/jvm_profiler | async_profiler |
应用启动
此项目是以agent的方式启动的,所以在启动命令中添加参数-javaagent:$HOME/spring-startup-analyzer/lib/spring-profiler-agent.jar即可。
如果是以java命令行的方式启动应用,则在命令行中添加,如果是在IDEA中启动,则需要在VM options选项中添加。
日志文件路径:$HOME/spring-startup-analyzer/logs
- startup.log: 启动过程中的日志
- transform.log: 被re-transform的类/方法信息
应用启动完成后会在console和startup.log文件中输出
======= spring-startup-analyzer finished, click http://localhost:8065 to visit details. ======
可以通过此输出来判断采集是否完成。
启动优化
- 耗时分析
根据该Profile的耗时排序,我们发现诸如businessClientWrapper等业务Bean的构建非常耗时,且不被其他的Bean依赖(Root Bean)。基于该Bean的构建,结合源码,我们试图通过Async Bean Init优化其构造、初始化过程。
@Bean(destroyMethod = "destroy")
@ConditionalOnClass(BusinessHttpClient.class)
@ConditionalOnProperty(prefix = "business.client.options.remote", name = "address")
public BusinessClient businessHttpClientWrapper() throws Exception {
log.info("start build business http client, options is {}", businessClientProperties.getOptions());
BusinessHttpClient businessHttpClient = BusinessHttpClient.builder()
.options(businessClientProperties.getOptions()).build();
GlobalOptions.setENV(businessClientProperties.getEnv());
businessHttpClient.init();
if (Optional.ofNullable(businessClientProperties.getEnableCustomEventListener()).orElse(true)
&& CollectionUtils.isNotEmpty(listeners)) {
listeners.forEach(listener -> businessHttpClient.getBusinessEventBus().register(listener));
}
if (Optional.ofNullable(businessClientProperties.getAutoStart()).orElse(true)) {
long now = System.currentTimeMillis();
log.info("business http client starting");
businessHttpClient.start();
log.info("business http client started, cost: {}", System.currentTimeMillis() - now);
}
return businessHttpClient;
}
- 代码分析
- 该业务Bean的构造过程中,初始化动作和Bean的构造是同时的,其中最耗时的业务逻辑为businessHttpClient.start(),主要是为了方便业务加载初始化数据,执行过程约为18s,是最耗时的一个业务Bean。
- 代码优化
- 根据Async Bean的工作原理,我们把上述的businessHttpClient.start()从原有的Bean的构造方法中剥离出来,放到Bean的init方法中执行。
- 异步配置
# Asynchronous beans may be at the end of the Spring bean initialization order, which may result in suboptimal effects of asynchronous optimization. Open the configuration to prioritize loading asynchronous beans.
spring-startup-analyzer.boost.spring.async.bean-priority-load-enable=true
# name of bean to async init
spring-startup-analyzer.boost.spring.async.bean-names=businessHttpClientWrapper,subLabelService,cdcEsService,distributeIdService
# init bean thread pool core size
spring-startup-analyzer.boost.spring.async.init-bean-thread-pool-core-size=4
# init bean thread pool max size
spring-startup-analyzer.boost.spring.async.init-bean-thread-pool-max-size=8
- 异步行为
- 优化效果
- 优化前
- 优化后
- 效果提升 - 40.74%
落地实践
- 工具集成
[TextStorage -master]
[TextStorage-86609]
- 指标分析
- 问题分析
2023-09-08 14:24:37.406 INFO 10284 --- [ main] c.n.i.a.b.c.h.BusinessHttpClient : load CUSTOM_FUNCTION init data from remote success, total count is 4076, cost is 1440ms.
2023-09-08 14:24:35.808 INFO 10284 --- [ main] c.n.i.a.b.c.h.BusinessHttpClient : load TARGET_CHECKER init data from remote success, total count is 84087, cost is 3851ms.
2023-09-08 14:24:31.957 INFO 10284 --- [ main] c.n.i.a.b.c.h.BusinessHttpClient : load TARGET_CONFIG init data from remote success, total count is 10885, cost is 10225ms.
- 源码剖析
this.options.getOperateTargetTypes().forEach((operateTargetType) -> {
long lastOperateTargetId = 0L;
BusinessDataInitRequest businessDataInitRequest = BusinessDataInitRequest.builder().batchSize(this.options.getLoadBatchSize()).operateTargetType(operateTargetType.getCode()).build();
long totalCount = 0L;
long startTime = System.currentTimeMillis();
boolean hasMoreData = true;
while(hasMoreData) {
- 代码优化
private void loadFromRemote() {
AtomicLong lastRefreshTime = new AtomicLong(System.currentTimeMillis());
List<CompletableFuture<Boolean>> futures = new ArrayList<>();
for (OperateTargetType operateTargetType : options.getOperateTargetTypes()) {
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(() -> consumer(operateTargetType,lastRefreshTime));
futures.add(future);
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
this.lastRefreshTime = lastRefreshTime.get();
}
- 效果对比
- 优化前
- 代码优化
- Async Bean
- 效果提升
- 30%
思考总结
当我们直观的去面对应用启动过程中的瓶颈时,我们会发现,“罪魁祸首”还是我们自己业务层面的问题。为了追求“产出”、“效率”,我们时常会忽略一些重要的“底线”和“操守”。最终,整个团队会为此背锅。当我们为此反抗时,我们又会有收益;当我们选择沉默时,等待我们的是无止境的煎熬。下面总结下启动优化建议:
- 应该优先从代码层面优化初始化时间长的Bean,从根本上解决Bean初始化耗时长问题
- 对于二方包/三方包中初始化耗时长的Bean(无法进行代码优化)再考虑Bean的异步化
- 对于不被依赖的Bean可以放心进行异步化,可以通过各个Bean加载耗时中的Root Bean判断Bean是否被其他Bean依赖
- 对于被依赖的Bean需要小心分析,在应用启动过程中不能其他Bean被调用,否则可能会存在问题