前言
本文是JNI编程注意事项的第二篇文章。在上篇中讲解了 JavaVM/JNIEnv, Threads, jclass/jfieldID/jmethodID 以及 Local/Global 引用。今天我们继续讲解余下的部分。
Native 库
我们可以使用System.loadLibrary将共享库导入进来。引入Native代码的最好方法如下:
- 静态类初始化时,调用System.loadLibrary。参数是未声明的库名子,如要加载“libfubar.so”,你应传入“fubar”
- 提供一个本地函数 jint JNI_OnLoad(JavaVM* vm, void* reserved)。
- 在JNI_OnLoad函数里,注册所有Native方法。你应该用"static"声明方法 ,这样名子在设备的符号表里不占空间。
如果用C++编写,JNI_OnLoad函数应该看起来像下面的样子:
jint JNI_OnLoad(JavaVM* vm, void* reserved)
{
JNIEnv* env;
if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) {
return -1;
}
// Get jclass with env->FindClass.
// Register methods with env->RegisterNatives.
return JNI_VERSION_1_6;
}
你也可以用共享库的完整路径名调用System.load函数而不是System.loadLibrary。对于Andrioid应用来说, 您可能会发现从上下文对象获取应用程序的私有数据存储区域的完整路径非常方便。
上面的方法是推荐方法,但不是唯一的方法。其实,可以不需要显式注册JNI方法,也不需要提供JNI_OnLoad函数。您可以使用以特定方式命名的Native方法。但这种方式很不好,因为如果方法签名是错的,直到第一次它被使用时你才知道它出错了。
另一个关于JNI_OnLoad需要注意的事项:任何FindClass操作,都应该在加载共享库的类加载器上下文中调用。通常,FindClass使用与解释栈顶端方法相关联的加载器,如果没有(因为线程刚刚绑定),它将使用“系统”类加载器。这使JNI_OnLoad成为查找和缓存类对象引用的最好地方。
UTF-8 和 UTF-16 符字串
Java编程语言使用UTF-16编码。为了方便,JNI提供了与UTF-8一起使用的方法。但这种UTF-8是修改过的UTF-8编码方式。这种方式对于C代码是有用的,因为它将\u0000编码为0xc0 0x80而不是0x00。好处是,您可以依靠拥有C风格的零终止字符串。坏处是,您不能将任意的UTF-8数据传递给JNI,并希望它能正常工作。
如果可能,通常使用UTF-16字符串操作更快。在Android当前版本中,使用GetStringChars函数不需要拷贝其内容(它的内容是UTF-8编码),但使用GetStringUTFChars则需要分配和转换为UTF-8。请注意,UTF-16字符串不是以零终止的,\u0000被认为是正常数据,所以你需要自己保存字符串长度以及jchar指针。
不要忘记释放你获得的字符串。字符串函数返回jchar *或jbyte *,它们是C样式的指向原始数据的指针,而不是本地引用。它们被保证有效,直到调用Release,这意味着当native方法返回时它们不会自动释放。
传递给NewStringUTF的数据必须使用修改过的UTF-8格式。常见的错误是从文件或网络流读取字符数据,并将其传递给NewStringUTF,而不对其进行过滤。除非你知道数据是7位ASCII,否则你需要去掉高ASCII字符或将它们转换成适当的UTF-8格式。
如果不这样做,UTF-16转换可能不会是您期望结果的。扩展的JNI检查将扫描字符串并警告您它是无效数据,但它们不会捕获所有内容。
原始数组
JNI提供了访问数组对象内容的功能,虽然对象数组必须一次访问一个条目,但是可以直接读取和写入原始数组,就像它们在C中被声明一样。
使接口尽可能高效,除非受到VM实现的限制,Get
您可以通过传递isCopy参数是否是NULL来确定数据是否被复制了。但这种方式基本没什么用。
Release函数的mode参数有三种值。运行时的行为依赖于返回的是实际数据的指针还是其副本:
- 0
- 实际:数组对象是非固定的。
- 复制:数据被复制回来。具有副本的缓冲区被释放。
- JNI_COMMIT
- 实际:什么都不做。
- 复制:数据被复制回来。具有副本的缓冲区被释放。
- JNI_ABORT
- 实际:数组对象是非固定的。早期写入的数据不会被中止。
- 复制:具有副本的缓冲区被释放;对它的任何更改都会丢失。
检查isCopy标志的原因之一,是在更改数组后知道是否需要使用JNI_COMMIT参数调用Release。如果在更改数组和执行代码之间进行交替,你可以什么都不做。检查标志的第二个原因,是有效地处理JNI_ABORT。例如,您可能需要得到一个数组,修改它,并将其传递给其他函数,然后丢弃更改。如果您知道JNI正在为您制作新的副本,则无需创建另一个“可编辑的”副本。如果JNI传给你的是原始的数据,那么你需要自己做拷贝。
常见的错误,是认为如果 *isCopy为false,则可以跳过Release调用。如果没有分配复制缓冲区,则原始内存必须被固定,并且不能被垃圾收集器移动。另请注意,JNI_COMMIT标志不会释放数组,您需要再次使用不同的标志调用Release。
Region Calls
拷贝数据时有一种替代方法,例如,使用Ge
jbyte* data = env->GetByteArrayElements(array, NULL);
if (data != NULL) {
memcpy(buffer, data, len);
env->ReleaseByteArrayElements(array, data, JNI_ABORT);
}
上面的代码首先获得数组,将len字节元素复制出来,然后释放数组。根据实现方式,Get要么是获得地址,要么是复制数组内容。代码复制数据(可能是第二次),然后调用Release;在这种情况下,JNI_ABORT确保没有第三副本的机会。
下在的代码可以更简单地完成同样的事情:
env->GetByteArrayRegion(array, 0, len, buffer);
这种方式有几种优势:
- 需要一个JNI调用而不是2,减少开销。
- 不需要固定或额外的数据拷贝。
- 减少程序员错误的风险 - 没有任何失败后忘记调用释放的风险。
类似地,您可以使用Set
异常
当异常待处理时,不能调用大多数JNI函数。您的代码应该会注意到异常(通过函数的返回值,ExceptionCheck或ExceptionOccurred)并返回,或者清除异常并处理它。
当异常挂起时,您允许调用的JNI函数有:
- DeleteGlobalRef
- DeleteLocalRef
- DeleteWeakGlobalRef
- ExceptionCheck
- ExceptionClear
- ExceptionDescribe
- ExceptionOccurred
- MonitorExit
- PopLocalFrame
- PushLocalFrame
- Release
ArrayElements - ReleasePrimitiveArrayCritical
- ReleaseStringChars
- ReleaseStringCritical
- ReleaseStringUTFChars
许多JNI调用可能会引发异常,但通常会提供更简单的检查失败的方法。例如,如果NewString返回非NULL值,则不需要检查异常。但是,如果调用方法(使用像CallObjectMethod这样的函数),则必须始终检查异常,因为如果抛出异常,返回值将无效。
注意,被解释的代码抛出的异常不能解开本机堆栈帧,因为Android不支持C++异常。JNI Throw和ThrowNew指令在当前线程中设置了一个异常指针。返回到本地代码管理后,异常将被注意到和处理。
本地代码可以通过调用ExceptionCheck或ExceptionOccurred“捕获”异常,并用ExceptionClear清除它。像往常一样,抛弃异常而不处理它们可能会导致问题。
没有用于操作Throwable对象的内置函数,所以如果你想得到异常字符串,你需要找到Throwable类,查找getMessage的方法ID "()java/lang/String;",并且如果结果是非空的,则使用GetStringUTFChars获取可以传递给printf(3)或等同物的信息。
扩展检查
JNI几乎没有错误检查,错误通常会导致崩溃。Android提供了一种称为CheckJNI的模式,在调用标准实现之前,将JavaVM和JNIEnv函数表指针切换到执行扩展系列检查的函数表。
扩展检查包括:
- 数组:尝试分配负大小的数组。
- 错误的指针:将一个坏的jarray/jclass/jobject/jstring传递给JNI调用,或者传递一个NULL指针到一个不可空参数的JNI调用。
- 类名称:传递类似 “java/lang/String” 样式的类名传给JNI调用。
- Critical调用:在“Critical”获取和释放之间进行JNI调用。
- Direct ByteBuffers:将错误的参数传递给NewDirectByteBuffer。
- Exceptions:在异常挂起时进行JNI调用。
- JNIEnv* :在错误的线程中使用 JNIEnv* 。
- fieldIDs :使用空的jfieldID,或使用jfieldID将字段设置为错误类型的值(尝试将StringBuilder分配给String字段),或给静态 jfieldID设置实例的字段或者相反,或者使用一个类的实例但却用的另一个类的字段。
- jmethodIDs:在进行调用时,使用错误的jmethodID方法做JNI调用:不正确的返回类型,静态/非静态不匹配,错误类型为'this'(非静态调用)或错误类(用于静态调用)。
- References:使用DeleteGlobalRef/DeleteLocalRef时,用了错误的引用。
- 释放模式:将错误的mode值传递给Release(除0,JNI_ABORT或JNI_COMMIT之外)。
- 类型安全:从本机方法返回不兼容的类型(例如:从声明返回String的方法返回StringBuilder)。
- UTF-8:将无效的修改后的UTF-8字节序列传递给JNI调用。
(方法和字段的辅助功能仍未被检查:访问限制不适用于Native代码。)
有几种启用CheckJNI的方法:
如是你使用的是模拟器,CheckJNI默认是打开的。
如果拥有root权限的设备,你可以使用下面的一系列命令重启 Runtime 并开启 CheckJNI:
adb shell stop
adb shell setprop dalvik.vm.checkjni true
adb shell start
在这些情况下,当 Runtime 启动时,在 logcat 输出中可以看到如下信息:
D AndroidRuntime: CheckJNI is ON
如果你是一台普通设备,你可以使用下面的命令
adb shell setprop debug.checkjni 1
这不会影响已经运行的应用程序,但从该点启动的任何应用程序将启用CheckJNI。(将属性更改为任何其他值或重新启动将会再次禁用CheckJNI。)在这种情况下,你能在下次应用程序启动时在logcat输出中看到下面的信息:
D Late-enabling CheckJNI
您还可以在应用程序的manifest中设置android:debuggable属性,以便为您的应用程序启用CheckJNI。请注意,Android构建工具会自动为某些构建类型执行此操作。
常见问题
FAQ: 为什么会出现 UnsatisfiedLinkError?
在处理Native代码时,看到这样的失败并不罕见:
java.lang.UnsatisfiedLinkError: Library foo not found
在某些情况下这意味着,库没有发现。其它情况是说库存在,但不能由 dlopen 打开。失败的具体信息在异常的信息中可以找到。
您可能遇到“库未找到”异常的常见原因:
- 库不存在或应用程序无法访问。使用adb shell ls -l <path>来检查其存在和权限。
- 库没不是用NDK编译的。这可能导致依赖于设备上不存在的函数或库。
另一类UnsatisfiedLinkError故障类似于:
java.lang.UnsatisfiedLinkError: myfunc
at Foo.myfunc(Native Method)
at Foo.main(Foo.java:10)
在 logcat 中你将看到:
W/dalvikvm( 880): No implementation found for native LFoo;.myfunc ()V
这意味着在运行时无法成功找到匹配的方法,一些常见的原因是:
- 库没有加载。检查logcat输出,了解有关库加载的消息。
- 该方法由于名称或签名不匹配而未找到。这通常是由:
- 对于惰性方法查找,未能使用extern“C”声明C ++函数和适当的可见性(JNIEXPORT)。 请注意,在Ice Cream Sandwich之前,JNIEXPORT宏不正确,因此使用新的GCC与旧的jni.h将无法正常工作。您可以使用arm-eabi-nm查看在库中出现的符号;如果它们看起来很像(_Z15Java_Foo_myfuncP7_JNIEnvP7_jclass而不是Java_Foo_myfunc),或者如果符号类型是小写't'而不是大写字母'T',则需要调整声明。
- 对于显式注册,输入方法签名时会出现较小的错误。确保您传递到registration 调用的内容与日志文件中的签名相匹配。记住'B'是字节,'Z'是布尔值。签名中的类名称组件以'L'开始,以';'结尾,使用'/'分隔包/类名称,并使用'$'分隔内部类名称(Ljava / util / Map $ Entry; say )。
使用javah自动生成JNI头可能有助于避免一些问题。
FAQ: 为什么FindClass找不到我的类?
这个建议大多数情况下同样适用于使用GetMethodID或GetStaticMethodID无法找到方法,或无法找到GetFieldID或GetStaticFieldID字段)。
确保类名字符串格式正确。JNI类名以包名开头,并以斜杠分隔,如java/lang/String。如果您正在查找数组类,则需要从适当数量的方括号开始,并且还必须用'L'和';'包装类,所以String的一维数组将是[Ljava/lang/String;。如果你正在查找一个内部类,请使用'$'而不是'.'。一般来说,在.class文件中使用javap是查找类的内部名称的好方法。
如果您使用混淆器,请确保混淆器没有抽出您的类。如果您的类/方法/字段仅用于JNI,则可能会发生这种情况。
如果类名称正确,您可能会遇到类加载器问题。FindClass想要在与你的代码相关联的类加载器中启动类搜索。它检查调用堆栈,看起来像下面这样:
Foo.myfunc(Native Method)
Foo.main(Foo.java:10)
最上面的方法是Foo.myfunc。 FindClass找到与Foo类关联的ClassLoader对象并使用它。
这种做法通常都是没问题的。但如果您自己创建一个线程,可能会遇到麻烦(可能通过调用pthread_create然后使用AttachCurrentThread连接)。现在您的应用程序没有堆栈帧。如果你从这个线程调用FindClass,JavaVM将在“系统”类加载器中启动,而不是与您的应用程序相关联的加载器,因此尝试查找应用程序特定的类将失败。
有几种方法可以解决这个问题:
- 在JNI_OnLoad中,做一次FindClass查找,并缓存类引用以供以后使用。作为执行JNI_OnLoad的一部分,任何FindClass调用都将使用与System.loadLibrary函数关联的类加载器(这是一个特殊规则,方便了库的初始化)。如果您的应用程序代码正在加载库,FindClass将使用正确的类加载器。
- 将类的实例传递到需要它的函数中,通过声明本地方法来接受Class参数,然后传递Foo.class。
- 缓存对ClassLoader对象的引用,方便起见,并直接发出loadClass调用。这相对麻烦一些。
FAQ: 在Native代码间如何共享原始数据?
您可能会发现自己需要在从托管和本地代码之间访问大量原始数据缓冲区的情况。通常的例子包括操作位图或声音样本。有两种基本方法:
您可以将数据存储在byte[]中。这样从托管代码访问非常快。但是,在本地方面您无法保证不复制数据就可访问数据。在某些实现中,GetByteArrayElements和GetPrimitiveArrayCritical将返回实际指向托管堆中原始数据的指针,但另一方面,它将在本机堆上分配一个缓冲区并复制数据。
另一种方法是将数据存储在直接字节缓冲区中。这些可以使用java.nio.ByteBuffer.allocateDirect或JNI NewDirectByteBuffer函数创建。与常规字节缓冲区不同,存储不会在托管堆上分配,并且可以直接从本地代码访问(使用GetDirectBufferAddress获取地址)。根据实现直接字节缓冲访问的方式,从托管代码访问数据可能非常慢。
选择哪个使用取决于两个因素:
- 大多数数据访问是由Java或C / C ++编写的代码发生的?
- 如果数据最终被传递给系统API,那么它应该是什么形式的?(例如,如果数据最终被传递给byte[]的函数,那么在直接ByteBuffer中进行处理可能是不明智的。)
如果基于上面的两点仍然判断不出来,请使用直接字节缓冲区。JNI直接构建对它们的支持,并且在将来的版本中性能会得到改善。
小结
本文首先介绍了JNI加载动态库的常用规则,然后讲了使用UTF-8需要注意的事项。仅接着介绍了访问原始数组,区块调用,异常等要注意的点,最后对编写JNI程序常见的问题给出了问题的原因和解决办法。