Note
为简化无关环节,部分细节可能会省去,而进一步的深入解释将标记在 NOTE 内。 如果这是您第一次了解 MethodHandle 和 VarHandle,可直接掠过这些部分。
Tip
本文将用 TIP 标记您需要使用刚刚学到的技能实践的部分。 边学边练,效果 ++。
想必大家都写过类似这样的代码
Field f = MyClass.class.getDeclaredField("name");
f.setAccessible(true);
AnotherClass value = (AnotherClass) f.get(instance);
诚然,这样写并没有任何问题,而且代码可读性极高:获取 MyClass
的 name
字段,然后在 instance
上读取他。
可惜,如果这样一段代码需要在每一帧渲染时运行 1w 次,那你就会发现绝大部分 CPU 都被浪费在了反射的额外开销上。
在这篇文章,我们将一同探讨传统反射的开销来源,并尝试最小化这些开销。
以上代码包括两个环节:
这一步包括
- 检查访问权限:如果对应字段不能被当前代码访问,则抛出异常;
- 调用 VM 本地方法
Class::getField0
来获取一个 Field 对象; - 复制该获取的 Field 对象。
这一步包括
- 检查访问权限:如果对应字段不能被当前代码访问且未覆盖访问权限,则抛出异常;
- 构建访问器;
- 使用 Unsafe 获取对应字段的值。
Note
2-2 仅在一对某一特定的字段第一次访问时构建。后续会有全局(指在全 JVM 级别)的缓存。
显然,最开头的代码会在每次运行时完整的运行以上五步。即便提前将 Field 存储至常量池,2-1 这一无效步骤也无法跳过。
此外,由于这一调用链过于冗长,VM 不能有效的内联 get
方法内部的其他方法调用。
Note
“无法内联”导致的开销在高版本 JVM 上已得到优化。
至此,我们自然地得到了以下结论:将更多的检查放在创建某种类 Field
对象上,而运行时则尽量简单。
在获取 MethodHandle
时,应先构造 Lookup
对象,然后构造 MethodType
对象。
Lookup
对象存储了访问权。例如,你不能够在 AClass
中访问 BClass
的私有方法,是因为 AClass
的访问权不足以访问
BClass
中的私有成员。
要获取代表当前类访问权的 Lookup
对象,可直接调用 MethodHandles::lookup
。
要提升访问权,例如从 AClass
中获取一可以访问 BClass
私有成员的 Lookup
对象,请使用 MethodHandles::privateLookupIn
。
// In AClass.java
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandles.Lookup promotedLookup = MethodHandles.privateLookupIn(BClass.class, lookup);
此时,promotedLookup
能够自由的操作 BClass
中的所有私有成员,但不再能自由操作 AClass
的私有成员。
例如,为创建以下 Object::equals
方法的 MethodType
,
public class Object {
public boolean equals(Object obj) {
// ...
}
}
在构建 MethodType
对象时,应按照 Java 方法声明的书写顺序。即,先传递方法返回类型,再按照从左到右的顺序传递方法参数类型。
Warning
MethodType 是完全指定的,不会有任何的继承查找、装箱、拆箱。
Note
MethodType::methodType 方法有数个不同的重载。其第一个参数始终为返回类型,后续参数则支持通过不同的方式来传递参数类型。
MethodType methodType = MethodType.methodType(boolean.class, Object.class);
由此,我们成功创建了一描述 Object::equals
方法的 MethodType
对象。
MethodHandle
在用于反射时,其必然为以下类型之一:
- 调用一静态方法:
findStatic
; - 调用一实例方法:
findVirtual
; - 调用一类构造器:
findConstructor
。
注:这里提到的“类型”只对 DirectMethodHandle
成立,且并不包括字段访问器等类型。
延续上一个例子中获取到的 Object::equals
方法类型,接下来我们将尝试获取他的 MethodHandle
。
考虑到该方法为实例方法,应使用 findVirtual
来构建 MethodHandle
。
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle handle = lookup.findVirtual(
Object.class, // equals 方法处于 Object 类内
"equals", // 方法名称
MethodType.methodType(boolean.class, Object.class) // 方法类型
);
Tip
试一试:创建 String
类下 replaceFirst
方法的 MethodHandle
。
参考答案
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType methodType = MethodType.methodType(String.class, String.class);
MethodHandle handle = lookup.findVirtual(
String.class, // equals 方法处于 Object 类内
"replaceFirst", // 方法名称
methodType // 方法类型
);
对于静态方法和构造器方法,只需对应更改 findVirtual
到 findStatic
和 findConstructor
。
findStatic
: 用法和findVirtual
一致;findConstructor
: 方法类型返回值始终为void
,且不再需要指定方法名称。
Tip
试一试:创建 String
类下接收一 char[] 参数的构造器的 MethodHandle
。
参考答案
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType methodType = MethodType.methodType(void.class, String.class); // 方法返回值为 void
MethodHandle handle = lookup.findConstructor(
String.class, // 构造器处于 Object 类内
// 不需要指定方法名称
methodType // 方法类型
);
您应在类初始化块中完成 MethodHandle
的获取操作。
MethodHandle::invokeExact
方法可调用一 MethodHandle
对象。
对于热点代码,请保证 MethodHandle
对象是直接从一 static final
字段中加载的,而不是函数的返回值或局部变量。
Warning
如果 MethodHandle
不是从 static final
字段中加载的,那么将失去 5% 的性能。
例如,Objects.requireNonNull(handle).invokeExact()
会比 handle.invokeExact
慢。
public class Foo {
private static final MethodHandle HANDLE = acquireMethodHandle();
// 如果你有好几个 MethodHandle 需要初始化,可以写数个 acquireMethodHandle。
// 将 MethodHandle 初始化放入独立函数并不是标准的一部分,但这可以让我们更专注于类的其他部分。
private static MethodHandle acquireMethodHandle() {
try {
// 在这里构造 Lookup, MethodType 并获取 MethodHandle
return ...;
} catch (NoSuchMethodException e) {
// 异常处理。通常来说,我们简单地将错误包装为 RuntimeException。
throw new RuntimeException(e); // 不应发生。如果确实发生了,说明你 findVirtual 写错了。
} catch (IllegalAccessException e) {
throw new RuntimeException(e); // 不应发生。如果确实发生了,说明你没有提升 lookup 的权限。
}
}
public static void foo() {
HANDLE.invokeExact( /* 传入参数 */); // 正:直接从 static final 字段加载 MethodHandle。
Objects.requireNonNull(HANDLE).invokeExact(); // 误:从函数返回值中加载了 MethodHandle。
MethodHandle handle = HANDLE;
handle.invokeExact(); // 误:从局部变量中加载了 MethodHandle。
}
}
Note
实际上 JVM 能够通过常量内联将上述两个例子中对 HANDLE 的处理消除,等效地转变为直接加载的情况。 但这会降低方法句柄启动时的效率,请不要这么做!
invokeExact
方法可接收数个参数。
对于 findVirtual
加载的 MethodHandle
,其第一个参数必须为代表 this
的对象。
对于 findConstructor
加载的 MethodHandle
,其返回值为构造出的对象,而非 void
。
public class Foo {
private static final MethodHandle HANDLE = acquireMethodHandle();
private static MethodHandle acquireMethodHandle() {
try {
MethodHandles.Lookup lookup = MethodHandles.lookup();
return lookup.findVirtual(
Object.class,
"equals",
MethodType.methodType(boolean.class, Object.class)
);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e); // 不应发生。如果确实发生了,说明你 findVirtual 写错了。
} catch (IllegalAccessException e) {
throw new RuntimeException(e); // 不应发生。如果确实发生了,说明你没有提升 lookup 的权限。
}
}
public static boolean equals(@NotNull Object object1, Object object2) throws Throwable {
return HANDLE.invokeExact(object1, object2);
}
}
需要特别注意的是,invokeExact
方法的参数和返回值类型必须和原方法完全匹配。
在该例子中,HANDLE
指向的方法签名为 (Object, Object) -> boolean
。
为使方法签名匹配,所有传入的参数必须通过紧邻的强转到所需要的类型。
在该例子中,即为:(boolean) HANDLE.invokeExact((Object) object1, (Object) object2);
由于 equals
方法返回 boolean
类型,且 object1
, object2
类型均为 Object
,上述三处强转可以省略。
Warning
如果你错误地省略了强转或参数个数不对,将会收获 WrongMethodTypeException: handle's method type <1> but found <2>
。
此时,<1>
会标出 MethodHandle
的类型,<2>
会标出 invokeExact
时使用的类型。
Tip
试一试:创建 String
类下 replaceFirst
方法的 MethodHandle
,然后将 Hello World
字符串中的 Hello
替换为 Hi
参考答案
public class Foo {
private static final MethodHandle HANDLE = acquireMethodHandle();
private static MethodHandle acquireMethodHandle() {
try {
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType methodType = MethodType.methodType(String.class, String.class);
return lookup.findVirtual(
String.class, // equals 方法处于 Object 类内
"replaceFirst", // 方法名称
methodType // 方法类型
);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e); // 不应发生。如果确实发生了,说明你 findVirtual 写错了。
} catch (IllegalAccessException e) {
throw new RuntimeException(e); // 不应发生。如果确实发生了,说明你没有提升 lookup 的权限。
}
}
public static void main(String[] args) throws Throwable {
// invokeExact 根据字符串字面量推断参数类型为 String, String, String,根据将 (String) 强转推断返回类型为 String。
String result = (String) HANDLE.invokeExact("Hello World", "Hello", "Hi");
System.out.println(result);
}
}
VarHandle
在用于反射时,其必然为以下类型之一:
- 操作一静态字段:
findStaticVarHandle
; - 操作一实例字段:
findVarHandle
。
例如:若想获取 System::out
这一静态字段,我们应使用 findStaticVarHandle
。
MethodHandles.Lookup lookup = MethodHandles.lookup();
VarHandle handle = lookup.findStaticVarHandle(
System.class, // out 字段处于 System 类中
"out", // 字段名称
PrintWriter.class // out 字段类型
);
Tip
试一试:创建 Boolean
类下 TRUE
字段的 VarHandle
。
参考答案
MethodHandles.Lookup lookup = MethodHandles.lookup();
VarHandle handle = lookup.findStaticVarHandle(
Boolean.class, // TRUE 字段处于 System 类中
"TRUE", // 字段名称
Boolean.class // TRUE 字段类型
);
需要特别注意的是,在查找 VarHandle 时,字段类型是不会按继承关系自动查找的。
例如,在上述代码中,若将 findStaticVarHandle
的最后一个参数改为 Object.class
,那么查找将直接失败。
你同样应当将 VarHandle
放入 static final
字段中。
- 获取字段:使用
get*
方法; - 写入字段:使用
set*
方法,并附带想要写入的值。
对于操作实例字段的 VarHandle
,应将第一个参数前插入指向 this
的对象。
其类型依然是完全匹配的。
public class Foo {
private static final VarHandle HANDLE = acquireVarHandle();
private static VarHandle acquireVarHandle() {
MethodHandles.Lookup lookup = MethodHandles.lookup();
return lookup.findStaticVarHandle(
Boolean.class, // TRUE 字段处于 System 类中
"TRUE", // 字段名称
Boolean.class // TRUE 字段类型
);
}
public static void foo() {
Boolean booleanTrue = (Boolean) HANDLE.get();
}
}
Note
实际上 VarHandle
默认为 invoke-behavior
,JVM 会执行向上、向下转型来让类型匹配。
如果调用 withInvokeExactBehavior
,那么会禁用这些操作并略微提升性能。
Warning
如果您是 MethodHandle 的初学者,那么您不需要继续阅读以下部分。
模块化后,Java 不再允许用户随意反射 JDK 内部类和未开放反射权限的第三方模块。
但,Lookup.IMPL_LOOKUP
可以能够不受限的访问一切内容。
- Java 15 及以前:直接使用
privateLookupIn
提权到Lookup
类即可。 - Java 15 - 23:https://gist.github.com/burningtnt/e8f43d6917a60a3c2be59f41b2b2e653
- Java 24+:https://gist.github.com/burningtnt/c188e65f048c2cf096db095e5858b5af
在正常创建 MethodHandle
后,使用 asType
方法传入一个新的 MethodType
来修改其类型。
该 MethodType
应当是原方法的向上转形版本。