
从Java项目里需要调用一个Windows平台上的DLL开始讲起。做桌面应用对接硬件设备、调用老旧的C/C算法库、或者需要直接访问操作系统API的时候Java程序员迟早会撞上“Java怎么调DLL”这个问题。这篇文章我会把完整链路拆开讲清楚从JNI的原理、环境准备、一个能跑通的demo、参数传递和内存管理到常见报错排查再到不想写C代码时的JNA替代方案。内容按我实际做过的项目经验来写踩过的坑和验证过的方法都会直接给出来适合刚接触JNI的Java开发也适合已经把项目跑起来但被各种UnsatisfiedLinkError折磨过的朋友。1. 先弄清楚Java和DLL到底怎么连起来1.1 什么场景下需要Java调用DLL先说需求不然没有代入感。我遇到过几种典型情况第一种对接硬件设备。读卡器、扫描仪、加密锁、工业控制板卡厂商基本只提供C/C写的SDK编译出来就是一个DLL加一堆导出函数说明文档Java这边想调就必须想办法跨语言。第二种复用已有的算法库。比如音视频编解码、图像处理、OCR识别这类重计算逻辑很多年积累下来的C/C代码性能好、稳定性高已经编译成了DLL。让你用Java把几十万行C代码重写一遍既不现实也容易引入bug直接调反而是最省事的方案。第三种调用Windows系统API。Java标准库封装了部分操作系统能力但覆盖面有限。有些时候需要获取窗口句柄、模拟输入、管理系统服务或者访问注册表某个特殊键位标准库做不到就需要直接调用系统DLL里的函数。第四种第三方商业组件的约束。有些收费SDK、加密狗授权程序只提供DLL且不允许客户查看源代码或修改调用方式这时候Java项目只能被动地去适配这个DLL。无论哪种场景核心问题都一样Java运行在JVM的虚拟机环境里C/C运行在原生内存空间两边数据类型不同、内存管理方式不同、方法调用的约定也不同。要让两边互相认识必须有一座桥。1.2 JNI是怎么工作的这座桥就是JNIJava Native Interface。JNI是JDK标准的一部分从Java 1.1开始就有了属于“官方认证”的跨语言调用方案。JNI的思路很直接Java代码里声明一个带native关键字的方法表示“这个方法我不管实现交给外部原生代码去写”。然后由C/C代码按照JNI规范提供对应的函数实现编译成DLL。Java代码调用这个native方法时JVM会通过JNI的接口表找到DLL里对应的导出函数把Java方法的参数转换成C类型传过去执行完之后再把C的返回值转换成Java类型传回来。Java侧看起来就是调用一个普通方法。但底层干的事情包括JVM在加载时通过System.loadLibrary或System.load把DLL载入进程然后根据完整的符号名找到C函数。这种设计的好处是JNI本身是JVM规范和特定平台绑定的Windows、Linux、macOS都有对应的实现但Java层的API和C层的头文件结构是统一的。也就是说你只要学会一套JNI规则在Windows上调DLL的逻辑和Linux上调.so的逻辑其实是同一个套路只是编译出来的产物从.dll变成了.so。它有代价。最大的代价是开发复杂度高要写C代码要处理JNI函数签名要小心内存释放还得保证Java和C两边的类型对齐。但作为Java官方底层能力凡是涉及JVM与原生层交互的地方JNI都是绕不开的基础知识哪怕你不用JNI用JNA或者JavaCPP底层最终也绕不过JNI机制。1.3 为什么不能直接从Java调用Windows API很多人第一反应是我能不能直接在Java里写一句类似“call user32.dll的MessageBoxW”就弹个窗答案是Java标准库里没有这个能力。原因在于Java的跨平台设计目标Java希望“一次编写到处运行”所以规范里不包含任何操作系统特有的API调用方式。如果Java直接支持调用Windows API那就得同样支持Linux的X11、macOS的Cocoa这个标准会变得无比庞大且不可维护。JNI的存在就是为了在“跨平台”和“访问平台能力”之间开一扇门。你有需要就可以把门打开但门怎么开、开了之后怎么处理门后面的脏活JNI规范已经给你规定好了。理解了这一点再看下面所有的步骤你就能明白为什么每个JNI函数看起来都那么啰嗦——不是Java故意刁难你而是它必须让所有平台的行为一致。2. 开工前要搞定的环境和工具链2.1 JDK版本和32/64位架构必须统一这是所有JNI新手最容易踩的第一个坑。我先说结论Java的位数必须跟DLL的位数一致32位JDK只能加载32位DLL64位JDK只能加载64位DLL。混着来百分之百报错。检查JDK位数的方式很简单在命令行执行java -version输出里会有“64-Bit”或者“32-Bit”字样。比如我机器上显示的是java version 17.0.8 2023-07-18 LTS Java(TM) SE Runtime Environment (build 17.0.89-LTS-211) Java HotSpot(TM) 64-Bit Server VM (build 17.0.89-LTS-211, mixed mode, sharing)看到“64-Bit”就明确了我编译DLL的时候也必须用64位工具链生成的是64位DLL。假设你手头有一个DLL但不知道它是32位还是64位有个很简单的查看方法用Notepad或者任何十六进制编辑器打开DLL文件找到PE头附近。如果是64位DLL在文件偏移0x20的位置你会看到“PE..d?”更准确地说在PE头里Machine字段会显示x64如果是32位则显示“PE..L?”Machine字段是i386。不过对大多数开发者来说更实用的方式是直接拿一个已知位数的JDK去加载试试加载失败时错误信息往往会提示这不是有效的Win32应用程序。关于JDK版本本身我建议直接用JDK 8或JDK 11/17这类LTS版本。JNI机制在这些版本上非常稳定没有本质区别。JDK 8之前的javah命令在JDK 10之后被移除了改用javac -h。现在网上很多老教程还在写javah照着做会直接提示“无法识别的命令”这一点后面会详细说。2.2 编译DLL的工具链怎么选写JNI的C代码不复杂但得有一个C编译器把它变成DLL。Windows平台上主流有两个选择一个是微软的Visual Studio。从VS2015到现在的VS2022都行。它集成度高调试方便如果你本身做Windows C/C开发肯定已经装了。用VS编译JNI DLL的典型步骤是先打开“x64 Native Tools Command Prompt”命令行环境然后执行cl命令。另一个是MinGW-w64Windows版本的GCC工具链。优点是完全免费、轻量、依赖简单不需要安装巨大的VS。我个人的项目里非大型工程直接用MinGW-w64就够了。需要说明的是不能下载网上那种老旧的 MinGW32它默认只能编32位必须选带w64字样的、支持x86_64架构的版本。我这里会用GCC做主要演示因为命令简洁、可复现度高而且不需要额外配置Visual Studio环境。如果你有自己的偏好用VS也完全可以后面我会把两边的命令都写出来。2.3 JNI头文件从哪里拿JNI头文件不需要额外下载JDK安装目录里自带。以我本机为例JDK安装在C:\Program Files\Java\jdk-17那么关键头文件路径是C:\Program Files\Java\jdk-17\include\jni.h C:\Program Files\Java\jdk-17\include\win32\jni_md.hjni.h是主头文件里面定义了JNIEnv、jint、jstring这些类型和函数指针结构。jni_md.h是平台相关头文件Windows环境下最主要的作用是定义jint为long类型、jlong为__int64之类的基础类型映射。因为Windows的long是32位而Linux的long在64位下是64位如果没有这层平台适配JNI的类型跨平台就乱了。编译C代码时必须把这两个目录都加进头文件搜索路径。MinGW64命令里对应写-IC:\Program Files\Java\jdk-17\include -IC:\Program Files\Java\jdk-17\include\win32如果你用的不是Windows而是macOS或Linux第二个目录会对应变成darwin或linux。逻辑相同路径不同。还有一个细节JAVA_HOME环境变量如果配置好了可以直接用“%JAVA_HOME%\include”代替完整路径命令会简洁很多。Linux/macOS下则是“$JAVA_HOME/include”。这也是环境变量存在的意义之一。3. 一个完整demo从Java类到DLL跑起来理论说了一堆直接上代码。我这次设计一个很小但覆盖面比较全的demo名字叫NativeMath干两件事一个是整数加法一个是字符串拼接。前者演示基本类型传递后者演示字符串传递。全部都放在默认包底下方便复现。3.1 第一步写Java类并声明native方法新建一个文件NativeMath.java内容如下public class NativeMath { static { System.loadLibrary(NativeMath); } public static native int add(int a, int b); public static native String concat(String prefix, String suffix); public static void main(String[] args) { int sum NativeMath.add(2, 3); System.out.println(add(2,3) sum); String result NativeMath.concat(hello, world); System.out.println(concat result); } }几个关键点static块里的System.loadLibrary(NativeMath)作用是把名为NativeMath的DLL加载进JVM进程。这里要注意loadLibrary接收的是库名不带.dll后缀不带路径。JVM会按照java.library.path系统属性和平台默认搜索路径去找“NativeMath.dll”。native关键字表示这个方法是外部实现的方法体不能有内容。我这两个方法都声明成static对应DLL里的函数将接收jclass参数而不是jobject参数。如果声明成实例方法则对应接收jobject。这个区别在写C代码时非常重要。从Java层面看这个类已经写完了。编译它javac NativeMath.java此时会生成NativeMath.class。如果直接运行java NativeMath会报UnsatisfiedLinkError提示找不到NativeMath.dll因为DLL还不存在。不用担心这是正常流程。3.2 第二步生成JNI头文件我们接下来需要生成一个C头文件里面包含编译C代码时必须的函数签名。JDK 8及以前使用javah命令javah NativeMathJDK 10开始javah被正式移除统一使用javac -h。我以JDK 11/17为例执行javac -h . NativeMath.java参数-h后面的“.”表示头文件生成到当前目录。执行完之后当前目录里多了一个文件“NativeMath.h”。打开这个头文件核心内容是/* DO NOT EDIT THIS FILE - it is machine generated */ #include jni.h /* Header for class NativeMath */ #ifndef _Included_NativeMath #define _Included_NativeMath #ifdef __cplusplus extern C { #endif JNIEXPORT jint JNICALL Java_NativeMath_add (JNIEnv *, jclass, jint, jint); JNIEXPORT jstring JNICALL Java_NativeMath_concat (JNIEnv *, jclass, jstring, jstring); #ifdef __cplusplus } #endif #endif这个头文件非常重要有几个信息量很大的地方。第一C函数的名字是“Java_NativeMath_add”、“Java_NativeMath_concat”由Java包名、类名、方法名用下划线拼接而成。如果你修改了Java方法名或类名DLL里的函数名也必须跟着改。JVM加载的时候就是靠这个完整符号名来找函数地址的少一个字符都找不到。第二每个函数第一个参数都是JNIEnv *这是JNI的环境指针它本质上是一个指向函数指针表的指针所有JNI API比如操作字符串、访问数组、抛异常都要通过它来调用。第二个参数对于static方法是jclass表示调用者的Class对象对于实例方法是jobject表示this对象。从第三个参数开始才是Java方法真正声明的参数。第三JNIEXPORT和JNICALL是宏Windows平台上JNIEXPORT展开为__declspec(dllexport)负责告诉链接器这个函数要导出JNICALL展开为__stdcall调用约定在64位上实际上是空。用C编译器编译时必须保证函数在extern C块内避免C的名字修饰把函数名改掉。3.3 第三步用C实现native函数好现在真正动手写C代码。新建一个NativeMath.c文件代码如下#include jni.h #include stdio.h #include string.h JNIEXPORT jint JNICALL Java_NativeMath_add (JNIEnv *env, jclass clazz, jint a, jint b) { return a b; } JNIEXPORT jstring JNICALL Java_NativeMath_concat (JNIEnv *env, jclass clazz, jstring prefix, jstring suffix) { const char *pre (*env)-GetStringUTFChars(env, prefix, NULL); const char *suf (*env)-GetStringUTFChars(env, suffix, NULL); char buffer[256]; snprintf(buffer, sizeof(buffer), %s-%s, pre, suf); (*env)-ReleaseStringUTFChars(env, prefix, pre); (*env)-ReleaseStringUTFChars(env, suffix, suf); return (*env)-NewStringUTF(env, buffer); }第一个add函数没什么好说的就是普通加法。注意一点虽然C这边参数名和Java一致a、b但这个名字无所谓位置才是关键。第二个concat函数展示了JNI的字符串处理方式。Java的String是一个对象内部采用UTF-16编码和C的字符串以\0结尾的字节数组完全不同。所以不能把jstring直接当作const char *用必须通过GetStringUTFChars把它转换成一个C可以操作的UTF-8字符串。这里的UTF-8不是标准意义上的UTF-8而是JNI的“Modified UTF-8”修改版UTF-8它用两字节形式表示\0字符和补充字符。对绝大部分中英文字符串来说和标准UTF-8是兼容的直接用即可。但如果你的字符串里包含类似“\u0000”的NUL字符就会遇到坑这点我会在第4章再展开。拿到C字符串之后我用snprintf拼了一个“hello-world”格式的结果然后调用NewStringUTF把C字符串转回jstring返回给Java。需要注意的是GetStringUTFChars和ReleaseStringUTFChars必须成对出现。GetStringUTFChars在多数实现里会做一次内存拷贝返回一个独立的char*缓冲区用完不释放就是内存泄漏。如果JVM选择了一个内部非连续的内存布局这个转换过程会更加用力不释放的后果也更明显。在函数末尾调用ReleaseStringUTFChars时如果当初的isCopy参数返回了JNI_TRUE这个函数还会把改动拷贝回原来的Java字符串。在我的例子里isCopy传了NULL表示不关心是否拷贝desc就只用于读。3.4 第四步编译DLL并在Java中加载调用接下来用GCC把C代码编译成DLL。假设JAVA_HOME已经配置好打开命令行进入源码目录执行gcc -shared -o NativeMath.dll -I%JAVA_HOME%\include -I%JAVA_HOME%\include\win32 NativeMath.c -Wl,--kill-at整个命令的含义是-shared表示生成动态库-o指定输出文件名两个-I头文件路径指到JDK的include目录最后是C源文件。-Wl,--kill-at是为了处理Windows的stdcall符号修饰问题多数情况下加上它更稳妥如果你的工具链版本比较新或者用VS编译这个参数可以不写。如果你用Visual Studio在x64 Native Tools命令行里执行的命令是cl /LD NativeMath.c -I%JAVA_HOME%\include -I%JAVA_HOME%\include\win32 /Fe:NativeMath.dll/LD表示生成DLL/Fe指定输出文件名。编译成功之后当前目录下应该有NativeMath.class和NativeMath.dll两个文件。现在运行Java程序java -Djava.library.path. NativeMath有个容易踩的坑必须提醒JDK 9开始默认的java.library.path不再包含当前目录“.”所以即使NativeMath.dll就在当前目录直接运行java NativeMath也会报找不到DLL。解决办法就是显式加-Djava.library.path.或者干脆用System.load(C:\完整\路径\NativeMath.dll)。我这里推荐一个更省心的写法把loadLibrary换成load加绝对路径。比如static { System.load(D:\\projects\\jni-demo\\NativeMath.dll); }load方法接收完整路径不依赖java.library.path也省去了配置环境变量。缺点是路径写死了换机器就得改代码。实际项目里我一般会把DLL路径做成可配置项从配置文件里读出来再调System.load。这是一个很实用的经验。顺利的话输出是add(2,3) 5 concat hello-world到这里一个完整的Java调用DLL的流程已经跑通了。整个链路就是从Java的native声明开始经过javac -h生成头文件然后在C里实现再用GCC或VS编译成DLL最后在Java侧加载调用。4. JNI参数传递和内存管理的核心细节demo能跑通代表你已经迈过最难的坎了。但真实项目里参数远比两个int和一个String复杂对象、数组、回调都是绕不开的话题。4.1 基本类型映射JNI基本类型的映射其实非常规律。Java的int对应jintboolean对应jbooleanbyte对应jbytechar对应jcharshort对应jshortlong对应jlongfloat对应jfloatdouble对应jdouble。除了jlong和jint在部分平台上有长度区别其他基本就是一一对应。比较常见的坑出现在jboolean和jchar上。C语言里bool通常被实现为int而jboolean是unsigned char只有0和1两种取值不能拿它当C的bool用。jchar对应Java的char是16位无符号整数刚好对应UTF-16编码单元它是JNI里少数不使用“平台默认char”的类型因此用jchar数组处理Unicode时不会出现编码转换问题。4.2 字符串的传递与释放字符串已经在demo里演示过GetStringUTFChars/ReleaseStringUTFChars和NewStringUTF。这里补充几个实际项目里高频率踩坑的点。第一个是编码。JNI的GetStringUTFChars返回的是Modified UTF-8而不是标准的UTF-8。Java字符串如果包含NUL字符\u0000在Modified UTF-8里会编码成C0 80两个字节而标准UTF-8里这个字符就是一个00字节。如果你把JNI返回的字符串直接交给C的printf或strlen处理看到字符串提前截断不要奇怪是编码方式不同。如果转换出来的字符串需要传给另一个完全不认识Modified UTF-8的第三方库就会出bug。解决办法是改用GetStringChars拿到UTF-16的jchar数组按照UTF-16LE规则自己转成目标编码。第二个是线程。JNIEnv *是线程相关的你不能把一个native方法里拿到的JNIEnv缓存下来给另一个线程用。同一线程的JNIEnv可以通过JVM的GetEnv接口获取但不能直接保存着给别的线程。如果要在后台线程操作Java字符串必须通过AttachCurrentThread把线程绑定到JVM上拿到自己的JNIEnv。这个约束经常被忽略出现了奇怪的崩溃。第三个是释放。GetStringUTFChars第三个参数isCopy可以传一个jboolean*它会告诉你返回的char*是Java字符串的拷贝还是直接指向内部数据。但无论返回值是什么ReleaseStringUTFChars都必须调用。就算isCopy指出不是拷贝这个Release调用也不会出问题它是标准的配对清理动作。4.3 数组的传递数组的传递逻辑跟字符串类似但更直接。以int数组为例JNIEXPORT jint JNICALL Java_NativeMath_sumArray (JNIEnv *env, jclass clazz, jintArray array) { jsize length (*env)-GetArrayLength(env, array); jint *elements (*env)-GetIntArrayElements(env, array, NULL); jint sum 0; for (jsize i 0; i length; i) { sum elements[i]; } (*env)-ReleaseIntArrayElements(env, array, elements, JNI_ABORT); return sum; }GetIntArrayElements返回一个指向元素的指针可以把jintArray当作C的jint数组直接操作。ReleaseIntArrayElements的第四个参数mode有讲究JNI_ABORT表示“不需要把修改同步回Java数组”适合只读场景0表示“同步回Java数组且释放内存”JNI_COMMIT表示“同步但不释放”。如果你只求和没修改数组用JNI_ABORT最合理省掉了写回的开销。对于byte数组——这在处理文件读写、网络协议时极其常见——用GetByteArrayElements或更推荐的GetByteArrayRegion。后者可以把数组内容直接拷贝到C缓冲区不需要先拿到整个指针jbyte buffer[1024]; (*env)-GetByteArrayRegion(env, array, 0, 1024, buffer);这种方式避免了Get/Release的成对管理也避免了JVM对数组的锁定在性能上往往更好。它唯一的缺点是如果数组长度比你需要的短会抛出ArrayIndexOutOfBoundsException需要在C端调用ExceptionCheck检查。4.4 回调Java方法和访问对象字段有时候需求是反过来的C代码那边处理完数据希望调用Java的回调方法。这也完全可行。JNI提供的机制是通过jclass或jobject找到MethodID再调用CallVoidMethod。假设Java这边声明了一个native方法setCallback参数是一个接口类型的回调public interface ResultListener { void onResult(String message); } public static native void setCallback(ResultListener listener);C端代码大致是JNIEXPORT void JNICALL Java_App_setCallback (JNIEnv *env, jclass clazz, jobject listener) { jclass cls (*env)-GetObjectClass(env, listener); jmethodID mid (*env)-GetMethodID(env, cls, onResult, (Ljava/lang/String;)V); if (mid NULL) { return; // 方法没找到异常已抛出 } jstring msg (*env)-NewStringUTF(env, hello from native); (*env)-CallVoidMethod(env, listener, mid, msg); }这段代码里的“(Ljava/lang/String;)V”就是JNI方法描述符也就是JVM中方法签名的标准写法括号内是参数类型括号外是返回值类型。V代表voidLjava/lang/String;代表String类型。不熟悉这个语法没关系生成完头文件后用javap命令可以看到所有JNI签名javap -s -p NativeMath.class输出里会清晰地列出每个方法的描述符。这是写JNI代码时非常有用的工具。访问对象字段也是类似套路GetFieldID拿到字段ID然后GetIntField/SetIntField等函数读写。如果字段是private也没关系JNI层面的访问不受Java访问修饰符限制。这也是JNI能用来做逆向调试的原因之一。不过正常的业务代码不会在JNI里乱改字段毕竟跨语言边界的开销和复杂度都不低。4.5 异常处理和局部引用JNI里有一类非常隐蔽的坑C端某个JNI函数调用失败JVM会设置一个待处理的异常但并不会像Java代码那样中断执行。如果你没检查异常就继续往下走后续JNI调用可能直接崩溃或者行为诡异。处理方式是在关键调用之后检查异常if ((*env)-ExceptionCheck(env)) { (*env)-ExceptionClear(env); return; }比如调用GetMethodID没找到方法调用CallIntMethod时被调方法抛了异常都必须做这个检查。ExceptionCheck效率很高不会真的创建Java异常对象只是查一下标志位所以在调用频繁的地方使用也问题不大。局部引用方面JNI默认创建的引用都是局部local reference会在native方法返回时自动释放。但如果native方法里有一个循环在循环里反复创建字符串、对象引用局部引用表就可能溢出。遇到这种情况需要在循环体内主动调用DeleteLocalRef清理。全局引用则需要显式管理用NewGlobalRef创建用DeleteGlobalRef释放通常用于缓存MethodID对应的类、或者跨线程保存引用。全局引用如果不释放相当于Java对象永远不会被GC回收长时间运行的服务会内存越涨越高。5. 常见报错和排查经验5.1 问题快查表下面这些是我做JNI项目时实际遇到过的报错也是论坛里被问烂的问题。直接给结论。报错信息直接原因解决方案java.lang.UnsatisfiedLinkError: NativeMath.dll : 找不到指定的模块DLL文件不在java.library.path里或者依赖的运行时库缺失用-Djava.library.path指定目录或用System.load全路径用Dependency Walker检查依赖java.lang.UnsatisfiedLinkError: NativeMath.add()V函数符号找不到通常是函数名不匹配或没导出对比头文件里的完整函数名检查extern C检查JNIEXPORTjava.lang.UnsatisfiedLinkError: %1 不是有效的 Win32 应用程序DLL位数和JDK位数不一致确认JDK是64位DLL也是64位重新编译调用native方法时JVM崩溃EXCEPTION_ACCESS_VIOLATION内存越界、全局引用误用、Release不配对优先检查字符串和数组释放用hs_err_pid日志定位崩溃位置ExceptionInInitializerErrorstatic块里的load抛了异常查看cause最可能还是DLL找不到或者位数不匹配中文变成乱码Modified UTF-8和本地编码不一致统一用UTF-8或者改用GetStringChars按UTF-16处理第2行单独说。UnsatisfiedLinkError后面跟着的是Java方法描述符比如NativeMath.add()V。这意味着JVM已经成功加载了DLL但在DLL的导出表里找不到“Java_NativeMath_add”这个符号。常见原因有三个第一Java类被放进了包package头文件里的函数名会有包名前缀你写C代码时漏掉了前缀第二C编译器编译时没有包extern C导致符号名被改成了HelloWorld加一堆修饰符第三函数没有被正确导出。还有一个非常容易被忽略的点如果Java方法有重载JNI函数名会附上参数签名。比如add(int, int)的C函数名是Java_NativeMath_add__II两个连续下划线后面跟着参数签名。所以Java代码里尽量避免重载native方法否则C命名复杂度会暴涨。5.2 排查技巧排查JNI问题常用工具就三个Dependency WalkerDepends、dumpbin、Process Explorer。Dependency Walker可以查看一个DLL依赖了哪些其他DLL、导出了哪些函数。如果你的DLL依赖了某个VC运行时库但目标机器上没有装加载时就会报“找不到指定的模块”表现为UnsatisfiedLinkError。这个问题在分发给普通用户时尤其常见因为开发机装了Visual Studio所以不缺运行时用户机器上没有就出问题。解决方法是静态链接运行时库或者把对应运行时一起分发。dumpbin是Visual Studio自带的工具命令行里执行dumpbin /exports NativeMath.dll输出会列出所有导出符号能直接确认Java_NativeMath_add是否存在、有没有被C修饰。如果没有dumpbin也可以用MinGW带的工具objdump -p NativeMath.dll | grep -A 20 Export Address TableProcess Explorer可以用来查看进程加载了哪些DLL。因为DLL加载顺序和路径有时候很难从报错里看出来比如JVM可能加载到了Java安装目录下的旧版本DLL而你改了源码重新编译的新DLL根本没被使用。用Process Explorer打开Java进程CtrlD就能看到进程加载的DLL列表确认你期望的DLL是否在列表里、路径是不是你想的那个。排查的思路总结成一句话先确认DLL有没有被加载再确认加载的位数对不对最后确认符号有没有导出。按这个顺序走90%的问题能快速定位。6. 不想写C代码用JNA调用DLL更省事如果看完上面这些C代码就觉得头大我完全可以理解。JNI的灵活性和性能是有代价的对很多场景来说用JNAJava Native Access是性价比更高的选择。6.1 JNA示例调用user32.dllJNA是一个开源库它基于JNI做了一层封装让你可以在Java里直接定义接口来映射DLL的导出函数完全不用写C代码、不用处理头文件、不用编译DLL。它的原理是运行时动态生成代理类反射地把Java方法调用转换成对DLL导出函数的调用。最简单的例子调用Windows的MessageBox弹窗。import com.sun.jna.Library; import com.sun.jna.Native; import com.sun.jna.win32.StdCallLibrary; public class JnaDemo { public interface User32 extends StdCallLibrary { User32 INSTANCE Native.load(user32, User32.class); int MessageBoxW(int hWnd, String text, String caption, int type); } public static void main(String[] args) { User32.INSTANCE.MessageBoxW(0, 来自JNA的调用, JNA Demo, 0x00000040); } }这里Native.load(user32, User32.class)会把系统目录下的user32.dll加载进来接口里的MessageBoxW方法会自动匹配user32.dll里导出的同名函数。你只需要按照Windows API的签名定义Java方法即可。相比JNIJNA省掉了几乎所有机械性工作。头文件不用写了C代码不用写了DLL编译环节整个消失了。数据类型映射JNA也做得很完善Java int对应C intJava String在stdcall约定下默认映射成const char*如果函数是宽字符版本以W结尾JNA会自动按const wchar_t*处理。数组、结构体也都有对应的映射方式。6.2 什么情况下用JNI什么情况下用JNA我自己的选型经验可以整理成一张表对比维度JNIJNA开发速度慢需要C工具链快纯Java操作运行时性能高无额外代理层略低有反射和映射开销依赖DLL的复杂接口需要手写适配层接口定义即可需要修改C代码需要按JNI规范调整不需要直接对接现有导出内存控制完全掌控自动但相对黑盒排查难度难报错信息比较底层相对轻松依赖体积无额外依赖需要引入jna.jar如果是对性能极度敏感的场景比如视频帧处理、大规模数组计算、高频的逐帧调用我推荐用JNI。JNA每次调用都会有一层动态代理开销虽然没有想象中那么严重但架不住调用次数多累积起来能拉开几个百分点的性能差距。如果只是对接系统API、给老DLL写个简单封装、或者原型验证性质的项目直接用JNA就好省下来的时间很可观。JNA还有TypeMapper和Structure这些机制能做相当复杂的结构体映射大部分非性能敏感需求都能覆盖。第三方还有JavaCPP这类工具可以通过注解自动生成JNI胶水代码既保留了接近手写JNI的性能又降低了开发成本。如果你做的是机器视觉或者深度学习推理这类涉及大量OpenCV、C库的项目JavaCPP是另一个值得研究的方案。不过它的学习曲线也有需要理解它的映射语法实际用起来并不是“零成本”。6.3 一些额外的工程建议无论选哪条路有几个工程层面的建议想跟你分享。一是DLL路径管理要提前做。开发环境下DLL可以放在项目根目录用-Djava.library.path.临时指定。但到了生产环境Java应用可能以Windows服务方式运行当前目录不一定是你想的那样工作目录更不可控。最好是把DLL放到一个固定目录做成配置项启动时用System.load加载并且加载失败时记录详细日志把路径和位数都打印出来。二是DLL相关的问题一定要留足排查时间。JNI的报错不像Java异常那么友好一个崩溃往往意味着整个JVM进程退出连日志都来不及写。调试阶段我建议加上JVM参数-XX:CreateCoredumpOnCrash拿到core文件之后用WinDbg或Visual Studio分析能直接定位到出错的C函数。这一步能节省大量瞎猜的时间。三是不要迷信单个DLL文件。很多第三方SDK的DLL还依赖其他DLL分发时不止拷一个文件。部署阶段把整个依赖文件夹一起拷贝并在配置里把DLL目录加到系统的PATH环境变量上避免出现“上一秒还跑得好好的换台机器就报缺DLL”的问题。最后分享两个实操里的小体会第一个体会是“最小可跑demo先行”。我第一次做JNI项目时上来就想直接封装一个几百个函数的工业SDK结果头文件生成的函数签名密密麻麻C代码写了一堆一运行崩溃在毫无概念的地方。后来我学乖了先拿一个只有两个基础参数和返回值的极简函数跑通整条链路然后再逐步增加复杂类型。这条路看着绕实际是最快的一条路。凡是涉及跨语言边界的东西链路完整性的验证永远比功能量铺开更重要。第二个体会是JNI的报错信息其实比想象中有用。很多人遇到UnsatisfiedLinkError就懵然后到处乱改环境变量。其实先看报错里DLL加载阶段还是符号查找阶段能帮你少走很多弯路。前者检查路径和依赖项后者核对函数名和位数。把上一章那个问题快查表打印出来放工位旁边真能救急。Java调用DLL的这个能力学会了就是Java开发工具箱里一把趁手的扳手。平时用不上但真到需要对接底层系统、接老库、调硬件的时候你会庆幸自己已经把这条路摸通了。