一、JNI 是什么?
Android 应用主要用Java / Kotlin编写,但有些代码是用C / C++编写的,比如:
- 游戏引擎(Unity 的核心就是 C++)
- 音视频编解码
- 已有的 C++ 代码库
这两种语言不能直接互相调用。
JNI(Java Native Interface)就是让它们能互相调用的一套规则和工具。
二、打个比方:翻译官
想象一个中国人和一个美国人合作:
| 比喻 | 实际 |
|---|---|
| 中国人 | Java 代码 |
| 美国人 | C/C++ 代码 |
| 翻译官 | JNI |
他们之间有几个"沟通障碍",翻译官要一一解决:
- 说的话不一样→ 数据类型不同(Java 的
String和 C 的char*不一样) - 管东西的方式不一样→ Java 有垃圾回收器自动管理内存,C 要手动管理
- 出错的处理方式不一样→ Java 用异常,C 没有这一套
后面会逐个讲这几点。
三、先看一个最简单的例子
目标:Java 调用 C++ 写的加法函数。
第 1 步:Java 这边声明"这个方法由 C++ 实现"
packagecom.example;publicclassMathUtil{static{System.loadLibrary("mymath");// 加载 libmymath.so}publicstaticnativeintadd(inta,intb);// native 表示:实现在 C++ 里}native:告诉 Java"我只有声明,具体代码去 C++ 那边找"。loadLibrary("mymath"):把编译好的 C++ 库libmymath.so加载进来。
第 2 步:C++ 这边写实现
#include<jni.h>extern"C"JNIEXPORT jint JNICALLJava_com_example_MathUtil_add(JNIEnv*env,jclass clazz,jint a,jint b){returna+b;}这一大串看着吓人,拆开看其实很简单:
| 部分 | 意思 |
|---|---|
Java_com_example_MathUtil_add | 函数名是按规则拼出来的:Java_+ 包名 + 类名 + 方法名,Java 就靠这个名字找到它 |
jint | JNI 版的int,和 Java 的int对应 |
JNIEnv* env | 翻译官本人,后面所有"翻译"操作都要通过它 |
jclass clazz | 是哪个 Java 类调用的(静态方法传类,普通方法传对象) |
a, b | 就是 Java 传过来的两个参数 |
extern "C" | 让 C++ 不要改函数名,否则 Java 找不到 |
第 3 步:Java 正常调用
intresult=MathUtil.add(3,5);// 得到 8,实际是 C++ 算的背后发生了什么
Java 调用 MathUtil.add(3, 5) ↓ 发现是 native 方法 → 去已加载的 libmymath.so 里找 Java_com_example_MathUtil_add ↓ 找到后调用它,顺便把"翻译官" env 交给它 ↓ C++ 计算 3 + 5,返回 8 ↓ 结果交回 Java这就是 JNI 最核心的过程。后面的内容都是在这个基础上处理更复杂的情况。
四、难题 1:数据类型不同
简单类型:直接对应,不用操心
| Java | C++ 里写成 |
|---|---|
int | jint |
long | jlong |
float | jfloat |
boolean | jboolean |
本质上就是同一种数字,换了个名字。
字符串:需要"翻译"
Java 的String和 C 的char*在内存里的样子完全不同,必须让翻译官转换:
// Java: public static native void hello(String name);extern"C"JNIEXPORTvoidJNICALLJava_com_example_MathUtil_hello(JNIEnv*env,jclass clazz,jstring name){constchar*cName=env->GetStringUTFChars(name,nullptr);// ① 借:Java 字符串 → C 字符串printf("你好,%s\n",cName);env->ReleaseStringUTFChars(name,cName);// ② 还:用完必须还回去}可以理解为:向翻译官"借"一份 C 能看懂的副本,用完要"还"。忘记还就会内存泄漏。
反过来,C 字符串变成 Java 字符串:
jstring result=env->NewStringUTF("来自 C++ 的问候");五、难题 2:Java 的对象,C++ 不能直接拿
这是 JNI 最容易出错的地方。
为什么不能直接拿?
Java 有垃圾回收器(GC),它会定期整理内存,对象的位置可能会被搬走。
所以 C++ 拿到的不是对象本身,而是一张**“寄存牌”**(JNI 叫"引用",类型是jobject):
C++ 拿着寄存牌(jobject) → 交给翻译官 → 翻译官去找到对象当前的真实位置寄存牌有两种
| 类型 | 比喻 | 有效期 |
|---|---|---|
| 局部引用(默认) | 一次性寄存牌 | 这次 native 函数返回后就作废 |
| 全局引用 | 长期寄存牌 | 一直有效,直到你主动注销 |
典型错误
staticjobject gCallback;// 想把 Java 对象存起来,以后再用extern"C"JNIEXPORTvoidJNICALLJava_com_example_Foo_setCallback(JNIEnv*env,jobject thiz,jobject callback){gCallback=callback;// ❌ 一次性寄存牌,函数返回就作废了gCallback=env->NewGlobalRef(callback);// ✅ 换成长期寄存牌}// 不用的时候env->DeleteGlobalRef(gCallback);// ✅ 记得注销,否则泄漏记住一句话:要在函数之外继续用的 Java 对象,必须转成全局引用。
六、难题 3:翻译官是每个线程"专属"的
JNIEnv(翻译官)每个线程有自己的一个,不能借给别的线程用。
可以把它想成工牌:每个员工一张,不能借给别人。
| 情况 | 怎么办 |
|---|---|
| 从 Java 调用进来的 C++ 函数 | 参数里已经给了env,直接用 |
| C++ 自己创建的新线程 | 没有工牌,要先去"办一张":AttachCurrentThread |
JavaVM*gVM;// 虚拟机对象:整个进程只有一个,可以随便保存// 在 C++ 自己创建的线程里JNIEnv*env;gVM->AttachCurrentThread(&env,nullptr);// 办工牌// ... 使用 env 调用 Java ...gVM->DetachCurrentThread();// 离职前交还工牌JavaVM可以全局保存,JNIEnv不能跨线程保存。
Unity 里也一样:在 C# 的子线程中调用
AndroidJavaObject之前,要先调用AndroidJNI.AttachCurrentThread(),原因就是这个。
七、反过来:C++ 调用 Java
比如 C++ 处理完数据,要通知 Java。
假设 Java 有这样一个方法:
publicclassCallback{publicvoidonResult(intcode){...}}C++ 调用它分三步:找类 → 找方法 → 调用。
jclass clazz=env->GetObjectClass(callbackObj);// ① 找到类jmethodID mid=env->GetMethodID(clazz,"onResult","(I)V");// ② 找到方法env->CallVoidMethod(callbackObj,mid,200);// ③ 调用,传入 200第 ② 步里的"(I)V"叫方法签名,用来描述参数和返回值:
(I)V = ( 参数是 int ) 返回 void| 字母 | 含义 |
|---|---|
I | int |
J | long |
Z | boolean |
V | void |
Ljava/lang/String; | String |
不用手写签名:对编译出的 class 文件执行
javap -s 类名可以直接看到。
八、难题 4:Java 抛异常,C++ 不会停
Java 方法如果抛了异常,回到 C++ 时不会中断 C++ 代码,C++ 会若无其事地继续往下跑。
所以调用完 Java 方法后,要主动检查:
env->CallVoidMethod(obj,mid);if(env->ExceptionCheck()){// 检查刚才是否出了异常env->ExceptionClear();// 处理掉return;// 不要继续执行}九、新手最常遇到的 4 个报错
| 报错 | 原因 | 解决 |
|---|---|---|
UnsatisfiedLinkError: dlopen failed | .so 没打进包,或 CPU 架构不对(如缺少arm64-v8a) | 检查 APK 里的lib/目录 |
UnsatisfiedLinkError: No implementation found | C++ 函数名拼错,或漏了extern "C" | 对照包名、类名、方法名检查 |
| 程序在调用时崩溃,提示引用无效 | 把局部引用存起来后再用 | 改用NewGlobalRef |
local reference table overflow | 循环里创建了大量局部引用没释放 | 循环内调用DeleteLocalRef |
十、一张图总结
Java / Kotlin C / C++ ┌─────────────────────────┐ ┌─────────────────────────┐ │ native int add(a, b); │ ───────→ │ Java_..._add(env, ...) │ │ │ JNI 调用 │ │ │ void onResult(int code) │ ←─────── │ env->CallVoidMethod(...) │ └─────────────────────────┘ └─────────────────────────┘ 翻译官 JNIEnv 负责: ① 转换类型(String ↔ char*) ② 管理"寄存牌"(局部引用 / 全局引用) ③ 每个线程一个,新线程要先 Attach ④ 异常不会自动中断,要主动检查一句话总结
JNI 就是 Java 和 C++ 之间的翻译官:Java 用
native声明方法,C++ 按约定的名字实现;C++ 通过JNIEnv来操作 Java 的字符串、对象和方法。
新手只要记住三条规则:借的字符串要还、要长期保存的对象转成全局引用、新线程先 Attach,就能避开大部分坑。