
有时,我们需要在Java应用程序里调用一些 Win32 系统接口,而你发现这些接口是微软在几十年前基于“组件对象模型(COM)”技术实现的。但凡稍作尝试,你就会发现这会变成一个彻底的恶梦。
笔者最近在开发一个 Minecraft MOD,目标是在 Windows 上为 MC 提供完美的输入法支持。因此刚好就是从 Java 应用调用系统 COM 接口的场景。本以为能在一周之内做出一个基本可用的 Demo。结果几乎过去了一个月,笔者才找到了自认为最满意的方案,并计划从头开始重新实现功能。遂作此文分享。
Tnze · 2025年12月31日 凌晨2点45分
最容易想到的,也是最标准的方案,自然是用JNI。笔者在此方案上约投入了两个星期的时间,笔者自认为是一名具有丰富经验的开发者,但是在此方案上的开发体验依然特别痛苦。
笔者非常清楚需要满足哪些条件以使得 JNI 正常工作。
你需要编写原生代码并编译出一个“动态链接库”,在 Windows 上是一个 .dll 文件,btw在Linux上是 .so,而在macOS上是 .dylib。
在动态链接库里,你需要包含几个“导出的函数的符号”,这样在 Java 进程加载这个动态库后,才能通过名字找到对应函数的指针。而且这些函数名必须符合 Java 规定的特定规则。
在 Java 实际运行时,调用 System.loadLibrary() 来加载动态库,并且在类中声明 native 方法,然后就能直接调用了。
笔者知道有 JNA,也有封装好的 COM bridge 可以选择,但是在了解完之后就是非常不想用
笔者遇到的第一个问题是如何编译 C++ 代码。
JNI 作为 Java 最主要的 FFI 能力,居然到现在也没有官方的 Gradle 插件支持,甚至连第三方的插件都没有。谷歌的 Android 插件那边有自带的 JNI 编译功能,但是常规的 Java 应用开发却完全没有这方面的支持。你必须一步一步手搓整个构建配置。
C++ 的构建是出了名的复杂,笔者希望用尽可能简单的方式构建 C++ 代码,并且集成进 Gradle。我们是在写一些 C++ 的胶水代码,而不是在开发一个 C++ 项目。集成进 Gradle 是为了在运行 Java 项目时就能够自动更新用 C++ 开发的 JNI,而不用每次都手动分别构建两个项目。
笔者选择用 Gradle 的官方 cpp 插件来做 JNI 代码的编译。网上的教程几乎全都过期了,就算你问 AI 也不好用。当前最新版本的 Gradle 的官方 cpp 插件是无法在同一个项目中和 java 共存的。你需要创建一个 Gradle 子项目,并且在根项目里将子项目的 task 作为依赖项。在构建出动态库后,笔者开始尝试令 Java 项目加载这个动态库。
令人难以想象的是,JNI 作为 Java 官方的调用 Native接口的方式,动态库的加载居然直到现在都没有一个最佳实践出来。
为了实现动态库的加载,笔者选择的方式是将 dll 文件作为资源文件编译进 .jar 包,然后在运行时将其释放到系统 tmp 目录,然后再调用 System.load() 指定库的绝对路径来加载。
据说完全在内存中加载 dll 也是可行的,不过由于笔者感觉这种方案过于 hack,并且有被安全软件误杀的风险,故没有研究如何实现。
释放文件的方案很容易实现:调用 getClass().getResourceAsStream() 打开资源,调用 Files.createTempFile() 创建临时文件,然后调用 Files.copy() 完整拷贝即可。唯一的问题在于程序退出的时候无法自行删除这个文件,希望有知道解决方案的大佬可以介绍一下。
为了正确编写调用 COM 接口的代码,必须要对 COM 的心智模型有基础的了解。而由于 COM 技术本身就非常古早,资料也非常匮乏。微软官方的文档一看一个不吱声,目前能找到最好的学习资料是一位大佬在知乎上发表的一系列介绍性文章。
以笔者的理解,COM 模型是微软为了解决 C++ 没有标准 ABI 的问题而设计的 workaround。其核心是为了在不同版本之间保持二进制的兼容性,COM模型将所有 casting 操作封装在一个 QueryInterface 方法中。并将其与引用计数接口 AddRef 和 Release 共同封装在接口 IUnknown 里,作为所有 COM 对象的基类。
另一个难以理解的点是 COM 的线程模型,这里有一堆令人摸不着头脑的专有术语:“公寓模型”、“单线程套间(STA)”、“多线程套间(MTA)”、“代理对象”、“列集(marshaling)”、“封送处理”……同样也是一学一个不吱声。
笔者对此也只有一些肤浅的理解,但是应当已足以应付简单的开发:STA和MTA其实指的是 COM 对象的方法能在哪些线程上被调用。并不是任何线程上都能调用 COM 对象提供的方法,你需要先调用 CoInitialize 为当前线程初始化 COM 的运行环境,同时指定当前线程的模型,其中单线程模型被莫名其妙的叫做 APARTMENT(公寓)。在这个线程中创建的 COM 对象就会运行在 STA 模型下,即所有操作只能在该线程内调用。如果需要在其他线程调用这个 COM 对象,那就需要创建一个代理,并且调用这个代理时实际会像 RPC 一样经过序列化等动作,最终还是在那个单线程上运行。
那么 COM 调用到底是怎么从另一个线程切换到这个单线程上执行的呢?没错,STA 会在窗口的事件循环里处理来自外部的调用,如果你没有创建窗口,系统会在底层自动帮你创建一个不可见的窗口,并且你必须处理这个窗口的事件循环,否则该 STA 上的 COM 调用就得不到执行。
编写 JNI 胶水代码本身就是一件吃力不讨好的工作。并且有许多设计需要由你来决策。假设一个 COM 接口要传入窗口的句柄,那么你是在 Java 调用 JNI 之前先从 Window 对象拿到 long 类型的句柄,还是直接在 JNI 里访问 Window 对象取?选择前者你需要额外定义一个private native方法,选后者一句简单的 .getHandle() 会变成三四行超级长的 JNIEnv->XXXX() 组合技。
在 C++ 中调用 Java 方法、访问 Java 成员变量是一件繁琐又复杂的事,必须通过 JNIEnv 对象进行所有的操作。先通过 FindClass 获取 jclass,然后再从 jclass 里获取 jmethodID,这里你要通过一种奇怪格式的字符串描述符手动指定目标方法的签名,当然笔者已经是熟练工了,这里举几个例子:
void: "V"
byte: "B"
short: "S"
char: "C"
int: "I"
long: "J"
float: "F"
double: "D"
boolean: "Z"
Object: "Ljava/lang/Object;"
tech.tnze.Main: "Ltech/tnze/Main;"
long[]: "[J"
float[][]: "[[F"
tech.tnze.Foo[]: "[Ltech/tnze/Foo;"
对于基础类型,基本上用其类型的首字母大写来表示,需要特殊记忆的只有 boolean 和 long。对于类,需要用 "L" + 全限定名 + ";" 来表示,对于数组,需要用 "[" + 元素类型来表示。
对于方法签名的描述符,格式是 "(" + 参数类型... + ")" + 返回值类型。例如 "(I)I" 表示一个接受一个 int 参数,返回值类型为 int 的方法。
获取到 jmethodID 之后,需要调用 JNIEnv 来执行该方法。需要特别注意的是 JNIEnv 的有效作用域。绝对不能将 native 方法中的 JNIEnv 对象“储存”起来,然后从其他线程调用。如果你需要在 COM 系统中注册回调(他们叫做Sink),那么你事实上必须获取一个 JavaVM 对象,并且获取一个你需要调用的 jobject 的 GlobalRef 全局引用。在收到 COM 的回调之后,你需要从 JavaVM 里自己创建一个 JNIEnv,在这个 JNIEnv 上对 GlobalRef 进行操作,最后还要记得释放你的 JNIEnv。
注意 jclass 也是一个 jobject,并且你获取到的 jmethodID 也是关联到这个 jclass 的作用域上的,如果你想缓存方法或者成员变量之类的id。一定要注意你的 jclass 是不是一个 GlobalRef。
不仅如此,还要记得做好错误处理哦!上面说的所有方法都有可能失败,Java 出现 Exception 的时候记得转换成 HRESULT 给 COM。COM 出现 HRESULT 的时候记得转换成 Exception 抛给 Java。唯一值得庆幸的是 Java 和 Win32 都是基于 UTF-16 的接口,在转换字符串方面不会有太多的困难。
另一个问题是如何保存对 COM 对象的引用?你不得不将 COM 对象的指针 reiniterupt_cast 成 jlong,然后放到 Java 对象的 field 里保存。在要用到的时候再 reinterupt_cast 回接口的指针。当然,引用计数也需要你手动管理,如果你没有记得在 Java 对象释放时调用 COM的 ->Release(),就会造成内存泄漏,如果你在 Release() 之后又去访问,又会造成 Use After Free。
也许还有很多很多需要注意的细节没有在这里提到。而且别忘了,你要在每个你要封装的方法中完成上面所有的操作。一旦有一步做错,JVM就会死给你看。
JNI 代码及其难以调试,你通过 IDEA 只能调试 Java 侧的代码,而 Native 代码则需要 Visual Studio,通过附加到 java.exe 进程的方式来调试。
C++ 代码崩溃的时候是不会打印调用栈的,输出窗口只能看到一个进程退出的错误码。
好吧,你也许会说打开AddressSanitizer就能看到调用栈了,即在编译时加上这个参数:/fsanitize=address。思路不错!恭喜你,现在连 dll 都加载不上了。
你的JNI库现在平白无故多出了一大堆依赖项,因为 MSVC 并不支持静态链接 sanitizer,它甚至没有自己的 sanitizer,而是用的 clang 提供的库。在一通dump找到正确的依赖库之后,你会发现依赖库还有自己的依赖库,无穷无尽,笔者直到最后还是没能找齐AddressSanitizer的全部依赖项。
有时候程序并不是崩溃在你自己的代码里,而是崩溃在系统的 dll 里,你可以从微软的符号服务器下载这个 dll 的调试符号。不过大部分情况下你最好祈祷自己的汇编功底足够扎实。
C++ 并不会在你写错的那一行代码崩溃,它会当作无事发生并继续向后运行,然后在一个你不经意的小角落悄悄崩溃,如果这时候你挂着调试器赶来现场,你会惊奇地发现,调试器对局部变量的求值会影响程序的行为,它会让程序提前崩溃,并且有时候还会带着调试器一起崩。
笔者在不厌其烦地给每个 COM 对象写了一个 C++ 封装后终于忍受不了了 C++。决定用 Rust 重写(RIIR),如果你在看完方案三后依然决定用 JNI 的方案,笔者强烈建议用 Rust 而不是 C++ 来做 JNI。因为现在微软有官方的 Win32 Rust 接口,光是 Rust 错误处理就能甩 C++ 几条街,除非你选择放弃错误处理,否则你的代码中几乎肯定会充斥着占据总篇幅 60% 以上的错误处理判断逻辑。
使用 Rust 而不是 C++ 让笔者的开发体验提升了几倍,不过这是否适用于你本质上还是取决于开发者本人对两门语言的熟悉程度差异。从未接触过 Rust 的 C++ 大手子想必还是 C++ 用起来更得心应手。
让笔者放弃 Rust JNI 方案的原因很简单,微软提供的 Rust binding(以及官方的 C# binding)是基于 win32metadata 生成的,那我们为什么不直接利用元数据直接生成 Java binding?
Java 21 新出的 Panama project 是一种 JNI 的替代方案。它提供了所谓的 Foreign Function & Memory API,即直接从 Java 调用 Native 函数与内存操作的能力。这一举解决了上述提到的 JNI 编译、加载、调试等大部分问题,并且仅仅是以引入了一个新问题的代价:你的用户升级 Java 23 了吗?
当然,把这个方案跑通也不是那么一帆风顺。
直接使用 FFM 调用 COM 还是很繁琐的,这是因为我们本质上需要调用“C语言版的COM接口”,需要手动处理vtable之类的东西。
这时候微软提供的官方元数据就很有用了,先用元数据基于 FFM 接口的 Win32 API 封装,笔者顺着 issue 在 Github 上找到了一个完美符合条件的项目:https://github.com/manuelbl/WindowsApiGenerator
wip