renyunzhong
本帖最后由 renyunzhong 于 2021-3-1 21:33 编辑

这是我发的第一个教程,如有瑕疵请指正~
其实这个教程核心跟插件不搭边,不过既然是写给NMS用的,那也算是插件教程吧。
虽然这里是在研究使用JNI实现反射,其实也是在讨论本地库与Bukkit结合使用的一种可能。

这篇文章旨在通过JNI提高反射速度,但这样可能并不能大幅提高依赖反射的插件的性能,因为相比调用MC和BukkitAPI,使用反射的性能开销相当小,所以如果想提高自己插件的性能,应该先考虑优化算法或使用多线程,其次再考虑使用JNI这种“歪门邪道”。

文中的测试均为for循环64次,总共十次取平均值,为什么只有64次呢,因为次数多了会触发JVM一些奇奇怪怪优化。

文中使用Java版本为JDK11,C编译器为MinGW64,CPU为i9-9900k,使用Windows 10





能点进来的大概都知道反射能用来做什么。。。
。。。我还是简单介绍下吧
什么是反射呢?
反射是一种执行你先前无法知道运行时方法或域的功能。

比较绕?看看实例:
BukkitMC核心类文件路径为net.minecraft.server.vXX_XX.Clazz
中间的包名vXX_XX即是MC版本,也就是说每个MC版本这块都会不一样
很明显正常方式调用的话版本就得写死中间版本,但这样就会让插件丧失跨版本能力
即使使用穷举法import所有老版本的类,将来新版本MC出现插件不升级仍然用不了(你不能指望编译器从未来获得不存在的包的相关信息)

而此时使用反射,就能通过类完整路径直接获取类对象,进而获取其中方法进行执行
比如:
String version = "v1_16_R3";  // 可以是任意版本Class<?> cls = Class.forName("net.minecraft.server." + version + ".Clazz");
Method mth = cls.getDeclaredMethod("init", String.class, int.class);
mth.invoke(null, "Bar!", 5);
等价于
net.minecraft.server.v1_16_R3.Clazz.init("Bar!", 5);

反射虽然很好用,但缺点也很明显,通过字符串寻找类和方法非常耗时,同时Method.invoke()方法由于层层的安全、权限检查和其他杂项导致其耗时是直接调用的十几倍甚至几十倍,对于类Example测试的结果是:
class Example {
    static void foo(String str, int i) {
        String ss = str + i;
        String version = "v1_16_R3";  // 可以是任意版本
        Class<?> cls = Class.forName("net.minecraft.server." + version + ".Clazz");
        Method mth = cls.getDeclaredMethod("init", String.class, int.class);
        mth.invoke(null, "Bar!", 5);
    }
}

直接:Time used: 0.00269ms
反射:Time used: 0.23186ms

那么有什么办法提高反射性能吗?
<del>比如java.lang.invoke.MethodHandle(这个东西比反射还慢)</del>

比如使用JNI和动态代码(比如ReflectASM库)生成,前者缩小差距到几倍以内,而后者速度和直接调用几乎是相同的。

这里我们使用前者来达成目标

首先使用JNI需要你同时掌握Java和C语言,下面涉及C语言的部分我会尽量写的简单易懂。

JNI(Java Native Interface),Java本地接口,是给本地代码(C和C++)用来访问JVM内容的接口,其所有的函数都以函数指针(方法)形式存在于JNIEnv这个结构体(类似于一个类)中。
其中有两个函数叫CallObjectMethod(调用返回Object的成员方法)和CallStaticObjectMethod(调用返回Object的静态方法)。

于是对于上方的foo(),我们就可以定义一个本地方法:
public class JNIReflect {
    public static native void invoke(Method mth, String str, int t);
}

通过在类文件目录下执行javac -h JNIReflect JNIReflect.java生成一个包含JNIReflect.h文件的同名文件夹




创建一个JNIReflect.c文件并包含JNIReflect.h并实现其中的方法:
JNIEXPORT void JNICALL Java_JNIReflect_invokeVaArgs
        (JNIEnv * env, jclass cls, jobject mth, jstring str, jint i) {
    jmethodID id = (*env)->FromReflectedMethod(env, mth);
    jfieldID mth_cls_id = (*env)->GetFieldID(env, (*env)->GetObjectClass(env, mth), "clazz", "Ljava/lang/Class;");
    jclass mth_cls = (*env)->GetObjectField(env, mth, mth_cls_id);
    (*env)->CallStaticObjectMethodA(env, mth_cls, id, str, i);
}

在JNI中,所有Java对象均为jobject,也就是全都是Object,这里与Java反射非常像。
第一行通过FromReflectedMethod(JNIEnv * env, jobject method)函数获取到了传进来的Method对象所代表方法在JVM内部的ID
第二行主要获取Method.clazz这个域对应的内部ID,里面的GetObjectClass(JNIEnv * env, jobject obj)函数与Object.getClass()对应,后面的神秘代码"Ljava/lang/Class;"是域的特征码,具体可以写一个包含Class<?> clazz;域的空壳类编译后使用javap -v <类名> 进行查看。
第三行GetObjectField(JNIEnv * env, jobject obj, jfieldID id)函数与Field.get()对应,获取Method.clazz域上的那个类,就是方法所属的类
最后一行通过CallStaticObjectMethod(JNIEnv * env, jclass method_class, jmethodID id, ...)函数执行即可;
(函数开头的JNIEXPORT和JNICALL分别是导出dll函数的,和Win32标准函数的声明,不用深究)

编译为dll后放在项目Source目录下,使用System.load(new File("dll名字含后缀").getAbsolutePath())加载dll后即可调用本地方法了。
测试结果:
直接:Time used: 0.00278ms
反射:Time used: 0.30192ms
JNI  :Time used: 0.00770ms
可以看到相比传统反射,使用JNI效率相当可观。

那怎么实现Method.invoke()那种支持变长参数的呢?
首先要知道Java中的变长参数本质是数组,然后就好处理了:
JNIEXPORT jobject JNICALL Java_JNIReflect_invokeVaArgs
        (JNIEnv * env, jclass cls, jobject mth, jobject member, jobjectArray args) {

    jmethodID id = (*env)->FromReflectedMethod(env, mth);
    jfieldID mth_cls_id = (*env)->GetFieldID(env, (*env)->GetObjectClass(env, mth), "clazz", "Ljava/lang/Class;");
    jclass mth_cls = (*env)->GetObjectField(env, mth, mth_cls_id);

    jfieldID mth_prmtype = (*env)->GetFieldID(env, (*env)->GetObjectClass(env, mth), "returnType", "[Ljava/lang/class;");
    jobjectArray mth_params_type = (*env)->GetObjectField(env,mth, mth_prmtype);
    jint params_count = (*env)->GetArrayLength(env, mth_params_type);

    // 传入参数数量和方法需要的不一样,有问题
    if (params_count != (*env)->GetArrayLength(env, args)) return NULL;

    // 将Java的Object数组转为C的指针数组
    jvalue * params = malloc(sizeof(jvalue) * params_count);
    for (int i = 0; i < params_count; i++) {
        params = (jvalue)(*env)->GetObjectArrayElement(env, args, i);
    }

  
  // 判断是否为静态方法,使用member参数非空检查,此方法不可靠,应获取Method.modifiers域的值按位与上Modifier.STATIC的值检查非0
    jobject returns = NULL;
    if (member != NULL) {
        returns = (*env)->CallObjectMethodA(env, member, id, params);
    } else {
        returns = (*env)->CallStaticObjectMethodA(env, mth_cls, id, params);
    }

    free(params);

    return returns;
}

将jobjectArray(Object[])转为C语言的jvalue(所有Java内变量类型的超集,包括基础类型)指针数组,传入下方调用方法的函数中。

当然现在这个函数对于包含基础类型参数和返回值的函数支持很差,因为这涉及到Java的拆装箱等问题,完整版请看文章最后的源码。
其中寻找方法ID,域ID都可以使用全局引用缓存起来,调用的方法找到的方法ID也可以使用C语言版的Queue或者C++的queue缓存。

说了这么多,终于把JNI执行方法这部分讲完了,不过查找域的值和调用构造器都是同理了,相比原版反射都能提高不少,虽然读写域值这块使用sun.misc.Unsafe或jdk.internal.misc.Unsafe可以更快,但这个东西被Java藏起来了所以。。。

最后一部分说一下插件加载dll的注意事项(终于回到MC了):
1. 编写插件时,dll需要包含在Jar内但不能作为资源打包,因为这样会损坏掉,所以需要作为额外文件打包进Jar:在IDEA中,在Artifacts下Output Layout下方右击jar选择Add copy of中File,找到dll文件添加即可。
2. dll功能需要经过仔细严格的测试再实装进mc服务器,因为C代码bug导致的问题不是抛个异常能解决的,大概率直接导致JVM崩溃。
3. 直接使用System.load()加载dll会导致服务器重启后使用dll的插件产生UnsatisfiedLinkError。

对于第三条的解决方法:
1. 禁止服务器重载
2. 使用自定义类加载器隔离使用dll的类和其他类,中间使用接口链接,这样类加载器被删除引用后System.gc()调用GC就会把类和dll一起回收掉,从而在重载时能重新加载dll,具体实现请看源码中接口部分。

后记:理论上这个代码全平台通用,但C语言编译出来的东西不跨平台,所以编译出dll基本就是Windows专属。

源码奉上

https://github.com/HenryRenYz/CReflection

更新日志:
2020.3.1修复代码格式问题






2021.12 数据,可能有更多内容这是我发的第一个教程,如有瑕疵请指正~
其实这个教程核心跟插件不搭边,不过既然是写给NMS用的,那也算是插件教程吧。
虽然这里是在研究使用JNI实现反射,其实也是在讨论本地库与Bukkit结合使用的一种可能。


这篇文章旨在通过JNI提高反射速度,但这样可能并不能大幅提高依赖反射的插件的性能,因为相比调用MC和BukkitAPI,使用反射的性能开销相当小,所以如果想提高自己插件的性能,应该先考虑优化算法或使用多线程,其次再考虑使用JNI这种“歪门邪道”。


文中的测试均为for循环64次,总共十次取平均值,为什么只有64次呢,因为次数多了会触发JVM一些奇奇怪怪优化。


文中使用Java版本为JDK11,C编译器为MinGW64,CPU为i9-9900k,使用Windows 10






能点进来的大概都知道反射能用来做什么。。。
。。。我还是简单介绍下吧
什么是反射呢?
反射是一种执行你先前无法知道运行时方法或域的功能。


比较绕?看看实例:
BukkitMC核心类文件路径为net.minecraft.server.vXX_XX.Clazz
中间的包名vXX_XX即是MC版本,也就是说每个MC版本这块都会不一样
很明显正常方式调用的话版本就得写死中间版本,但这样就会让插件丧失跨版本能力
即使使用穷举法import所有老版本的类,将来新版本MC出现插件不升级仍然用不了(你不能指望编译器从未来获得不存在的包的相关信息)


而此时使用反射,就能通过类完整路径直接获取类对象,进而获取其中方法进行执行
比如:
String version = &quot;v1_16_R3&quot;;// 可以是任意版本Class&lt;?&gt; cls = Class.forName(&quot;net.minecraft.server.&quot; + version + &quot;.Clazz&quot;);
Method mth = cls.getDeclaredMethod(&quot;init&quot;, String.class, int.class);
mth.invoke(null, &quot;Bar!&quot;, 5);
等价于
net.minecraft.server.v1_16_R3.Clazz.init(&quot;Bar!&quot;, 5);

反射虽然很好用,但缺点也很明显,通过字符串寻找类和方法非常耗时,同时Method.invoke()方法由于层层的安全、权限检查和其他杂项导致其耗时是直接调用的十几倍甚至几十倍,对于类Example测试的结果是:
class Example {
    static void foo(String str, int i) {
  String ss = str + i;
  String version = &quot;v1_16_R3&quot;;// 可以是任意版本
  Class&lt;?&gt; cls = Class.forName(&quot;net.minecraft.server.&quot; + version + &quot;.Clazz&quot;);
  Method mth = cls.getDeclaredMethod(&quot;init&quot;, String.class, int.class);
  mth.invoke(null, &quot;Bar!&quot;, 5);
    }
}

直接:Time used: 0.00269ms
反射:Time used: 0.23186ms


那么有什么办法提高反射性能吗?
&lt;del&gt;比如java.lang.invoke.MethodHandle(这个东西比反射还慢)&lt;/del&gt;


比如使用JNI和动态代码(比如ReflectASM库)生成,前者缩小差距到几倍以内,而后者速度和直接调用几乎是相同的。


这里我们使用前者来达成目标


首先使用JNI需要你同时掌握Java和C语言,下面涉及C语言的部分我会尽量写的简单易懂。


JNI(Java Native Interface),Java本地接口,是给本地代码(C和C++)用来访问JVM内容的接口,其所有的函数都以函数指针(方法)形式存在于JNIEnv这个结构体(类似于一个类)中。
其中有两个函数叫CallObjectMethod(调用返回Object的成员方法)和CallStaticObjectMethod(调用返回Object的静态方法)。


于是对于上方的foo(),我们就可以定义一个本地方法:
public class JNIReflect {
    public static native void invoke(Method mth, String str, int t);
}

通过在类文件目录下执行javac -h JNIReflect JNIReflect.java生成一个包含JNIReflect.h文件的同名文件夹






创建一个JNIReflect.c文件并包含JNIReflect.h并实现其中的方法:
JNIEXPORT void JNICALL Java_JNIReflect_invokeVaArgs
  (JNIEnv * env, jclass cls, jobject mth, jstring str, jint i) {
    jmethodID id = (*env)-&gt;FromReflectedMethod(env, mth);
    jfieldID mth_cls_id = (*env)-&gt;GetFieldID(env, (*env)-&gt;GetObjectClass(env, mth), &quot;clazz&quot;, &quot;Ljava/lang/Class;&quot;);
    jclass mth_cls = (*env)-&gt;GetObjectField(env, mth, mth_cls_id);
    (*env)-&gt;CallStaticObjectMethodA(env, mth_cls, id, str, i);
}

在JNI中,所有Java对象均为jobject,也就是全都是Object,这里与Java反射非常像。
第一行通过FromReflectedMethod(JNIEnv * env, jobject method)函数获取到了传进来的Method对象所代表方法在JVM内部的ID
第二行主要获取Method.clazz这个域对应的内部ID,里面的GetObjectClass(JNIEnv * env, jobject obj)函数与Object.getClass()对应,后面的神秘代码&quot;Ljava/lang/Class;&quot;是域的特征码,具体可以写一个包含Class&lt;?&gt; clazz;域的空壳类编译后使用javap -v &lt;类名&gt; 进行查看。
第三行GetObjectField(JNIEnv * env, jobject obj, jfieldID id)函数与Field.get()对应,获取Method.clazz域上的那个类,就是方法所属的类
最后一行通过CallStaticObjectMethod(JNIEnv * env, jclass method_class, jmethodID id, ...)函数执行即可;
(函数开头的JNIEXPORT和JNICALL分别是导出dll函数的,和Win32标准函数的声明,不用深究)


编译为dll后放在项目Source目录下,使用System.load(new File(&quot;dll名字含后缀&quot;).getAbsolutePath())加载dll后即可调用本地方法了。
测试结果:
直接:Time used: 0.00278ms
反射:Time used: 0.30192ms
JNI:Time used: 0.00770ms
可以看到相比传统反射,使用JNI效率相当可观。


那怎么实现Method.invoke()那种支持变长参数的呢?
首先要知道Java中的变长参数本质是数组,然后就好处理了:
JNIEXPORT jobject JNICALL Java_JNIReflect_invokeVaArgs
  (JNIEnv * env, jclass cls, jobject mth, jobject member, jobjectArray args) {


    jmethodID id = (*env)-&gt;FromReflectedMethod(env, mth);
    jfieldID mth_cls_id = (*env)-&gt;GetFieldID(env, (*env)-&gt;GetObjectClass(env, mth), &quot;clazz&quot;, &quot;Ljava/lang/Class;&quot;);
    jclass mth_cls = (*env)-&gt;GetObjectField(env, mth, mth_cls_id);


    jfieldID mth_prmtype = (*env)-&gt;GetFieldID(env, (*env)-&gt;GetObjectClass(env, mth), &quot;returnType&quot;, &quot;[Ljava/lang/class;&quot;);
    jobjectArray mth_params_type = (*env)-&gt;GetObjectField(env,mth, mth_prmtype);
    jint params_count = (*env)-&gt;GetArrayLength(env, mth_params_type);


    // 传入参数数量和方法需要的不一样,有问题
    if (params_count != (*env)-&gt;GetArrayLength(env, args)) return NULL;


    // 将Java的Object数组转为C的指针数组
    jvalue * params = malloc(sizeof(jvalue) * params_count);
    for (int i = 0; i &lt; params_count; i++) {
  params = (jvalue)(*env)-&gt;GetObjectArrayElement(env, args, i);
    }


    // 判断是否为静态方法,使用member参数非空检查,此方法不可靠,应获取Method.modifiers域的值按位与上Modifier.STATIC的值检查非0
    jobject returns = NULL;
    if (member != NULL) {
  returns = (*env)-&gt;CallObjectMethodA(env, member, id, params);
    } else {
  returns = (*env)-&gt;CallStaticObjectMethodA(env, mth_cls, id, params);
    }


    free(params);


    return returns;
}

将jobjectArray(Object[])转为C语言的jvalue(所有Java内变量类型的超集,包括基础类型)指针数组,传入下方调用方法的函数中。


当然现在这个函数对于包含基础类型参数和返回值的函数支持很差,因为这涉及到Java的拆装箱等问题,完整版请看文章最后的源码。
其中寻找方法ID,域ID都可以使用全局引用缓存起来,调用的方法找到的方法ID也可以使用C语言版的Queue或者C++的queue缓存。


说了这么多,终于把JNI执行方法这部分讲完了,不过查找域的值和调用构造器都是同理了,相比原版反射都能提高不少,虽然读写域值这块使用sun.misc.Unsafe或jdk.internal.misc.Unsafe可以更快,但这个东西被Java藏起来了所以。。。


最后一部分说一下插件加载dll的注意事项(终于回到MC了):
1. 编写插件时,dll需要包含在Jar内但不能作为资源打包,因为这样会损坏掉,所以需要作为额外文件打包进Jar:在IDEA中,在Artifacts下Output Layout下方右击jar选择Add copy of中File,找到dll文件添加即可。
2. dll功能需要经过仔细严格的测试再实装进mc服务器,因为C代码bug导致的问题不是抛个异常能解决的,大概率直接导致JVM崩溃。
3. 直接使用System.load()加载dll会导致服务器重启后使用dll的插件产生UnsatisfiedLinkError。


对于第三条的解决方法:
1. 禁止服务器重载
2. 使用自定义类加载器隔离使用dll的类和其他类,中间使用接口链接,这样类加载器被删除引用后System.gc()调用GC就会把类和dll一起回收掉,从而在重载时能重新加载dll,具体实现请看源码中接口部分。


后记:理论上这个代码全平台通用,但C语言编译出来的东西不跨平台,所以编译出dll基本就是Windows专属。


源码奉上
https://github.com/HenryRenYz/CReflection


更新日志:2020.3.1修复代码格式问题



reobf
支持
不过"JVM一些奇奇怪怪优化"也包括JIT在内吧...这个不算是干扰项吧

性能测试中跑个几轮几百万遍 warnup 再跑测试内容是正确做法;对某个游戏进行跑分也没有将启动耗时计入平均帧率的计算吧。

反射运行多次后(一般为 15 次)会生成字节码直接调用的实现,并且反射一般情况下会强制内联;MethodHandle 经过 JIT 后可以达到堪比直接调用的性能,不知道从何而来的慢这一说。



3TUSK
本帖最后由 3TUSK 于 2021-3-3 03:04 编辑
匿名者 发表于 2021-3-3 01:31
性能测试中跑个几轮几百万遍 warnup 再跑测试内容是正确做法;对某个游戏进行跑分也没有将启动耗时计入平均 ...

补一个 intrinsic method 列表:

OpenJDK 8: https://gist.github.com/apangin/7a9b7062a4bd0cd41fcc
OpenJDK 9: https://gist.github.com/apangin/8bc69f06879a86163e490a61931b37e8

可以发现 HotSpot JVM 的 Intrinsic method 列表中有 Method.invoke 和 MethodHandle 下的一系列方法。
对于 Intrinsic method,在实际调用的时候会直接调用预先准备的优化过的实现,有时候甚至是一条 native instruction。自行实现是不会触发这种优化的。
延伸阅读:https://stackoverflow.com/questi ... -jvm-use-intrinsics

曾任 Google 首席 Java 架构师的 Joshua Bloch 所著的 Effective Java (Second Edition, ISBN 978-0-321-35668-0) 中列出的第 54 个项目即是 "Use native methods judiciously"(谨慎使用 native 方法)。文中列出了 native method 的劣势:

但C语言编译出来的东西不跨平台

C 是一门跨平台的语言。维基百科上的记载写明了它支持的操作系统是 "Cross-platform"。你只是需要重新编译一次而已。
引用某个操作系统专有的头文件不算。要知道,主流的操作系统或多或少都兼容 POSIX 标准。

renyunzhong
3TUSK 发表于 2021-3-3 03:00
补一个 intrinsic method 列表:

OpenJDK 8: https://gist.github.com/apangin/7a9b7062a4bd0cd41fcc

感谢指正,JNI的确缺点一点一大堆,不过对于反射随机访问JNI还是有速度优势,只是放到整体来看影响也不是很大

第一页 上一页 下一页 最后一页