你有没有见过这种场景:JNI 层调用 FindClass 返回空指针,或者 GetMethodID 直接让进程崩溃,日志里写着一行 JNI DETECTED ERROR IN APPLICATION: method not found。遇到这种问题,十有八九不是 Java 层代码的问题,而是你在 C/C++ 里写的那串“描述符”根本没对上号。说白了,JNI 是 Java 和 C/C++ 两套类型系统之间的桥梁,而描述符就是这座桥上的门牌号——写错了,两边都不知道你找的是谁。
这篇是 JNI 编程指南系列的第三篇,专门拆描述符。上一篇讲了 JNI 的整体结构和线程模型之后,我发现很多读者对“签名”“描述符”这类概念一直是懵的,甚至有人会把 java.lang.String 直接传进 FindClass 去跑,然后对着 UnsatisfiedLinkError 发呆。所以这篇我打算把类描述符、方法描述符、字段描述符一次讲透,并结合动态注册 RegisterNatives 实战演示怎么用,最后聊聊我在实际调试中踩过的那些坑。内容确实有点干,但看完能帮你省下大量排查时间。
1. 描述符不是给 JVM 看的,是给“中间人”看的
先说清楚一个很多人搞混的问题:JNI 描述符跟操作系统里的“文件描述符”、USB 设备里的“设备描述符”完全是两码事。它是 JNI 规范定义的一套类型编码规则,用一串 ASCII 字符代表某个 Java 类、方法或字段。之所以要搞这么一套编码,根本原因在于 Java 层和 Native 层没办法直接共享内存里的类型信息,C/C++ 编译器不认得 java.lang.String 这种带包名的类符号,JVM 也不理解 C 语言的 jstring 到底对应哪个 Java 类型。所以 JNI 规范想了一个办法:把类型信息编码成字符串,让 JVM 在运行时去解析这个字符串,找到对应的类、方法或字段。
这套规则其实不是 JNI 首创,它基本继承了 JVM 规范里的字段描述符(Field Descriptor)和方法描述符(Method Descriptor)体系。也就是说,不仅 JNI 在用,Java 字节码、反射 API、热修复框架、字节码插桩工具里也到处能看到类似结构。很多人只在写 JNI 时才碰到它,容易被绕晕,但如果理解了它本质上是 JVM 体系内通用的一种“类型序列化格式”,很多疑问就自然解开了。
JNI 层的类型系统其实分两大类:基本类型和引用类型。基本类型就是 byte、short、int、long、float、double、char、boolean 这八种,它们的描述符是单个大写字母,比较好记:
| Java 类型 | 描述符 |
|---|---|
| byte | B |
| char | C |
| double | D |
| float | F |
| int | I |
| long | J |
| short | S |
| boolean | Z |
| void(仅用于返回值) | V |
你可以发现几个特殊点:long 的字母是 J 而不是 L,因为 L 被对象类型占用了;boolean 是 Z 而不是 B,因为 B 给了 byte。这两个属于需要硬记的,我第一次学的时候就把 long 写成 L,然后 FindClass 一直报找不到类,排查了好一会儿才意识到是描述符编错了。
类描述符和方法描述符的规则,我会在后面两节分别展开。这里先建立一个整体认知:描述符里的每一个字符都对应一种明确含义,不能凭感觉省略或替换。字符串里多一个分号、少一个斜杠,JVM 根本不会帮你纠错,它会直接告诉你找不到对应元素,而且报错方式在不同 Android 版本上还不一样,有的直接 abort,有的只留一行 warning。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 类描述符:包名里的斜杠是最容易破防的地方
2.1 基本规则:点号换成斜杠,带上分号
类描述符的规则其实一句话就能概括:对于普通引用类型,把 Java 包名里的点号 . 全部替换成斜杠 /,然后在类名尾部补一个分号 ;。比如 java.lang.String,对应描述符就是:
text复制Ljava/lang/String;
注意,开头的大写 L 代表这是一个对象类型,结尾的分号表示这个类型的描述到此结束。如果去掉首尾这两个标记,中间那部分 java/lang/String 在 JNI 里被称为“类名”(binary name),FindClass 函数接收的参数就是这种类名格式,不需要首尾的 L 和 ;。很多初学者会把两者混淆:FindClass(env, "Ljava/lang/String;") 实际上是错误的写法,因为 FindClass 期望的参数是 "java/lang/String" 这种内部形式。
同样的道理,我们自己写的类 com.example.jniapp.NativeLib,在 FindClass 里要写成:
c复制jclass clazz = (*env)->FindClass(env, "com/example/jniapp/NativeLib");
如果这个类是在 Android 项目里用 Kotlin 写的,包名路径规则不变,依然是点号换斜杠,不用额外加别的后缀。
2.2 数组类型描述符:左括号加元素类型,对象数组要补分号
数组的类描述符规则是:一个 [ 加元素描述符。有多少维数组,就写多少个 [。比如:
| Java 数组类型 | 类描述符 |
|---|---|
| int[] | [I |
| int[][] | [[I |
| String[] | [Ljava/lang/String; |
| Object[] | [Ljava/lang/Object; |
| byte[] | [B |
注意 [Ljava/lang/String; 这种写法,末尾的分号是 java/lang/String 这个对象描述符自带的,不是多余的。我第一次写 String[] 的描述符时漏掉过末尾分号,结果运行时 FindClass 返回空,还以为是数组创建方式不对。 int[] 这种基本类型数组看起来只是元素描述符前加一个 [,没有任何分号,这跟对象数组的写法形成了鲜明对比——原因很简单,I 本身不是以 L 开头、以分号结尾的完整描述符,它就是一个单字母描述符。
在 JNI 里创建数组时,NewObjectArray 的第一个参数是数组长度,第二个参数是元素类型,需要用到元素类的 jclass。如果想获取 String[] 的 jclass,可以这样:
c复制jclass stringArrayClass = (*env)->FindClass(env, "[Ljava/lang/String;");
或者先拿到 String 的 jclass,再用 NewObjectArray 去创建。前者更直接,但前提是你确实需要数组本身的类对象引用。
2.3 内部类与匿名类的表示方式
如果类里有内部类,描述符中内部类和外部类之间使用美元符号 $ 连接而不是点号。比如外部类 Outer 的内部类 Inner,完整类描述符是:
text复制Lcom/example/Outer$Inner;
在 FindClass 里则要写成 "com/example/Outer$Inner"。这个规则同样适用于 Kotlin 的 companion object、内部类等编译后的字节码结构。匿名类的情况更特殊,类名里通常含有数字,比如 MainActivity$1$onClick 这类,实际写 JNI 时如果拿不到稳定类名,最好别用 FindClass 去硬编码,用 getClass().getName() 在 Java 层打印一次再搬到 Native 层更稳妥。
2.4 类描述符和“字段描述符”的关系
值得说明的是,类描述符同时被 JNI 里的字段查询函数使用。比如你想读取某个 Java 对象的 String name 字段,GetFieldID 的第三个实参就是该字段的描述符,而字段是对象类型时字段描述符正是对应的类描述符。看个例子:
c复制jfieldID fid = (*env)->GetFieldID(env, clazz, "name", "Ljava/lang/String;");
这里 Ljava/lang/String; 既是 java.lang.String 类的类描述符,也是在字段场景下的字段描述符。可见类描述符是更多复杂
