在实际工作中的一些特定应用场景下,JAVA 类反射是经常用到、必不可少的技术,在项目研发过程中,我们也遇到了不得不运用 JAVA 类反射技术的业务需求,并且不可避免地面临这个技术固有的性能瓶颈问题。
通过近两年的研究、尝试和验证,我们总结出一套利用缓存机制、大幅度提高 JAVA 类反射代码运行效率的方法,和没有优化的代码相比,性能提高了 20~30 倍。本文将与大家分享在探索和解决这个问题的过程中的一些有价值的心得体会与实践经验。
简述:JAVA 类反射技术
首先,用最简短的篇幅介绍 JAVA 类反射技术。
如果用一句话来概述,JAVA 类反射技术就是:
绕开编译器,在运行期直接从虚拟机获取对象实例/访问对象成员变量/调用对象的成员函数。
抽象的概念不多讲,用代码说话……举个例子,有这样一个类:
如果按照下列代码来使用这个类,就是传统的“创建对象-调用”模式:
如果按照如下代码来使用它,就是“类反射”模式:
类反射属于古老而基础的 JAVA 技术,本文不再赘述。
从上面的代码可以看出:
相比较于传统的“创建对象-调用”模式,“类反射”模式的代码更抽象、一般情况下也更加繁琐;
类反射绕开了编译器的合法性检测——比如访问了一个不存在的字段、调用了一个不存在或不允许访问的函数,因为编译器设立的防火墙失效了,编译能够通过,但是运行的时候会报错;
实际上,如果按照标准模式编写类反射代码,效率明显低于传统模式。在后面的章节会提到这一点。
缘起:为什么使用类反射
前文简略介绍了 JAVA 类反射技术,在与传统的“创建对象-调用”模式对比时,提到了类反射的几个主要弱点。但是在实际工作中,我们发现类反射无处不在,特别是在一些底层的基础框架中,类反射是应用最为普遍的核心技术之一。最常见的例子:Spring 容器。
这是为什么呢?我们不妨从实际工作中的具体案例出发,分析类反射技术的不可替代性。
大家几乎每天都和银行打交道,通过银行进行存款、转帐、取现等金融业务,这些动账操作都是通过银行核心系统(包括交易核心/账务核心/对外支付/超级网银等模块)完成的,因为历史原因造成的技术路径依赖,银行核心系统的报文几乎都是 xml 格式,而且以这种格式最为普遍:
和常用的 xml 格式进行对比:
银行核心系统的 xml 报文不是用标签的名字区分元素,而是用属性(name 属性)区分,在解析的时候,不管是用 DOM、SAX,还是 Digester 或其它方案,都要用条件判断语句、分支处理,伪代码如下:
显而易见,这样的代码非常粗劣、不优雅,每解析一个接口的报文,都要写一个专门的类或者函数,堆砌大量的条件分支语句,难写、难维护。如果报文结构简单还好,如果有一百个甚至更多的字段,怎么办?毫不夸张,在实际工作中,我遇到过一个银行核心接口有 140 多个字段的情况,而且这还不是最多的!
试水:优雅地解析 XML
当我们碰到这种结构的 xml、而且字段还特别多的时候,解决问题的钥匙就是类反射技术,基本思路是:
从 xml 中解析出字段的 name 和 value,以键值对的形式存储起来;
用类反射的方法,用键值对的 name 找到字段或字段对应的 setter(这是有规律可循的);
然后把 value 直接 set 到字段,或者调用 setter 把值 set 到字段。
接口类应该是这样的结构:
nodes 是存储字段的 name-value 键值对的列表,MessageNode 就是键值对,结构如下:
createNode 是在解析 xml 的时候,把键值对添加到列表的函数;
initialize 是用类反射方法,根据键值对初始化每个字段的函数。
这样,解析 xml 的代码可以变得非常优雅、简洁。如果用 Digester 解析之前列举的那种格式的银行报文,可以这样写:
initialize 函数的代码,可以写在一个基类里面,子类继承基类即可。具体代码如下:
上面被注释的段落是直接访问 Field 的方式,下面的段落是调用 setter 的方式,两种方法在效率上没有差别。
考虑到 JAVA 语法规范(书写 bean 的规范),调用 setter 是更通用的办法,因为接口类可能是被继承、派生的,子类无法访问父类用 private 关键字修饰的 Field。
getSetter 函数很简单,就是用 Field 的帽子反推 setter 的名字,然后用类反射的办法获取 setter。代码如下:
如果设计得好,甚至可以用一个解析函数处理所有的接口,这涉及到 Digerser 的运用技巧和接口类的设计技巧,本文不作深入讲解。
2017 年,我们在一个和银行有关的金融增值服务项目中使用了这个解决方案,取得了非常不错的效果,之后在公司内部推广开来成为了通用技术架构。经过一年多的实践,证明这套架构性能稳定、可靠,极大地简化了代码编写和维护工作,显著提高了生产效率。
问题:类反射性能差
但是,随着业务量的增加,2018 年末在进行压力测试的时候,发现解析 xml 的代码占用 CPU 资源居高不下。进一步分析、定位,发现问题出在类反射代码上,在某些极端的业务场景下,甚至会占用 90%的 CPU 资源!这就提出了性能优化的迫切要求。
类反射的性能优化不是什么新课题,因此有一些成熟的第三方解决方案可以参考,比如运用比较广泛的 ReflectASM,据称可以比未经优化的类反射代码提高 1/3 左右的性能。
(参考资料:Java高性能反射工具包ReflectASM,ReflectASM-invoke,高效率java反射机制原理)
在研究了 ReflectASM 的源代码以后,我们决定不使用现成的第三方解决方案,而是从底层入手、自行解决类反射代码的优化问题。主要基于两点考虑:
ReflectASM 的基本技术原理,是在运行期动态分析类的结构,把字段、函数建立索引,然后通过索引完成类反射,技术上并不高深,性能也谈不上完美;
类反射是我们系统使用的关键技术,使用场景、调用频率都非常高,从自主掌握和控制基础、核心技术,实现系统的性能最优化角度考虑,应该尽量从底层技术出发,独立、可控地完成优化工作。
思路和实践:缓存优化
前面提到 ReflectASM 给类的字段、函数建立索引,借此提高类反射效率。进一步分析,这实际上是变相地缓存了字段和函数。那么,在我们面临的业务场景下,能不能用缓存的方式优化类反射代码的效率呢?
我们的业务场景需要以类反射的方式频繁调用接口类的 setter,这些 setter 都是用 public 关键字修饰的函数,先是 getMethod()、然后 invoke()。基于以上特点,我们用如下逻辑和流程进行了技术分析:
用调试分析工具统计出每一句类反射代码的执行耗时,结果发现性能瓶颈在 getMethod();
分析 JAVA 虚拟机的内存模型和管理机制,寻找解决问题的方向。JAVA 虚拟机的内存模型,可以从下面两个维度来描述:
A.类空间/对象空间维度
B.堆/栈维度
从 JAVA 虚拟机内存模型可以看出,getMethod()需要从不连续的堆中检索代码段、定位函数入口,获得了函数入口、invoke()之后就和传统的函数调用差不多了,所以性能瓶颈在 getMethod();
代码段属于类空间(也有资料将其描述为“函数空间”/“代码空间”),类被加载后,除非虚拟机关闭,函数入口不会变化。那么,只要把 setter 函数的入口缓存起来,不就节约了 getMethod()消耗的系统资源,进而提高了类反射代码的执行效率吗?
把接口类修改为这样的结构(标红的部分是新增或修改):
setterMap 就是缓存字段 setter 的 HashMap。为什么是两层嵌套结构呢?因为这个 Map 是写在基类里面的静态变量,每个从基类派生出的接口类都用它缓存 setter,所以第一层要区分不同的接口类,第二层要区分不同的字段。如下图所示:
当 ClassLoader 加载基类时,创建 setterMap(内容为空):
这样写可以保证 setterMap 只被初始化一次。
Initialize()函数作如下改进:
基本思路就是把 setter 缓存起来,通过 MessageNode 的 name(字段的名字)找 setter 的入口地址,然后调用。
因为只在初始化第一个对象实例的时候调用 getMethod(),极大地节约了系统资源、提高了效率,测试结果也证实了这一点。
验证:测试方法和标准
1)先写一个测试类,结构如下:
2)在构造函数中,用 UUID 初始化存储键值对的列表 nodes:
之所以用 UUID,是保证每个实例、每个字段的值都不一样,避免 JAVA 编译器自动优化代码而破坏测试结果的原始性。
3)Initialize_ori()函数是用传统的硬编码方式直接调用 setter 的方法初始化实例字段,代码如下:
优化效果就以它作为对照标准 1,对照标准 2 就是没有优化的类反射代码。
4)checkUnifomity()函数用来验证:代码是否用 name-value 键值对正确地初始化了各字段。
每一种优化方案,我们都会用它验证实例的字段是否正确,只要出现一次错误,该方案就会被否定。
5)创建 100 万个 TestInvoke 类的实例,然后循环调用每一个实例的 initialize_ori()函数(传统的硬编码,非类反射方法),记录执行耗时(只记录初始化耗时,创建实例的耗时不记录);再创建 100 万个实例,循环调用每一个实例的类反射初始化函数(未优化),记录执行耗时;再创建 100 万个实例,改成调用优化后的类反射初始化函数,记录执行耗时。
6)以上是一个测试循环,得到三种方法的耗时数据,重复做 10 次,得到三组耗时数据,把记录下的数据去掉最大、最小值,剩下的求平均值,就是该方法的平均耗时。某一种方法的平均耗时越短则认为该方法的效率越高。
7)为了进一步验证三种方法在不同负载下的效率变化规律,改成创建 10 万个实例,重复 5/6 两步,得到另一组测试数据。
测试结果显示:在确保测试环境稳定、一致的前提下,8 个字段的测试实例、初始化 100 万个对象,传统方法(硬编码)耗时 850~1000 毫秒;没有优化的类反射方法耗时 23000~25000 毫秒;优化后的类反射代码耗时 600~800 毫秒。10 万个测试对象的情况,三种方法的耗时也大致是这样的比例关系。这个数据取决于测试环境的资源状况,不同的机器、不同时刻的测试,结果都有出入,但总的规律是稳定的。
基于测试结果,可以得出这样的结论:缓存优化的类反射代码比没有优化的代码效率提高 30 倍左右,比传统的硬编码方法提高了 10~20%。有必要强调的是,这个结论偏向保守。和 ReflecASM 相比,性能大幅度提高也是毋庸置疑的。
第一次迭代:忽略字段
缓存优化的效果非常好,但是,这个方案真的完美无缺了么?
经过分析,我们发现:如果数据更复杂一些,这个方案的缺陷就暴露了。比如键值对列表里的值在接口类里面并没有定义对应的字段,或者是没有对应的、可以访问的 setter,性能就会明显下降。
这种情况在实际业务中是很常见的,比如对接银行核心接口,往往并不需要解析报文的全部字段,很多字段是可以忽略的,所以接口类里面不用定义这些字段,但解析代码依然会把这些键值对全部解析出来,这时就会给优化代码造成麻烦了。
分析过程如下:
1)举例而言,如果键值对里有两个值在接口类(Interface01)并未定义,假定名字是 fieldX、filedY,第一次执行 initialize()函数:
初始状态下,setterMap 检索不到 Interface01 类的 setter 缓存,initialize()函数会在第一次执行的时候,根据键值对的名字(field01/field02/……/fieldN/fieldX/fieldY)调用 getMethod()函数、初始化 sertter 引用的缓存。因为 fieldX 和 fieldY 字段不存在,找不到它们对应的 setter,缓存里也没有它们的引用。
2)第二次执行 initialize()函数(也就是初始化第二个对象实例),field01/field02/……/fieldN 键值对都能在缓存中找到 setter 的引用,调用速度很快;但缓存里找不到 fieldX/fieldY 的 setter 的引用,于是再次调用 getMethod()函数,而因为它们的 setter 根本不存在(连这两个字段都不存在),做的是无用功,setterMap 的状态没有变化。
3)第三次、第四次……第 N 次,都是如此,白白消耗系统资源,运行效率必然下降。
测试结果印证了这个推断:在 TestInvoke 的构造函数增加了两个不存在对应字段和 setter 的键值对(姑且称之为“无效键值对”),进行 100 万个实例的初始化测试,经过优化的类反射代码,耗时从原来的 600~800 毫秒,增加到 7000~8000 毫秒,性能下降 10 倍左右。如果增加更多的键值对(不存在对应字段),性能下降更严重。
所以必须进一步完善优化代码。为了加以区分,我们把之前的优化代码称为 V1 版;进一步完善的代码称为 V2 版。
怎么完善?从上面的分析不难找到思路:增加忽略字段(ignore field)缓存。
基类 BaseModel 作如下修改(标红部分是新增或者修改),增加了 ignoreMap:
ignoreMap 的数据结构类似于 setterMap,但第二层不是 HashMap,而是 Set,缓存每个子类需要忽略的键值对的名字,使用 Set 更节约系统资源,如下图所示:
同样的,当 ClassLoader 加载基类的时候,创建 ignoreMap(内容为空):
Initialize()函数作如下改进:
虽然代码复杂了一些,但思路很简单:用键值对的名字寻找对应的 setter 时,如果找不到,就把它放进 ignoreMap,下次不再找了。另外还增加了对 setter 引用失效的处理。虽然理论上说“只要虚拟机不重启,setter 的入口引用永远不会变”,在测试中也从来没有遇到过这种情况,但为了覆盖各种异常情况,还是增加了这段代码。
继续沿用前面的例子,分析改进后的代码的工作流程:
1)第一次执行 initialize()函数,实例的状态是这样变化的:
因为 fieldX 和 fieldY 字段不存在,找不到它们对应的 setter,它们被放到 ignoreMap 中。
2)再次调用 initialize()函数的时候,因为检查到 ignoreMap 中存在 fieldX 和 fieldY,这两个键值对被跳过,不再徒劳无功地调用 getMethod();其它逻辑和 V1 版相同,没有变化。
还是用上面提到的 TestInvoke 类作验证(8 个字段+2 个无效键值对),V2 版本虽然代码更复杂了,但 100 万条纪录的初始化耗时为 600~800 毫秒,V1 版代码这个时候的耗时猛增到 7000~8000 毫秒。哪怕增加更多的无效键值对,V2 版代码耗时增加也不明显,而这种情况下 V1 版代码的效率还会进一步下降。
至此,对 JAVA 类反射代码的优化已经比较完善,覆盖了各种异常情况,如前所述,我们把这个版本称为 V2 版。
第二次迭代:逆向思维
这样就代表优化工作已经做到最好了吗?不是这样的。
仔细观察 V1、V2 版的优化代码,都是循环遍历键值对,用键值对的 name(和字段的名字相同)推算 setter 的函数名,然后去寻找 setter 的入口引用。第一次是调用类反射的 getMethod()函数,以后是从缓存里面检索,如果存在无效键值对,那就必然出现空转循环,哪怕是 V2 版代码,ignoreMap 也不能避免这种空转循环。虽然单次空转循环耗时非常短,但在无效键值对比较多、负载很大的情况下,依然有无效的资源开销。
如果采用逆向思维,用 setter 去反推、检索键值对,又会如何?
先分析业务场景以及由业务场景所决定的数据结构特点:
接口类的字段数量可能大于 setter 函数的数量,因为可能需要一些内部使用的功能性字段,并不是从 xml 报文里解析出来的;
xml 报文里解析出的键值对和字段是交集关系,多数情况下,键值对的数量包含了接口类的字段,并且大概率存在一些不需要的键值对;
相比较字段,setter 函数和需要解析的键值对最接近于一一对应关系,出现空转循环的概率最小;
因为接口类编写要遵守 JAVA 编程规范,从 setter 函数的名字反推字段的名字,进而检索键值对,是可行、可靠的。
综上所述,逆向思维用 setter 函数反推、检索键值对,初始化接口类,就是第二次迭代的具体方向。
需要把接口类修改成这样的结构(标红的部分是新增或者修改):
1)为了便于逆向检索键值对,nodes 字段改成 HashMap,key 是键值对的名字、value 是键值对的值。
2)为了提高循环遍历的速度,setterMap 的第二层改成链表,链表的成员是内部类 FieldSetter,结构如下:
setterMap 的第二层继续使用 HashMap 也能实现功能,但循环遍历的效率,HashMap 不如链表,所以我们改用链表。
3)同样的,setterMap 在基类被加载的时候创建(内容为空):
4)第一次初始化某个接口类的实例时,调用 initSetters()函数,初始化 setterMap:
5)Initialize()函数修改为如下逻辑:
不妨把这版代码称为 V3……继续沿用前面 TestInvoke 的例子,分析改进后代码的工作流程:
1)第一次执行 initialize()函数,实例的状态是这样变化的:
通过 setterMap 反向检索键值对的值,fieldX、fieldY 因为不存在对应的 setter,不会被检索,避免了空转。
2)之后每一次初始化对象实例,都不需要再初始化 setterMap,也不会消耗任何资源去检索 fieldX、fieldY,最大限度地节省资源开销。
3)因为取消了 ignoreMap,取消了 V2 版判断字段是否应该被忽略的逻辑,代码更简洁,也能节约一部分资源。
结果数据显示:用 TestInvoke 测试类、8 个 setter+2 个无效键值对的情况下,进行 100 万/10 万个实例两个量级的对比测试,V3 版比 V2 版性能最多提高 10%左右,100 万实例初始化耗时 550~720 毫秒。如果增加无效键值对的数量,性能提高更为明显;没有无效键值对的最理想情况下,V1、V2、V3 版本的代码效率没有明显差别。
至此,用缓存机制优化类反射代码的尝试,已经比较接近最优解了,V3 版本的代码可以视为到目前为止最好的版本。
总结和思考:方法论
总结过去两年围绕着 JAVA 类反射性能优化这个课题,我们所进行的探索和研究,提高到方法论层面,可以提炼出一个分析问题、解决问题的思路和流程,供大家参考:
1)从实践中来
多数情况下,探索和研究的课题并不是坐在书斋里凭空想出来的,而是在实际工作中遇到具体的技术难点,在现实需求的驱动下发现需要研究的问题。
以本文为例,如果不是在对接银行核心系统的时候遇到了大量的、格式奇特的 xml 报文,不会促使我们尝试用类反射技术去优雅地解析报文,也就不会面对类反射代码执行效率低的问题,自然不会有后续的研究成果。
2)拿出手术刀,解剖一只麻雀
在实践中遇到了困难,首先要分析和研究面对的问题,不能着急,要有解剖一只麻雀的精神,抽丝剥茧,把问题的根源找出来。
这个过程中,逻辑分析和实操验证都是必不可少的。没有高屋建瓴的分析,就容易迷失大方向;没有实操验证,大概率会陷入坐而论道、脑补的怪圈。还是那句话:实践是最宝贵的财富,也是验证一切构想的终极考官,是我们认识世界改造世界的力量源泉。但我们也不能陷入庸俗的经验主义,不管怎么说,这个世界的基石是有逻辑的。
回到本文的案例,我们一方面研究 JAVA 内存模型,从理论上探寻类反射代码效率低下的原因;另一方面也在实务层面,用实实在在的时间戳验证了 JAVA 类反射代码的耗时分布。理论和实践的结合,才能让我们找到解决问题的正确方向,二者不可偏废。
3)头脑风暴,勇于创新
分析问题,找到关键点,接下来就是寻找解决方案。JAVA 程序员有一个很大的优势,同时也是很大的劣势:第三方解决方案非常丰富。JAVA 生态比较完善,我们面临的麻烦和问题几乎都有成熟的第三方解决方案,“吃现成的”是优势也是劣势,很多时候,我们的创造力也因此被扼杀。所以,当面临高价值需求的时候,应该拿出大无畏的勇气,啃硬骨头,做底层和原创的工作。
就本文案例而言,ReflexASM 就是看起来很不错的方案,比传统的类反射代码性能提升了至少三分之一。但是,它真的就是最优解么?我们的实践否定了这一点。JAVA 程序员要有吃苦耐劳、以底层技术为原点解决问题的精神,否则你就会被别人所绑架,失去寻求技术自由空间的机会。中国的软件行业已经发展到了这个阶段,提出了这样的需求,我们应该顺应历史潮流。
4)螺旋式发展,波浪式前进
研究问题和解决问题,迭代是非常有效的工作方法。首先,要有精益求精的态度,不断改进,逼近最优方案,迭代必不可少。其次,对于比较复杂的问题,不要追求毕其功于一役,把一个大的目标拆分成不同阶段,分步实施、逐渐推进,这种情况下,迭代更是解决问题的必由之路。
我们解决 JAVA 类反射代码的优化问题,就是经过两次迭代、写了三个版本,才得到最终的结果,逼近了最优解。在迭代的过程中会逐渐发现一些之前忽略的问题,这就是宝贵的经验,这些经验在解决其他技术问题时也能发挥作用。比如 HashMap 的数据结构非常合理、经典,平时使用的时候效率是很高的,如果不是迭代开发、逼近极限的过程,我们又怎么可能发现在循环遍历状态下、它的性能不如链表呢?
行文至此,文章也快要写完了,细心的读者一定会有一个疑问:自始至终,举的例子、类的字段都是 String 类型,类反射代码根本没有考虑 setter 的参数类型不同的情况。确实是这样的,因为我们解决的是银行核心接口报文解析的问题,接口字段全部是 String,没有其它数据类型。
其实,对类反射技术的研究深入到这个程度,解决这个问题、并且维持代码的高效率,易如反掌。比如,给 FieldSetter 类增加一个数据类型的字段,初始化 setterMap 的时候把接口类对应的字段的数据类型解析出来,和 setter 函数的入口一起缓存,类反射调用 setter 时,把参数格式转换一下,就可以了。限于篇幅、这个问题就不展开了,感兴趣的读者可以自己尝试一下。
本文转载自宜信技术学院网站。
原文链接:http://college.creditease.cn/detail/324
评论