多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

IntelliJ IDEA 中 Java 调用 DLL:JNI 与 JNA 实战

IntelliJ IDEA 中 Java 调用 DLL:JNI 与 JNA 实战 1. 为什么Java工程里还得去碰DLL这种事做企业级Java开发的人早晚会撞上一个绕不开的场景项目主体是Spring Boot跑得好好的结果某天产品经理拿着一个硬件厂商给的SDK文件夹过来说这个读卡器/加密狗/工业相机只能调他们这个.dll。这时候你会发现网上那些java面试八股文、java学习路线里讲的全是集合、并发、JVM调优没有一篇告诉你Java怎么从零把一个DLL调起来。我最早接触这块是在做一个医疗设备数据采集的项目设备厂商给了一份C接口的DLL附带一份PDF说明文档剩下的全靠自己摸索。那段时间我把IntelliJ IDEA里能踩的坑几乎踩了个遍从UnsatisfiedLinkError到动态链接库(DLL)初始化例程失败再到加载了却调不通的诡异问题全都经历过一遍。这篇东西想解决的问题很明确在IntelliJ IDEA这个IDE里把一个已经在本地存在、通常由C/C编译出来的DLL用Java代码稳稳当当地调起来。内容覆盖两种主流路线——JNI手写桥接层和JNA无侵入映射前者性能好但工作量大后者上手快但有额外开销同时会讲清楚位数匹配、调用约定、字符串编码、依赖DLL链、打包后DLL怎么跟着Jar走这些实际动手才碰得到的问题。不管你是刚学完java基础、第一次接手这种活的新人还是做了几年业务开发第一次被派来做本地库集成的老手都能照着这里的步骤复现出来。我尽量少讲教科书上抄来的定义多讲我实际写代码时怎么选、怎么配、报错了怎么查。需要提前说明的是调DLL这件事本身对Java开发者来说属于跨出了舒适区你不仅要写Java还得看懂C的函数签名甚至要装一个C编译器帮忙生成桥接代码。听起来吓人但真做起来核心步骤其实就那么几步难点全在细节里。1.1 什么场景下绕不开本地库先说清楚哪些情况你不得不走这条路免得有人为了炫技硬上。第一种是硬件SDK读卡器、指纹仪、身份证识别模块、PLC通信卡、工业相机、打印机驱动这些厂商几乎清一色给C接口的DLL因为他们的驱动本来就是C写的Java只是个上层调用方。第二种是已有的C/C遗产代码公司里跑了十年的核心算法用C写的性能敏感不可能为了迁就Java重写一遍只能包一层让Java调。第三种是操作系统原生能力比如要枚举Windows下的进程、读写特定注册表项、调用某些只有Win32 API才开放的功能。第四种是第三方商业库有些专业领域的库只有C版本没有对应的Java实现。反过来如果一个功能已经有成熟的纯Java库或者能通过命令行调用一个exe解决那没必要非得上DLL。命令行方式的缺点是进程开销大、交互麻烦但如果你一天就调几次完全够用还省了位数匹配和内存管理的一堆事。提示在动手之前先问自己一句调用频率有多高。每秒上千次的调用和一天十次的调用选型策略完全不同后者用JNA甚至命令行都行。1.2 三条技术路线的横向对比Java调本地库主流就三条路JNI、JNA、JNR。我做个表让你一眼看清区别。维度JNIJNAJNR是否需要写C代码需要自己写胶水层不需要不需要上手难度高低中调用性能最高接近原生中等有反射和转换开销较高类型映射全手写自动映射注解辅助自动映射调试难度高崩溃直接带走JVM中中依赖体积无额外依赖约1.5MB的jar约1MB适合场景高频调用、复杂结构体快速集成、中低频调用想兼顾性能和便利我的实际经验是第一个版本一律先用JNA跑通把业务流程验证出来。如果后来压测发现JNA的调用开销成了瓶颈再针对那几个热点方法改写成JNI。一上来就啃JNI很容易在类型映射上耗掉两三天结果发现方向根本不对。2. JNI和JNA底层到底在干什么很多人调不通DLL根子在于不理解这一层调用到底发生了什么只是照着网上的代码抄一报错就懵。我先把链路讲透后面所有报错你都能自己对号入座。2.1 JNI的调用链路拆解JNI是Java官方提供的机制全称Java Native Interface。它的工作方式是你在Java类里声明一个native方法只有签名没有实现然后用javac -h老版本是javah根据这个类生成一个C/C头文件头文件里会有一个名字很长的函数声明形如Java_com_example_NativeLib_add你按这个声明写出实现编译成一个DLLJava运行时用System.loadLibrary或System.load把DLL加载进JVM进程空间之后调用那个native方法时JVM就顺着符号名找到你实现的C函数执行。关键点在于符号名的生成规则和JNIEnv指针。那个超长的函数名不是随意的是包名、类名、方法名用下划线拼起来的中间的点换成下划线。如果你的包名里带下划线JNI会用_1来转义这一点经常被人忽略手动改函数名结果加载时报找不到方法。JNIEnv*这个参数是你的入口所有和Java对象打交道的操作比如创建String、访问数组元素、抛Java异常都得通过它调。注意JNI代码里一旦出现野指针、越界写、释放了不属于自己的内存JVM会直接崩掉不是抛异常是进程级崩溃日志只留下一个hs_err_pid文件。这就是JNI调试成本高的原因。2.2 JNA用接口映射换掉样板代码JNA的思路完全不同它不去生成任何C代码而是在运行时用一层动态代理去模拟调用。你只要定义一个Java接口继承Library接口里声明的方法和DLL里导出的函数一一对应然后调用Native.load(你的dll名, 接口.class)拿到一个代理实例直接调用接口方法JNA在底层通过libffi把参数打包、找到DLL里的函数地址、按调用约定压栈执行、再把返回值翻译回Java类型。代价就是性能。每次调用都有反射、参数类型转换、内存分配的开销粗略测下来比JNI慢几倍到十几倍具体倍数取决于参数复杂度。对于每秒调用几百次以内的业务这点开销完全无感对于图像处理这种一行像素要调一次的就必须考虑JNI了。JNA还有一个隐藏优势是能直接调用系统库比如Kernel32、User32这些Windows自带的DLL用来读进程信息、操作窗口不需要自己写C代码非常省事。2.3 位数、调用约定、导出名三个隐形杀手我见过八成以上的调不通都栽在这三个点上它们不会给你友好的报错只会甩一个UnsatisfiedLinkError。位数匹配。你的JDK是64位那么DLL也必须是64位反之亦然。混用的时候Windows会报Cant load IA 32-bit .dll on a AMD 64-bit platform这类信息。很多人从网上下了一个DLL或者从老项目里翻出来一个根本没注意它是32位编译的。判断方法是用Visual Studio自带的dumpbin /headers xxx.dll看machine那一行是x86还是x64。调用约定。Windows API和很多厂商SDK用的是__stdcall而C语言默认是__cdecl。两者在参数压栈顺序和栈清理责任上不一样。JNI里如果你在头文件里写错了调用约定编译能过运行就崩。JNA里则体现为接口要继承StdCallLibrary而不是Library选错了会报invalid memory access之类的错误。导出名修饰。C编译器会对函数名做名称修饰name mangling导致dumpbin /exports看到的符号名和你写的函数名对不上。解决办法是在C实现外面套一层extern C告诉编译器按C的方式导出不做修饰。如果需要用__stdcall还要配合__declspec(dllexport)和WINAPI宏。3. 动手实操JNA十分钟跑通第一次调用我建议你把这一节当成第一次集成的标准动作先复制一遍跑通有了正反馈再深入研究。3.1 准备一个能测的DLL如果你手上已经有厂商给的DLL跳到下一步。如果没有自己做一个小DLL练手最稳妥。用Visual Studio新建一个动态链接库(DLL)工程写两个函数一个做加法一个拼接字符串一个填结构体。核心代码大致是这样// demo.h #pragma once #ifdef DEMO_EXPORTS #define DEMO_API __declspec(dllexport) #else #define DEMO_API __declspec(dllimport) #endif extern C { DEMO_API int add(int a, int b); DEMO_API int fill_buffer(char* buf, int size); }注意extern C必须加否则JNA找不到add这个符号名。.cpp里实现好之后一定要把编译平台从默认的x86改成x64和你的JDK对齐编译出的demo.dll放到一个你自己记得住的目录比如D:\native\demo.dll。3.2 IDEA工程里的依赖和结构在IntelliJ IDEA里新建一个普通的Maven工程pom.xml里加两个依赖dependency groupIdnet.java.dev.jna/groupId artifactIdjna/artifactId version5.14.0/version /dependency dependency groupIdnet.java.dev.jna/groupId artifactIdjna-platform/artifactId version5.14.0/version /dependencyjna-platform是为了调用系统库方便如果你只调自己的DLL只引jna也行。工程目录我习惯这么放src/main/resources/native/放最终要打包进Jar的DLL开发阶段先用绝对路径加载调试等调试通了再改成从resources解压的方案这个后面第6节细讲。注意idea的默认编码和JVM编码要对齐。在idea.exe.vmoptions或者运行配置的VM options里加上-Dfile.encodingUTF-8同时把Settings - Editor - File Encodings里的三处都设成UTF-8。这一步看着无关实际上很多字符串乱码问题就出在这里。3.3 写第一个接口和方法调用接口定义是JNA的核心写法和普通Java接口没区别import com.sun.jna.Library; import com.sun.jna.Native; public interface DemoLib extends Library { DemoLib INSTANCE Native.load(D:\\native\\demo, DemoLib.class); int add(int a, int b); int fill_buffer(byte[] buf, int size); }两个细节Native.load的第一个参数可以带.dll后缀也可以不带不带的话JNA会自己补路径里不要出现中文和空格这是老生常谈但真的有效我遇到过一次DLL放在我的文档下面死活加载不了挪到D:\native\立刻就好。测试代码public class Main { public static void main(String[] args) { int r DemoLib.INSTANCE.add(3, 5); System.out.println(add result r); byte[] buf new byte[256]; int len DemoLib.INSTANCE.fill_buffer(buf, buf.length); System.out.println(filled new String(buf, 0, len, java.nio.charset.StandardCharsets.UTF_8)); } }跑起来如果add输出8说明整条链路已经通了。这一步看起来简单但它把依赖引对、DLL位数对齐、导出名正确这三件事全部验证了一遍后面出问题就只可能是更复杂的类型映射问题。3.4 指针、结构体、数组怎么传真正的工作量在复杂类型上。举几个高频场景的写法。输出参数用指针。C接口常见的做法是传一个指针让C函数往里写值JNA里用IntByReference、PointerByReference这类包装类int get_version(IntByReference major, IntByReference minor); IntByReference major new IntByReference(); IntByReference minor new IntByReference(); DemoLib.INSTANCE.get_version(major, minor); System.out.println(major.getValue() . minor.getValue());结构体用Structure子类。必须加FieldOrder注解字段顺序和C结构体严格一致否则读出来的数据是错位的Structure.FieldOrder({id, name, score}) public class DeviceInfo extends Structure { public int id; public byte[] name new byte[32]; public double score; public static class ByReference extends DeviceInfo implements Structure.ByReference {} public static class ByValue extends DeviceInfo implements Structure.ByValue {} }调用C函数拿到数据后要手动调一次read()把本地内存同步到Java字段往里写数据后要调write()同步回去。这两个方法忘掉任何一个都会出现明明C里赋值了Java读不到的诡异现象。回调函数用Callback接口。如果C库需要回调Java定义一个继承Callback的接口调用时把实现类实例传进去注意一定要把实例保存在一个静态字段里否则会被GC回收然后C那边回调时就会触发JVM崩溃。4. 动手实操JNI手写桥接层的完整流程当JNA满足不了性能要求或者你要对接的库有复杂的结构体指针操作JNI就是唯一的出路。流程比JNA长但每一步都是确定的。4.1 声明native方法并生成头文件先写Java类方法声明为native加一个静态块加载库package com.example.nativebridge; public class NativeLib { static { System.loadLibrary(bridge); } public native int compute(int a, int b); public native String describe(int code); }然后打开IDEA底部的Terminal在项目根目录执行生成头文件的命令。新版本JDK9以上用javac -hjavac -h ./native/include -d ./target/classes src/main/java/com/example/nativebridge/NativeLib.java执行完native/include目录下会出现com_example_nativebridge_NativeLib.h。打开看一眼里面有两个JNIEXPORT开头的函数声明函数名就是那个超长形式。这个头文件千万别手动改改了就对应不上。4.2 在IDEA里配好本地编译环境写C代码需要一个编译器。Windows上最省事的是装Visual Studio的使用C的桌面开发工作负载或者只装Build Tools。装完之后在IDEA里配一个External Tool把编译命令固化下来省得每次敲。编译成DLL的命令VS开发者命令行环境下cl /LD /I%JAVA_HOME%\include /I%JAVA_HOME%\include\win32 ^ bridge.cpp /Fe:bridge.dll/LD表示生成DLL两个/I把JNI的两个头文件目录加进来一个是通用的jni.h一个是平台相关的jni_md.h缺一个都会编译报错找不到jni.h。如果你用的是MinGW命令换成g -shared -o bridge.dll bridge.cpp -I...但要注意MinGW默认是__cdecl和某些SDK的__stdcall不匹配。4.3 实现胶水层要注意什么.cpp文件里实现那头文件声明的两个函数#include com_example_nativebridge_NativeLib.h #include string JNIEXPORT jint JNICALL Java_com_example_nativebridge_NativeLib_compute (JNIEnv* env, jobject obj, jint a, jint b) { return (jint)(a * b 1); } JNIEXPORT jstring JNICALL Java_com_example_nativebridge_NativeLib_describe (JNIEnv* env, jobject obj, jint code) { std::string s code- std::to_string(code); return env-NewStringUTF(s.c_str()); }三个高频坑我列一下。第一返回String时用NewStringUTF创建的是修改版UTF-8编码的Java字符串如果字符串里有中文和特殊符号在Java侧要小心处理建议C侧统一返回byte[]让Java自己按约定编码解析。第二需要调用Java对象的方法时记得先GetObjectClass再GetMethodID方法签名写错会抛NoSuchMethodError。第三多线程场景下C代码里起的线程要调Java方法必须先AttachCurrentThread用完DetachCurrentThread否则会崩。4.4 加载验证与IDEA运行配置把编译出的bridge.dll放到一个目录然后在IDEA的运行配置里加VM参数-Djava.library.pathD:\nativeSystem.loadLibrary(bridge)会去java.library.path下面找bridge.dllWindows下会自动补后缀找到就加载。加载成功后再调compute(3, 5)输出16说明通了。提示java.library.path在JVM启动后就固定了运行期不能再改。如果要在代码里动态指定用System.load(D:\\native\\bridge.dll)传绝对路径这个不受java.library.path影响。5. 类型映射与内存管理实战细节这一节是决定你能不能把DLL真正用起来的关键前面跑通的都是玩具这里开始才是硬骨头。5.1 Java与C的数据类型对照先看JNI侧的映射表写桥接层时天天要用Java类型JNI类型C/C类型说明booleanjbooleanunsigned char1字节bytejbytesigned char1字节charjcharunsigned short2字节shortjshortshort2字节intjintint4字节longjlonglong long8字节floatjfloatfloat4字节doublejdoubledouble8字节Stringjstring无直接对应通过JNIEnv转换byte[]jbyteArraychar*数组需要GetByteArrayElements注意long这一行。Java的long恒为8字节而C里的long在Windows上是4字节、在Linux 64位上是8字节。如果你的DLL是Windows下编译的C函数签名里的long实际是32位这时候Java侧应该用int对应写成long就会参数错位函数行为完全不对。这是跨平台移植时最容易翻车的地方。JNA侧的映射略有不同它做了更多自动转换int直接对应intlong在Windows上默认对应long4字节还是long long要看平台所以JNA里建议用NativeLong来规避歧义。String默认映射为char*宽字符用WString对应wchar_t*。5.2 字符串编码才是乱码的根源我在这上面吃过一次大亏。厂商DLL里有个函数接const char*我用JNA传String进去英文正常中文全是乱码。查了半天发现JNA的String转char*默认用什么编码取决于系统属性Windows中文环境下默认是GBK而我的源码是UTF-8两边对不上。三种处理方式按推荐程度排第一接口方法用byte[]接收参数自己用getBytes(GBK)编码好再传这样编码责任明确不会受环境影响第二在程序启动时设置System.setProperty(jna.encoding, GBK)让JNA统一按GBK转换第三如果DLL用的是宽字符接口函数名里带W或者参数是LPCWSTR直接改用WString它是UTF-16和Windows内核一致不会有编码问题。注意jna.encoding这个属性最好在第一次调用JNA之前设置好运行中途改不一定生效因为部分编码相关的缓存已经建好了。5.3 内存该谁释放跨语言调用的内存归属常常搞混我总结成一句话谁分配的谁释放Java侧分配的内存Java负责回收C侧malloc出来的内存必须调C侧提供的释放函数。绝不要试图用Java的方式去释放C分配的内存也不要让C去free一个由JVM管理的内存地址。JNI里还有一个特殊点GetByteArrayElements这类函数返回的可能是原始数组的指针也可能是复制品取决于JVM实现。用完必须调对应的ReleaseByteArrayElements并且通过第三个参数告诉JVM你是直接用了还是改过了。忘了释放会累积内存传错标志会导致数据丢失或者写坏JVM内部结构。JNA里用Memory类手动分配的内存Java的GC能管到但如果你把它传给C侧让C保存起来就要保证这个Memory对象一直有强引用持有否则被回收之后C那边访问的就是悬空指针。6. 打包发布让DLL跟着Jar一起走开发阶段用绝对路径没问题但上线的时候你不可能要求运维在服务器上手动摆DLL得让DLL打进Jar一起走。6.1 从resources解压加载的通用方案思路是把DLL放进src/main/resources/native/运行时从classpath读出字节流写到一个临时目录或者用户目录下的缓存目录再用绝对路径去System.load。这样不管Jar被放到哪台机器上都能自洽。我写过一个通用工具类逻辑大致是先算出来目标文件在不在不在就把资源流复制过去最后调用System.load。要注意两点一是临时文件名不能每次都不一样否则会不断产生新文件二是同一进程内同一个DLL不能被加载两次加载两次会报Native Library already loaded in another classloader所以加载前用AtomicBoolean做个标记。如果DLL之间存在依赖也就是A.dll依赖B.dll那加载顺序和搜索路径都要注意。Windows加载器找依赖DLL时会去进程的工作目录和PATH你可以用Java代码在启动时调System.setProperty(jna.library.path, 目录)或者干脆把依赖的DLL一起解压到同一个目录再把该目录通过java.library.path加进去。6.2 依赖DLL链和运行时组件不少厂商的DLL是个套娃你拿到的是个xxx.dll它内部LoadLibrary去加载msvcr120.dll、xxx_core.dll这些。这种时候常见报错是找不到指定的模块但实际上你加载的那个DLL明明就在那里。原因就是它的依赖没被找到。排查手法是用dumpbin /dependents xxx.dll列出依赖再一个一个确认这些依赖在不在搜索路径里。有的依赖是VS运行时库用户机器上可能没装对应的VC Redistributable这时候要么让部署方装要么把这些运行时DLL一起打包。还有一种情况是依赖的DLL版本不对虽然文件存在但接口签名变了加载会失败。6.3 生产环境的路径和权限服务器上跑的时候Java进程的工作目录不一定是Jar所在目录。如果你用固定相对路径去加载DLL很容易踩空。我的做法是把DLL解压到${user.home}/.yourapp/native/下面这个目录一般肯定可写而且不会因为Jar升级被覆盖。用System.getProperty(user.home)拿到路径注意Windows和Linux的路径分隔符不一样用File.separator或者Paths.get拼接别硬写反斜杠。另外容器化部署也要考虑。如果把应用打成Docker镜像跑在Linux上厂商的Windows DLL根本用不了这种情况只能要求厂商提供Linux版本的.soJNI和JNA都是跨平台的代码不用大改但库文件和加载体必须换。System.loadLibrary在Linux下找的是libxxx.so留意命名规则。7. 常见报错排查速查表我把这些年遇到的报错整理成表遇到问题先按这个对号入座能省大量时间。报错信息大概率原因处理办法UnsatisfiedLinkError: no xxx in java.library.path路径没配或者库名写错检查-Djava.library.path确认文件确实存在Cant load IA 32-bit .dll on a AMD 64-bit platform位数不匹配用64位编译器重新编译DLL或换32位JDK找不到指定的模块依赖DLL缺失用dumpbin /dependents查依赖链并补齐动态链接库(DLL)初始化例程失败DLL初始化代码出错或位数/运行时冲突换独立进程测试确认DLL自身能否正常加载Invalid memory accessJNA调用约定选错或指针参数传错检查是否该用StdCallLibrary核对参数类型调用返回乱码字符串编码不一致改用byte[]或WString统一编码进程直接崩溃无异常JNI层野指针或越界看hs_err_pid日志重点查内存操作Native Library already loaded同一进程重复加载加静态标记保证只加载一次NoSuchMethodErrorJNI方法签名或符号名不匹配重新生成头文件核对包名类名7.1 加载阶段的问题优先用独立进程验证遇到加载就失败的情况我第一件事是写一个五行的main方法只做加载不做任何调用用最高权限跑一遍。如果独立进程能加载成功而你的应用加载失败那问题一定出在应用的类加载器、路径配置或者某个已经改动过系统环境的地方这时候把范围缩小非常好定位。7.2 运行阶段的问题先怀疑参数加载成功但调用出错八成是参数映射的问题。我的排查顺序是先把所有参数都改成最简单的基本类型也就是把复杂结构体和字符串都从签名里拿掉看能不能跑通能跑通之后再一个一个加回来加到哪个出错就是哪个类型映射有问题。这个二分法比盯着代码猜快得多。7.3 稳定性问题看日志和重复调用偶尔崩一次、跑几个小时就挂、并发一上来就出问题这类稳定性故障最难查。我的经验是先在本地用单元测试循环调一万次看看能不能复现能复现就好办。如果只在生产环境偶发那就是并发或者内存的问题重点看有没有在多线程里共用一个Memory或者Structure对象这两类对象都不是线程安全的多线程下很容易出现字段读串、内存踩踏。解决办法是每个线程用独立实例或者加锁串行化。8. 我踩过的坑和一些实操心得最后说几个文档里不写、但真能省时间的东西。第一尽量让Java侧和C侧的类型定义有一个唯一真源。我现在的做法是让厂商提供一份C头文件我照着它手写JNA接口两边一一对应。如果连头文件都拿不到只有一份函数说明PDF那就要多问厂商要或者用dumpbin /exports把导出函数列出来反推虽然拿不到参数信息但至少知道有哪些函数。第二调试的时候把DLL的日志开关打开。很多商业DLL支持通过环境变量或者注册表打开日志出错时它会告诉你到底卡在哪里比自己瞎猜强太多。加载之前问清楚厂商有没有日志机制。第三DLL加载失败时Windows事件查看器和Process Monitor是你的朋友。Process Monitor能抓到一个进程在加载DLL时到底访问了哪些路径、哪个文件返回了找不到这个信息比Java抛出的异常精确一百倍。第四如果你只是要调Windows系统自带的几个API比如读内存、查进程真的不用自己写JNI直接引jna-platform里面已经封装好了Kernel32、User32这些接口拿来就用。我早期不知道这个白写了一次桥接层。第五跨平台的代码里把加载逻辑抽出来做成策略模式。Windows加载.dllLinux加载.soMac加载.dylib文件名前缀也不一样这些差异集中在一个工厂类里处理业务代码只调接口后面真要换平台就改一处。第六别忽略32位JDK的可能性。有些老设备厂商的SDK只提供32位DLL你非要用64位JDK那永远调不通。这时候要么换32位JDK要么起一个独立的32位转发进程让主应用通过本地socket或标准输入输出跟它通信。后者虽然麻烦但在现代系统上跑一个32位进程往往是最现实的方案。我做过一次这样的转发主应用64位转发进程32位只负责调DLL两边用长度前缀的协议传二进制数据跑了一年多很稳定。关于单元测试本地库的测试没法在CI上跑除非CI机器上也装了对应的DLL和运行时。我的做法是把本地库相关测试单独放一个local-native的test profile默认不执行只在人工验证时手动触发。CI上跑的是接口的mock实现保证业务逻辑本身有覆盖。这样既不阻塞流水线也保留了人工回归的能力。代码组织上我建议把JNA接口、实体结构、加载工具类分开放接口只声明方法不加逻辑结构体加好FieldOrder加载工具只负责把库弄进来。这样等哪天要换成JNI只要替换实现调用方完全无感。这个分层看着多余但我在两个项目里真的换了实现因为有这层隔离改动量不到半天。
返回列表