Android JNI 学习(二):JNI 设计概述

本章我们重点说明以下JNI设计的问题,本章中提到的大多数设计问题都与native方法有关。至于调用相关的API的设计,我们会在后面进行介绍。

一、JNI接口函数和指针

native 代码通过调用JNI函数来访问Java VM功能。JNI函数可通过接口指针获得接口指针是指向指针的指针。该指针指向一个指针数组,每个指针指向一个接口函数。每个接口函数都在数组内的预定义偏移处。下图说明了接口指针的组织。

 Android JNI 学习(二):JNI 设计概述

接口指针

JNI接口的组织方式类似于C ++虚函数表或COM接口。使用接口表而不是硬连接函数条目的优点是JNI名称空间与native代码分离。VM可以轻松提供多个版本的JNI功能表。例如,VM可能支持两个JNI函数表:

  • 一个用于平台执行彻底的非法参数检查,适合调试;
  • 另一个执行JNI规范所需的最小量检查,因此更有效。 

JNI接口指针仅在当前线程中有效。因此,native方法不能将接口指针从一个线程传递到另一个线程。实现JNI的VM可以在JNI接口指针指向的区域中分配和存储线程本地数据。

Native方法接收JNI接口指针作为参数。当VM从同一Java线程多次调用native方法时,保证将VM传递给native方法。但是,可以从不同的Java线程调用native方法,因此可以接收不同的JNI接口指针。

二、编译,加载和链接native方法

由于Java VM是多线程的,因此native库也应该与多线程感知的native编译器一起编译和链接。例如,该-mt标志应该用于使用Sun Studio编译器编译的C ++代码。对于符合GNU gcc编译器的代码,应使用标志-D_REENTRANT-D_POSIX_C_SOURCE有关更多信息,请参阅native编译器文档。

使用System.loadLibrary方法加载native方法。在以下示例中,类初始化方法加载特定于平台的native库,其中f定义了native方法

package pkg;  
class Cls { 
     native double f(int i, String s); 
     static { 
         System.loadLibrary(“pkg_Cls”); 
     } 
}  

参数System.loadLibrary是由程序员任意选择的库名。系统遵循标准但特定于平台的方法将库名称转换为native库名称。例如,Solaris系统将名称转换pkg_Clslibpkg_Cls.so,而Win32系统将同名转换pkg_Clspkg_Cls.dll

程序员可以使用单个库来存储任意数量的类所需的所有native方法,只要这些类要使用相同的类加载器加载即可。VM在内部维护每个类加载器的加载native库列表。供应商应选择本地库名称,以尽量减少名称冲突的可能性。

如果底层操作系统不支持动态链接,则必须将所有native方法与VM预先链接。在这种情况下,VM完成System.loadLibrary调用而不实际加载库。

程序员还可以调用JNI函数RegisterNatives()来注册与类关联的native方法。RegisterNatives()功能对于静态链接功能特别有用。

三、解析native方法名称 

动态链接器根据其名称解析条目。native方法名称由以下组件连接:

  • 前缀 Java_
  • 一个错位的完全限定的类名
  • 下划线(“_”)分隔符
  • 方法名称
  • 对于重载的native方法,两个下划线(“__”)后跟参数签名

VM检查驻留在native库中的方法的方法名称匹配。VM首先查找短名称; 也就是说,没有参数签名的名称。然后它查找长名称,这是带有参数签名的名称。只有当native方法使用另一个native方法重载时,程序员才需要使用长名称。但是,如果native方法与非native方法具有相同的名称,则这不是问题。非native方法(Java方法)不驻留在native库中。

在以下示例中,g不必使用长名称链接方法g,因为另一种方法不是native方法,因此不在native库中。

class Cls1 { 
  int g(int i); 
  native int g(double d); 
} 

我们采用了一种简单的名称修改方案,以确保所有Unicode字符都转换为有效的C函数名称。

我们使用下划线(“_”)字符代替完全限定类名中的斜杠(“/”)。由于名称或类型描述符从不以数字开头,因此我们可以使用_0...,_9表示转义序列。

native方法和接口API都遵循给定平台上的标准库调用约定。例如,UNIX系统使用C调用约定,而Win32系统使用__stdcall。

四、native方法参数 

JNI接口指针是native方法的第一个参数。JNI接口指针的类型为JNIEnv第二个参数根据native方法是静态方法还是非静态方法而有所不同。非静态native方法的第二个参数是对该对象的引用。静态native方法的第二个参数是对其Java类的引用。

其余参数对应于常规Java方法参数。native方法调用通过返回值将其结果传递回调用例程。下面的一篇文章,我们会介绍Java和C类型之间的映射。

下面的代码声明了C函数来实现native方法fnative方法f声明如下:

package pkg;  
class Cls { 
     native double f(int i, String s); 
     ... 
}  

具有长错位名称的C函数Java_pkg_Cls_f_ILjava_lang_String_2实现native方法f

下面代码使用C来实现native方法
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (
     JNIEnv *env,        /* interface pointer */
     jobject obj,        /* "this" pointer */
     jint i,             /* argument #1 */
     jstring s)          /* argument #2 */
{
     /* Obtain a C-copy of the Java string */
     const char *str = (*env)->GetStringUTFChars(env, s, 0);

     /* process the string */
     ...

     /* Now we are done with str */
     (*env)->ReleaseStringUTFChars(env, s, str);

     return ...
}

请注意,我们总是使用接口指针env操作Java对象使用C++的话,编写的代码如下:

extern "C" /* specify the C calling convention */  

jdouble Java_pkg_Cls_f__ILjava_lang_String_2 ( 
     JNIEnv *env,        /* interface pointer */ 
     jobject obj,        /* "this" pointer */ 
     jint i,             /* argument #1 */ 
     jstring s)          /* argument #2 */ 
{ 
     const char *str = env->GetStringUTFChars(s, 0); 
     ... 
     env->ReleaseStringUTFChars(s, str); 
     return ... 
} 

使用C ++,额外的间接级别和接口指针参数从源代码中消失。但是,底层机制与C完全相同。在C ++中,JNI函数被定义为内联成员函数,它们扩展为C对应函数。

五、引用Java对象 

原始类型(如整数,字符等)在Java和native代码之间复制。另一方面,任意Java对象都通过引用传递。VM必须跟踪已传递给native代码的所有对象,以便垃圾收集器不会释放这些对象。反过来,native代码必须有一种方法来通知VM它不再需要这些对象。此外,垃圾收集器必须能够移动native代码引用的对象。

全局引用和本地引用

JNI将native代码使用的对象引用分为两类:本地引用全局引用。native引用在native方法调用的持续时间内有效,并在native方法返回后自动释放。全局引用在显式释放之前仍然有效。

对象作为native引用传递给native方法。JNI函数返回的所有Java对象都是本地引用。JNI允许程序员从本地引用创建全局引用。JNI函数,期望Java对象接受全局和本地引用。native方法可以返回对VM的本地或全局引用作为其结果。

在大多数情况下,程序员应该依赖VM在native方法返回后释放所有本地引用。但是,有时程序员应该明确地释放本地引用。例如,考虑以下情况:

  • native方法访问大型Java对象,从而创建对Java对象的本地引用。然后,native方法在返回调用方之前执行其他计算。对大型Java对象的本地引用将阻止对象被垃圾回收,即使该对象不再用于计算的其余部分。
  • native方法会创建大量本地引用,但并非所有引用都同时使用。由于VM需要一定的空间来跟踪本地引用,因此创建太多本地引用可能会导致系统内存不足。例如,native方法循环遍历大量对象,将元素作为本地引用检索,并在每次迭代时对一个元素进行操作。在每次迭代之后,程序员不再需要对数组元素的本地引用。

JNI允许程序员在native方法中的任何点手动删除本地引用。为了确保程序员可以手动释放本地引用,不允许JNI函数创建额外的本地引用,除了它们作为结果返回的引用。 

本地引用仅在创建它们的线程中有效。native代码不能将本地引用从一个线程传递到另一个线程。

实现本地引用 

为了实现本地引用,Java VM为从Java到native方法的每次控制转换创建了一个注册表。注册表将不可移动的本地引用映射到Java对象,并防止对象被垃圾回收。传递给native方法的所有Java对象(包括那些作为JNI函数调用结果返回的对象)都会自动添加到注册表中。在native方法返回后删除注册表,允许其所有条目被垃圾回收。

有不同的方法来实现注册表,例如使用表,链表或哈希表。虽然引用计数可用于避免注册表中的重复条目,但JNI实现没有义务检测和折叠重复条目。 

注意:通过遍历native堆栈无法实现本地引用。因为native代码可以将本地引用存储到全局或堆数据结构中。

六、访问Java对象

JNI在全局和本地引用上提供了丰富的访问器函数。这意味着无论VM如何在内部表示Java对象,相同的native方法实现都会起作用。这是JNI可以被各种VM实现支持的关键原因。

通过不透明引用使用访问器函数的开销高于直接访问C数据结构的开销。我们相信,在大多数情况下,Java程序员使用native方法来执行非常重要的任务,这些任务会掩盖此接口的开销。

访问原始数组

对于包含许多基本数据类型的大型Java对象(例如整数数组和字符串),此开销是不可接受的。迭代Java数组并使用函数调用检索每个元素是非常低效的。

为解决此问题我们引入了“固定”的概念,以便native方法可以要求VM确定数组的内容。然后,native方法接收指向元素的直接指针。然而,这种方法有两个含义:

  • 垃圾收集器必须支持固定。
  • VM必须在内存中连续布局原始数组。

为了克服上述两个问题,我们采取以下方案:

首先,我们提供了一组函数来复制Java数组的一段和本机内存缓冲区之间的原始数组元素。如果native方法只需要访问大型数组中的少量元素,请使用这些函数。

其次,程序员可以使用另一组函数来检索数组元素的固定版本。请记住,这些功能可能需要Java VM执行存储分配和复制。这些函数实际上是否复制数组取决于VM实现,如下所示:

  • 如果垃圾收集器支持固定,并且数组的布局与native方法的预期相同,则不需要复制。
  • 否则,将数组复制到不可移动的内存块(例如,在C堆中)并执行必要的格式转换。返回指向副本的指针。

最后,该接口提供了通知VM native代码不再需要访问数组元素的功能。当您调用这些函数时,系统会取消数组,或者将原始数组与其不可移动的副本进行协调并释放副本。

我们的方法提供灵活性 垃圾收集器算法可以针对每个给定阵列单独决定复制或固定。例如,垃圾收集器可以复制小对象,但可以固定较大的对象。

JNI实现必须确保在多个线程中运行的native方法可以同时访问同一个数组。例如,JNI可以为每个固定数组保留一个内部计数器,这样一个线程就不会取消固定另一个线程固定的数组。请注意,JNI不需要锁定原始数组以供native方法独占访问。同时从不同的线程更新Java数组会导致不确定的结果。

访问字段和方法 

JNI允许native代码访问字段并调用Java对象的方法。JNI通过符号名称和类型签名来标识方法和字段。两步过程会从字段名称和签名中分析出定位字段或方法的成本。例如,要f在类cls中调用该方法,native代码首先获取方法ID,如下所示:

jmethodID mid = env-> GetMethodID(cls,“f”,“(ILjava / lang / String;)D”);

然后,native代码可以重复使用方法ID,而无需查找方法,如下所示:

jdouble result = env-> CallDoubleMethod(obj,mid,10,str);

字段或方法ID不会阻止VM卸载已从中派生ID的类。卸载类后,方法或字段ID将变为无效。因此,native代码必须确保:

  • 保持对基础类的实时引用
  • 重新计算方法或字段ID

如果它打算长时间使用方法或字段ID。

JNI不对内部如何实现字段和方法ID施加任何限制。

七、报告编程错误

JNI不检查编程错误,例如传入NULL指针或非法参数类型。非法参数类型包括使用普通Java对象而不是Java类对象。由于以下原因,JNI不检查这些编程错误:

  • 强制JNI函数检查所有可能的错误条件会降低正常(正确)native方法的性能。
  • 在许多情况下,没有足够的运行时类型信息来执行此类检查。

大多数C库函数都不能防止编程错误。printf()例如,函数在收到无效地址时通常会导致运行时错误,而不是返回错误代码。强制C库函数检查所有可能的错误条件可能会导致重复此类检查 - 一次在用户代码中,然后再次在库中。

程序员不得将非法指针或错误类型的参数传递给JNI函数。这样做可能会导致任意后果,包括系统状态损坏或VM崩溃。

Java异常

JNI允许native方法引发任意Java异常。native代码也可以处理未完成的Java异常。未处理的Java异常会传播回VM。 

例外和错误代码

某些JNI函数使用Java异常机制来报告错误情况。在大多数情况下,JNI函数通过返回错误代码抛出Java异常来报告错误情况错误代码通常是一个特殊的返回值(如NULL),它超出了正常返回值的范围。因此,程序员可以:

  • 快速检查上次JNI调用的返回值,以确定是否发生了错误,并且
  • 调用函数,ExceptionOccurred()以获取包含错误条件的更详细描述的异常对象。

在两种情况下,程序员需要检查异常而无法首先检查错误代码:

  • 调用Java方法的JNI函数返回Java方法的结果。程序员必须调用ExceptionOccurred()以检查在执行Java方法期间可能发生的异常。
  • 某些JNI数组访问函数不返回错误代码,但可能会抛出一个ArrayIndexOutOfBoundsExceptionArrayStoreException

在所有其他情况下,非错误返回值可确保不会抛出任何异常。

异步异常

在多线程的情况下,除当前线程之外的线程可能发布异步异常。异步异常不会立即影响当前线程中native代码的执行,直到:

  • native代码调用可能引发同步异常的JNI函数之一,或
  • native代码用于ExceptionOccurred()显式检查同步和异步异常。

请注意,只有那些可能引发同步异常的JNI函数才会检查异步异常。 

native方法应ExceptionOccurred()在必要的位置插入检查(例如在没有其他异常检查的紧密循环中),以确保当前线程在合理的时间内响应异步异常。

异常处理

有两种方法可以处理native代码中的异常:

  • native方法可以选择立即返回,从而导致在启动native方法调用的Java代码中抛出异常。
  • native代码可以通过调用清除异常ExceptionClear(),然后执行自己的异常处理代码。

 引发异常后,native代码必须首先清除异常,然后再进行其他JNI调用。当存在挂起的异常时,可以安全调用的JNI函数是:

  ExceptionOccurred()
  ExceptionDescribe()
  ExceptionClear()
  ExceptionCheck()
  ReleaseStringChars()
  ReleaseStringUTFChars()
  ReleaseStringCritical()
  Release<Type>ArrayElements()
  ReleasePrimitiveArrayCritical()
  DeleteLocalRef()
  DeleteGlobalRef()
  DeleteWeakGlobalRef()
  MonitorExit()
  PushLocalFrame()
  PopLocalFrame()

八、总结 

本文是译文,原文地址为:https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/design.html

同时也是本人整理的JNI教程的第二篇,可能部分内容语法有点不通顺,但是看完了也能基本了解JNI的设计思路。后续我们会进一步对JNI相关的知识做更进一步的整理。

Android JNI 学习(二):JNI 设计概述

上一篇:axios拦截器搭配token使用


下一篇:Android JNI 学习(三):JNI 数据类型和数据结构