Jimmy Chen

A Programmer

(译文) JNI编程指南与规范 第四章 字段和方法

第四章 字段和方法

  现在你已经知道了JNI是如何让本地代码访问基本数据类型和引用类型,例如字符串和数组,下一步需要学习怎么样和任意对象的字段和方法进行交互。除了访问字段外,这里还包括在本地代码中调用使用Java编程语言编写的方法,这通常称为从本地代码执行回调。

  我们将首先介绍支持字段访问和方法回调的JNI函数。本章的后面部分我们会讨论通过简单但是有效的缓存技术使这些操作更加有效率。本章最后部分,我们会讨论调用本地方法和从本地方法中访问字段以及执行回调的性能特性。

4.1 访问字段

  Java编程语言支持两种类型的字段。类的每个实例对象都有该类实例字段的单独副本,而类的所有实例都贡献该类的静态字段。JNI提供方法使得本地代码能够获取或者设置对象中的实例字段和类中的静态字段。让我们首先看一个例子程序,看该例子是如何本地代码实现是如何访问实例字段的。

  InstanceFiledAccess类定义了一个实例字段s,main方法中创建类一个该类的对象,设置实例字段,然后调用本地方法InstanceFiledAccess.accessFiled。我们即将会看到,本地方法会打印实例字段现在得值,然后再将该实例字段的值设置为一个新的值。等到本地方法返回后,我们会再次打印这个字段的值,以演示该字段的值确实是改变了。下面是本地方法InstanceFiledAccess.accessField方法的具体实现:

搭配InstanceFieldAccess本地库执行InstanceFiledAccess可以得到如下输出:

4.1.1 访问实例字段的过程

  要访问实例字段,本地方法遵循两步过程。首先,调用GetFieldID从类引用、字段名和字段描述符中取得字段ID。

这个示例代码通过在实例引用obj上调用GetObjectClass来获得类引用,obj引用将作为第二个参数传送给本地方法实现。

  你一旦取得了字段ID,你可以将对象引用和字段ID传给合适的实例字段访问函数:

因为字符串和数组是特殊类型的对象,我们使用GetObjectField来访问字符串实例字段。除了Get/SetObjectField外,JNI还支持其他的函数例如GetIntField和SetFloatField来访问基本数据类型的实例字段。

4.1.2 字段描述符

  你可能注意到在上一节中,我们使用了特殊编码的C字符串”Ljava/lang/String”来代表Java编程语言中的实例字段。这些C字符串就称为JNI字段描述符。

  字符串的内容是由声明的字段决定的。例如,使用“I”代表一个int字段,“F”代表float字段,“D”代表double字段,“Z”代表boolean字段。

  引用类型的字段描述符,例如java.lang.String,以字母L开头,紧接着是JNI类描述字段并以分号作为终结符。完全限定类名中的“.”分隔符在JNI类描述符中更改为“/”,因此你为类型为java.lang.String的字段形成的字段描述符为:”Ljava/lang/String;”。

  数组类型的描述符包含“[”字符,紧接着是数组组件类型的描述符。例如,“[I”是int[]字段类型的字段描述符。12.3.3节(图片先放一下)包含字段描述符的细节以及其在Java编程语言中的对应类型。

《(译文) JNI编程指南与规范 第四章 字段和方法》

  你可以使用javap工具(随JDK或者Java 2 SDK一同发布)从类文件中生成字段描述符。通常javap会打印给定类的方法和字段类型,如果你使用-s选项(和-p选项来显示私有成员),javap只打印JNI描述符。

上面的指令会给出包含字段s的JNI描述符信息:

使用javap工具有助于消除手工导出JNI描述符字符串时可能发生的错误。

4.1.3 访问静态字段

让我们看看InstanceFieldAccess示例的一个小小的变化:

  StaticFieldAccess类包含一个静态整型资源si,StaticFieldAccess.main方法首先创建一个对象,初始化静态字段,然后调用本地方法StaticFieldAccess.accessField。正如我们即将见到的那样,本地方法先打印静态字段现在的值,然后给该静态字段设置一个新的值。为了验证这个静态字段的值是否真的改变了,在调用完该静态方法后再次打印该静态字段的值。

  下面是静态方法StaticFieldAccess.accessField的实现代码:

使用本地库运行程序会产生以下输出:

  如何访问一个静态字段和如何访问一个实例字段存在两个不同的地方:

  1. 对于静态字段,你应该调用GetStaticFieldID,而相对的对于实例字段,你应该调用GetFieldID。GetStaticFieldID和GetFieldID都有相同的返回类型就fieldID。
  2. 一旦取得了静态字段ID,你将类引用传送类引用给合适静态字段访问函数,而对于实例字段,你应该传送对象引用。

4.2 调用方法

  在Java编程语言中,存在几种类型的方法。实例方法必须通过特定类的实例来调用,而静态方法可以独立于任何实例被调用。下一节我们将讨论构造函数。

  JNI支持一组完整的函数,允许你在本地代码中进行回调操作。下面的示例程序中包含本地方法,它依次调用用Java语言实现的实例方法。

下面是本地代码的实现:

运行上面的程序可以获得如下输出:

4.2.1 调用实例方法

  Java_InstanceMethodCall_nativeMethod方法实现表明需要两个步骤来调用一个实例方法:

  • 本地方法首先调用JNI方法GetMethodID。GetMethodID对给定的类进行方法查询。查询是基于方法的名字和方法的类型描述符的。如果这个方法不存在,GetMethodID放回NULL,在这个点上,从本地方法立刻返回并且会导致在调用InstanceMethodCall.nativeMethod的代码中抛出NoSuchMethodError异常。
  • 本地方法然后调用CallVoidMethod。Ca llVoidMethod调用一个返回类型为void的实例方法。你将对象,方法ID和实际的参数(但是上面的实例中,这些都为空)传送给CallVoidMethod。

  除了CallVoidMethod方法外,JNI支持其他返回类型的方法调用函数。例如,如果你回调的方法返回一个int类型的值,然后你的本地方法可以使用CallIntMethod。类似的,你可以使用CallObjectMethod来调用返回值为对象(包含java.lang.String实例和数组)的方法。

  你可以使用CallMethod系列函数来调用接口函数。你必须从接口类型中导出方法ID。下面的代码片段,在一个java.lang.Thread实例中调用Runnable.run方法:

  我们已经在3.3.5节中看到过FindClass返回一个明明类的引用。这里我们也可以用它来获取一个命名接口的引用。

4.2.2 生成方法描述符

  JNI使用描述符字符串来表示方法类型,类似于它如何表示字段类型。方法描述符组合了参数类型和返回类型。参数类型首先出现,并被一对括号括起来,参数类型按照方法声明的中的顺序列出。多个参数之间没有分隔符,如果一个方法没有参数,则用一对空的圆括号表示。将方法的返回类型放在参数类型的右括号后面。

  举个例子,“(I)V”表明该方法有一个类型为int的参数并且返回类型为void。“()D”表明该方法不需要参数并且返回一个double值。不要让C函数原型如“int f(void)”误导你认为“(V)I”是一个有效的方法描述符,这里应该使用“()I”作为其方法描述符。方法描述符可能包含类描述符,例如如下方法:

其方法描述符为

12.3.4节给出了如何构建JNI方法描述符的完整描述。你可以使用javap工具打印JNI方法描述符。例如执行如下指令:

你可以获取到如下输出信息:

-s标志通知javap输出JNI描述符字符串,而不是他们在Java编程语言中出现类型。-p标志使javap在其输出中包含有关该类的私有成员的信息

4.2.3 调用静态方法

  前面的例子演示了在本地代码中如何调用一个实例方法。类似的,你可以通过下面的一些步骤从本地方法中进行静态方法回调:

  • 通过GetStaticMethodID获取静态方法ID,而不是GetMethodID
  • 将类、方法ID和参数传给静态方法调用函数之一:CallStaticVoidMethod,CallStaticBooleanMethod等。

  在允许你调用静态方法的函数和允许你调用实例方法的函数中有一个关键性的区别,前者使用类引用作为参数,而后者使用对象引用作为参数。例如:将类引用传递给CallStaticVoidMethod,但是将对象引用传递给CallVoidMethod。

  在Java编程语言层面,您可以使用两种可选语法来调用类Cls中的静态方法f:Cls.f或obj.f,其中obj是Cls的实例。(后者是推荐的编程风格。)在JNI中,当从本地代码发出静态方法调用时,必须始终指定类引用。

让我们看一个实例:在静态代码中使用回调调用一个静态方法。它和之前的InstanceMethodCall有一些不同:

下面是本地方法的实现:

确保你将通过cls(用粗体突出显示),而不是obj传递给CallStaticVoidMethod。运行上述程序可以得到如下结果:

4.2.4 调用父类的实例方法

  你可以调用在父类中定义但是被实例对象所在的类覆盖的实例方法。JNI为此提供了一组CallNonvirtualMethod方法。要调用超类中定义的实例方法,请执行下面的步骤:

  • 使用GetMethodID而不是GetStaticMethodID从超类引用中获取方法ID
  • 将对象、超类、方法ID和参数传给非虚调用函数系列之一,例如CallNonvirtualVoidMethod、CallNonvirtualBooleanMethod等。

  你需要调用超类的实例方法的机会相对较少,该工具类似于使用Java编程语言中的以下构造来调用覆盖的超类方法(如f):

CallNonvirtualVoidMethod方法同样能够用来调用构造函数,如下节中介绍的一样。

4.3 调用构造方法

  在JNI中,可以按照类似于调用实例方法的那些步骤来来调用构造方法。要获取构造方法的方法ID,在方法描述符中将“”作为方法名并且将“V”作为返回类型。然后你可以通过传递方法ID给JNI函数(例如NewObject)来调用构造函数。以下代码实现了JNI函数NewString的等效功能,它从Unicode字符中创建一个java.lang.String对象并存储在一个C缓冲区中:

  这个例子是复杂的,值得进行仔细的分析。首先,FindClass返回java.lang.String类的引用。下一步,GetMethodID返回字符串构造函数(String(char[] chars))的方法ID。然后我们调用NewCharArray分配一个字符数组来存放所有的字符元素。JNI函数调用由方法ID指定的构造函数。NewObject将需要构造的类的引用、构造函数的方法ID和需要传送给构造方法的参数作为参数。

  DeleteLocalRef调用允许虚拟机释放elemArr和stringClass占用的本地资源。5.2.1节会提供一个详细的描述说明什么时候和为什么需要调用DeleteLocalRef。

  字符串是对象,这个例子进一步突出了这一点。但是这个例子也引出了一个问题。鉴于我们可以使用其他的JIN函数实现等效的功能,为什么JNI还要提供NewString之类的函数呢?这是因为内置的字符串函数要比本地代码调用java.lang.String API更有效率。因为String是最使用的对象类型,所以在JNI中值得特别支持。

  也可以使用CallNonvirtualVoidMethod函数调用构造函数。在这种情况下,本地代码必须首先通过AllocObject函数创建一个为初始化的对象。上面的单个NewObject调用

可以被AllocObject后跟一个CallNonvirtualVoidMethod代替。

  AllocObject创建一个为初始化的对象,并且必须小心使用,以便每个对象最多调用一个构造函数。本地代码不应该在同一个对象上多次调用构造函数。

  有时候你会发现,先创建一个为初始化的对象然后调用构造函数是非常有用的。但是在更多的时候你应该调用NewObject,并避免使用更容易产生错误的AllocObject/CallNonvirtualVoidMethod方法对。

4.4 缓存字段和方法ID

  获取字段和方法ID需要基于字段和方法ID的名字和描述符进行符号查找。符号查找消耗相对较多,本节我们将介绍一种能够减少这种开销的技术。这种方法是计算字段和方法ID,然后缓存它们以便后续重复使用。有两种方法来缓存字段和方法ID,具体取决于是在使用字和方法ID时执行缓存还是在静态初始化块中定义字段或者方法来执行缓存。

4.4.1 在使用时执行缓存

  字段或者方法ID可以在本地代码访问字段值或者执行方法回调的时候被缓存。在下面的Java_InstanceFieldAccess_accessField函数实现中,使用静态变量对方法ID进行缓存,以便在每次调用InstanceFieldAccess.accessField方法时,不需要重新计算了。

  加粗显示的静态变量fid_s保存了为InstanceFiledAccess.s预先计算的方法ID。该静态变量初始化为NULL,当InstanceFieldAccess.accessField方法第一次被调用时,它计算该字段ID然后将其缓存到该静态变量中以方便后续使用。

  你可能注意到上面的代码中存在着明显的竞争条件。多个线程可能同时调用InstanceFieldAccess.accessField方法并且同时计算相同的字段ID。一个线程可能会覆盖另一个线程计算好的静态变量fid_s。幸运的是,虽然这种竞争条件在多线程中导致重复的工作,但是明显是无害的。同一个类的同一个字段被多个线程计算出来的字段ID必然是相同的。

  根据上面的想法,我们同样可以在MyNewString例子的开始部分缓存java.lang.String构造方法的方法ID。

  当MyNewString第一次被调用的时候,我们为java.lang.String构造器计算方法ID。加粗突出显示的静态变量cid缓存这个结果。

4.4.2 在类的静态初始化块中执行缓存

  当我们在使用时缓存字段或方法ID的时候,我们必须引入一个坚持来坚持字段或方法ID是否已被缓存。当ID已经被缓存时,这种方法不仅在“快速路径”上产生轻微的性能影响,而且还可能导致缓存和检查的重复工作。举个例子,如果多个本地方法全部需要访问同一个字段,然后他们就需要计算和检查相应的字段ID。在许多情况下,在程序能够有机会调用本地方法前,初始化本地方法所需要的字段和方法ID会更为方便。虚拟机会在调用该类中的任何方法前,总是执行类的静态初始化器。因此,一个计算并缓存字段和方法ID的合适位置是在该字段和方法ID的类的静态初始化块中。例如,要缓存InstanceMethodCall.callback的方法ID,我们引入了一个新的本地方法initIDs,它由InstanceMethodCall类的静态初始化器调用:

跟4.2节的原始代码相比,上面的程序包含二外的两行(用粗体突出显示),initIDs的实现仅仅是简单的为InstanceMethodCall.callback计算和缓存方法ID。

  在InstanceMethodCall类中,在执行任何任何方法(例如nativeMethod或main)之前虚拟机先运行静态初始化块。当方法ID已经缓存到一个全局变量中,InstanceMethodCall.nativeMethod方法的本地实现就不再需要执行符号查找了。

4.4.3 缓存ID的两种方法之间的比较

  如果JNI程序员无法控制定义了字段和方法的类的源代码,那么在使用时缓存ID是合理的解决方案。例如在MyNewString例子当中,我们没有办法为了预先计算和缓存java.lang.String构造器的方法ID而向java.lang.String类中插入一个用户定义的initIDs本地方法。与在定义类的静态初始化块中执行缓存相比,在使用时进行缓存存在许多缺点:

  • 如之前解释,在使用的时候进行缓存,在快速路径执行过程中需要进行检查,而且可能对同一个字段和方法ID进行重复的检查和初始化。
  • 方法和字段ID仅在类卸载前有效,如果你是在运行时缓存字段和方法ID,则必须确保只要本地代码仍然依赖缓存ID的值时,定义类就不能被卸载或者重新加载。(下一章将介绍如何通过使用JNI创建对该类的引用来保护类不被卸载。)另一方面,如果缓存是在定义类的静态初始化块中完成的,当类被卸载并稍后重新加载时,缓存的ID将会自动重新计算。

  因此在可行的情况下,最好在其定义类的静态初始化块中缓存字段和方法ID。

4.5 JNI字段和方法的操作性能

  知道如何缓存字段和方法ID以提高性能后,你可能在想:使用JNI访问字段和调用方法的性能特性如何?从本地代码中执行方法回调的成本和调用本地方法的成本以及调用常规方法的成本相比如何?这个问题的答案无疑取决于底层虚拟机实现JNI的效率性了。因此不可能给出准确的性能特性,这些性能特性被保证适用于各种各样的虚拟机实现。相反我们将会分析本地方法调用和JNI字段和方法操作的固有成本,并未JNI程序员和实现者提供一般的性能指南。让我们首先开始比较Java/native调用和Java/Java调用的成本。由于以下的原因Java/native调用可能比Java/Java调用慢:

  • 在Java虚机实现中,本地方法调用最有可能遵循与Java/Java调用不同的约定。因此,虚拟机必须执行额外的操作来构建参数,并在跳到本地方法入口之前设置堆栈结构。
  • 虚拟机经常使用内联方法调用。内联Java/native调用比内联Java/Java调用要困难得多。

  我们估计,一个典型的虚拟机实现执行Java/native调用比执行Java/Java调用大概慢两到三倍。因为Java/Java调用只需要几个周期,所以额外的开销基本可以忽略不计,除非本地方法执行一些微不足道的操作。构建一个Java虚拟机实现,让其Java/native调用性能接近或者等于Java/Java调用是可行的。(例如,这种虚拟机可以将JNI调用规则调整为和Java/Java调用规则一样。)

  native/Java回调的性能特性在技术上类似于Java/native调用。理论上,native/Java回调的开销也可能是Java/Java调用的两到三倍内。但是在实际上,native/Java调用相对少见,虚拟机通常不会优化优化回调性能。在撰写本文时,许多虚拟机实现使得native/Java回调的开销可以比Java/Java调用高出10倍。

  使用JNI进行字段访问的开销主要是通过JNIEnv调用的成本。本地代码不是直接引用对象,而是通过C调用的返回值来引用对象。函数调用时必须的,因为它将本地代码与虚拟机实现维护的内部兑现表示隔离起来。JNI字段访问的开销是可以忽略不计的,因为函数调用只需要几个周期

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注