为何Class.getMethods结果无序
在从一个随机出现的错误开读Mybatis源码中,导致 Mybatis 出现不确定行为的原因指向了 JDK 自带的 Class.getMethods 这个方法,当时只给出了这个方法返回结果是无序的结论,并没有再深入,这篇文章准备深挖底层的原因
复现
首先说明一下,要复现这个 Class.getMethods 无序还没想象的那么简单,第一次用了 String 这个类,发现没法复现,第二次自己定义了一个接口:
public interface Mapper {
void a();
void b();
void c();
}
也没法复现,为了尽量贴近生产环境,使用一些有意义的方法命名,最终复现了:
public interface Mapper {
void selectUserByAgeAndGender();
void findUserById();
void deleteUserById();
}
推测
根据已了解的 JVM 原理大概可知,JVM 通过读取 class 文件来获取这些反射数据,但理论上每次对 class 文件扫描加载方法的行为也应该是确定的,经过上面的复现,似乎跟方法名字符串排序有关系,推测可能底层以方法名为 key,使用了无序的数据结构,类似于哈希表
验证
翻查 Class 的源码,发现其获取类本身的方法最后都指向了 getDeclaredMethods0 的方法,这个方法是 native 方法,看来只能去翻翻 JVM 的源码才能知道原因了
这里我直接在 Github 上使用 Codespaces 来浏览代码,很方便
我对于 c++ 基本就是文盲,根据学的一些基础知识,c++ 源码分为 cpp 和 hpp,前者是实打实的实现源码,后者类似于 Java 中的接口定义,所以要找的话,二者基本都要看
一开始当然直接搜索 getDeclaredMethods0 这个关键词,由于要看的是 JVM 的源码,所以直接排除 java 后缀的源码
在 Class.c 中找到了这个关键字:
{"getDeclaredMethods0","(Z)[" MHD, (void *)&JVM_GetClassDeclaredMethods}
这是一个映射,我猜大概意思就是 Java 的方法映射到 C 的哪个方法,再搜索 JVM_GetClassDeclaredMethods 这个关键字,我们可以找到这样的一个的入口:
JVM_ENTRY(jobjectArray, JVM_GetClassDeclaredConstructors(JNIEnv *env, jclass ofClass, jboolean publicOnly))
{
return get_class_declared_methods_helper(env, ofClass, publicOnly,
/*want_constructor*/ true,
vmClasses::reflect_Constructor_klass(), THREAD);
}
继续搜索 get_class_declared_methods_helper,就来到了 jvm.cpp,这里大概就是他的获取方法的一个高层实现了,这个方法获取 Methods 大概就分两部,第一步就是获取并存储 idnums,这个看了看 method.cpp,每个方法都有一个 idnum,类似唯一 ID 的作用;第二部就是根据这个 idnums 去获取所有 Methods 并返回出去。
那么我们就可以大胆猜测,是不是每次获取的 idnum 顺序都不一样或者压根每次生成的 idnum 都不一样,所以才导致无序的?
因为 JVM 要先加载 Class 文件,然后解析里面的方法,根据已知的这个原理,我找到了 classFileParser.cpp 这个文件,听名字就知道它是干啥的,就是用来处理 class 文件的;同时在 method.cpp 内部也有一个 sort_methods 的方法,听名字也知道是干啥的,看实现就是通过 idnum 来排序的,在后面的源码分析中,我们会发现排序跟 idnum 没有关系:
// Reset method ordering
if (set_idnums) {
for (int i = 0; i < length; i++) {
Method* m = methods->at(i);
m->set_method_idnum(i);
m->set_orig_method_idnum(i);
}
}
寻找这个 sort_methods 的调用,原来在 classFileParser.cpp 有一个 post_process_parsed_stream 的调用,这个流程就包含对方法进行排序。
那么整体流程清楚了,我们需要再明确每个方法的 idnum 到底是怎么生成的,是不是每次生成的都不一样?
在翻查源码的过程中,发现 instanceKlass.hpp,这个文件就是类的类,它有一个 next_method_idnum 方法,内部就是通过计数器自增的方式来生成 idnum:
if (_idnum_allocated_count == ConstMethod::MAX_IDNUM) {
return ConstMethod::UNSET_IDNUM; // no more ids available
} else {
return _idnum_allocated_count++;
}
搜索了一下 _idnum_allocated_count 这个被用到的地方,其值会被初始化为所有方法的长度:
ik->set_initial_method_idnum(ik->methods()->length());
它的注释也说了:
// increments with the addition of methods, old ids don't change
// 随着方法的增加而增加,旧的ID不会改变
这就很奇怪,难道是因为在类加载时,parse_methods 每次加载的方法顺序不是固定的?
到这里时,我读源码卡了很久,找了许多地方都没有找到 method 加载顺序不是固定的证据,还重点围绕了 idnum 这个假设不断寻找其每次生成都是随机的设想,一直都没有进展,事实证明这两个假设方向都是错误的。最后转换了思路,是不是排序的时候有什么问题呢?
在上面的 Reset method ordering 那段代码的上面,其调用了一个快速排序方法进行排序:
{
NoSafepointVerifier nsv;
QuickSort::sort(methods->data(), length, func, /*idempotent=*/false);
}
这个排序方法传递了一个排序函数,也就是 func 这个东西,在这个方面上面几行,就能看到它具体是怎么排序的:
static int method_comparator(Method* a, Method* b) {
return a->name()->fast_compare(b->name());
}
这里的 name() 返回的是 symbol 的实例,在 symbol.hpp 这个文件里,可以找到这个方法的实现:
// Note: this comparison is used for vtable sorting only; it doesn't matter
// what order it defines, as long as it is a total, time-invariant order
// Since Symbol*s are in C_HEAP, their relative order in memory never changes,
// so use address comparison for speed
int Symbol::fast_compare(const Symbol* other) const {
return (((uintptr_t)this < (uintptr_t)other) ? -1
: ((uintptr_t)this == (uintptr_t) other) ? 0 : 1);
}
就算没有上面的注释,也能大概看出它用了指针来进行比较,指针比较,也就意味着这个方法实例一旦创建,没被回收前内存地址是不会变化的,它就是通过这种方式来优化排序速度。但由于是内存地址,每次 JVM 启动后类加载进行内存申请的地址都是会变化的,也就导致了每次启动 JVM,返回的方法序列都是没有规律的。
总结
前面我的底层数据结构猜测论错了,不过此时排查源码耗费了挺长时间,大部分时间都是在错误的假设上兜圈子了,阅读源码大概就是要这样大胆假设,小心求证,当每次阅读一部分代码后,就可以修正自己的假设,用新的假设来寻求问题的真正答案。