拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JNI基础数据类型全解析:从jboolean到jlong的常见坑

JNI基础数据类型全解析:从jboolean到jlong的常见坑

1. 从JNI的类型系统说起:为什么第一课必须讲这个

JNI(Java Native Interface)是Java生态里一个特别的存在。它让Java代码可以直接调用C/C++写的动态库,反过来,C/C++代码也能调用Java层的代码。十年前我做Android系统定制时第一次真正用上JNI,后来转做后端中间件,在Java服务里用JNA/JNI调一些底层算法库,可以说JNI贯穿了我整个职业生涯的很多关键阶段。

很多初学者一上来就急着写代码、调接口,结果被jstring、jobjectArray、jfieldID这些东西搞得一头雾水。其实JNI的所有困难,根源都在类型系统——你用什么类型接收Java层传过来的参数、用什么类型把数据传回Java层,直接决定了后面一切调用的成败。

这篇内容,我会把JNI类型系统的全貌讲清楚,重点聚焦基础数据类型这一块。内容偏向实战:为什么JNI要定义一套自己的typedef,这些类型和C/C++原生类型之间怎么换算,在CLion里搭环境时要注意哪些坑,以及我在实际项目中踩过的那些"看起来能编译、跑起来就崩"的问题。

如果你是下面这几种情况,这篇文章值得读完:

  • 正在学JNI,刚开始接触javac -h和C/C++代码生成,对jni.h里的类型定义感到陌生;
  • 在维护老项目,遇到了UnsatisfiedLinkError、JNI参数错位或者native层读到乱码的问题;
  • 想在CLion里配置一套完整的JNI开发调试环境,不想再用命令行手动javac/编译的笨办法。

JNI类型系统说白了就两件事:一是Java和C/C++之间的数据怎么“翻译”,二是翻译过程中谁能保证字节数、符号、内存布局完全不出错。基础数据类型是整个JNI类型系统的地基,地基不牢,后面说什么jobject回调、局部引用管理都是在空中楼阁。

2. 基础数据类型全解析:jboolean到jdouble的每一个坑

2.1 JNI为什么不用C语言的int、long

先回答一个非常自然的疑问:Java层有int、long、boolean,C语言也有int、long,为什么JNI不能直接用?

我在给团队做内部培训时经常举这个例子:C标准里只规定了int最短16位、long最长不超过double,但具体是多少位由编译器和平台决定。在Windows上是LLP64模型,long是32位;在Linux x86_64上是LP64模型,long是64位。Java的int永远32位、long永远64位,这是语言规范写死的事情。

如果JNI直接映射,同一个C函数在Windows下编译和Linux下编译,对于long类型参数的二进制布局可能完全不同。老练的C程序员肯定会说用int32_t、int64_t这些<stdint.h>里的定长类型不就行了?JNI思路一致,但它比这个更进一步:它直接在jni.h头文件里把类型全部typedef出来,形成一套Java世界和原生世界之间的“契约语言”。

这就是JNI类型系统存在的核心价值:用一套固定的、平台无关的typedef,屏蔽Java与C/C++之间的类型差异。你看jni.h时会发现,所有平台的JNI实现都约定同一个头文件、同一套类型名,保证Java代码“一处编写、处处原生”。

2.2 八大基础数据类型的完整映射表

JNI的基础类型定义在jni.h的头部,核心映射我整理成一张表:

Java类型JNI的C/C++类型实际C语言底层定义占用字节数说明
booleanjbooleanunsigned char1字节只约定0和1
bytejbytesigned char1字节有符号8位
charjcharunsigned short2字节Java的char是UTF-16编码单元,不是ASCII
shortjshortshort2字节有符号16位
intjintint4字节固定32位,等价int32_t
longjlonglong long8字节固定64位,等价int64_t
floatjfloatfloat4字节IEEE 754单精度
doublejdoubledouble8字节IEEE 754双精度
voidvoidvoid—仅用于方法返回类型

这张表里最值得警惕的就是jboolean和jchar,它们也是我见到的错误率最高的两个类型。

jboolean底层是unsigned char,而不是C++里的bool。Java层的boolean语义上只有true/false,native层用unsigned char只是为了严格保证1个字节的存储尺寸。C++的bool标准虽然也通常是1字节,但C++标准的bool和这毕竟不是同一个东西,所以JNI规范里特意定义了jboolean。实际调用时,如果Java传了true,你收到的是1;传false,收到0。但你在native层用jboolean接收后用if (value == JNI_TRUE)判断,这里的JNI_TRUE定义就是常量1。

jchar是unsigned short,2字节无符号。这里有个隐蔽的坑:C/C++里的char几乎都是1字节,很多新手在C/C++侧把JNI返回的jchar直接强转为char,结果遇到中文或者4字节以上的Unicode码点时,数据直接截断。Java的char是UTF-16 code unit,你接到的jchar可能是代理对(surrogate pair)中的一半。如果你要做字符串处理,应该走GetStringChars或者GetStringUTFChars,而不是自己逐个转char。

jlong的坑更偏向“习惯”:在Windows上的C代码里,long是32位,但JNI的jlong明确是long long。如果你写成long接收jlong,编译能过,但高32位直接被丢掉,返回的数据完全错乱。我见过不止一个同事在这种地方花了一整天才排查出来。

2.3 类型映射背后的数字讲究:为什么正好是这些字节长度

有人会问:Java的byte如果映射成signed char,为什么不用int8_t?Java的int为什么不用int32_t?

答案不是JNI老,而是JNI要考虑C89/C99的老编译器兼容性。当年JNI规范定下来的时候,C99的stdint.h还没有被广泛支持,JNI在jni.h里自己用typedef定义了一套类型,保证哪怕在非常老旧的C编译环境下也能编译通过。不过现代的jni.h里也大量配合使用<stdint.h>里的类型:

typedef unsigned char jboolean; typedef unsigned short jchar; typedef short jshort; typedef int jint; // 在特定的JNI实现里,jlong也可能是: // typedef long long jlong;

你去看JDK自带的jni.h,有的版本里jint就是int,jlong就是long long,但这都无所谓,因为JNI规范保证的是“字节宽度固定、符号固定”。真正重要的是你在自己的C/C++代码里,用等宽类型去接:

// 推荐做法:使用定长类型 int32_t myJavaInt = jint_value; int64_t myJavaLong = jlong_value;

这种写法的好处在于:你的native代码逻辑是自文档化的,明示了数据宽度,未来跨平台移植时不会因为int、long在不同平台上的默认宽度不一致而出问题。

2.4 为什么没有jbyteArray的基础类型对应物

JNI区分“基础类型”和“引用类型”。八种基础类型对应Java的基本类型,引用类型则对应数组、String、Class、Throwable以及所有Java对象。最容易混淆的是jbyteArray、jintArray这类东西——它们不是基础类型,而是数组类型,属于引用类型范畴。JNI设计时给每种基础类型都配了一个对应的Array类型,但不建议把它们和基础类型混在一起记忆:

基础类型对应的JNI数组类型获取数据的JNI函数
jbooleanjbooleanArrayGetBooleanArrayElements
jbytejbyteArrayGetByteArrayElements
jcharjcharArrayGetCharArrayElements
jshortjshortArrayGetShortArrayElements
jintjintArrayGetIntArrayElements
jlongjlongArrayGetLongArrayElements
jfloatjfloatArrayGetFloatArrayElements
jdoublejdoubleArrayGetDoubleArrayElements

从这里能看出JNI的一贯思路:基础类型是值传递的,数组和对象是引用类型的,需要JNI函数专门获取与释放。这篇着重讲基础类型,但数组类型你一定会碰到,尤其是byte数组在传输二进制数据时几乎避不开。后面的章节里我会详细展开数组和引用类型的细节。

3. JNIEnV的取值与传值机制:基础数据到底怎么跨语言流动

3.1 基础类型按值传递,这句话怎么理解

熟悉C++的人都知道,函数参数有两种传递方式:值传递和引用传递。JNI对于基础类型采用的就是值传递。Java层调用native方法时,int参数直接复制一份原始值传到C函数栈上;C函数返回jint时,也直接返回值本身。

这意味着native函数里对参数的修改不会影响Java层的原变量。这和Java本身的基本类型语义是一致的。理解这一点对调试很有用:如果你在native层改了jint的值,Java层没变,不是JNI出bug了,是你对值传递的预期错了。

但是,有个重要的例外:如果你传入的是一个int[]数组(jintArray),哪怕每个元素是int,整个数组在JNI层面是一个引用类型。你操作数组元素时,必须通过GetIntArrayElements拿到C侧指针,这时候你在C侧改数组元素,如果调用了ReleaseIntArrayElements时用了JNI_COMMIT或0,是可以把修改同步回Java数组的。所以“基础类型按值传递”只对单个标量成立,对数组完全不成立。

3.2 JNIEXPORT和JNIEXPORT的符号导出原理

看JNI的native函数声明,会有两个醒目的宏:

JNIEXPORT jint JNICALL Java_com_example_NativeLib_add(JNIEnv *env, jobject thiz, jint a, jint b);

JNIEXPORT这个宏在Windows上是__declspec(dllexport),在Linux/Unix上为空或__attribute__((visibility("default")));JNICALL在Windows上是__stdcall调用约定,在Linux等平台上为空。

这段宏的作用是控制函数导出和调用约定。Windows下DLL的符号默认不导出,如果没有JNIEXPORT,Java层运行时就会报“找不到add符号”,也就是UnsatisfiedLinkError。所以如果你在Windows上手动写JNI函数名,千万别漏了这两个宏。在CMakeLists.txt里设置C_VISIBILITY_PRESET hidden时,这两个宏的意义就更重要了。

从JVM的角度看,动态库加载后JVM会通过dlsym(Linux)或GetProcAddress(Windows)查找符号。如果找不到,就报错。符号本身还必须是extern "C"的,否则C++编译器会做名字改编(name mangling),JVM按C符号去找就会扑空。

这里我列一下最常见的三个native层找不到符号的场景:

  • 漏了extern "C"
  • Windows下漏了JNIEXPORT导致未导出
  • 用C++写了函数重载,名字被编译器改编

每个都让你报错UnsatisfiedLinkError,但报错位置和时机略有不同。实际开发中,我建议所有JNI封装函数都按这个格式严格写全,不要偷懒省宏。

3.3 从Java层调用native方法时的完整数据流

以最简单的add方法为例,完整数据流是这样的:

  1. Java层调用NativeLib.add(3, 5)
  2. JVM根据System.loadLibrary("native")加载动态库,把符号解析注册到当前进程;
  3. 调用时JVM把JNIEnv指针和jobject(或jclass,看方法是否static)压栈,再把参数按JNI类型压栈;
  4. native函数执行,拿到a=3、b=5,算出jint返回值;
  5. 返回值通过JNI约定传回Java层,Java方法获得8。

整个过程中,JVM不关心你native函数内部怎么实现的,它只关心入口符号约定是否匹配、参数栈布局是否匹配。这就是为什么类型语义这么重要——一旦某个参数类型对不上,压栈/出栈的字节数不一样,函数可能不立即崩溃,直到下一次调用栈返回时才炸,这种bug极其难查。

我举一个我真实遇到过的例子。项目里有人把native方法签名里的long参数换成了int参数,因为两边的代码不是同一个人写的,Java层传的是long,C层接的是jint。64位系统下,long是8字节,jint是4字节。JVM压栈了8字节,C函数按4字节解析局部变量,后面的参数全部错位。最终表现是:函数能进能出,但第二个参数永远是个垃圾值,而且概率性崩溃。用valgrind一跑才发现栈被写坏了。

所以,Java和native层的类型签名必须严格一致,这是JNI第一条军规。类型映射表不是参考建议,是法律条文。

4. CLion中配置JNI开发环境:从零到一跑通第一个example

4.1 为什么推荐CLion作为JNI开发IDE

CLion对JNI开发的支持,在当前的IDE阵营里算是比较友好的一档。一方面,JetBrains家的IDEA和CLion可以联动,端到端调试Java调用native方法的场景可以直接在CLion的调试器里断点;另一方面,CLion原生支持CMake,而JNI的C/C++侧正好绝大多数都是用CMake构建的。相比Eclipse + CDT那套老掉牙的组合,CLion的环境配置要顺滑很多。

我在开篇提到的“在CLion中配置jni环境”这个热搜词,也确实反映了很多人的刚需。下面我给的配置流程基于Linux/macOS环境,Windows步骤基本一致,差异主要是JAVA_HOME路径和动态库后缀名(Windows是.dll,macOS是.jnilib或.dylib,Linux是.so)。

4.2 第一步:准备JDK和CLion

先用java -version确认你的JDK版本。JNI开发建议用JDK 8或更高的版本。然后记下你的JAVA_HOME路径:

# Linux/macOS echo $JAVA_HOME # 如果没有设置,先找到java所在路径 which java # 然后推断JAVA_HOME,一般是 /usr/lib/jvm/java-11-openjdk-amd64 之类

一般CMakeLists.txt里需要这个路径来引入jni.h和jni_md.h。

4.3 第二步:创建Java端代码并生成头文件

CLion虽然主打C/C++,但它也能混编。我习惯把Java文件放在项目的java_src目录下,这样便于管理。

新建一个Java类:

public class NativeLib { static { System.loadLibrary("native"); } public native int add(int a, int b); public native String getMessage(); public static void main(String[] args) { NativeLib lib = new NativeLib(); System.out.println("3 + 5 = " + lib.add(3, 5)); System.out.println(lib.getMessage()); } }

编译后生成头文件:

cd java_src javac NativeLib.java javac -h . NativeLib.java

如果用的是JDK 8,请用javac -h,不是javac -jni(JDK 8以前用的是javah命令,JDK 8虽然还保留javah,但建议直接用javac -h)。成功后会生成一个NativeLib.h文件,里面就是JNIEXPORT声明的函数原型。

/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> #ifndef _Included_NativeLib #define _Included_NativeLib #ifdef __cplusplus extern "C" { #endif JNIEXPORT jint JNICALL Java_NativeLib_add (JNIEnv *, jobject, jint, jint); JNIEXPORT jstring JNICALL Java_NativeLib_getMessage (JNIEnv *, jobject); #ifdef __cplusplus } #endif #endif

请仔细看这个头文件:所有函数都包裹在extern "C"里,这就是为什么我们自己的.cpp实现文件也要包含这个头文件,以确保函数定义时也是C链接。

4.4 第三步:CMakeLists.txt配置

在项目根目录创建CMakeLists.txt,重点是将jni.h所在的include目录和平台相关的目录暴露给编译器。

cmake_minimum_required(VERSION 3.20) project(jni_native LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) # JDK 路径,按你的实际JAVA_HOME修改 set(JAVA_HOME "/usr/lib/jvm/java-11-openjdk-amd64") include_directories( ${JAVA_HOME}/include ${JAVA_HOME}/include/linux # macOS 下是 ${JAVA_HOME}/include/darwin ) add_library(native SHARED native_lib.cpp ) # macOS 需要额外设置安装名 if(APPLE) set_target_properties(native PROPERTIES INSTALL_RPATH "@loader_path" BUILD_WITH_INSTALL_RPATH TRUE ) endif()

注意,Windows上的include目录是JAVA_HOME/include和JAVA_HOME/include/win32,Linux是include/linux,macOS是include/darwin。很多人在这一步漏了平台子目录,导致找不到jni_md.h。

4.5 第四步:C++侧实现

实现文件里,我们的函数签名要和生成的头文件完全一致。这里我用一个关键点示范:jstring和jint混合处理。

#include <jni.h> #include <string> #include "NativeLib.h" extern "C" { JNIEXPORT jint JNICALL Java_NativeLib_add(JNIEnv *env, jobject thiz, jint a, jint b) { return a + b; } JNIEXPORT jstring JNICALL Java_NativeLib_getMessage(JNIEnv *env, jobject thiz) { std::string msg = "Hello from C++ JNI"; return env->NewStringUTF(msg.c_str()); } }

这个函数里出现了jstring和NewStringUTF,这属于引用类型和JNI函数调用的范畴了,但先记住一点:jstring你不能当成普通字符串返回,必须通过NewStringUTF或NewString创建。后面章节讲引用类型时我会重点展开。

4.6 第五步:编译并运行

在CLion里直接点击Build,生成libnative.so(或libnative.dylib、native.dll)。然后把生成的动态库放到Java代码能加载到的地方,运行NativeLib。

运行Java主类的方式,可以直接用CLion的Run Configuration搭配IDEA,也可以命令行:

java -Djava.library.path=./build/lib NativeLib

Idea自身也可以在VM options里配置-Djava.library.path,指向你的动态库所在目录。

第一次跑通这个流程,你基本就对JNI的开发闭环有了整体感知:Java定义native方法,javac -h生成头文件,C++实现,编译成一个动态库,Java程序加载并调用。后面所有复杂功能,包括字符串、数组、Java对象回调,都是在这条流水线上扩展出来的。

5. 基础数据类型实操案例:从int到jcharArray的完整演示

5.1 一个综合示例:Java层传多种基础类型

前面只演示了add函数,这里我多给一个综合一点的例子,涉及所有基础类型。Java端:

public class TypeDemo { static { System.loadLibrary("typedemo"); } public native void passAllTypes(boolean boolVal, byte byteVal, char charVal, short shortVal, int intVal, long longVal, float floatVal, double doubleVal); public native long testLongReturn(); }

生成头文件后,C++侧实现:

extern "C" JNIEXPORT void JNICALL Java_TypeDemo_passAllTypes(JNIEnv *env, jobject thiz, jboolean boolVal, jbyte byteVal, jchar charVal, jshort shortVal, jint intVal, jlong longVal, jfloat floatVal, jdouble doubleVal) { // 打印时全部显式转成可打印的类型 char cStr[64]; snprintf(cStr, sizeof(cStr), "char code point: %u", static_cast<unsigned int>(charVal)); printf("boolVal=%d, byteVal=%d, shortVal=%d, intVal=%d, longVal=%lld\n", static_cast<int>(boolVal), static_cast<int>(byteVal), static_cast<int>(shortVal), static_cast<int>(intVal), static_cast<long long>(longVal)); printf("floatVal=%.2f, doubleVal=%.2f, %s\n", static_cast<double>(floatVal), doubleVal, cStr); }

这里的关键点在printf里。jboolean是unsigned char,不能直接匹配%d;float传给printf的%f之前必须转成double。如果你偷懒直接把jfloat当double传给printf,在64位平台上可能会因为参数类型不匹配读到垃圾值。C语言的printf是类型不安全的,这里我要再强调一次:JNI函数内部不是避风港,C/C++的类型规则照常生效。

5.2 jcharArray入参的处理示范

传单个jchar很简单,但是一旦遇到char数组,事情就不一样了。看下面这个例子,Java层传一个char[]过来。

public native int calcCharArrayLength(char[] data);

C侧实现:

extern "C" JNIEXPORT jint JNICALL Java_TypeDemo_calcCharArrayLength(JNIEnv *env, jobject thiz, jcharArray array) { if (array == nullptr) { return 0; } jsize len = env->GetArrayLength(array); jchar *elements = env->GetCharArrayElements(array, nullptr); if (elements == nullptr) { return 0; } // 这里临时用elements做一些只读操作 jsize count = 0; for (jsize i = 0; i < len; i++) { if (elements[i] != 0) { count++; } } env->ReleaseCharArrayElements(array, elements, JNI_ABORT); return count; }

GetCharArrayElements拿回来的是底层缓冲区的指针,这个指针可能指向Java堆的拷贝,也可能直接指向原始数组(取决于JVM实现和调用场景)。用完之后必须Release,否则会内存泄漏。第三个参数mode有三种取值:

  • 0:将修改拷贝回Java数组,并释放原生数组;相当于“写回”
  • JNI_COMMIT:将修改拷贝回Java数组,但不释放原生数组
  • JNI_ABORT:不拷贝回Java数组,直接释放原生数组

小程序里很多人图省事永远传0,但当你修改的是从Java传进来的只读数据时,用JNI_ABORT性能更高,避免一次无谓的拷贝回写。这是JNI性能优化里最基础的一课。

5.3 jboolean的特异功能:JNI_TRUE和JNI_FALSE

JNI在jni.h里还定义了两个常量:

#define JNI_FALSE 0 #define JNI_TRUE 1

实际写代码时请用这两个常量,不要直接用数字0、1。同时,因为jboolean是unsigned char,不要把它和C++的bool混用,更不要拿它直接做某些系统API的bool参数。如果需要转换,显示转换出来:

bool cppBool = (jboolValue == JNI_TRUE);

这样避免了一个经典异常:你把jboolean直接当作bool传给了某个C接口,C接口内部检查value == true或者is true时,如果jboolean的值是1以外的其他非零值,结果就是未定义行为。

6. JNI中无处不在的JNIEnv指针:理解它是理解一切的钥匙

6.1 JNIEnv到底是什么

JNIEnv是JNI Native Interface的核心句柄。每个native方法第一个参数都是它(除非是JNI_CreateJavaVM那类特殊入口)。它本质上是一个指向线程局部数据的指针,这个数据里装着一整个函数表,你调用的所有JNI函数(NewStringUTF、GetArrayLength、CallIntMethod等),都是这张表里的函数指针。

可以把JNIEnv想象成一个“Java虚拟机在native层的翻译”。你通过它向JVM查询数组长度、创建Java对象、抛异常、访问字段。没有它,JNI代码寸步难行。

C和C++访问JNIEnv的方式有很大差别:

  • C语言中,JNIEnv是JNIEnv_结构体的指针,调用时要写成(*env)->GetArrayLength(env, array)
  • C++中,有内联函数包装,可以直接写成env->GetArrayLength(array)

两种调用方式等价,但混用容易出错。我在C文件里写了(*env)->这种,在C++里写成env->这种,都没问题。怕就怕C++文件里混用了(*env)->,虽然能编译,但可读性很差。

6.2 JNIEnv是与线程绑定的

JNIEnv一个极度重要的特性是:它绑定的是当前native调用所在的线程。你在一个线程里拿到JNIEnv,如果把它存起来,到了另一个线程再用,轻则拿不到正确的引用对象,重则导致进程崩溃。

正确做法是:在需要访问JNI的线程里,通过JavaVM去获取对应的JNIEnv。在native方法里拿到JNIEnv时,如果要跨线程使用,可以先调用AttachCurrentThread把新线程挂载到JVM上,再拿到这个线程的JNIEnv,用完后再DetachCurrentThread。

这里我提一个在Android或者服务端线程池场景中非常常见的坑。你有一个线程池里的工作线程,想回调Java层方法,直接用了之前主线程的JNIEnv,结果“时好时坏”。本质上就是这个线程绑定问题。你需要存一个JavaVM全局引用,而不是存JNIEnv局部引用。

JavaVM *g_vm; // 在JNI_OnLoad或初始化时保存JavaVM JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { g_vm = vm; return JNI_VERSION_1_8; } // 在子线程中获取JNIEnv JNIEnv *env = nullptr; g_vm->AttachCurrentThread((void**)&env, nullptr); // 使用env... g_vm->DetachCurrentThread();

这个示例提前引入了JNI_OnLoad和JavaVM两个概念,但其实是避不开的。基础数据类型那一层你可能暂时用不到,但只要你继续深入JNI,一定会撞上。

6.3 JNI_OnLoad:JNI环境的入口

动态库被System.loadLibrary加载后,JVM会先找JNI_OnLoad这个导出函数。如果找到了,就调用它;返回值必须是一个表示JNI版本号的常量,比如JNI_VERSION_1_6、JNI_VERSION_1_8。

这个函数的常见用途有三个:

  • 保存全局的JavaVM指针
  • 注册native方法(RegisterNatives)
  • 做一些native侧的初始化工作

对比起来,不写JNI_OnLoad的库也能跑,JVM会默认按JNI_VERSION_1_2规范去找native符号。但我建议所有正经的JNI库都写上,因为后续全局引用管理、方法注册、日志初始化都需要它。

7. 常见问题与排查技巧实录

7.1 编译期报错找不到jni.h或jni_md.h

这个问题90%的原因是CMAKE的include路径没配好。CLion里报错时仔细看是哪个头文件缺失:

  • jni.h缺失:JAVA_HOME/include没加进来;
  • jni_md.h缺失:JAVA_HOME/include/linux或win32或darwin没加进来。

Windows用户经常会碰到另一个情况:JAVA_HOME路径里带了空格,比如C:\Program Files\Java\jdk-17,CMake如果没处理好引号,路径会被截断。解决办法是把JAVA_HOME路径用双引号包起来,或者干脆设一个不含空格的JDK路径。

7.2 运行时UnsatisfiedLinkError:找不到符号

UnsatisfiedLinkError可能的原因,我按排名列出:

  1. 动态库没被系统找到(java.library.path不对)
  2. 函数签名和Java native方法不对应(类名/方法名/参数不一致)
  3. C++函数没有用extern "C"
  4. Windows上漏了JNIEXPORT
  5. 动态库extension不对(Linux一般是.so,macOS是.dylib或.jnilib,Windows是.dll)

检查顺序建议:先确认库路径,然后javap -s看Java侧的native签名,再用nm命令查出动态库里的导出符号对不对比,最后核对JNI函数签名。

比如我经常用的排查命令:

# 查看动态库导出符号 nm -D libnative.so | grep Java_

如果这里输出为空,那就说明符号导出或者extern "C"有问题。

7.3 native方法参数错位但没崩溃

这类bug最磨人心态。表现为:运行不报错,但某个参数的数值总是错。我一般优先怀疑是类型宽度不匹配,比如Java层是long,C侧用了jint;或者Java层是char(16位),C侧用了jchar(16位)结果又强转成了char(8位)。

排查思路:

  • 用javap -s反编译确认Java侧签名;
  • 在native函数入口处把所有参数按二进制dump出来,对比期望值;
  • 检查所有用了printf的转换是否类型安全;
  • 如果涉及数组,检查GetXXXArrayElements后是否动过指针偏移。

我还有一个习惯:所有JNI函数入口第一行都用宏写一个“参数打印”日志,把每个参数类型和值打印出来,日志会极大缩短定位时间。实际项目里这个习惯救了我很多次。

7.4 char相关的中文乱码问题

Java的char和C/C++的char不是一回事,这在前面已经强调了。如果你在C层收到jchar数组并想转成UTF-8字符串输出,千万别直接强转。

推荐的做法是使用JNI的字符串翻译函数:

  • GetStringUTFChars:把Java的String转成UTF-8格式的char*
  • NewStringUTF:把UTF-8的char*转成Java的String

对于char[]数组,想好你要的是Unicode码点还是UTF-8字节流后再动手。很多图像处理项目传byte[]就是这个原因:字节流的语义明确,不会和UTF-16的代理对打架。

7.5 局部引用表溢出

JNI里通过NewStringUTF、FindClass、NewObject等函数创建的都是局部引用(local reference),它们只在当前native方法调用期间有效,且在一个线程中额度有限(默认在某些JVM上是16KB或32KB以上,具体因实现而异)。

基础数据类型阶段还不会频繁创建局部引用,但你在循环里反复调用NewStringUTF时就会踩到这个坑。解法只有两种:使用DeleteLocalRef显式释放,或者让代码逻辑减少循环内引用创建。更深层的局部引用、全局引用管理会在后续章节单独细讲,这里先埋个预告。

8. 后续内容预告与个人经验

JNI这套东西,类型系统是第一个坎,但不是最后一个坎。基础数据类型处理熟之后,整个JNI的常用面就打开了。后续系列里,我计划重点写这么几块:

第一部分是引用类型,jstring、jobject、jclass这些东西的内部结构和访问方式,特别是字符串处理的UTF-8和UTF-16各种坑;第二部分是数组与直接缓冲区,byte数组在图像、音视频、序列化场景里的高性能传输;第三部分是字段与方法调用,包括访问Java对象的实例字段、静态字段以及回调Java方法;第四部分是全局引用和局部引用的管理策略,这在长期运行的服务里直接决定内存稳定性;第五部分是RegisterNatives手动注册方法,很多工业级JNI库都用它绕开自动符号查找。

当初我学JNI的时候,啃完类型系统后最大的体会是:所有难懂的概念,最后都能落到内存布局和线程绑定这两个核心问题上。你理解了数据在Java堆和native堆之间怎么流动、谁手里握着的JNIEnv属于哪条线程,后面再复杂的坑也都能推出来。

这篇文章能写的内容还有很多,但基础数据类型这层地基,值得你花一个下午亲手敲一遍代码。我已经把CLion环境下从Java到C++的闭环流程都列出来了,照着走一遍,再去解决那些报错和错位问题,你对JNI的信心会完全不一样。

返回列表