简介:这是一套Java通过JNI调用C++ DLL的完整Demo,适合需要实现跨语言高性能调用的Java开发者参考。压缩包共6个文件、约54KB,包含2个Java源文件、JNI生成的头文件与C++实现源文件、编译好的DLL以及说明文档,从native方法声明、javah生成头文件到C++侧实现与动态链接触点均清晰可见。资源已吸引267人学习,整体结构精简,可直接对照工程代码梳理Java与C++之间的类型映射、加载与调用流程,也能为后续封装自己的DLL接口提供可复用的脚手架。
1. 为什么你的Java项目需要直接调C++ DLL:从一次“性能报警”说起
做Java后端的人,早晚会遇到一个尴尬时刻:业务逻辑跑在HotSpot上,但核心计算必须交给C++编译出来的二进制。Java调用C++ DLL这种需求在图像处理、加解密、硬件驱动和实时风控场景里特别常见,因为它能把成熟的C++算法库直接复用,又保住Java这门语言的生态和运维体系。我最初接触这个Demo,是因为某个图像处理模块在纯Java实现下性能始终压不到预算线,换成调C++ DLL后耗时从几百毫秒降到几十毫秒。这篇笔记就是把那个Demo拆开给你看:从JNI选型、工程搭建、内存传参到多线程踩坑,照着走一遍你也能跑通。
2. JNI还是JNA:选型之前先把这三张表看明白
Java调用C++ DLL,最常见的技术路径是JNI和JNA。Demo虽然用的是JNI,但动手前你得知道为什么不用更省事的JNA。JNI要求你写一堆C++桥接代码,编译产物和Java类强绑定;JNA则靠反射和动态代理自动映射,省去手写头文件的功夫。代价是JNA每次调用都要做类型转换和缓存查找,性能差一个量级。如果你的DLL接口超过几十个或者要求低延迟,JNI更稳;如果只是偶尔调一两个函数,JNA够用。
2.1 JNI的调用链路与DLL导出规则
JNI的调用链路从Java侧开始:你声明一个native方法,JVM根据这个方法的包名、类名和方法名,在DLL里找一个导出符号。这个符号的命名规则很死板——Java_包名_类名_方法名,包名里的点换成下划线,如果方法有重载还要在后面加两个下划线加参数签名。很多新人第一次编译DLL,导出函数名和头文件里不一致,运行时直接抛出UnsatisfiedLinkError。
C++侧要导出JNI函数,必须用extern "C"加上__declspec(dllexport),否则C++编译器会对函数名做修饰,生成一大串带特殊字符的符号,Java根本找不到。我一般会在实现文件里写一个宏:
#define JNI_EXPORT extern "C" __declspec(dllexport) JNI_EXPORT jdouble JNICALL Java_com_example_ImageProc_compute( JNIEnv* env, jobject obj, jdoubleArray input) { // 具体实现 return 0.0; }这段代码里extern "C"去掉C++的name mangling,__declspec(dllexport)告诉链接器把这个函数放进DLL导出表;JNICALL在Windows上就是__stdcall,决定参数传递顺序。JNIEnv*是每个JNI调用必须的第一个参数,它指向一个函数指针表,你能通过它调所有JNI API。jobject是调用这个native方法的Java对象,如果方法是static的,这里就是jclass。这两个参数不需要你主动传,JVM会帮你填好。
2.2 JNA的自动映射与内存风险
如果用JNA,你只需要定义Java接口,继承Library,然后用Native.load加载。例如:
public interface CLibrary extends Library { CLibrary INSTANCE = Native.load("mycpplib", CLibrary.class); double compute(double[] input, int len); }JNA会自动把Java数组转换成C指针,函数返回后JNA再负责回收。听起来省心,但风险在于内存管理:如果C++侧把指针缓存到全局,或者返回了堆内存地址,JNA的自动回收会跟你预期的生命周期错位,轻则内存泄漏,重则野指针崩溃。JNI虽然代码多,但每个GetByteArrayElements、ReleaseByteArrayElements都是显式配对,出问题时你能准确知道是谁没释放。
2.3 选型决策表:什么时候别犹豫
我把两种方案的关键差异整理成一张表,你在写代码前先对照一下。
| 维度 | JNI | JNA |
|---|---|---|
| 开发成本 | 高,需要C++编译工具链 | 低,纯Java + DLL即可 |
| 调用性能 | 接近零开销 | 每次有反射和类型转换开销 |
| 数据类型支持 | 基础类型、数组、对象、字符串都有精确控制 | 自动映射,复杂结构体调试困难 |
| 内存控制 | 手动Get/Release,明确 | 自动处理,但生命周期不透明 |
| 跨平台 | 需要为每个平台编译DLL | 同样需要每个平台,但加载更灵活 |
如果你的DLL是给一个高并发接口用的,别犹豫,选JNI。像Demo里这个C++图像处理库,单张图要循环处理几万像素,用JNA那点开销会被放大到肉眼可见。反过来,如果你的DLL是给内部小工具调用的,一周调几百次,那JNA省下的开发时间绝对值。
3. 搭一个最简JNI Demo:从CMake到Java端全过程
这个Demo的完整流程可以分成四步:定义Java native方法、生成C++头文件、用Visual Studio或CMake编译DLL、Java端加载并测试。每一步都可能出问题,我们按顺序来。
3.1 准备C++工程:导出函数接口设计
先想清楚你要暴露哪些功能。Demo里只有两个函数:一个计算图像灰度级的直方图,另一个把RGB数据转成灰度数组。接口设计有个原则:尽量用基础类型或数组,不要直接传Java对象,否则你在C++侧要解析JNI对象结构,复杂度立刻上去。我设计的接口是这样:
JNI_EXPORT jdoubleArray JNICALL Java_com_example_ImageProc_computeHistogram( JNIEnv* env, jobject obj, jintArray argb, jint width, jint height);这里jintArray传ARGB像素数组,width、height是宽高,返回jdoubleArray表示256个灰度级的分布。选jintArray而不是jobject,是因为C++侧能通过GetIntArrayElements直接拿到内存指针,不用一层层拆对象。
C++工程本身我建议用CMake管理,方便跨平台。最简的CMakeLists.txt如下:
cmake_minimum_required(VERSION 3.10) project(java_cpp_demo) set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) add_library(ImageProc SHARED image_proc.cpp) include_directories("${JAVA_HOME}/include" "${JAVA_HOME}/include/win32")CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS这个开关很关键。不开的话,CMake默认只导出带头文件声明并标记过__declspec(dllexport)的函数,如果你某天忘写导出宏,Java就找不到符号。开了这个开关,MSVC会把所有全局符号都导出,省去在头文件里逐个标注的麻烦。JAVA_HOME是你的JDK安装路径,编译时必须把jni.h和jni_md.h的目录加进来。
3.2 用javac生成头文件并实现C++侧
写Java类时,native方法要用native关键字声明,并注明加载哪个库。Demo的Java类很简单:
package com.example; public class ImageProc { static { System.loadLibrary("ImageProc"); } public native double[] computeHistogram(int[] argb, int width, int height); public native int[] rgbToGray(int[] rgb, int width, int height); }注意System.loadLibrary的名字不带lib前缀也不带.dll后缀,JVM会自动拼上当前平台的约定。编译这个类后,用javac -h生成头文件:
javac -encoding UTF-8 -h . com/example/ImageProc.java-h参数在JDK 8以后是标准选项,它会根据native方法签名生成一个com_example_ImageProc.h,里面有你必须在C++侧实现的函数声明。用这个头文件作为契约,C++侧就不会写错函数名和参数类型。
有了头文件后,C++实现就可以直接填逻辑。灰度转换的典型实现是:
JNIEXPORT jintArray JNICALL Java_com_example_ImageProc_rgbToGray( JNIEnv* env, jobject obj, jintArray rgb, jint width, jint height) { jint* src = env->GetIntArrayElements(rgb, nullptr); jintArray result = env->NewIntArray(width * height); jint* dst = env->GetIntArrayElements(result, nullptr); for (int i = 0; i < width * height; ++i) { int pixel = src[i]; int gray = (int)(0.299 * ((pixel >> 16) & 0xFF) + 0.587 * ((pixel >> 8) & 0xFF) + 0.114 * (pixel & 0xFF)); dst[i] = (gray << 16) | (gray << 8) | gray; } env->ReleaseIntArrayElements(rgb, src, JNI_ABORT); env->ReleaseIntArrayElements(result, dst, 0); return result; }这里GetIntArrayElements返回的是JVM内部数组的指针副本或者直接引用,这取决于JVM的GC策略。nullptr这个参数表示不要求复制行为的信息,第二个参数是isCopy输出,一般不用管。ReleaseIntArrayElements第三个参数很讲究:JNI_ABORT表示只释放数组拷贝,不写回原数组;0表示写回并释放。对输入数组用JNI_ABORT能省一次拷贝,对输出数组用0确保Java拿到结果。这是JNI内存操作里最容易忽略的细节。
3.3 编译DLL:Visual Studio与CMake两条路径
在Windows上,最简单的编译方式是用Visual Studio的命令行工具。打开"开发者命令提示符",进入源码目录:
cl /LD /Fe:ImageProc.dll /I"%JAVA_HOME%\include" /I"%JAVA_HOME%\include\win32" ^ image_proc.cpp/LD表示生成动态链接库,/Fe:指定输出DLL文件名。这里有几个坑:一是cl命令必须来自x64或x86对应版本的提示符,否则生成的DLL位数不对;二是如果C++文件依赖了额外的库,还得加链接参数,Demo不涉及所以省掉。
用CMake会更规整。命令行执行:
cmake -B build -G "Visual Studio 17 2022" -A x64 -DJAVA_HOME="C:\Program Files\Java\jdk-21" cmake --build build --config Release生成的DLL在build/Release/ImageProc.dll。-A x64指定生成64位库,这个参数必须和Java进程的位数一致。如果你用-A Win32,Java是64位的,加载时直接崩。
3.4 Java侧加载DLL:System.loadLibrary还是绝对路径
System.loadLibrary依赖java.library.path,也就是系统环境变量PATH。如果你把DLL放在项目根目录,运行Java时经常找不到。我习惯用另一种方式:
static { System.load(new File("native/ImageProc.dll").getAbsolutePath()); }System.load接收完整路径,不依赖PATH。这个Demo我推荐你在static块里做一步检测:
static { String libPath = System.getProperty("user.dir") + "/native/" + System.mapLibraryName("ImageProc"); File libFile = new File(libPath); if (libFile.exists()) { System.load(libFile.getAbsolutePath()); } else { System.loadLibrary("ImageProc"); } }先用项目目录下的native子目录找,找不到再退回系统PATH。这样在IDE里直接跑很顺,打包成JAR后也能通过外部配置指定路径。System.mapLibraryName会根据操作系统返回ImageProc.dll或libImageProc.so,这样同一套代码能搬到Linux。
4. 数据类型与内存传参:最容易翻车的五个参数场景
JNI调用本质是Java和C++跨语言交换数据,交换的每一项都有对应规则。这一章我挑出五个必须背熟的场景,每个都配了代码和当时的踩坑记录。
4.1 int、double和boolean:基础类型像喝水
Java的int和C++的jint都是32位带符号整数,直接映射。double就是jdouble,boolean就是jboolean,占1字节。如果你的C++函数返回一个bool,在Java侧对应boolean,但在Windows的MSVC里bool占1字节,JNI的jboolean也是1字节,能对上。Linux GCC的bool同样1字节,所以大多数情况没问题。唯一要注意的是jboolean只有0和1两个合法值,如果C++返回一个非0非1的字节,Java侧可能会得到奇怪的结果。我吃过这个亏,C++那边用了std::vector<bool>::operator[],返回值是_Bit_reference,强制转成jboolean后变成了128,Java判断if(flag == true)直接失效。
4.2 String:UTF-8与UTF-16的编码陷阱
Java的String在JNI里是jstring,它内部是UTF-16编码。C++侧取字符串内容有两条路:
const char* utfChars = env->GetStringUTFChars(jstr, nullptr); // 用完后务必Release env->ReleaseStringUTFChars(jstr, utfChars);GetStringUTFChars返回的其实是修改过的UTF-8,普通ASCII字符没区别,但中文、emoji这类字符会和标准UTF-8不一样。JNI规范里叫Modified UTF-8,它把U+0000编码成0xC0 0x80,而且对补充平面字符用代理对分开编码。你如果把这串字节直接传给一个按标准UTF-8解析的C++库,认出来的字符串会错位。解决办法是在C++侧转成真正的UTF-8,或者干脆走GetStringChars拿UTF-16数组再用std::wstring处理。Demo里我拍板的方案是:Java侧传字节数组,C++侧完全不知道字符串编码这回事。
4.3 数组与缓冲区:GetByteArrayElements的配套使用
数组是JNI传参的主力。前面讲过GetIntArrayElements,它有一个亲兄弟叫GetPrimitiveArrayCritical,后者在GC不移动对象的情况下直接返回内部指针,速度快很多。但它有个致命限制:调用后不能做任何可能阻塞或触发GC的操作,比如分配Java对象、调其他JNI函数、加锁。否则JVM可能因GC移动对象而让你的指针悬空。我见过有人在这段代码里写日志,日志框架又分配String,然后程序随机崩溃。正确姿势是能不用Critical就不用,普通Get*ArrayElements已经够快,顶多多一次内存拷贝。
释放数组时有几个取舍:
jint* elems = env->GetIntArrayElements(array, nullptr); // 读取后如果没改内容,用JNI_ABORT env->ReleaseIntArrayElements(array, elems, JNI_ABORT);如果你用NewIntArray创建了新的Java数组,并把C++指针填好了,Release时用0。如果你调用C++函数修改了Java数组内容且想写回,也用0。如果只读取输入数组,用JNI_ABORT省下写回那趟拷贝。这个细节在循环调用大数组时能省出一大块时间。
4.4 对象回传:用JNIEnv的NewObject拼对象
单个返回值用基础类型或数组够用,但真实场景常常要返回一个结构体。JNI里没有直接映射Java对象的魔法,你得手动拼。假设Java侧定义了一个Result类:
public class Result { public int code; public String message; public double[] data; }C++侧构造这个对象的过程大概是:
jclass resCls = env->FindClass("com/example/Result"); jmethodID ctor = env->GetMethodID(resCls, "<init>", "()V"); jobject resObj = env->NewObject(resCls, ctor); jfieldID codeField = env->GetFieldID(resCls, "code", "I"); env->SetIntField(resObj, codeField, 0); jfieldID dataField = env->GetFieldID(resCls, "data", "[D"); jdoubleArray dataArr = env->NewDoubleArray(3); env->SetDoubleArrayRegion(dataArr, 0, 3, yourCArray); env->SetObjectField(resObj, dataField, dataArr);这里每个FindClass、GetMethodID、GetFieldID都是按名字和JNI签名查元数据,签名不是Java类型名,而是I表示int,[D表示double数组,()V表示无参构造。这些签名写错不会在编译时报错,运行时才爆NoSuchMethodError。所以我通常会把常用类和方法ID缓存到静态变量里,每次调用直接查表,省掉重复查找的开销。缓存时要小心,JNI的全局引用必须显式创建,否则局部引用在方法返回后就失效。我一般用env->NewGlobalRef把类引用保存起来,JVM卸载DLL前再释放。
4.5 回调函数:Java接口与全局引用的隐藏坑
很多C++库允许你注册回调函数,比如进度通知或日志输出。JNI里让C++回调Java方法,流程是:Java侧把实现了某接口的this传给native方法,C++侧保存一个jobject全局引用,然后在其他线程里通过CallVoidMethod调用。
这个流程最大的坑是:C++在任意线程回调时,必须先通过JavaVM获取当前线程的JNIEnv*。JNIEnv和线程绑定,其他线程没有。你需要在C++工程初始化时保存JavaVM*:
JavaVM* g_jvm = nullptr; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { g_jvm = vm; return JNI_VERSION_1_8; }回调具体线程时:
JNIEnv* env = nullptr; jint ret = g_jvm->AttachCurrentThread((void**)&env, nullptr); env->CallVoidMethod(globalObj, methodId, arg); g_jvm->DetachCurrentThread();AttachCurrentThread会把这线程挂到JVM上,让它能拿JNIEnv*。如果这个线程已经附着过,再Attach一次也没什么问题,但Detach时要注意:线程如果本身就是Java线程创建的,不该Detach。我在这块吃过亏,C++工作线程循环回调,每次回调都Attach/Detach,结果JVM崩溃说DetachCurrentThread called from wrong thread。后来改成:先判断GetEnv返回是否JNI_EDETACHED,是才Attach,线程退出前Detach一次。
全局引用的释放也要小心。保存Java回调对象时用NewGlobalRef,不用了在JNI_OnUnload里统一释放。如果你忘了释放,JVM里的对象永远不会被GC,就内存泄漏了。这个“泄漏”在Java侧看到的就是老年代不断增长。
5. Java调用C++ DLL的避坑指南:现象、原因、解决
下面这些坑不是我一个人踩的,是demo测试阶段几个人轮流填进坑里又爬出来汇总出来的。每条都按“现象→原因→解决”整理,你在自己项目中照着排查就行。
5.1 现象:UnsatisfiedLinkError, no XXX in java.library.path
这大概是最常见的错误。提示很直白,就是JVM没找到DLL。原因通常是:DLL路径不在java.library.path里;DLL名字拼写不对;或者DLL依赖的其他DLL缺失,导致加载这个DLL时失败,但JVM只给你报“no XXX in java.library.path”。解决时先确认三件事:第一,用System.load绝对路径排除查找顺序问题;第二,用dumpbin /dependents ImageProc.dll查它依赖了哪些DLL,把缺失的也放进去;第三,确认java.library.path包含的那条路径真的是x64的DLL,别拿32位库充数。
5.2 现象:程序崩溃在native方法,连堆栈都看不到
JNI调用栈和Java调用栈是两层,Java侧抛异常时你只看到# JNI EXCEPTION这样的信息,具体哪一行C++代码崩了得靠Windows事件查看器或Visual Studio调试器。最常见的原因是忘记Release*ArrayElements,导致JVM内部数组被GC移动,但你的指针还指着老位置。这类问题在我身边被称为“玄学崩溃”,因为每次崩的位置不一样。解决方法是把Get和Release配对写在同一个作用域,不要试图跨函数保存指针。
5.3 现象:DLL依赖的C++运行库缺失
你在开发机编译的DLL,用的是你机器上那个版本的VC运行库。放到部署环境,如果那台机器没装对应版本,加载DLL会失败,但错误可能五花八门——有时是Exception in thread "main" UnsatisfiedLinkError,有时是Did not find either the Microsoft Visual C++ Redistributable。解决有两个方向:静态链接运行库,或安装对应的Redistributable。CMake里设置:
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Release>:>")或者直接用Visual Studio把C/C++ > Code Generation > Runtime Library改成/MT。这样做出来的DLL体积变大,但部署不依赖那台机器装没装运行库。Demo里我用的就是静态链接。
5.4 现象:32位/64位不匹配导致加载即死
Java进程是64位的,DLL是32位编译的,一加载就UnsatisfiedLinkError或者直接崩。更隐蔽的是Java进程是32位,DLL却是64位,提示还一样。检查方法:命令行输入java -version看输出里有没有64-Bit;用dumpbin /headers ImageProc.dll看Magic字段,x64是0x20B,x86是0x10B。编译DLL前先确认你的JAVA_HOME对应的JDK位数和CMake的-A x64一致。
5.5 现象:多线程调用时数据错乱
JNI本身是线程安全的,前提是你每个线程都拿到正确的JNIEnv*。问题出在C++侧保存了跨越调用的字段指针或类引用。比如你把GetIntArrayElements拿到的指针存到类成员变量,另一个线程再用,结果两个线程同时读写同一个指针,数据错乱而且不定时崩溃。解决这个问题只有一个原则:native方法内部用局部变量,任何跨越调用边界的存储都需要用全局引用并对C++侧加锁。我后来把需要跨线程共享的状态封装成一个独立结构体,用std::mutex保护,Java侧只传一个不透明的long句柄,这样至少把崩溃范围控制在C++内部。
6. 生产级用法:DLL动态加载、日志回传与热替换验证
Demo跑通只是第一步,真正上生产还得加三样东西:动态加载和降级、日志回传、以及一个可重复的性能验证基线。这一章分享我在项目里沉淀出来的做法。
6.1 用动态代理代替静态加载,失败时可降级
System.loadLibrary放在类的static块里,意味着类一加载就必须有DLL,否则整个类不可用。在微服务环境下,我更推荐把native调用隔离在一个独立的门面类里,用反射去决定加载策略。生产上我会这么设计:
public class NativeProcessor { private NativeBridge bridge; public NativeProcessor(String libPath) { try { System.load(new File(libPath).getAbsolutePath()); bridge = new NativeBridgeImpl(); } catch (Throwable t) { // 记录并降级到纯Java实现 bridge = new FallbackProcessor(); } } }这样即使DLL在某台机器上加载失败,服务还能用落地实现继续跑。降级逻辑要谨慎:如果降级后的性能差太多,建议直接让服务启动失败,而不是带着坏眼睛上路。
6.2 C++侧把日志打回Java层:全局引用与回调模板
调试JNI时最痛苦的是C++侧没有日志。我把日志回调做成一个稳定的机制:Java侧传入一个Consumer<String>,C++侧通过全局引用回调。核心代码就是前面4.5节提到的Attach和CallVoidMethod,我封装成模板:
template<typename... Args> void logToJava(const char* level, const char* fmt, Args... args) { JNIEnv* env = getEnv(); jstring msg = env->NewStringUTF(formatString(fmt, args...).c_str()); env->CallVoidMethod(g_logCallback, g_logCallbackMethod, msg); }这里formatString是用vsnprintf手动拼接的,避免依赖std::format。注意一点:NewStringUTF在栈上引用计数,如果回调会异步执行,你必须先NewGlobalRef保存字符串,否则回调所在的线程拿到的jstring已经失效。
6.3 用单元测试验证内存与性能:一次JNI调用的开销到底多大
很多人以为JNI调用和Java方法调用一样快,其实一次最简单的无参数native调用在本机也要几十纳秒到几百纳秒,加上参数转换和数组拷贝很容易到微秒级。我给Demo写了一个基准测试:
public class JniBenchmark { public interface Callable { void call(); } public static void main(String[] args) { ImageProc proc = new ImageProc(); int[] pixels = new int[1920 * 1080]; // 预热 for (int i = 0; i < 10; i++) { proc.rgbToGray(pixels, 1920, 1080); } long start = System.nanoTime(); for (int i = 0; i < 100; i++) { proc.rgbToGray(pixels, 1920, 1080); } long avgNanos = (System.nanoTime() - start) / 100; System.out.println("avg call: " + avgNanos + " ns"); } }预热10次是必不可少的,因为JIT可能把某些JNI相关调用也编译成优化代码。这个基准告诉我,在Demo的数组转换场景里,JNI调用本身的耗时只占全部耗时的5%,剩下95%都在数组拷贝和像素循环里。所以如果性能不达标,优先优化C++算法而不是纠结调用次数。
写到这里,我想起最开始带着这份Demo踩坑的日子:每次换个JDK版本,JVM输出不同,就怀疑是不是编译器出问题;后来我养成一个习惯,凡是用JNI做新功能,第一件事就是写一个最小化可运行的测试类,把每个native方法的入参、出参、异常路径都列出来,再跑一遍开头那个基准。从那以后,我每个涉及JNI的迭代都强制走一遍这条验证链路,省掉的排障时间足以抵消前期搭建成本。JNI不是黑匣子,把类型映射和线程规则搞清楚了,它就是你手上最顺手的性能武器。希望这些记录对你有帮助,需要这个Demo的完整工程和踩坑备注,按自己项目的编译环境再动一遍就稳了。
本文还有配套的精品资源,点击获取