摘要
在玩大型整合包的时候,常常会遇到并发修改异常(ConcurrentModificationException,CME)和下标越界异常(IndexOutOfBoundsException,IOOBE)等问题导致游戏崩溃。
由于并发修改往往发生在多个不同的线程中,因而仅通过崩溃日志中一个线程的调用栈输出,通常无法确认是哪个模组导致的:
SimpleReloadInstance 的并发修改异常于是本模组应运而生,旨在通过输出一段时间内特定容器在每个线程被修改的历史,辅助玩家进行问题排查:
本模组可以输出一个容器的修改历史
使用方法
将模组(jar 文件)放进 mods 文件夹,即和其它模组一样正常安装,无需在意加载器是什么。
在启动器(或保存有虚拟机参数的文件)中修改 Java 虚拟机参数(即 JVM 参数),向其中加入 “-javaagent:mods/CMESuckMyDuck-<version>.jar=<class full name>;<field name>;<type>;<phase>”(参数含义与示例见下文)。
启动游戏,运行并直到崩溃再次发生。
阅读游戏目录中的 CMESuckMyDuck.log 文件查看被监视容器的修改历史。
参数含义
class full name
被监视容器所在类(class)的全名(即内部名,用“/”代替“.”,如“net/minecraft/server/packs/resources/SimpleReloadInstance”)。
field name
被监视容器的变量名,目前只支持类成员变量,不支持局部变量。
对于 Forge,请使用 SRG 名(1.16.5 及以前如 field_123456_a,1.17 及以后如 f_123456_)。特别的,1.20.6 及以后的 Forge 请使用官方名。
对于 Fabric,请使用 intermediary 名(包括类全名也要使用中间名,如 field_123456)。
对于 NeoForge,请使用官方名(如 instances、structureRepository 等)。
type
被监视容器的类型,目前只支持 List、Set、Map。
phase
static 或 nonstatic,表示容器是类静态成员还是非静态成员。
如何确认填什么参数
这里我们举一个例子。首先我们根据 CME 或 IOOBE 的调用栈,确认容器所在的类:
SoundEngine 的并发修改异常的调用栈接着,阅读该类的代码,确认该类的哪个成员遭受了并发修改:
SoundEngine 中被并发修改的类于是我们确认需要被监视的容器是 field_217942_m(SoundEngine 中的 instanceToChannel),于是我们安装 CMESuckMyDuck-1.0.0.jar 模组,向 Java 虚拟机参数中加入“-javaagent:mods/CMESuckMyDuck-1.0.0.jar=net/minecraft/client/audio/SoundEngine;field_217942_m;Map;nonstatic”,并等待下一次报错。
最后,打开 CMESuckMyDuck.log 日志,阅读容器修改历史,便可以确认哪几个线程发生了冲突,并发修改了容器导致了崩溃。
JVM 参数示例
ConcurrentModificationException 来自 SoundEngine 在 Forge 1.16.5 环境
-javaagent:mods/CMESuckMyDuck-1.0.0.jar=net/minecraft/client/audio/SoundEngine;field_217942_m;Map;nonstatic
ConcurrentModificationException 来自 PotionBrewing 在 Forge 1.20.1 环境
-javaagent:mods/CMESuckMyDuck-1.0.0.jar=net/minecraft/world/item/alchemy/PotionBrewing;f_43494_;List;static
ArrayIndexOutOfBoundsException 来自 Zeta Mod
-javaagent:mods/CMESuckMyDuck-1.0.0.jar=org/violetmoon/zetaimplforge/event/ForgeZetaEventBus;convertedHandlers;Map;nonstatic
其它设置
日志级别(不推荐修改)
使用系统属性“-Dcme_suck_my_duck.log_level=<level>”修改日志级别,有 0~3 四个级别。
0 将会输出容器一切访问历史,不仅包括修改,还包括查询如 Map#get、Set#containsAll 等,往往会导致日志过于冗长。
1 将不再输出查询历史,而是仅输出容器的修改历史,是模组默认的日志级别。
2 将不再输出容器修改历史,仅输出向类注入修改时的警告与异常,方便开发者调试。
3 将仅输出模组 premain 阶段的异常。
ASM API 版本(v1.0.2+)
为了保持最新版本 Minecraft 的兼容,本模组使用 asm 9.7 版本参与编译。对于旧版 Minecraft 游戏(如 1.12.2),无法应用 ASM_9 等 API 级别的操作,因此玩家需要使用“-Dcme_suck_my_duck.asm_api_version=<version>”修改 ASM API 版本兼容。
例如对于 1.12.2 游戏,请添加:
-Dcme_suck_my_duck.asm_api_version=5
<version> 的默认值为 9。
文件最大元素数(v1.0.3+)
由于日志最后几次操作比前面的内容重要得多,因此模组日志在 v1.0.3 之后采取了分页策略,且结束时只保留最终两个页面。每个页面输出的日志数量(即操作调用栈的数量)固定,默认值为 00,玩家可以使用“-Dcme_suck_my_duck.file_max_entries=<size>”修改页面最大元素数。
构造函数白名单(v1.0.3+)
有时一个容器所在的类会被应用于很多地方,如 CompoundTag#tags,此时直接使用该模组会导致日志过长,或有效内容被后续操作替换掉。因此玩家可以额外指定构造函数白名单,来使特定模块的对应类中对应的容器被监控。默认为空,即没有白名单,玩家可以使用“-Dcme_suck_my_duck.whitelist_constructor_stacktrace=<str>”来指定。如果容器被构造的调用栈中任何一行包括了白名单字符串的内容,则容器得到监控;否则容器不会被监控,极大地简化了日志输出信息。