
01
简述:JAVA类反射技术
绕开编译器,在运行期直接从虚拟机获取对象实例/访问对象成员变量/调用对象的成员函数。
public class ReflectObj {
private String field01;
public String getField01() {
return this.field01;
}
public void setField01(String field01) {
this.field01 = field01;
}
}
如果按照下列代码来使用这个类,就是传统的“创建对象-调用”模式
ReflectObj obj = new ReflectObj();
obj.setField01("value01");
System.out.println(obj.getField01());
如果按照如下代码来使用它,就是“类反射”模式:
直接获取对象实例
ReflectObj obj = ReflectObj.class.newInstance();
// 直接访问Field
Field field = ReflectObj.class.getField("field01");
field.setAccessible(true);
field.set(obj, "value01");
// 调用对象的public函数
Method method = ReflectObj.class.getMethod("getField01");
System.out.println((String) method.invoke(obj));
相比较于传统的“创建对象-调用”模式,“类反射”模式的代码更抽象、一般情况下也更加繁琐; 类反射绕开了编译器的合法性检测——比如访问了一个不存在的字段、调用了一个不存在或不允许访问的函数,因为编译器设立的防火墙失效了,编译能够通过,但是运行的时候会报错; 实际上,如果按照标准模式编写类反射代码,效率明显低于传统模式。在后面的章节会提到这一点。
02
缘起:为什么使用类反射
<?xml version='1.0' encoding='UTF-8'?>
<service>
<sys-header>
<data name="SYS_HEAD">
<struct>
<data name="MODULE_ID">
<field type="string" length="2">RB</field>
</data>
<data name="USER_ID">
<field type="string" length="6">OP0001</field>
</data>
<data name="TRAN_TIMESTAMP">
<field type="string" length="9">003026975</field>
</data>
<!-- 其它字段略过 -->
</struct>
</data>
</sys-header>
<!-- 其它段落略过 -->
<body>
<data name="REF_NO">
<field type="string" length="23">OPS18112400302633661837</field>
</data>
</body>
</service>
和常用的xml格式进行对比:
<?xml version="1.0" encoding="UTF-8"?>
<recipe>
<recipename>Ice Cream Sundae</recipename>
<ingredlist>
<listitem>
<quantity>3</quantity>
<itemdescription>chocolate syrup or chocolate fudge</itemdescription>
</listitem>
<listitem>
<quantity>1</quantity>
<itemdescription>nuts</itemdescription>
</listitem>
<listitem>
<quantity>1</quantity>
<itemdescription>cherry</itemdescription>
</listitem>
</ingredlist>
<preptime>5 minutes</preptime>
</recipe>
银行核心系统的xml报文不是用标签的名字区分元素,而是用属性(name属性)区分,在解析的时候,不管是用DOM、SAX,还是Digester或其它方案,都要用条件判断语句、分支处理,伪代码如下:
// ……
接口类实例 obj = new 接口类();
List<Node> nodeList = 获取xml标签列表
for (Node node: nodeList) {
if (node.getProperty("name") == "张三") obj.set张三 (node.getValue());
else if (node.getProperty("name") == "李四") obj.set李四 (node.getValue());
// ……
}
// ……
显而易见,这样的代码非常粗劣、不优雅,每解析一个接口的报文,都要写一个专门的类或者函数,堆砌大量的条件分支语句,难写、难维护。如果报文结构简单还好,如果有一百个甚至更多的字段,怎么办?毫不夸张,在实际工作中,我遇到过一个银行核心接口有140多个字段的情况,而且这还不是最多的!
03
试水:优雅地解析XML
从xml中解析出字段的name和value,以键值对的形式存储起来; 用类反射的方法,用键值对的name找到字段或字段对应的setter(这是有规律可循的); 然后把value直接set到字段,或者调用setter把值set到字段。


public class MessageNode {
private String name;
private String value;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public MessageNode() {
super();
}
}
createNode是在解析xml的时候,把键值对添加到列表的函数;
initialize是用类反射方法,根据键值对初始化每个字段的函数。
这样,解析xml的代码可以变得非常优雅、简洁。如果用Digester解析之前列举的那种格式的银行报文,可以这样写:
Digester digester = new Digester();
digester.setValidating(false);
digester.addObjectCreate("service/sys-header", SysHeader.class);
digester.addCallMethod("service/sys-header/data/struct/data", "createNode", 2);
digester.addCallParam("service/sys-header/data/struct/data", 0, "name");
digester.addCallParam("service/sys-header/data/struct/data/field", 1);
parseObj = (SysHeader) digester.parse(new StringReader(msg));
parseObj.initialize();
public void initialize() {
for (MessageNode node: nodes) {
try {
/**
* 直接获取字段、然后设置字段值
*/
//String fieldName = StringUtils.camelCaseConvert(node.getName());
// 只获取调用者自己的field(private/protected/public修饰词皆可)
//Field field = this.getClass().getDeclaredField(fieldName);
// 获取调用者自己的field(private/protected/public修饰词皆可)和从父类继承的field(必须是public修饰词)
//Field field = this.getClass().getField(fieldName);
// 把field设为可写
//field.setAccessible(true);
// 直接设置field的值
//field.set(this, node.getValue());
/**
* 通过setter设置字段值
*/
Method method = this.getSetter(node.getName());
// 调用setter
method.invoke(this, node.getValue());
} catch (Exception e) {
log.debug("It's failed to initialize field: {}, reason: {}", node.getName(), e);
};
}
}
private Method getSetter(String fieldName) throws NoSuchMethodException, SecurityException {
String methodName = String.format("set%s", StringUtils.upperFirstChar(fieldName));
// 获取field的setter,只要是用public修饰的setter、不管是自己的还是从父类继承的,都能取到
return this.getClass().getMethod(methodName, String.class);
}
04
问题:类反射性能差
但是,随着业务量的增加,2018年末在进行压力测试的时候,发现解析xml的代码占用CPU资源居高不下。进一步分析、定位,发现问题出在类反射代码上,在某些极端的业务场景下,甚至会占用90%的CPU资源!这就提出了性能优化的迫切要求。
类反射的性能优化不是什么新课题,因此有一些成熟的第三方解决方案可以参考,比如运用比较广泛的ReflectASM,据称可以比未经优化的类反射代码提高1/3左右的性能。
【参考资料】
Java高性能反射工具包ReflectASM(https://www.cnblogs.com/juetoushan/p/7724793.html)
ReflectASM-invoke,高效率java反射机制原理(https://www.cnblogs.com/tohxyblog/p/8661090.html)
在研究了ReflectASM的源代码以后,我们决定不使用现成的第三方解决方案,而是从底层入手、自行解决类反射代码的优化问题。主要基于两点考虑:
ReflectASM的基本技术原理,是在运行期动态分析类的结构,把字段、函数建立索引,然后通过索引完成类反射,技术上并不高深,性能也谈不上完美;
类反射是我们系统使用的关键技术,使用场景、调用频率都非常高,从自主掌握和控制基础、核心技术,实现系统的性能最优化角度考虑,应该尽量从底层技术出发,独立、可控地完成优化工作。
05
思路和实践:缓存优化
用调试分析工具统计出每一句类反射代码的执行耗时,结果发现性能瓶颈在getMethod(); 分析JAVA虚拟机的内存模型和管理机制,寻找解决问题的方向。JAVA虚拟机的内存模型,可以从下面两个维度来描述:


从JAVA虚拟机内存模型可以看出,getMethod()需要从不连续的堆中检索代码段、定位函数入口,获得了函数入口、invoke()之后就和传统的函数调用差不多了,所以性能瓶颈在getMethod(); 代码段属于类空间(也有资料将其描述为“函数空间”/“代码空间”),类被加载后,除非虚拟机关闭,函数入口不会变化。那么,只要把setter函数的入口缓存起来,不就节约了getMethod()消耗的系统资源,进而提高了类反射代码的执行效率吗?



static {
setterMap = new HashMap<String, Map<String, Method>>();
}
这样写可以保证setterMap只被初始化一次。
public void initialize() {
// 先检查子类的setter是否被缓存
String className = this.getClass().getName();
if (setterMap.get(className) == null) setterMap.put(className, new HashMap<String, Method>());
Map<String, Method> setters = setterMap.get(className);
// 遍历报文节点
for (MessageNode node: nodes) {
try {
// 检查对应的setter是否被缓存了
Method method = setters.get(node.getName());
if (method == null) {
// 没有缓存,先获取、再缓存
method = this.getSetter(node.getName());
setters.put(node.getName(), method);
}
// 用类反射方式调用setter
method.invoke(this, node.getValue());
} catch (Exception e) {
log.debug("It's failed to initialize field: {}, reason: {}", node.getName(), e);
};
}
}
基本思路就是把setter缓存起来,通过MessageNode的name(字段的名字)找setter的入口地址,然后调用。
06
验证:测试方法和标准


this.createNode("test001",String.valueOf(UUID.randomUUID().toString().hashCode()));
this.createNode("test002",String.valueOf(UUID.randomUUID().toString().hashCode()));
// ……
之所以用UUID,是保证每个实例、每个字段的值都不一样,避免JAVA编译器自动优化代码而破坏测试结果的原始性。
3)Initialize_ori()函数是用传统的硬编码方式直接调用setter的方法初始化实例字段,代码如下:
for (MessageNode node: this.nodes) {
if (node.getName().equalsIgnoreCase("test001")) this.setTest001(node.getValue());
else if (node.getName().equalsIgnoreCase("test002")) this.setTest002(node.getValue());
……
}
4)checkUnifomity()函数用来验证:代码是否用name-value键值对正确地初始化了各字段。
for (MessageNode node: nodes) {
if (node.getName().equalsIgnoreCase("test001") && !node.getValue().equals(this.test001)) return false;
else if (node.getName().equalsIgnoreCase("test002") && !node.getValue().equals(this.test002)) return false;
// ……
}
return true;
每一种优化方案,我们都会用它验证实例的字段是否正确,只要出现一次错误,该方案就会被否定。
07
第一次迭代:忽略字段



static {
setterMap = new HashMap<String, Map<String, Method>>();
ignoreMap = new HashMap<String, Set<String>>();
}
public void initialize() {
// 先检查子类的setter是否被缓存
String className = this.getClass().getName();
if (setterMap.get(className) == null) {
setterMap.put(className, new HashMap<String, Method>());
}
if (ignoreMap.get(className) == null) {
ignoreMap.put(className, new HashSet<String>());
}
Map<String, Method> setters = setterMap.get(className);
Set<String> ignores = ignoreMap.get(className);
// 遍历报文节点
for (MessageNode node : nodes) {
String sName = node.getName();
try {
// 检查该字段是否被忽略
if (ignores.contains(sName)) {
continue;
}
// 检查对应的setter是否被缓存了
Method method = setters.get(sName);
if (method == null) {
// 没有缓存,先获取、再缓存
method = this.getSetter(sName);
setters.put(sName, method);
}
// 用类反射方式调用setter
method.invoke(this, node.getValue());
} catch (NoSuchMethodException | SecurityException e) {
log.debug("It's failed to initialize field: {}, reason: {}", sName, e);
// 找不到对应的setter,放到忽略字段集合,以后不再尝试
ignores.add(sName);
} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
log.error("It's failed to initialize field: {}, reason: {}", sName, e);
try {
// 不能调用setter,可能是虚拟机回收了该子类的全部实例、入口地址变化,更新地址、再试一次
Method method = this.getSetter(sName);
setters.put(sName, method);
method.invoke(this, node.getValue());
} catch (Exception e1) {
log.debug("It's failed to initialize field: {}, reason: {}", sName, e1);
}
} catch (Exception e) {
log.error("It's failed to initialize field: {}, reason: {}", sName, e);
}
}
}
虽然代码复杂了一些,但思路很简单:用键值对的名字寻找对应的setter时,如果找不到,就把它放进ignoreMap,下次不再找了。另外还增加了对setter引用失效的处理。虽然理论上说“只要虚拟机不重启,setter的入口引用永远不会变”,在测试中也从来没有遇到过这种情况,但为了覆盖各种异常情况,还是增加了这段代码。

08
第二次迭代:逆向思维
接口类的字段数量可能大于setter函数的数量,因为可能需要一些内部使用的功能性字段,并不是从xml报文里解析出来的; xml报文里解析出的键值对和字段是交集关系,多数情况下,键值对的数量包含了接口类的字段,并且大概率存在一些不需要的键值对; 相比较字段,setter函数和需要解析的键值对最接近于一一对应关系,出现空转循环的概率最小; 因为接口类编写要遵守JAVA编程规范,从setter函数的名字反推字段的名字,进而检索键值对,是可行、可靠的。


private class FieldSetter {
private String name;
private Method method;
public String getName() {
return name;
}
public Method getMethod() {
return method;
}
public void setMethod(Method method) {
this.method = method;
}
public FieldSetter(String name, Method method) {
super();
this.name = name;
this.method = method;
}
}
setterMap的第二层继续使用HashMap也能实现功能,但循环遍历的效率,HashMap不如链表,所以我们改用链表。
static {
setterMap = new HashMap<String, List<FieldSetter>>();
}
protected List<FieldSetter> initSetters() {
String className = this.getClass().getName();
List<FieldSetter> setters = new ArrayList<FieldSetter>();
// 遍历类的可调用函数
for (Method method : this.getClass().getMethods()) {
String methodName = method.getName();
// 如果从名字推断是setter函数,添加到setter函数列表
if (methodName.startsWith("set")) {
// 反推field的名字
String fieldName = StringUtils.lowerFirstChar(methodName.substring(3));
setters.add(new FieldSetter(fieldName, method));
}
}
// 缓存类的setter函数列表
setterMap
.put(className, setters);
// 返回可调用的setter函数列表
return setters;
}
public void initialize() {
// 从缓存获取接口类的setter列表
List<FieldSetter> setters = setterMap.get(this.getClass().getName());
// 如果还没有缓存、初始化接口类的setter列表
if (setters == null) {
setters = this.initSetters();
}
// 遍历接口类的setter
for (FieldSetter setter : setters) {
// 用setter的名字(也就是字段的名字)检索键值对
String fieldName = setter.getName();
String fieldValue = nodes.get(fieldName);
// 没有检索到键值对、或者键值对没有赋值,跳过
if (StringUtils.isEmpty(fieldValue)) {
continue;
}
try {
Method method = setter.getMethod();
// 用类反射方式调用setter
method.invoke(this, fieldValue);
} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
log.error("It's failed to initialize field: {}, reason: {}", fieldName, e);
// 不能调用setter,可能是虚拟机回收了该子类的全部实例、入口地址变化,更新地址、再试一次
try {
Method method = this.getSetter(fieldName);
setter.setMethod(method);
method.invoke(this, fieldValue);
} catch (Exception e1) {
log.debug("It's failed to initialize field: {}, reason: {}", fieldName, e1);
}
} catch (Exception e) {
log.error("It's failed to initialize field: {}, reason: {}", fieldName, e);
}
}
}

09
总结和思考:方法论
推荐阅读:





