为什么你不应该在静态初始化块里创建游戏对象?

如果你还不知道静态初始化块是什么:

1
2
3
4
5
6
7
8
public class Foo {
public static final int BAR;

// 这个就是。
static {
BAR = 666;
}
}

当然,上述写法并不常见,更常见的写法是:

1
2
3
4
public class Foo {
// 编译结果中,赋值 666 的操作是在静态初始化块中完成的。
public static final int BAR = 666;
}

所以,有一个看上去很诱人的写法

1
2
3
4
public class ModItems {
// This can open a can of worms!
public static final Item MY_ITEM = new Item();
}

但这个写法充满了各种陷阱。今天我们来走进一个真实案例,来讲述为什么你不应该这么写。

起源

2021 年 2 月 19 日,ZekerZhayard 联系 3TUSK 发了这么一个 issue ticket:

https://github.com/vectorwing/FarmersDelight/issues/190

表面上看,这个问题是「Typetools 无法正确捕获 lambda 表达式里的类型」。关于这一点是怎么做到的可以参考 zzzz 早先撰写的一篇文章

ZekerZhayrd 对着这个问题「简单」调试了三个小时无果。期间二人针对这个问题提出了很多神棍理论,例如

  • 方法/字段上的 final 修饰符影响常量池
  • 阴间编译器输出的 class 文件中包含不寻常的常量池

但都因为过于神棍而逐一否决。3TUSK 建议 ZekerZhayard 去咨询一些更专业的人士,看看如何 debug 这个问题。

进展

第二天,ZekerZhayrd 发现了 HotSpot 和 OpenJ9 的行为差异:

  • HotSpot:对于未加载的类,ConstantPool#getMethodAt 会首先加载那个类,然后返回一个不是 null 的结果。这个过程不会触发《Java 虚拟机规范》中所定义的类的初始化(Class initialization)流程。(参考 Class.forName(String, ClassLoader, boolean) 中第三个 boolean 参数的含义)
  • OpenJ9:对于未加载的类,ConstantPool#getMethodAt 会触发类加载和类初始化,但初始化过程中如果 <clinit> 抛出异常,这个异常就直接消失了,直到下一次访问该类成员时再次因为类初始化抛出一个新的异常。

他也因此找到了那个被吃掉的异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[modloading-worker-2/ERROR] [ne.mi.fm.ja.FMLModContainer/LOADING]: Failed to create mod instance. ModID: farmersdelight, class vectorwing.farmersdelight.FarmersDelight
java.lang.ExceptionInInitializerError: null
at vectorwing.farmersdelight.FarmersDelight.<init>(FarmersDelight.java:35) ~[farmersdelight:1.16.3-0.3.2] {re:classloading}
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[?:1.8.0_281] {}
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62) ~[?:1.8.0_281] {}
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[?:1.8.0_281] {}
at java.lang.reflect.Constructor.newInstance(Constructor.java:423) ~[?:1.8.0_281] {}
at java.lang.Class.newInstance(Class.java:442) ~[?:1.8.0_281] {}
at net.minecraftforge.fml.javafmlmod.FMLModContainer.constructMod(FMLModContainer.java:81) ~[forge:36.0] {re:classloading}
at net.minecraftforge.fml.ModContainer.lambda$buildTransitionHandler$4(ModContainer.java:120) ~[forge:?] {re:classloading}
at java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1640) [?:1.8.0_281] {}
at java.util.concurrent.CompletableFuture$AsyncRun.exec(CompletableFuture.java:1632) [?:1.8.0_281] {}
at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289) [?:1.8.0_281] {}
at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1067) [?:1.8.0_281] {}
at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1703) [?:1.8.0_281] {}
at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:172) [?:1.8.0_281] {}
Caused by: java.lang.NullPointerException: Registry Object not present: farmersdelight:wild_cabbages
at java.util.Objects.requireNonNull(Objects.java:290) ~[?:1.8.0_281] {}
at net.minecraftforge.fml.RegistryObject.get(RegistryObject.java:120) ~[forge:?] {re:mixin,re:classloading}
at vectorwing.farmersdelight.world.CropPatchGeneration.<clinit>(CropPatchGeneration.java:22) ~[farmersdelight:1.16.3-0.3.2] {re:classloading}
... 14 more

其中:

  • FarmersDelight.<init> 指的是主类的构造器
  • CropPatchGeneration.<clinit> 指的是 CropPatchGeneration静态初始化块

查阅源码后不难发现在 CropPatchGeneration 的第 21 - 22 行是

1
2
public static final BlockClusterFeatureConfig CABBAGE_PATCH_CONFIG = (new BlockClusterFeatureConfig.Builder(
new SimpleBlockStateProvider(ModBlocks.WILD_CABBAGES.get().getDefaultState()), new SimpleBlockPlacer())).tries(64).xSpread(2).zSpread(2).whitelist(ImmutableSet.of(Blocks.SAND.getBlock())).func_227317_b_().build();

注意到中间的 ModBlocks.WILD_CABBAGES.get() 了吗?而 ModBlocks.WILD_CABBAGES 是这样声明的:

1
2
public static final RegistryObject<Block> WILD_CABBAGES = BLOCKS.register("wild_cabbages",
() -> new WildPatchBlock(Block.Properties.from(Blocks.TALL_GRASS)));

这是 Forge 从 1.15.2 起引入的 DeferredRegister 机制。

这个机制的诞生实际上正是「依赖静态初始化」的写法过于泛滥而做出的一个妥协:它允许我们以类似的写法写出更安全的代码。注意到上面的 lambda 表达式了吗?RegistryObject 的加载是惰性的:仅在注册事件发布的时候它才会去获取真正的对象并缓存起来,同时在遇到注册表覆盖的情况时正确获取覆盖后的结果。

所以真正的问题现在很清楚了:RegistryObject.get() 仅在游戏对象成功注册之后才会有返回值。而它早在 Mod 主类实例化的时候就被错误调用了,自然就会抛出异常。

那么解决问题的方法也很简单了:CABBAGE_PATCH_CONFIG 是下面的 ConfiguredFeature<?, ?> PATCH_WILD_CABBAGES 初始化时需要的,那我们也用 DeferredRegister 注册……

等一下。ConfiguredFeature<?, ?> 并不能用 DeferredRegister,因为它没有实现 IForgeRegistryEntry,也就是说 Forge 并没有接管这个注册表。这下麻烦了。不过,使用 DeferredRegister 的主要目的是保证一个相对稳定的加载顺序,那么我们可以把这些字段单独放到一个类中来解决这个问题,就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 单独一个类放 ConfiguredFeature<?, ?>
public class MyConfiguredFeatures {
public static final ConfiguredFeature<?, ?> ORE_FEATURE = ...;
}

// 另外单独一个类,放 BiomeLoadingEvent 的监听方法
@Mod.EventBusSubscriber(modid = "my_mod")
public class BiomeLoadingListener {
@SubscribeEvent
public static void onBiomeLoad(BiomeLoadingEvent event) {
// 在这里面引用 MyConfiguredFeatures.ORE_FEATURE
}
}

这里有一个典型的例子可供参考:
https://github.com/MysticMods/Traverse/blob/1.16/src/main/java/epicsquid/traverse/init/ModFeatures.java

兔子洞

但这个非常明显的异常本应该在类初始化时就 propagate 出来(JVMS §5.5),而不是在注册事件监听器的时候才出现。

1
2
3
// 这个 method reference 可能会导致 Foo 类的初始化(JVMS §5.5、§6.5-invokedynamic)
// 而 Foo 类加载失败时会抛出异常,此时 IEventBus#addListener 还未调用。
MinecraftForge.EVENT_BUS.addListener(Foo::test);

ZekerZhayard 对此进行更进一步的调试,但仍然不确定这个问题该如何解决。

2021 年 2 月 22 日。ZekerZhayard 在反复 debug 了几天后得出了结论:OpenJ9 的实现有问题,在某些情况下调用静态方法不会触发类初始化,而 JVMS §6.5 中对 invokestatic 的描述是:

On successful resolution of the method, the class or interface that declared the resolved method is initialized (§5.5) if that class or interface has not already been initialized.

即如果目标类还没有初始化的话,invokestatic 将触发类初始化。

然后他就去发了一个 issue ticket:https://github.com/eclipse/openj9/issues/12016

目前 OpenJ9 的这个实现问题已被修复。

结语

通过这个案例,我们不难发现这一大串令人费解的问题的终极起因不过就是「游戏对象的初始化依赖于类加载顺序,但这个顺序不慎被打乱了,进而导致过早地访问还没初始化的字段」。

触发类加载的方法很简单,只要访问一个类的静态成员,或者创建这个类的实例就行了(JVMS §6.5,putstaticgetstatic invokestaticnew)。你很可能稍有不慎就会忘记你在 Mod 主类的构造器中写了一个 new 或者别的什么,然后你的类加载顺序就打乱了。

从可维护性的角度出发,依赖于类加载顺序并不是好习惯:你可能会忘记你在哪里漏了一个调用,然后你要花很长时间把这个调用找出来。

通过显式为 static 字段赋值,你可以明确知道你在什么地方初始化了这些字段,并为以后的调试提供便利。