一、
速度与效率与激情
什么是速度?速度就是快,快有很多种。
有小李飞刀的快,也有闪电侠的快,当然还有周星星的快:(船家)"我是出了名够快"。(周星星)“这船好像在下沉?” (船家)“是呀!沉得快嘛”。
并不是任何事情越快越好,而是那些有价值有意义的事才越快越好。对于这些越快越好的事来说,快的表现是速度,而实质上是提效。今天我们要讲的java应用的研发效率,即如何加快我们的java研发速度,提高我们的研发效率。
提效的方式也有很多种。但可以分成二大类。
我们使用一些工具与平台进行应用研发与交付。当一小部分低效应用的用户找工具与平台负责人时,负责人建议提效的方案是:你看看其他应用都这么快,说明我们平台没问题。可能是你们的应用架构的问题,也可能是你们的应用中祖传代码太多了,要自己好好重构下。这是大家最常见的第一类提效方式。
而今天我们要讲的是第二类,是从工具与平台方面进行升级。即通过基础研发设施与工具的微创新改进,实现研发提效,而用户要做的可能就是换个工具的版本号。
买了一辆再好的车,带来的只是速度。而自己不断研究与改造发动机,让车子越来越快,在带来不断突破的“速度”的同时还带来了“激情”。因为这是一个不断用自己双手创造奇迹的过程。
所以我们今天要讲的不是买一辆好车,而是讲如何改造“发动机”。
在阿里集团,有上万多个应用,大部分应用都是java应用,95%应用的构建编译时间是5分钟以上,镜像构建时间是2分钟以上,启动时间是8分钟以上,这样意味着研发同学的一次改动,大部分需要等待15分钟左右,才能进行业务验证。而且随着业务迭代和时间的推移,应用的整体编译构建、启动速度也越来越慢,发布、扩容、混部拉起等等一系列动作都被拖慢,极大的影响了研发和运维整体效能,应用提速刻不容缓。
我们将阐述通过基础设施与工具的改进,实现从构建到启动全方面大幅提速的实践和理论,相信能帮助大家。
二、
maven构建提速
2.1 现状
maven其实并不是拖拉机。
相对于ant时代来说,maven是一辆大奔。但随着业务越来越复杂,我们为业务提供服务的软件也越来越复杂。虽然我们在提倡要降低软件复杂度,但对于复杂的业务来说,降低了复杂度的软件还是复杂的。而maven却还是几年的版本。在2012年推出maven3.0.0以来,直到现在的2022年,正好十年,但maven最新版本还是3系列3.8.6。所以在十年后的今天,站在复杂软件面前,maven变成了一辆拖拉机。
2.2 解决方案
在这十年,虽然maven还是停留在主版本号是3,但当今业界也不断出现了优秀的构建工具,如gradle,bazel。但因各工具的生态不同,同时工具间迁移有成本与风险,所以目前在java服务端应用仍是以maven构建为主。所以我们在apache-maven的基础上,参照gradle,bazel等其它工具的思路,进行了优化,并以“amaven”命名。
因为amaven完全兼容apache-maven,所支持的命令与参数都兼容,所以对我们研发同学来说,只要修改一个maven的版本号。
2.3 效果
从目前试验来看,对于mvn build耗时在3分钟以上的应用有效果。对于典型应用从2325秒降到188秒,提升了10倍多。
我们再来看持续了一个时间段后的总体效果,典型应用使用amaven后,构建耗时p95的时间有较明显下降,对比使用前后二个月的构建耗时降了50%左右。
2.4 原理
如果说发动机是一辆车的灵魂,那依赖管理就是maven的灵魂。
因为maven就是为了系统化的管理依赖而产生的工具。使用过maven的同学都清楚,我们将依赖写在pom.xml中,而这依赖又定义了自己的依赖在自己的pom.xml。通过pom文件的层次化来管理依赖的确让我们方便很多。
一次典型的maven构建过程,会是这样:
从上图可以看出,maven构建主要有二个阶段,而第一阶段是第二阶段的基础,基本上大部分的插件都会使用第一阶段产生的依赖树:
1.解析应用的pom及依赖的pom,生成依赖树;在解析过程中,一般还会从maven仓库下载新增的依赖或更新了的SNAPSHOT包。
2.执行各maven插件。
我们也通过分析实际的构建日志,发现大于3分钟的maven构建,瓶颈都在“生成依赖树”阶段。而“生成依赖树”阶段慢的根本原因是一个module配置的依赖太多太复杂,它表现为:依赖太多,则要从maven仓库下载的可能性越大。依赖太复杂,则依赖树解析过程中递归次数越多。
在amaven中通过优化依赖分析算法,与提升下载依赖速度来提升依赖分析的性能。除此之外,性能优化的经典思想是缓存增量,与分布式并发,我们也遵循这个思想作了优化。
在不断优化过程中,amaven也不断地C/S化了,即amaven不再是一个client,而有了server端,同时将部分复杂的计算从client端移到了server端。而当client越做越薄,server端的功能越来越强大时,server的计算所需要的资源也会越来越多,将这些资源用弹性伸缩来解决,慢慢地amaven云化了。
从单个client到C/S化再到云化,这也是一个工具不断进化的趋势所在。
2.4.1.1 依赖树缓存
既然依赖树生成慢,那我们就将这依赖树缓存起来。缓存后,这依赖树可以不用重复生成,而且可以不同人,不同的机器的编译进行共享。使用依赖树缓存后,一次典型的mvn构建的过程如下:
从上图中可以看到amaven-server,它主要负责依赖树缓存的读写性能,保障存储可靠性,及保证缓存的正确性等。
2.4.1.2 依赖树生成算法优化
虽在日常研发过程中,修改pom文件的概率较修改应用java低,但还是有一定概率;同时当pom中依赖了较多SNAPSHOT且SNAPSHOT有更新时,依赖树缓存会失效掉。所以还是会有不少的依赖树重新生成的场景。所以还是有必要来优化依赖树生成算法。
在maven2,及maven3版本中,包括最新的maven3.8.5中,maven是以深度优先遍历(DF)来生成依赖树的(在社区版本中,目前master上已经支持BF,但还未发release版本[1]。在遍历过程中通过debug与打日志发现有很多相同的gav或相同的ga会被重复分析很多次,甚至数万次。
树的经典遍历算法主要有二种:深度优先算法(DF)及 广度优先算法(BF),BF与DF的效率其实差不多的,但当结合maven的版本仲裁机制考虑会发现有些差异。
我们来看看maven的仲裁机制,无论是maven2还是maven3,最主要的仲裁原则就是depth。相同ga或相同gav,谁更deeper,谁就skip,当然仲裁的因素还有scope,profile等。结合depth的仲裁机制,按层遍历(BF)会更优,也更好理解。如下图,如按层来遍历,则红色的二个D1,D2就会skip掉,不会重复解析。(注意,实际场景是C的D1还是会被解析,因为它更左)。
算法优化的思路是:“提前修枝”。之前maven3的逻辑是先生成依赖树再版本仲裁,而优化后是边生成依赖树边仲裁。就好比一个树苗,要边生长边修枝,而如果等它长成了参天大树后则修枝成本更大。
2.4.1.3 依赖下载优化
maven在编译过程中,会解析pom,然后不断下载直接依赖与间接依赖到本地。一般本地目录是.m2。对一线研发来说,本地的.m2不太会去删除,所以除非有大的重构,每次编译只有少量的依赖会下载。
但对于CICD平台来说,因为编译机一般不是独占的,而是多应用间共享的,所以为了应用间不相互影响,每次编译后可能会删除掉.m2目录。这样,在CICD平台要考虑.m2的隔离,及当.m2清理后要下载大量依赖包的场景。
而依赖包的下载,是需要经过网络,所以当一次编译,如要下载上千个依赖,那构建耗时大部分是在下载包,即瓶颈是下载。
1) 增大下载并发数
依赖包是从maven仓库下载。maven3.5.0在编译时默认是启了5个线程下载。我们可以通过aether.connector.basic.threads来设置更多的线程如20个来下载,但这要求maven仓库要能撑得住翻倍的并发流量。所以我们对maven仓库进行了架构升级,根据包不同的文件大小区间使用了本地硬盘缓存,redis缓存等包文件多级存储来加快包的下载。
下表是对热点应用A用不同的下载线程数来下载5000多个依赖得到的下载耗时结果比较:
在amaven中我们加了对下载耗时的统计报告,包括下载多少个依赖,下载线程是多少,下载耗时是多少,方便大家进行性能分析。如下图:
同时为了减少网络开销,我们还采用了在编译机本地建立了mirror机制。
2) 本地mirror
有些应用有些复杂,它会在maven构建的仓库配置文件settings.xml(或pom文件)中指定下载多个仓库。因为这应用的要下载的依赖的确来自多个仓库.当指定多个仓库时,下载一个依赖包,会依次从这多个仓库查找并下载。
虽然maven的settings.xml语法支持多个仓库,但localRepository却只能指定一个。所以要看下docker是否支持将多个目录volume到同一个容器中的目录,但初步看了docker官网文档,并不支持。
为解决按仓库隔离.m2,且应用依赖多个仓库时的问题,我们现在通过对amaven的优化来解决。
(架构5.0:repo_mirror)
当amaven执行mvn build时,当一个依赖包不在本地.m2目录,而要下载时,会先到repo_mirror中对应的仓库中找,如找到,则从repo_mirror中对应的仓库中将包直接复制到.m2,否则就只能到远程仓库下载,下载到.m2后,会同时将包复制到repo_mirror中对应的仓库中。
通过repo_mirror可以实现同一个构建node上只会下载一次同一个仓库的同一个文件。
2.4.1.4 SNAPSHOT版本号缓存
其实在amavenServer的缓存中,除了依赖树,还缓存了SNAPSHOT的版本号。
我们的应用会依赖一些SNAPSHOT包,同时当我们在mvn构建时加上-U就会去检测这些SNAPSHOT的更新.而在apache-maven中检测SNAPSHOT需要多次请求maven仓库,会有一些网络开销。
现在我们结合maven仓库作了优化,从而让多次请求maven仓库,换成了一次cache服务直接拿到SNAPSHOT的最新版本。
增量是与缓存息息相关的,增量的实现就是用缓存。maven的开放性是通过插件机制实现的,每个插件实现具体的功能,是一个函数。当输入不变,则输出不变,即复用输出,而将每次每个函数执行后的输出缓存起来。
上面讲的依赖树缓存,也是maven本身(非插件)的一种增量方式。
要实现增量的关键是定义好一个函数的输入与输出,即要保证定义好的输入不变时,定义好的输出肯定不变。每个插件自己是清楚输入与输出是什么的,所以插件的增量不是由amaven统一实现,而是amaven提供了一个机制。如一个插件按约定定义好了输入与输出,则amaven在执行前会检测输入是否变化,如没变化,则直接跳过插件的执行,而从缓存中取到输出结果。
增量的效果是明显的,如依赖树缓存与算法的优化能让maven构建从10分钟降到2分钟,那增量则可以将构建耗时从分钟级降到秒级。
daemon是为了进一步达到10秒内构建的实现途径。maven也是java程序,运行时要将字节码转成机器码,而这转化有时间开销。虽这开销只有几秒时间,但对一个mvn构建只要15秒的应用来说,所占比例也有10%多。为降低这时间开销,可以用JIT直接将maven程序编译成机器码,同时mvn在构建完成后,常驻进程,当有新构建任务来时,直接调用mvn进程。
一般,一个maven应用编译不会超过10分钟,所以,看上去没必要将构建任务拆成子任务,再调度到不同的机器上执行分布式构建。因为分布式调度有时间开销,这开销可能比直接在本机上编译耗时更大,即得不偿失。所以分布式构建的使用场景是大库。为了简化版本管理,将二进制依赖转成源码依赖,将依赖较密切的源码放在一个代码仓库中,就是大库。当一个大库有成千上万个module时,则非用分布式构建不可了。使用分布式构建,可以将大库几个小时的构建降到几分钟级别。
三、
本地idea环境提速
3.1 从盲侠说起
曾经有有一位盲人叫座头市,他双目失明,但却是一位顶尖的剑客,江湖上称他为“盲侠”。
在我们的一线研发同学中,也有不少盲侠。
这些同学在本地进行写代码时,是盲写。他们写的代码尽管全都显示红色警示,写的单测尽管在本地没跑过,但还是照写不误。
我们一般的开发流程是,接到一个需求,从主干拉一个分支,再将本地的代码切到这新分支,再刷新IDEA。但有些分支在刷新后,尽管等了30分钟,尽管自己电脑的CPU沙沙直响,热的冒泡,但IDEA的工作区还是有很多红线。这些红线逼我们不少同学走上了“盲侠”之路。
一个maven工程的java应用,IDEA的导入也是使用了maven的依赖分析。而我们分析与实际观测,一个需求的开发,即在一个分支上的开发,在本地使用maven的次数绝对比在CICD平台上使用的次数多。
所以本地的maven的性能更需要提升,更需要改造。因为它能带来更大的人效。
3.2 解决方案
amaven要结合在本地的IDEA中使用也很方便。
下载amaven最新版本。
在本地解压,如目录 /Users/userName/soft/amaven-3.5.0。
设置Maven home path:
4.重启idea后,点import project.
最后我们看看效果,对热点应用进行import project测试,用maven要20分钟左右,而用amaven3.5.0在3分钟左右,在命中缓存情况下最佳能到1分钟内。
简单四步后,我们就不用再当“盲侠”了,在本地可以流畅地编码与跑单元测试。
除了在IDEA中使用amaven的依赖分析能力外,在本地通过命令行来运行mvn compile或dependency:tree,也完全兼容apache-maven的。
3.3 原理
IDEA是如何调用maven的依赖分析方法的?
在IDEA的源码文件[2]中979行,调用了dependencyResolver.resolve(resolution)方法:
dependencyResolver就是通过maven home path指定的maven目录中的DefaultProjectDependenciesResolver.java。
而DefaultProjectDependenciesResolver.resolve()方法就是依赖分析的入口。
IDEA主要用了maven的依赖分析的能力,在 “maven构建提速”这一小节中, 我们已经讲了一些amaven加速的原理,其中依赖算法从DF换到BF,依赖下载优化,整个依赖树缓存,SNAPSHOT缓存这些特性都是与依赖分析过程相关,所以都能用在IDEA提速上,而依赖仓库mirror等因为在我们自己的本地一般不会删除.m2,所以不会有所体现。
amaven可以在本地结合IDEA使用,也可以在CICD平台中使用,只是它们调用maven的方法的方式不同或入口不同而已。但对于maven协议来说“灵魂”的还是依赖管理与依赖分析。
四、
docker构建提速
4.1 背景
自从阿里巴巴集团容器化后,开发人员经常被镜像构建速度困扰,每天要发布很多次的应用体感尤其不好。我们几年前已经按最佳实践推荐每个应用要把镜像拆分成基础镜像和应用镜像,但是高频修改的应用镜像的构建速度依然不尽如人意。
为了跟上主流技术的发展,我们计划把CICD平台的构建工具升级到moby-buildkit,docker的最新版本也计划把构建切换到moby- buildkit了,这个也是业界的趋势。同时在 buildkit基础上我们作了一些增强。
4.2 增强
我们先用增量的思想,相对于COPY增加了一个新语法SYNC。
我们分析java应用高频构建部分的镜像构建场景,高频情况下只会执行Dockerfile中的一个指令:
COPY appName.tgz /home/appName/target/appName.tgz
发现大多数情况下java应用每次构建虽然会生成一个新的app.war目录,但是里面的大部分jar文件都是从maven等仓库下载的,它们的创建和修改时间虽然会变化但是内容的都是没有变化的。对于一个1G大小的war,每次发布变化的文件平均也就三十多个,大小加起来2-3 M,但是由于这个appName.war目录是全新生成的,这个copy指令每次都需要全新执行,如果全部拷贝,对于稍微大点的应用这一层就占有1G大小的空间,镜像的copy push pull都需要处理很多重复的内容,消耗无谓的时间和空间。
如果我们能做到定制dockerfile中的copy指令,拷贝时像Linux上面的rsync一样只做增量copy的话,构建速度、上传速度、增量下载速度、存储空间都能得到很好的优化。因为moby-buildkit的代码架构分层比较好,我们基于dockerfile前端定制了内部的SYNC指令。我们扫描到SYNC语法时,会在前端生成原生的两个指令,一个是从基线镜像中link 拷贝原来那个目录(COPY),另一个是把两个目录做比较(DIFF),把有变化的文件和删除的文件在新的一层上面生效,这样在基线没有变化的情况下,就做到了高频构建每次只拷贝上传下载几十个文件仅几兆内容的这一层。
而用户要修改的,只是将原来的COPY语法修改成SYNC就行了。
如将:
COPY appName.tgz /home/admin/appName/target/appName.tgz
修改为:
SYNC appName.dir /home/admin/appName/target/appName.war
我们再来看看SYNC的效果。集团最核心的热点应用A切换到moby-buildkit以及我们的sync指令后90分位镜像构建速度已经从140秒左右降低到80秒左右:
为了让moby- buildkit能在CICD平台上面用起来,首先要把none-gzip支持起来。
这个需求在 docker 社区也有很多讨论[3],内部环境网络速度不是问题,如果有gzip会导致90%的时间都花在压缩和解压缩上面,构建和下载时间会加倍,发布环境拉镜像的时候主机上一些CPU也会被gzip解压打满,影响同主机其它容器的运行。
虽然none-gzip后,CPU不会高,但会让上传下载等传输过程变慢,因为文件不压缩变大了。但相对于CPU资源来说,内网情况下带宽资源不是瓶颈。只需要在上传镜像层时按配置跳过 gzip 逻辑去掉,并把镜像层的MediaType从 application/vnd.docker.image.rootfs.diff.tar.gzip 改成application/vnd.docker.image.rootfs.diff.tar 就可以在内网环境下充分提速了。
在CICD过程中,即使是同一个应用的构建,也可能会被调度到不同的编译机上。即使构建调度有一定的亲和性。
为了让新构建机,或应用换构建机后能快速拉取到基础镜像,由于我们以前的最佳实践是要求用户把镜像分成两个(基础镜像与应用镜像),而基础镜像一般单层就有超过1G大小的,多层并发拉取对于单层特别大的镜像已经没有效果。
所以我们在“层间并发拉取”的基础上,还增加了“层内并发拉取”,让拉镜像的速度提升了4倍左右。
当然实现这层内并发下载是有前提的,即镜像的存储需要支持分段下载。因为我们公司是用了阿里云的OSS来存储docker镜像,它支持分段下载或多线程下载。
现在都是用containerd中的content store来存储镜像原始数据,也就是说每个节点本身就存储了一个镜像的所有原始数据manifest和layers。所以如果多个相邻的节点,都需要拉镜像的话,可以先看到中心目录服务器上查看邻居节点上面是否已经有这个镜像了,如果有的话就可以直接从邻居节点拉这个镜像。而不需要走镜像仓库去取镜像layer,而manifest数据还必须从仓库获取是为了防止镜像名对应的数据已经发生了变化了,只要取到manifest后其它的layer数据都可以从相邻的节点获取,每个节点可以只在每一层下载后的五分钟内(时间可配置)提供共享服务,这样大概率还能用到本地page cache,而不用真正读磁盘。
中心OSS服务总共只能提供最多20G的带宽,从历史拉镜像数据能看到每个节点的下载速度都很难超过30M,但是我们现在每个节点都是50G网络,节点相互之间共享镜像层数据可以充分利用到节点本地的50G网络带宽,当然为了不影响其它服务,我们把镜像共享的带宽控制在200M以下。
4.2.5 镜像ONBUILD支持
社区的 moby-buidkit 已经支持了新的 schema2 格式的镜像的 ONBUILD 了,但是集团内部还有很多应用 FROM 的基础镜像是 schema1 格式的基础镜像,这些基础镜像中很多都很巧妙的用了一些 ONBUILD 指令来减少 FROM 它的 Dockerfile中的公共构建指令。如果不能解析 schema1 格式的镜像,这部分应用的构建虽然会成功,但是其实很多应该执行的指令并没有执行,对于这个能力缺失,我们在内部补上的同时也把这些修改回馈给了社区[4]。
五、
JDK提速
5.1 AppCDS
CDS(Class Data Sharing)[5]在Oracle JDK1.5被首次引入,在Oracle JDK8u40[6]中引入了AppCDS,支持JDK以外的类 ,但是作为商业特性提供。随后Oracle将AppCDS贡献给了社区,在JDK10中CDS逐渐完善,也支持了用户自定义类加载器(又称AppCDS v2[7])。
目前CDS在阿里的落地情况:
热点应用A使用CDS减少了10秒启动时间
云产品SAE和FC在使用Dragonwell11时开启CDS、AOT等特性加速启动
经过十年的发展,CDS已经发展为一项成熟的技术。但是很容易令人不解的是CDS不管在阿里的业务还是业界(即便是AWS Lambda)都没能被大规模使用。关键原因有两个:
5.1.1.1 AppCDS在实践中效果不明显
jsa中存储的InstanceKlass是对class文件解析的产物。对于boot classloader(加载jre/lib/rt.jar下面的类的类加载器)和system(app) 类加载器(加载-classpath下面的类的类加载器),CDS有内部机制可以跳过对class文件的读取,仅仅通过类名在jsa文件中匹配对应的数据结构。
Java语言还提供用户自定义类加载器(custom class loader)的机制,用户通过Override自己的 Classloader.loadClass() 查找类,AppCDS 在为customer class loade时加载类是需要经过如下步骤:
调用用户定义的Classloader.loadClass(),拿到class byte stream
计算class byte stream的checksum,与jsa中的同类名结构的checksum比较
如果匹配成功则返回jsa中的InstanceKlass,否则继续使用slow path解析class文件
5.1.1.2 工程实践不友好
使用AppCDS需要如下步骤:
针对当前版本在生产环境启动应用,收集profiling信息
基于profiling信息生成jsa(java shared archive) dump
将jsa文件和应用本身打包在一起,发布到生产环境
由于这种trace-replay模式的复杂性,在SAE和FC云产品的落地都是通过发布流程的定制以及开发复杂的命令行工具来解决的。
针对上述的问题1,在热点应用A上CDS配合JarIndex或者使用编译器团队开发的EagerAppCDS特性(原理见5.1.3.1)都能让CDS发挥最佳效果。
经验证,在热点应用A已经使用JarIndex做优化的前提下进一步使用EagerAppCDS依然可以获得15秒左右的启动加速效果。
面向对象语言将对象(数据)和方法(对象上的操作)绑定到了一起,来提供更强的封装性和多态。这些特性都依赖对象头中的类型信息来实现,Java、Python语言都是如此。Java对象在内存中的layout如下:
+-------------+
| mark |
+-------------+
| Klass* |
+-------------+
| fields |
| |
+-------------+
mark表示了对象的状态,包括是否被加锁、GC年龄等等。而Klass*指向了描述对象类型的数据结构 InstanceKlass :
// InstanceKlass layout:
// [C++ vtbl pointer ] Klass
// [java mirror ] Klass
// [super ] Klass
// [access_flags ] Klass
// [name ] Klass
// [methods ]
// [fields ]
...
基于这个结构,诸如 o instanceof String 这样的表达式就可以有足够的信息判断了。要注意的是InstanceKlass结构比较复杂,包含了类的所有方法、field等等,方法又包含了字节码等信息。这个数据结构是通过运行时解析class文件获得的,为了保证安全性,解析class时还需要校验字节码的合法性(非通过javac产生的方法字节码很容易引起jvm crash)。
CDS可以将这个解析、校验产生的数据结构存储(dump)到文件,在下一次运行时重复使用。这个dump产物叫做Shared Archive,以jsa后缀(java shared archive)。
为了减少CDS读取jsa dump的开销,避免将数据反序列化到InstanceKlass的开销,jsa文件中的存储layout和InstanceKlass对象完全一样,这样在使用jsa数据时,只需要将jsa文件映射到内存,并且让对象头中的类型指针指向这块内存地址即可,十分高效。
Object:
+-------------+
| mark | +-------------------------+
+-------------+ |classes.jsa file |
| Klass* +--------->java_mirror|super|methods|
+-------------+ |java_mirror|super|methods|
| fields | |java_mirror|super|methods|
| | +-------------------------+
+-------------+
5.1.3.1 Alibaba Dragonwell对AppCDS的优化
上述AppCDS for custom classloader的加载流程更加复杂的原因是JVM通过(classloader, className)二元组来唯一确定一个类。
对于BootClassloader、AppClassloader在每次运行都是唯一的,因此可以在多次运行之间确定唯一的身份
对于customClassloader除了类型,并没有明显的唯一标识。AppCDS因此无法在加载类阶段通过classloader对象和类型去shared archive定位到需要的InstanceKlass条目。
Dragonwell提供的解决方法是让用户为customClassloader标识唯一的identifier,加载相同类的classloader在多次运行间保持唯一的identifier。并且扩展了shared archive,记录用户定义的classloader identifier字段,这样AppCDS便可以在运行时通过(identifier, className)二元组来迅速定位到shared archive中的类条目。从而让custom classloader下的类加载能和buildin class一样快。
在常见的微服务workload下,我们可以看到Dragonwell优化后的AppCDS将基础的AppCDS的加速效果从10%提升到了40%。
5.2 启动profiling工具
目前有很多Java性能剖析工具,但专门用于Java启动过程分析的还没有。不过有些现有的工具,可以间接用于启动过程分析,由于不是专门的工具,每个都存在这样那样的不足。
比如async-profiler,其强项是适合诊断CPU热点、墙钟热点、内存分配热点、JVM内锁争抢等场景,展现形式是火焰图。可以在应用刚刚启动后,马上开启aync-profiler,持续剖析直到应用启动完成。async-profiler的CPU热点和墙钟热点能力对于分析启动过程有很大帮助,可以找到占用CPU较多的方法 ,进而指导启动加速的优化。async-profiler有2个主要缺点,第1个是展现形式较单一,关联分析能力较弱,比如无法选择特定时间区间,也无法支持选中多线程场景下的火焰图聚合等。第2个是采集的数据种类较少,看不到类加载、GC、文件IO、SocketIO、编译、VM Operation等方面的数据,没法做精细的分析。
再比如arthas,arthas的火焰图底层也是利用async-profiler,所以async-profiler存在的问题也无法回避。
最后我们自然会想到OpenJDK的JDK Flight Recorder,简称JFR。AJDK8.5.10+和AJDK11支持JFR。JFR是JVM内置的诊断工具,类似飞机上的黑匣子,可以低开销的记录很多关键数据,存储到特定格式的JFR文件中,用这些数据可以很方便的还原应用启动过程,从而指导启动优化。JFR的缺点是有一定的使用门槛,需要对虚拟机有一定的理解,高级配置也较复杂,同时还需要搭配桌面软件Java Mission Control才能解析和阅读JFR文件。
面对上述问题,JVM工具团队进行了深入的思考,并逐步迭代开发出了针对启动过程分析的技术产品。
1、我们选择JFR作为应用启动性能剖析的基础工具。JFR开销低,内建在JDK中无第三方依赖,且数据丰富。JFR会周期性记录Running状态的线程的栈,可以构建CPU热点火焰图。JFR也记录了类加载、GC、文件IO、SocketIO、编译、VM Operation、Lock等事件,可以回溯线程的关键活动。对于早期版本JFR可能存在性能问题的特性,我们也支持自动切换到aync-profiler以更低开销实现相同功能。
2、为了降低JFR的使用门槛,我们封装了一个javaagent,通过在启动命令中增加javaagent参数,即可快速使用JFR。我们在javaagent中内置了文件收集和上传功能,打通数据收集、上传、分析和交互等关键环节,实现开箱即用。
3、我们开发了一个Web版本的分析器(或者平台),它接收到javaagent收集上传的数据后,便可以直接查看和分析。我们开发了功能更丰富和易用的火焰图和线程活动图。在类加载和资源文件加载方面我们也做了专门的分析,类似URLClassLoader在大量Jar包场景下的Class Loading开销大、Tomcat的WebAppClassLoader在大量jar包场景下getResource开销大、并发控制不合理导致锁争抢线程等待等问题都变得显而易见,未来还将提供评估开启CDS(Class Data Sharing)以及JarIndex后可以节省时间的预估能力。
当Oracle在OpenJDK11上开源了JDK Flight Recorder之后,阿里巴巴也是作为主要的贡献者,与社区包括 RedHat 等,一起将 JFR 移植到了 OpenJDK 8。
JFR是OpenJDK内置的低开销的监控和性能剖析工具,它深度集成在了虚拟机各个角落。JFR由两个部分组成:第1个部分分布在虚拟机的各个关键路径上,负责捕获信息;第2个部分是虚拟机内的单独模块,负责接收和存储第1个部分产生的数据。这些数据通常也叫做事件。JFR包含160种以上的事件。JFR的事件包含了很多有用的上下文信息以及时间戳。比如文件访问,特定GC阶段的发生,或者特定GC阶段的耗时,相关的关键信息都被记录到事件中。
尽管JFR事件在他们发生时被创建,但JFR并不会实时的把事件数据存到硬盘上,JFR会将事件数据保存在线程变量缓存中,这些缓存中的数据随后会被转移到一个global ring buffer。当global ring buffer写满时,才会被一个周期性的线程持久化到磁盘。
虽然JFR本身比较复杂,但它被设计为低CPU和内存占用,总体开销非常低,大约1%甚至更低。所以JFR适合用于生产环境,这一点和很多其它工具不同,他们的开销一般都比JFR大。
JFR不仅仅用于监控虚拟机自身,它也允许在应用层自定义事件,让应用程序开发者可以方便的使用JFR的基础能力。有些类库没有预埋JFR事件,也不方便直接修改源代码,我们则用javaagent机制,在类加载过程中,直接用ASM修改字节码插入JFR事件记录的能力。比如Tomcat的WebAppClassLoader,为了记录getResource事件,我们就采用了这个方法。
整个系统的结构如下:
六、
ClassLoader提速
6.1 现状
集团整套电商系统已经运行好多年了,机器上运行的jar包,不会因为最近大环境不好而减少,只会逐年递增,而中台的几个核心应用,所有业务都在上面开发,膨胀得更加明显,比如热点应用A机器上运行的jar包就有三千多个,jar包中包含的资源文件数量更是达到了上万级别,通过工具分析,启动有180秒以上是花在ClassLoader上,占总耗时的1/3以上,其中占比大头的是findResource的耗时。不论是loadClass还是getResource,最终都会调用到findResource,慢主要是慢在资源的检索上。现在spring框架几乎是每个java必备的,各种annotation,各种扫包,虽然极大的方便开发者,但也给应用的启动带来不少的负担。目前集团有上万多个Java应用,ClassLoader如果可以进行优化,将带来非常非常可观的收益。
6.2 解决方案
优化的方案可以简单的用一句话概括,就是给URLClassLoader的资源查找加索引。
6.3 提速效果
目前中台核心应用都已升级,基本都有100秒以上的启动提速,占总耗时的20~35%,效果非常明显!
6.4 原理
java的JIT(just in time)即时编译,想必大家都不陌生,JDK里不仅仅是类的装载过程按这个思想去设计的,类的查找过程也是一样的。通过研读URLClassPath的实现,你会发现以下几个特性:
URLClassPath初始化的时候,所有的URL都没有open;
findResources会比findResource更快的返回,因为实际并没有查找,而是在调用Enumeration的next() 的时候才会去遍历查找,而findResource去找了第一个;
URL是在遍历过程逐个open的,会转成Loader,放到loaders里(数组结构,决定了顺序)和lmap中(Map结构, 防止重复加载);
一个URL可以通过Class-Path引入新的URL(所以,理论上是可能存在新URL又引入新的URL,无限循环的场景);
因为URL和Loader是会在遍历过程中动态新增,所以URLClassPath#getLoader(int index) 里加了两把锁;
这些特性就是为了按需加载(懒加载),遍历的过程是O(N)的复杂度,按顺序从头到尾的遍历,而且遍历过程可能会伴随着URL的打开,和新URL的引入,所以,随着jar包数量的增多,每次loadClass或者findResources的耗时会线性增长,调用次数也会增长(加载的类也变多了),启动就慢下去了。慢的另一个次要原因是,getLoader(int index)加了两把锁。
6.4.2 JDK为什么不给URLClassLoader加索引
跟数据库查询一样,数量多了,加个索引,立杆见效,那为什么URLClassLoader里没加索引。其实,在JDK8里的URLClassPath代码里面,是可以看到索引的踪影的,通过加“-Dsun.cds.enableSharedLookupCache=true”来打开,但是,换各种姿势尝试了数次,发现都没生效,lookupCacheEnabled始终是false,通过debug发现JDK启动的过程会把这个变量从System的properties里移除掉。另外,最近都在升JDK11,也看了一下它里面的实现,发现这块代码直接被删除的干干净净,不见踪影了。
通过仔细阅读URLClassPath的代码,JDK没支持索引的原因有以下3点:
原因一:跟按需加载相矛盾,且URL的加载有不确定性
建索引就得提前将所有URL打开并遍历一遍,这与原先的按需加载设计相矛盾。另外,URL的加载有2个不确定性:一是可能是非本地文件,需要从网络上下载jar包,下载可能快,可能慢,也可能会失败;二是URL的加载可能会引入新的URL,新的URL又可能会引入新的URL。
原因二:不是所有URL都支持遍历
URL的类型可以归为3种:1. 本地文件目录,如classes目录;2. 本地或者远程下载下来的jar包;3. 其他URL。前2种是最基本最常见的,可以进行遍历的,而第3种是不一定支持遍历,默认只有一个get接口,传入确定性的name,返回有或者没有。
原因三:URL里的内容可能在运行时被修改
比如本地文件目录(classes目录)的URL,就可以在运行时往改目录下动态添加文件和类,URLClassLoader是能加载到的,而索引要支持动态更新,这个非常难。
首先必须承认,URLClassLoader需要支持所有场景都能建索引,这是有点不太现实的,所以,FastURLClassLoader设计之初只为满足绝大部分使用场景能够提速,我们设计了一个enable的开关,关闭则跟原生URLClassLoader是一样的。另外,一个java进程里经常会存在非常多的URLClassLoader实例,不能将所有实例都开打fast模式,这也是没有直接在AliJDK里修改原生URLClassLoader的实现,而是新写了个类的原因。
FastURLClassLoader继承了URLClassLoader,核心是将URLClassPath的实现重写了,在初始化过程,会将所有的Loader进行初始化,并遍历一遍生成index索引,后续findResources的时候,不是从0开始,而是从index里获取需要遍历的Loader数组,这将原来的O(N)复杂度优化到了O(1),且查找过程是无锁的。
FastURLClassLoader会有以下特征:
特征一:初始化过程不是懒加载,会慢一些
索引是在构造函数里进行初始化的,如果url都是本地文件(目录或Jar包),这个过程不会暂用过多的时间,3000+的jar,建索引耗时在0.5秒以内,内部会根据jar包数量进行多线程并发建索引。这个耗时,懒加载方式只是将它打散了,实际并没有少,而且集团大部分应用都使用了spring框架,spring启动过程有各种扫包,第一次扫包,所有URL就都打开了。
特征二:目前只支持本地文件夹和Jar类型的URL
如果包含其他类型的URL,会直接抛异常。虽然如ftp协议的URL也是支持遍历的,但得针对性的去开发,而且ftp有网络开销,可能懒加载更适合,后续有需要再支持。
特征三:目前不支持通过META-INF/INDEX.LIST引入更多URL
当前正式版本支持通过Class-Path引入更多的URL,但还不支持通过META-INF/INDEX.LIST来引入,目前还没碰用到这个的场景,但可以支持。通过Class-Path引入更多的URL比较常见,比如idea启动,如果jar太多,会因为参数过长而无法启动,转而选择使用"JAR manifest"模式启动。
特征四:索引是初始化过程创建的,除了主动调用addURL时会更新,其他场景不会更新
比如在classes目录下,新增文件或者子目录,将不会更新到索引里。为此,FastURLClassLoader做了一个兜底保护,如果通过索引找不到,会降级逐一到本地目录类型的URL里找一遍(大部分场景下,目录类型的URL只有一个),Jar包类型的URL一般不会动态修改,所以没找。
6.5 注意事项
索引对内存的开销:索引的是jar包和它目录和根目录文件的关系,所以不是特别大,热点应用A有3000+个jar包,INDEX.LIST的大小是3.2M
同名类的仲裁:tomcat在没有INDEX.LIST的情况下,同名类使用哪个jar包中的,存在一定不确性,添加索引后,仲裁优先级是jar包名称按字母排序来的,保险起见,可以对启动后应用加载的类进行对比验证。
七、
阿里中间件提速
在阿里集团的大部分应用都是依赖了各种中间件的Java应用,通过对核心中间件的集中优化,提升了各java应用的整体启动时间,提速8%。
7.1 Dubbo3 启动优化
Dubbo3 作为阿里巴巴使用最为广泛的分布式服务框架,服务集团内数万个应用,它的重要性自然不言而喻;但是随着业务的发展,应用依赖的 Jar 包 和 HSF 服务也变得越来越多,导致应用启动速度变得越来越慢,接下来我们将看一下 Dubbo3 如何优化启动速度。
Dubbo3 作为一个优秀的 RPC 服务框架,当然能够让用户能够进行灵活扩展,因此 Dubbo3 框架提供各种各样的扩展点一共 200+ 个。
Dubbo3 的扩展点机制有点类似 JAVA 标准的 SPI 机制,但是 Dubbo3 设置了 3 个不同的加载路径,具体的加载路径如下:
META-INF/dubbo/internal/
META-INF/dubbo/
META-INF/services/
也就是说,一个 SPI 的加载,一个 ClassLoader 就需要扫描这个 ClassLoader 下所有的 Jar 包 3 次。
以 热点应用A为例,总的业务 Bundle ClassLoader 数达到 582 个左右,那么所有的 SPI 加载需要的次数为: 200(spi) * 3(路径) * 582(classloader) = 349200次。
可以看到扫描次数接近 35万 次! 并且整个过程是串行扫描的,而我们知道 java.lang.ClassLoader#getResources 是一个比较耗时的操作,因此整个 SPI 加载过程耗时是非常久的。
由我们前面的分析可以知道,要想减少耗时,第一是需要减少 SPI 扫描的次数,第二是提升并发度,减少无效等待时间。
第一个减少 SPI 扫描的次数,我们经过分析得知,在整个集团的业务应用中,使用到的 SPI 集中在不到 10 个 SPI,因此我们疏理出一个 SPI 列表,在这个 SPI 列表中,默认只从 Dubbo3 框架所在 ClassLoader 的限定目录加载,这样大大下降了扫描次数,使热点应用A总扫描计数下降到不到 2万 次,占原来的次数 5% 这样。
第二个提升了对多个 ClassLoader 扫描的效率,采用并发线程池的方式来减少等待的时间,具体代码如下:
CountDownLatch countDownLatch = new CountDownLatch(classLoaders.size());
for (ClassLoader classLoader : classLoaders) {
GlobalResourcesRepository.getGlobalExecutorService().submit(() -> {
resources.put(classLoader, loadResources(fileName, classLoader));
countDownLatch.countDown();
});
}
7.1.4 其他优化手段
1、去除启动关键链路的非必要同步耗时动作,转成异步后台处理。
2、缓存启动过程中查询第三方可缓存的结果,反复重复使用。
热点应用A启动时间从 603秒 下降到 220秒,总体时间下降了 383秒 => 603秒 下降到 220秒,总体时间下降了 383秒。
7.2 TairClient 启动优化
背景介绍:1、tair:阿里巴巴内部的缓存服务,类似于公有云的redis;2、diamond:阿里巴巴内部配置中心,目前已经升级成MSE,和公有云一样的中间件产品
7.2.1 现状
目前中台基础服务使用的tair集群均使用独立集群,独立集群中使用多个NS(命名空间)来区分不同的业务域,同时部分小的业务也会和其他业务共享一个公共集群内单个NS。
早期tair的集群是通过configID进行初始化,后来为了容灾及设计上的考虑,调整为使用username进行初始化访问,但username内部还是会使用configid来确定需要链接的集群。整个tair初始化过程中读取的diamond配置的流程如下:
根据userName获取配置信息,从配置信息中可以获得TairConfigId信息,用于标识所在集群
dataid:ocs.userinfo.{username}
group : DEFAULT_GROUP
根据ConfigId信息,获取当前tair的路由规则,规定某一个机房会访问的集群信息。
dataId: {tairConfigId}
group : {tairConfigId}.TGROUP
通过该配置可以确定当前机房会访问的目标集群配置,以机房A为例,对应的配置集群tair.mdb.mc.XXX.机房A
获取对应集群的信息,确定tair集群的cs列表
dataid:{tairConfigId} // tair.mdb.mc.uic
group : {tairClusterConfig} // tair.mdb.mc.uic.机房A
从上面的分析来看,在每次初始化的过程中,都会访问相同的diamond配置,在初始化多个同集群的namespace的时候,部分关键配置就会多次访问。但实际这部分diamond配置的数据本身是完全一致。
由于diamond本身为了保护自身的稳定性,在客户端对访问单个配置的频率做了控制,超过一定的频率会进入等待超时阶段,这一部分导致了应用的启动延迟。
在一分钟的时间窗口内,限制单个diamond配置的访问次数低于-DlimitTime配置,默认配置为5,对于超过限制的配置会进入等待状态。
tair客户端进行改造,启动过程中,对Diamond的配置数据做缓存,配置监听器维护缓存的数据一致性,tair客户端启动时,优先从缓存中获取配置,当缓存获取不到时,再重新配置Diamond配置监听及获取Diamond配置信息。
7.3 SwitchCenter 启动优化
背景介绍:SwitchCenter:阿里巴巴集团内部的开关平台,对应阿里云AHAS云产品[8]
7.3.1 现状
All methods add synchronized made this class to be thread safe. switch op is not frequent, so don't care about performance here.
这是switch源码里存放各个switch bean 的SwitchContainer中的注释,可见当时的作者认为switch bean只需初始化一次,本身对性能的影响不大。但没有预料到随着业务的增长,switch bean的初始化可能会成为应用启动的瓶颈。
业务平台的定位导致了平台启动期间有大量业务容器初始化,由于switch中间件的大部分方法全部被synchronized修饰,因此所有应用容器初始化到了加载开关配置时(入口为com.taobao.csp.switchcenter.core.SwitchManager#init())就需要串行执行,严重影响启动速度。
去除了关键路径上的所有锁。
本次升级将存放配置的核心数据结构修改为了ConcurrentMap,并基于putIfAbsent等 j.u.c API 做了小重构。值得关注的是修改后原先串行的对diamond配置的获取变成了并行,触发了diamond服务端限流,在大量获取相同开关配置的情况下有很大概率抛异常启动失败。
(如图: 去锁后,配置获取的总次数不变,但是请求速率变快)
为了避免上述问题:
在本地缓存switch配置的获取
diamond监听switch配置的变更,确保即使switch配置被更新,本地的缓存依然是最新的
7.4 TDDL启动优化
背景介绍:TDDL:基于 Java 语言的分布式数据库系统,核心能力包括:分库分表、透明读写分离、数据存储平滑扩容、成熟的管控系统。
7.4.1 现状
TDDL在启动过程,随着分库分表规则的增加,启动耗时呈线性上涨趋势,在国际化多站点的场景下,耗时增长会特别明显,未优化前,我们一个核心应用TDDL启动耗时为120秒+(6个库),单个库启动耗时20秒+,且通过多个库并行启动,无法有效降低耗时。
通过工具分析,发现将分库分表规则转成groovy脚本,并生成groovy的class,这块逻辑总耗时非常久,调用次数非常多,且groovy在parseClass里头有加锁(所以并行无效果)。调用次数多,是因为生成class的个数,会剩以物理表的数量,比如配置里只有一个逻辑表 + 一个规则(不同表的规则也存在大量重复),分成1024张物理表,实际启动时会产生1024个规则类,存在大量的重复,不仅启动慢,还浪费了很多metaspace。
优化方案是新增一个全局的GuavaCache,将规则和生成的规则类实例存放进去,避免相同的规则去创建不同的类和实例。
八、
其他提速
除了前面几篇文章提到的优化点(ClassLoader优化、中间件优化等)以外,我们还对中台核心应用做了其他启动优化的工作。
8.1 aspectj相关优化
在进行启动耗时诊断的时候,意外发现aspectj耗时特别久,达到了54秒多,不可接受。
通过定位发现,如果应用里有使用到通过注解来判断是否添加切面的规则,aspectj的耗时就会特别久。
以下是热点应用A中的例子:
将aspectj相关jar包版本升级到1.9.0及以上,热点应用A升级后,aspectj耗时从54.5秒降到了6.3秒,提速48秒多。
另外,需要被aspectj识别的annotation,RetentionPolicy需要是RUNTIME,不然会很慢。
通过工具采集到老版本的aspectj在判断一个bean的method上是否有annotation时的代码堆栈,发现它去jar包里读取class文件并解析类信息,耗时耗在类搜索和解析上。当看到这个的时候,第一反应就是,java.lang,Method不是有getAnnotation方法么,为什么要绕一圈自己去从jar包里解析出来。不太理解,就尝试去看看最新版本的aspectj这块是否有改动,最终发现升级即可解决。
aspectj去class原始文件中读取的原因是annotation的RetentionPolicy如果不是RUNTIME的话,运行时是获取不到的,详见:java.lang.annotation.RetentionPolicy的注释
1.8.8版本在判断是否有注解的逻辑:
1.9.8版本在判断是否有注解的逻辑:与老版本的差异在于会判断annotation的RetentionPolicy是不是RUNTIME的,是的话,就直接从Method里获取了。
老版本aspectj的相关执行堆栈:(格式:时间|类名|方法名|行数)
8.2 tbbpm相关优化(javassist & javac)
中台大部分应用都使用tbbpm流程引擎,该引擎会将流程配置文件编译成java class来进行调用,以提升性能。tbbpm默认是使用com.sun.tools.javac.Main工具来实现代码编译的,通过工具分析,发现该过程特别耗时,交易应用A这块耗时在57秒多。
通过采用javassist来编译bpm文件,应用A预编译bpm文件的耗时从57秒多降到了8秒多,快了49秒。
com.sun.tools.javac.Main执行编译时,会把classpath传进去,自行从jar包里读取类信息进行编译,一样是慢在类搜索和解析上。而javassist是使用ClassLoader去获取这些信息,根据前面的文章“ClassLoader优化篇”,我们对ClassLoader加了索引,极大的提升搜索速度,所以会快非常多。
javac编译相关执行堆栈:(格式:时间|类名|方法名|行数)
九、
持续地...激情
一辆车,可以从直升机上跳伞,也可以飞驰在冰海上,甚至可以安装上火箭引擎上太空。上天入地没有什么不可能,只要有想象,有创新。
我们的研发基础设施与工具还在路上,还在不断改造的路上,还有很多的速度与激情可以追求。
学习更多JAVA知识与技巧,关注与私信博主(555)!
热爱学习和渴望进阶的小伙伴,各种JAVA学习路线、笔记、面试题,免费分享!