likes
comments
collection
share

(译文) JNI编程指南与规范1~4章Java 本地接口(JNI)是 JAVA 平台中的一个强大功能。使用 JNI 编程

作者站长头像
站长
· 阅读数 61

第一章 介绍

Java 本地接口(JNI)是 JAVA 平台中的一个强大功能。使用 JNI 编程的程序能够调用 C/C++编写的本地代码,同时也可以调用 JAVA 编写的代码。JNI 允许程序员能够在使用 JAVA 带来的便利的同时,不必丢弃以前写的代码。由于 JNI 是 JAVA 平台的一部分,所以程序员能够一次性解决互操作性的问题,并期望他们的所有实现在 JAVA 平台都能够运行起来。

这本书是 JNI 的编程指导,同时也是 JNI 的参考手册:

  • 第二章通过一个简单的例子来介绍 JNI,它是一个为不熟悉 JNI 的初学者准备的教程
  • 第二到第十章构成了一个程序员指南,大致给出一些 JNI 特性。我们会通过一系列短小但是有意义的例子去突出各种特性并介绍以证明在 JNI 编程中有用的技术
  • 第十一到十三章介绍所有 JNI 类型和功能的最终规范。这些章节也可当做参考手册

这本书尝试吸引 JNI 不同需求的广泛受众。该教材和编程指南主要以初学程序员为主要受众,而经验丰富的开发者和 JNI 实现者会发现参考部分可能会更有用。该书的主要读者应该是使用 JNI 技术进行程序开发的开发者。本书中的“你”一次隐含指的是使用 JNI 进行编程的开发者,而不是指 JNI 的实现人员或者使用 JNI 编写的程序的最终使用人员。

本书假设你对于 JAVA、C 以及 C++编程语言已经有了基本的认知。如果还没有,你可以参考下面其中一本优秀的参考书籍:The Java™ Programming Language, Second Edition, by Ken Arnold and James Gosling (Addison-Wesley, 1998), The C Programming Language, Second Edition, by Brian Kernighan and Dennis Ritchie (Prentice Hall, 1988), and The C++ Programming Language, Third Edition, by Bjarne Stroustrup (Addison-Wesley, 1997)

1.1 Java 平台以及主机环境

因为本书涉及到的程序是用 JAVA 编程语言和本地编程语言(C/C++)编写的,让我们首先弄清楚这些编程语言编程环境的范围。

JAVA 平台是由 JAVA 虚拟机和 JAVA 应用程序编程接口组成的编程环境。JAVA 应用程序是由 Java 语言编写的,并被编译成独立于极其的二进制类文件格式。任何一个虚拟机都可以执行这种类文件。Java 应用程序编程接口有一系列已经定义好的类文件组成。Java 平台的任何实现都必须保证支持 Java 编程语言、虚拟机和提供应用程序编程接口。

主机环境代表主机的操作系统、一系列本地库以及 CPU 指令集。本地应用程序是使用本地编程语言编写的,例如 C 和 C++,然后编译成主机特定的二进制代码并与本地库链接。本地应用程序和本地库通常以来与特定的主机环境。例如:一个 C 应用程序只为一个操作系统而构建,通常是无法在其他的操作系统上运行的。

Java 平台通常是部署在操作环境之上的。例如:Java 运行环境(JRE)是 Sun 公司的产品,在现有的操作系统,例如 Solaris 和 Windows 上,它能够支持 Java 平台。Java 平台提供了一组应用程序可以独立于主机环境的功能。

1.2 JNI 的角色

当 Java 平台部署在主机环境之上时,可能希望或者需要允许 Java 应用程序与其他编程语言编写的本地代码紧密合作。程序员已经开始采用 Java 平台来构建传统上用 C 和 C++编写的应用程序,由于现有遗留的代码,Java 应用程序将和 C/C++代码共存多年。

JNI 是一个强大的功能,能够允许利用 Java 平台但是同时能够其他语言编写的代码。作为 JAVA 虚拟机的部分实现,JNI 是一个双向接口,允许 JAVA 应用程序调用本地代码,反之亦然。图 1.1 表明了 JNI 的这种角色:

(译文) JNI编程指南与规范1~4章Java 本地接口(JNI)是 JAVA 平台中的一个强大功能。使用 JNI 编程

JNI 旨在处理需要将 Java 应用程序和本地代码组合在一起的情况。作为一种双向接口,JNI 能够支持两种类型的本地代码:本地库和本地应用程序。

  • 你能够使用 JNI 去编写本地方法,然后允许 Java 应用程序调用使用本地块实现的方法。Java 应用程序调用本地方法的方式和调用使用 Java 语言编写的方法的方式是一样的。然而在幕后,本地方法是使用另一种语言实现的,并驻留在本机库中。
  • JNI 支持一个调用接口,允许你将 Java 虚拟机实现嵌入到本机应用程序中。本地应用程序能够链接到一个实现了 Java 虚拟机的本地库中,然后是使用接口调用来执行 Java 编程语言编写的软件组件。例如,用 C 语言编写的浏览器能够运行已下载的潜入了 Java 虚拟机实现的 applets 程序。

1.3 使用 JNI 的意义

记住一旦程序使用了 JNI,将失去了 Java 平台提供的两个优势。

首先,依赖于 JNI 的 Java 应用程序将不能再在多个主机环境中运行。尽管使用 Java 编程语言编写的部分代码是可以移植到做个主机环境当中的,但是有必要重新编译以本地语言编写的部分。

其次,尽管 Java 编程语言是类型安全且安全的,但是 C/C++等本地语言却不是。因此在使用 JNI 编写应用程序的时候,需要格外的小心。行为不当的本地方法会引起整个应用程序崩溃。因此,在调用 JNI 方法前,Java 应用程序将进行安全检查。

作为一般规则,你应该构建应用程序以使本地方法被定义在尽可能少的类中。这需要本地代码和应用程序的其余部分之间更清晰的隔离。

1.4 什么时候使用 JNI

在开始使用 JNI 到项目之前,值得一步一步来调查是否有更好的替代方案。如上一节所述那样,与严格使用 Java 编程语言编写的应用程序相比,使用 JNI 的应用程序具有一些缺点。例如,你会失去 Java 编程语言提供的类型安全保障。

许多替代方法还允许 Java 应用程序与其他语言编写的代码进行互操作,例如:

  • 一个 Java 应用程序可能通过 TCP/IP 连接或者通过其他进程间通信(IPC)机制与本地应用程序进行通信
  • 一个 Java 应用程序可能通过 JDBC API 和传统的数据库进行连接
  • Java 应用程序可以利用分布式对象计数,如 Java IDL API    这些替代方法的一个共同特征是 Java 应用程序和本地代码是驻留在不同的进程中(在某些场景中,是驻留在不同的机器上)。过程分离提供了重要的好处。进程提供的地址空间保护计数能够提供一个高度的故障隔离,崩溃的本地应用程序不会立即终止与之通过 TCP/IP 通信的 Java 应用程序。

然而有些时候你会发现,你可能会发现 Java 应用程序需要与驻留在同一进程中的本地代码进行通信。这时候,JNI 计数就很有用了。考虑如下场景:

  • Java API 可能没有提供应用程序所需要的特定主机的相关功能。例如,一个应用程序可能想操作一个特定的文件,但是 Java 平台却没有提供相应的 API 支持,但是通过另一个进程来操作这个文件却是麻烦的和低效的。
  • 你可能希望访问现有的本地库,而不愿意为在不同进行间复制和传输数据而有额外开销。在同一个进场中加载本地库将是一个更搞笑的方法
  • 夸多进程的应用程序可能会导致不可接受的内存占用情况。如果这些继承驻留在同一个客户机上,这种情况往往是真的。将本地库加载到已经现有的应用程序中比启动新的程序,然后再将本地库加载到这个程序中需要更少的系统资源。
  • 你可能希望通过较低级的语言(如汇编)来实现一小部分时间敏感的代码。如果一个 3D 密集型应用程序花费大部分时间在图形渲染中,你可能会发现有必要使用汇编代码来编写图形库的核心部分以达到最佳性能。    总而言之,如果一个 Java 应用程序必须和本地代码必须在同一个进程中,那么就使用 JNI。

1.5 JNI 的演变

自 Java 平台的早期阶段开始,Java 应用程序与本机代码的互操作性得到肯定。Java 平台的第一个版本的 Java 开发组件包含了一个本地方法接口,允许 Java 应用程序调用其他语言(例如 C 和 C++)编写的函数。许多第三方应用程序以及 Java 类库(包括,例如 java.lang,java.io 和 java.net)的实现依赖于本地方法接口访问底层主机中的功能环境。

但是很不幸,在第一版 JDK 中的本地方法接口存在两个主要的问题:

  • 首先,本地代码访问对象中的字段作为 C 结构的成员。但是,Java 虚拟机规范中没有定义对象在内存中是如何布置的。如果给定的 Java 虚拟机实现以不同于本地方法接口假定的方式布置对象,则必须重新编译本地方法库

  • 其次,JDK 版本 1.0 中的本地方法接口依赖于保守的垃圾收集器,因为本地方法可以获得直接指向虚拟机对象中的指针。任何使用更高级的垃圾收集算法的虚拟机实现都无法支持 JDK 1.0 版本中的本地方法接口。 JNI 旨在克服这些问题。它是一种能够在各种主机环境中由所有 Java 虚拟机支持的接口。通过 JNI:

  • 每个虚拟机实现都可以支持更多的本地代码。

  • 开发工具供应商不需要处理各种不同的本地方法接口

  • 最重要的是,应用程序编程人员能够编写一个版本的本地代码,并且这个版本的代码将在不同的 Java 虚拟机实现上运行。   JNI 第一次被支持是在 JDK 1.1 版本中。然而在其内部,JDK 1.1 版本仍然使用旧类型的本地代码来完成 Java 编程接口。在 Java SDK 1.2 版本中不在是这样,本地方法已被重新,使其符合 JNI 的标准。

JNI 是被所有的 Java 虚拟机实现所支持的本地接口。从 JDK 1.1 版本开始,你应该向 JNI 编程。虽然旧式本地编程接口在 Java SDK 1.2 版本上还有支持,但是在将来不会(或不能)在高级 Java 虚拟机实现中得到支持。

Java SDK 1.2 版本中包含大量的 JNI 扩展功能。这些扩展功能是向后兼容的。JNI 的未来发展将保持完整的二进制兼容性。

1.6 示例代码

本书包含大量演示 JNI 功能的示例程序。示例程序通常有多个由 Java 编程语言、C 和 C++编写的代码片段组成。有时候这些本地代是指 Solaris 和 Win32 主机中的特定功能。我们也演示如何使用 JDK 和 Java SDK 2 版本的命令行(如 javah)构建 JNI 程序。

请记住,JNI 的使用不限于特定的主机环境或特定的开发工具。本书着重于编写代码,而不是使用工具构建和运行代码。与 JDK 和 Java SDK 2 发行版捆绑的命令行工具相当原始。第三方功能可能会提供一种改进的方法来构建 JNI 的应用程序。我们鼓励你查阅和选择开发工具捆绑在一起的 JNI 相关文档,你可以从以下网站上下载本书中的示例代码以及本书的最新更新:java.sun.com/docs/books/…

第二章 开始

本章将引导你了解如何使用 Java 本地接口。我们将编写一个 Java 应用程序调用一个 C 函数来答应“Hello World!”。

2.1 概述

图 2.1 表明使用 JDK 或者 Java SDK 2 发行版编写一个调用 C 函数来打印“Hello World!”的 Java 应用程序的过程。这个过程有以下步骤组成:

(译文) JNI编程指南与规范1~4章Java 本地接口(JNI)是 JAVA 平台中的一个强大功能。使用 JNI 编程

  • 创建申明了本地方法的类(HelloWorld.java)
  • 使用 javac 去编译这个 HelloWorld 源文件,得到一个 HelloWorld.class 的 class 文件。javac 编译工具是 JDK 或者 Java 2 SDK 发行版中提供的。
  • 使用 javah -jni 去生成包含本地方法函数原型的 C 头文件(HelloWorld.h)。javah 工具也是在 JDK 或者 Java 2 SDK 发行版中带有的。
  • 编写本地方法的 C 实现(HelloWorld.c)
  • 将 C 实现编译成一个本地库 helloWorld.dll 或者 libHelloWorld.so,使用主机环境中可用的 C 编译器和连接器
  • 使用 Java 运行时解析器运行 hello world 程序。类文件(HelloWorld.class)和本机库(HelloWorld.dll 或者 HelloWorld.so)都在运行时加载。 本章的剩余部分将详细讲解这些步骤。

2.2 定义本地方法

首先用Java编程语言编写以下程序。这个程序定义了一个包含本地方法print,类名为HelloWorld的类。

class HelloWorld {
    private native void print();

    public static void main(String[] args) {
        new HelloWorld().print();
    }

    static {
        System.loadLibrary("HelloWorld");
    }
}

HelloWorld 类定义从打印本地方法的声明开始。之后是实例化 HelloWorld 类并调用此示例的 print 本地方法。类定义最后一部分是一个静态初始化器,它加载包含本地 print 方法的本地库。

定义一个本地方法例如 print 和使用 Java 编程语言定义一个常规的方法存在两个不同的地方。一个本地方法声明必须包含 native 修饰符。native 修饰符表明该方法由其他编程语言实现。此外本地方法声明以分号终结(语句终结符),因为在这个类中没有实现这个本地方法。我们会在独立的 c 文件中完成 print 方法的编写。

在本地方法 print 能够被调用前,实现了 print 方法的本地库必须被加载。在这个例子中,我们在 HelloWorld 类的静态初始化块中将本地库加载进来。Java 虚拟机在调用 HelloWorld 类的任何方法前先运行静态初始化块的代码,因此可以肯定在本地方法 print 被调用前,本地库就已经被加载了。

我们定义了一个可以运行 HelloWorld 类的 main 方法。HelloWorld.main 方法以与常规方法相同的方式调用 print 本地方法。

System.loadLibrary 使用库名字,找到与库名字相关的本地库,并将本地库加载到应用程序中。我们会在本书的后面部分讨论准确的加载过程。现在只需要记住,为了使 System.loadLibrary(“HelloWorld”)能够成功,我们需要在 win32 系统上创建一个 HelloWorld.dll 文件,在 Solaris 系统中创建一个 libHelloWorld.so 文件。

2.3 编译 HelloWorld 类

在你完成 HelloWorld 类的编写,将源码保存到一个名为 HelloWorld.java 的文件中。使用 JDK 或者 Java SDK 2 中带有的 javac 工具进行编译:

javac HelloWorld.java

这条指令会在当前目录中产生一个 HelloWorld.class 文件。

2.4 创建本地方法的头文件

接下来我们会使用 javah 工具来生成一个 JNI 类型的头文件,这个文件在后面使用 C 语言完成本地方法时是非常有用的。执行 javah 的指令如下:

javah -jni HelloWorld

头文件的名字是类名并在其后面加上”.h”结尾。上面的指令会生成一个名字为 HelloWorld.h 的文件。在这里我们不会列出这个头文件的内容。这个文件的最重要的部分是 Java_HelloWorld_print 函数原型,它是实现了 HelloWorld.print 方法的 C 函数:

JNIEXPORT void JNICALL Java_HelloWorld_print
  (JNIEnv *, jobject);

现在先忽略 JNIEXPORT 和 JNICALL 宏。你可能有注意到本地方法的 C 实现接受两个参数,尽管相应本地方法的定义(指 HelloWorld.java 中的定义)却没有接受任何参数。每一个本地方法实现的第一个参数是一个 JNIEnv 接口指针。第二个参数是引用 HelloWorld 对象本身,类似于 C++的 this 指针。在本书的后面我们会讨论如何使用 JNIEnv 接口指针和 jobject 参数,但是在这个例子将忽略这两个参数。

2.5 编写本地方法实现

使用 javah 来生成的 JNI 类型头文件能够帮助你使用 C/C++来完成本地方法的实现。你编写的函数必须遵循生成的头文件中的函数原型。在 C 文件 HelloWorld.c 中,你可以按照下面的代码来实现 HelloWorld.print 方法。

#include <jni.h>
#include <stdio.h>
#include <HelloWorld.h>

JNIEXPORT void JNICALL Java_HelloWorld_print(JNIEnv *env, jobject obj){
    printf("Hello World!\n");
    return ;
}

这个本地方法的实现是非常简单的。它使用 printf 函数来显示字符串“Hello World!”,然后就返回。就像前面提到的,JNIEnv 指针和 obj 对象引用都忽略了。

这个 C 程序包含三个头文件:

  • jni.h:此头文件提供本地代码调用 JNI 函数所需的信息。 编写本机方法时,必须始终将此文件包含在 C 或 C 源文件中。
  • stdio.h:上面的代码片段还包括了 stdio.h 因为它使用 printf 函数
  • HelloWorld.h:通过 javah 工具生成的头文件。它包含 Java_HelloWorld_print 的函数原型。

2.6 编译 C 源码并生成一个本地库

请记住,你在文件 HelloWorld.java 文件中创建一个 HelloWorld 类时,包含一条将本地库加载到程序中的代码:

System.loadLibrary("HelloWorld");

现在所有需要的 C 源码都已经编写完成了,现在你需要编译 HelloWorld.c 文件并创建一个本地库。

不同的操作系统提供不同的方式去创建本地库。在 Solaris 上,下述命令能够创建一个名为 libHelloWorld.so 的动态库。

cc -G -I/java/include -I/java/include/solaris HelloWorld.c -o libHelloWorld.so

-G 编译选项表明让 C 编译器生成一个动态库而不是常规的 Solaris 可执行文件。在 win32 系统中,下面的指令使用 Microsoft Visual C++编译器创建的动态链接库(DLL)HelloWold.dll

cl -Ic:\java\include -Ic:\java\include\win32 -MD -LD HelloWorld.c -FeHelloWorld.dll

-MD 编译选项表明 HelloWorld.dll 和 win32 多线程 C 库链接。-LD 编译选项表明 C 编译器产生一个 DLL 文件而不是常规的 Win32 可执行文件。当然不管在 Win32 还是 Solaris 系统,你都需要在你的电脑上设置好头文件的包含路径。

博主注:我使用的编译环境是 Ubuntu 16.04 版本,JDK 版本是 openjdk 1.8,使用上面的指令是不行的,下面是我使用的编译指令

cc -I$JAVA_HOME/include -I$JAVA_HOME/include/linux -I. -fPIC -shared HelloWorld.c -o libHelloWorld.so

当然你也可以使用 gcc,其中,JAVA_HOME 是我配置到.bashrc 中的路径:

export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64

大家可以按照各自的 JAVA_HOME 进行配置,这样就能够编译成功了。

2.7 运行程序

到这里,运行该程序的两个主件都已经准备好了。类文件(HelloWor.class)调用一个本地方法,本地库(HelloWorld.dll)实现这个本地方法。因为 HelloWorld 类包含它自己的 main 方法,在 Solaris 和 Win32 上,可以通过如下方法执行这个程序:

java HelloWorld

你能够看到程序输出如下信息:

Hello World!

为了让你的程序能够正确的运行,正确设置好本地库的路径是非常重要的。本地库路径是一系列文件目录,当 Java 虚拟机加载本地库时会搜索这些路径。如果你没有正确设置本地库路径,你会看到如下类似的错误 log:

java.lang.UnsatisfiedLinkError: no HelloWorld in library path
    at java.lang.Runtime.loadLibrary(Runtime.java)
    at java.lang.System.loadLibrary(System.java)
    at HelloWorld.main(HelloWorld.java)

需要确保本地库在设置的本地库路径的其中一个目录中。如果你在 Solaris 系统上运行,LD_LIBRARY_PATH 环境变量是用来设置本地库路径的。确保该环境变量的路径包含动态库 libHelloWorld.so 文件所在的目录。如果 libHelloWorld.so 文件在当前目录,在标准 shell 或者 KornShell 中,你可以通过如下两条指令来设置 LD_LIBRARY_PATH 环境变量

LD_LIBRARY_PATH=.
export LD_LIBRARY_PATH

在 C Shell 中的等价指令如下:

setenv LD_LIBRARY_PATH .

如果你是在 Windows 95 或者 Windows NT 上运行,确保 HelloWorld.dll 在当前目录,或者其所在的目录已经列在 PATH 环境变量中。

在 Java 2 SDK 1.2 发行版中,你可以像下面的指令一样,通过 java 命令行来指定本地库的的路径:

java -Djava.library.path=. HelloWorld

-D 命令行选项设置了一个 Java 平台属性。将 java.library.path 设置为“.”,“.”表明 Java 虚拟机会在当前路径中搜索本地库。

博主注:博主运行 HelloWorld 使用的指令是:

java -Djava.library.path=. HelloWorld

第三章 基本类型、字符串和数组

当面对 Java 应用程序混合本地编程语言代码时,程序员经常会问的一个问题是:Java 编程语言中的数据类型是如何映射到 C/C++等本地编程语言中的数据类型的。上一章中介绍的“Hello World!”示例中,我们没有任何的参数需要传输给本地方法,本地方法也没有返回任何结果给调用者。本地方法只是简单的打印一条信息然后返回。

在实践中,许多程序都需要传送参数给本地方法,而且需要从本地方法中获取返回值。本章,我们将介绍如何在 Java 编程语言编写的代码和本地编程语言编写的代码之间交换数据类型。我们从原始类型,例如整型和常用的对象类型,例如字符串和数组开始讲解。我们将把任意对象的完整处理留到下一章,下一章我们将会介绍本地方法的代码如何访问字段和进行方法调用。

3.1 一个简单的本地方法

让我们先从一个简单的程序开始,这个程序和上一章的 HelloWorld 程序没有多少不同。示例程序,Prompt.java,包含一个打印一个字符串、等待用户输入、最后将用户输入的内容返回给调用函数的本地方法。该程序的代码如下:

class Prompt {
    // native method that prints a prompt and reads a line
    private native String getLine(String prompt);

    public static void main(String args[]) {
        Prompt p = new Prompt();
        String input = p.getLine("Type a line: ");
        System.out.println("User typed: " + input);
    }

    static {
        System.loadLibrary("Prompt");
    }
}

Prompt.main 调用本地方法 Prompt.getLine 来获取用户的输入。在静态初始化块中调用 System.loadLibrary 方法将名为 Prompt 的本地库加载到程序中。

3.1.1 本地方法的 C 原型

Prompt.getLine 方法可以使用以下 C 函数实现:

JNIEXPORT jstring JNICALL Java_Prompt_getLine
  (JNIEnv *, jobject, jstring);

你可以使用 javah 工具来生成包含上述函数原型的头文件。JNIEXPORT 和 JNICALL 宏(在 JNI.h 都在文件中定义)确保这个函数从本地库中导出而且 C 编译器使用该函数的正确调用约定生成代码。C 函数的名称是通过连接“Java_”前缀、类名称和方法名称构成的。11.3 节包含如何形成 C 函数名称的更准确的描述。

3.1.2 本地方法参数

如 2.4 节所述,本地方法实现(如 Java_Prompt_getLine)除了在本地方法中声明的参数外,还接受两个标准参数。第一个参数是 JNIEnv 接口指针,指向函数表指针的位置。每个方法表中的指针指向一个 JNI 函数。本地方法始终通过 JNI 函数之一访问 Java 虚拟机中的数据结构。如图 3.1 所示 JNIEnv 接口指针:

(译文) JNI编程指南与规范1~4章Java 本地接口(JNI)是 JAVA 平台中的一个强大功能。使用 JNI 编程

第二个参数取决于本地方法是实例方法静态方法还是实例方法。实例化本地方法的第二个参数是对该方法的调用对象的应用,与 C++语言中的 this 指针类似。静态本地方法的第二个参数是对定义该方法的类的应用。我们的例子,Java_Prompt_getLine 实现为一个实例化本地方法。所以第二个参数 jobject 就是对对象本身的引用。

3.1.3 类型映射

本地方法声明中的参数类型在本地编程语言中都有相应的类型。JNI 中定义了一组对应于 Java 编程语言中类型的 C/C++类型。在 Java 编程语言中存在着两种类型:基本类型,如 int、float 和 char 以及引用类型,如类、实例和数组。在 Java 编程语言中字符串是 java.lang.String 类的实例化。

JNI 以不同的方式处理基本类型和引用类型。基本类型的映射是直接的。Java 编程语言中的 int 类型映射为 C/C++的 jint(定义在 jni.h 中,为 32 位有符号整型数),Java 编程语言中的 float 类型映射为 C/C++的 jfloat(定义在 jni.h 中,为 32 位浮点类型数)。12.1.1 节包含所有在 JNI 中定义的基本类型(这里简单把截图放一下,如下图)。

(译文) JNI编程指南与规范1~4章Java 本地接口(JNI)是 JAVA 平台中的一个强大功能。使用 JNI 编程

JNI 传递对象给本地方法作为不透明引用。不透明引用指的是引用 Java 虚拟机内部数据类型的 C 指针类型。对于程序员而言,Java 虚拟机内部数据的准确布局是隐藏的。本地代码可以通过 JNIEnv 接口指针指向的适当的函数来操作底层对象。例如,java.lang.String 对应于 JNI 类型 jstring,jstring 引用的确切位置和本地代码是不相关的。本地代码通过 jni 函数,例如 GetStringUTFChars 来获取 string 的内容。

所有的 JNI 类型都有类型 jobject(感觉应该是 jobject 的子类的意思),为了方便和增强类型安全性,JNI 定义了一组在概念上是 jobject 的子类型的引用类型(A 是 B 的子类型,那么 A 的每个实例对象都会是 B 的实例对象(博主注:大概应该是这个意思吧))。这些子类型对应于 Java 编程语言中经常使用的引用类型。例如:jstring 表示字符串,jobjectArray 表示对象数据,12.1.2 节(这里也简单把截图放一下,如下图)完整的列出了 JNI 参考类型及其子类型的关系。

(译文) JNI编程指南与规范1~4章Java 本地接口(JNI)是 JAVA 平台中的一个强大功能。使用 JNI 编程

3.2 访问字符串

Java_Prompt_getLine 接收 prompt 参数为一个 jstring 类型。jstring 类型在 Java 虚拟机代表着字符串,但是有不同于常规的 C 字符串(指向字符的指针,char *)。你不能将 jstring 作为常规 C 字符串来使用。运行下面的代码将不会得到期望的结果,而事实上很可能会导致 Java 虚拟机崩溃。

JNIEXPORT jstring JNICALL
Java_Prompt_getLine(JNIEnv *env, jobject obj, jstring prompt) {
    /* ERROR: incorrect use of jstring as a char* pointer */
    printf("%s", prompt);
    ...
}

3.2.1 转换为本地字符串

你的本地代码必须使用恰当的 JNI 函数将 jstring 对象转换为 C/C++字符串。JNI 同时支持将 jstring 转换为 Unicode 和 UTF-8 字符串。Unicode 字符串使用 16 位值表示字符,而 UTF-8 字符串则使用向上兼容 7 位 ASCII 字符串的编码方法。尽管 UTF-8 字符串包含非 ASCII 字符,但是其表现类似于使用 NULL 终止符的 C 字符串。值在 1 到 127 之间的所有 7 位 ASCII 字符在 UTF-8 字符串中编码保持不变。一个字节中的最高位被设置了,表示多字节编码的 16 位 Unicode 值的开始。

Java_Prompt_getLine 方法调用 JNI 方法 GetStringUTFChars 来读取字符串的内容。可以通过 JNIEnv 接口指针使用 GetStringUTFChars 方法。它将通常在 Java 虚拟机中实现为 Unicode 序列的 jstring 引用转换为 UTF-8 格式的 C 式字符串。如果你可以确定原始的字符串只包含 7 位 ASCII 字符,你可以将转换后的字符串传给 C 库函数,例如 printf(我们会在 8.2 节讨论佮处理非 ASCII 字符串)。

#include "Prompt.h"
#include <stdio.h>

JNIEXPORT jstring JNICALL Java_Prompt_getLine
  (JNIEnv *env, jobject obj, jstring prompt)
{
    char buf[128];
    const jbyte *str;
    str = (*env)->GetStringUTFChars(env, prompt, NULL);
    if(str == NULL)
        return NULL;
    printf("%s", str);
    (*env)->ReleaseStringUTFChars(env, prompt, str);
    /* We assume here that the user does not type more than
     * 127 characters */
     scanf("%s", buf);
     return (*env)->NewStringUTF(env, buf);
}

不要忘记检查 GetStringUTFChars 的返回值,这是因为 Java 虚拟机的实现决定内部需要申请内存来容纳 UTF-8 字符串,内存的申请是有可能会失败的。如果内存申请失败,那么 GetStringUTFChars 将会返回 NULL 并且会抛出 OutOfMemoryError 异常。正如我们会在第六章介绍的一样,在 JNI 中抛出异常和在 Java 中抛出异常是不同的。通过 JNI 抛出的挂起异常不会自动更改本地 C 代码的控制流程。相反我们需要一个显示的 return 语句来跳过 C 函数中的剩余语句。Java_Prompt_getLine 返回后,该异常会返回给 Prompt.getLine 的调用者 Prompt.main 函数中。

3.2.2 释放本地字符串资源

当你的本地代码使用完通过 GetStringUTFChars 获取的 UTF-8 字符串后,需要调用 ReleaseStringUTFChars,调用 ReleaseStringUTFChars 表明本地代码不再需要 GetStringUTFChars 返回的 UTF-8 字符串了,调用 ReleaseStringUTFChars 就能够释放掉 UTF-8 字符串占用的内存。不调用 ReleaseStringUTFChars 进行内存释放会导致内存泄漏,最终导致内存耗尽。

3.2.3 创建新字符串

通过调用 JNI 函数 NewStringUTF,你可以在本地代码中创建一个新的 java.lang.String 实例。NewStringUTF 方法使用一个 UTF-8 格式的 C 式字符串作为参数并生成一个 java.lang.String 实例对象。新构造的 java.lang.String 实例和给定的 UTF-8 C 式字符串有相同的 Unicode 序列。如果虚拟机没办法申请足够的内存来构造 java.lang.String 实例的话,NewStringUTF 会抛出一个 OutOfMemoryError 异常并返回 NULL。在这个例子中,我们不需要检查返回值,因为本地代码会在之后立刻返回。如果 NewStringUTF 调用失败,OutOfMemoryError 异常会在该方法的调用者 Prompt.main 中被抛出。如果 NewStringUTF 调用成功,它会返回一个指向新构造的 java.lang.String 对象的引用。这个新构造的实例会在 Prompt.getLine 中返回,并在 Prompt.main 中赋值给 input。

3.2.4 其他 JNI 字符串方法

除了之前介绍的 GetStringUTFChars、ReleaseStringUTFChars 和 NewStringUTF 函数外,JNI 中还支持其他的字符串相关方法。GetStringChars 和 ReleaseStringChars 获取以 Unicode 格式表示的字符串字符。当操作系统支持将 Unicode 作为本地字符串格式的时候,这些函数将会非常有用。

UTF-8 字符串常以‘\0’结尾,而 Unicode 字符串却不是。为了统计一个 jstring 引用中的 Unicode 字符个数时,JNI 程序员可以调用 GetStringLength。为了统计一个 UTF-8 格式的 jstring 对象占用多少字节时,可以对 GetStringUTFChars 的返回值调用 ANSI C 函数 strlen 来获得,或者直接对 jstring 引用调用 JNI 函数 GetStringUTFLength 来获得。GetStringUTFChars 和 GetStringChars 方法的第三个参数需要做些而外的解释:

const jchar *GetStringChars(JNIEnv *env, jstring str, jboolean *isCopy);

从 GetStringChars 方法返时,如果返回的字符串是原始 java.lang.String 实例中的字符的副本,那么 isCopy 指向的内存地址的值被设置为 JNI_TURE。如果返回的字符串是原始 java.lang.String 实例中字符的的直接引用,那么 isCopy 指向的内存地址的值被设置为 JNI_FALSE。当 isCopy 指向的内存地址的值被设置为 JNI_FALSE 时,本地代码不能修改返回的字符串的内容。如果违反该规则,将会导致原始的 java.lang.String 实例对象也被修改。这将打破 java.lang.String 不可修改规则。

大部分情况是将 NULL 作为 isCopy 的参数传递给方法,因为你不用关注 Java 虚拟机返回的是 java.lang.String 实例的副本还是直接引用。

通常无法预测虚拟机是否会复制给定的 java.lang.String 中的字符。因此程序员必须了解到诸如 GetStringChars 之类的函数可能需要与 java.lang.String 实例中的字符数成比例的时间和空间开销。在典型的 Java 虚拟机实现中,垃圾收集器重新定位堆中的对象。一旦将指向 java.lang.String 实例的直接指针传回给本地代码中,垃圾收集器将不能重新定位 java.lang.String 实例。换句话说,虚拟机必须固定 java.lang.String 实例,因为过多的固定会导致内存碎片,所以虚拟机实现可以自由的选择为每个 GetStringChars 调用复制字符还是固定实例。

当你不再需要访问 GetStringChars 函数返回的字符串元素时,不要忘记调用 ReleaseStringChars。不管 GetStringChars 中的 isCopy 设置为 JNI_FALSE 还是 JNI_TRUE,ReleaseStringChars 都是必须调用的。ReleaseStringChars 会释放副本或者取消固定实例,具体取决于 GetStringChars 返回实例的副本还是固定实例。

3.2.5 在 Java 2 SDK 1.2 中新添加的 JNI 字符串函数

为了增加虚拟机返回 java.lang.String 实例字符的直接指针的可能性,Java 2 SDK 版本 1.2 引入了一组新的 Get/ReleaseStringCritical 函数。 在表面上,它们似乎与 Get/ReleaseStringChars 函数类似,如果可能的话,它们返回一个指向字符的指针; 否则,会复制一份。 但是,如何使用这些功能有很大的限制。

你必须将这对函数里的代码视为在临界区中运行,在临界区内,本地代码不能随意(arbitrary,博主这里翻译为随意的,但是感觉有点不对,但是不知道怎么翻译好,独占?感觉跟 Linux 内核中断处理有点像)调用 JNI 函数或者其他任意的会引起当前线程阻塞以及会等待 Java 虚拟机中另一个线程的本地函数。例如,当前线程不能够等待另一个线程的 I/O 输入流。这些限制使得虚拟机可以在本地代码持有通过 GetStringCritical 获取的字符串元素的直接指针时禁用垃圾回收。当垃圾收集器被禁用,其他触发垃圾收集器的线程都会被挂起。在 Get/ReleaseStringCritical 对之间的本地代码不能调用回引起阻塞的调用以及创建新对象。否则虚拟机可能会引起死锁,考虑如下场景:

  • 由另一个线程触发的垃圾回收无法进行,直到当前线程完成阻塞调用并重新启用垃圾回收。
  • 同时,当前的线程无法进行,因为阻塞调用需要获得一个已经被另一个正在等待执行垃圾回收的线程持有的锁。    重叠调用多对 GetStringCritical 和 ReleaseStringCritical 是安全的。例如:
jchar *s1, *s2;
s1 = (*env)->GetStringCritical(env, jstr1); if (s1 == NULL) {
... /* error handling */ }
s2 = (*env)->GetStringCritical(env, jstr2); if (s2 == NULL) {
(*env)->ReleaseStringCritical(env, jstr1, s1); ... /* error handling */
} ... /* use s1 and s2 */
(*env)->ReleaseStringCritical(env, jstr1, s1); (*env)->ReleaseStringCritical(env, jstr2, s2);

Get/ReleaseStringCritical 对的使用不需要以堆栈顺序严格嵌套。我们不能忘记检查其因内存不足导致返回值为 NULL 的情况,因为 GetStringCritical 仍然可以分配一个缓冲区,并且如果虚拟机内部表示不同格式的数组,则复制数组。例如,Java 虚拟机可能不会连续存储数组。在这种情况下,GetStringCritical 必须复制 jstring 实例中的所有字符,以便将本地代码的连续字符数组返回。

为了避免死锁,你应该确保你的本地代码在调用 GetStringCritical 之后和在调用 ReleaseStringCritical 之前不应当随意调用 JNI 函数,在临界区中唯一允许的 JNI 函数是嵌套调用 Get/ReleaseStringCritical 和 Get/ReleasePrimitiveArrayCritical。

JNI 不支持 GetStringUTFCritical 和 ReleaseStringUTFCritical 方法。这些函数几乎都需要虚拟机创建字符串的副本,这是因为虚拟机实现几乎在内部都是使用 Unicode 格式来存储字符串。

另外在 Java SDK 2 Release 1.2 中增加的函数是 GetStringRegion 和 GetStringUTFRegion。这些函数将字符串元素复制到一个预先分配的内存当中。Prompt.getLine 方法可以使用 GetStringUTFRegion 进行重写:

JNIEXPORT jstring JNICALL
Java_Prompt_getLine(JNIEnv *env, jobject obj, jstring prompt) {
    /* assume the prompt string and user input has less than 128 characters */
    char outbuf[128], inbuf[128];
    int len = (*env)->GetStringLength(env, prompt);
    (*env)->GetStringUTFRegion(env, prompt, 0, len, outbuf);
    printf("%s", outbuf);
    scanf("%s", inbuf);
    return (*env)->NewStringUTF(env, inbuf);
}

GetStringUTFRegion 将字符串开始的下标和长度作为参数,这两个值都是以 Unicode 字符来统计。这个函数同时做边界检查,同时有必要会抛出 StringIndexOutOfBoundsExecption 异常。上面的代码中,我们从字符串引用本身获取到长度,因此可以确认不会出现下标越界(但是上面的代码缺少对 prompt 的检查,以确保其长度低于 128 个字符)。

3.2.6 JNI 字符串函数总结

&esmp;表 3.1 中列出所有字符串相关的 JNI 函数,Java 2 SDK 1.2 版本增加了一些增强某些字符串操作性能的新功能。 除了提高性能之外,增加的功能不支持新的操作。

表 3.1 JNI 字符串函数总结

JNI 函数描述从哪个版本开始
GetStringChars\ReleaseStringChars获取或释放指向 Unicode 格式的字符串内容的指针。可能会返回字符串的副本。JDK 1.1
GetStringUTFChars\ReleaseStringUTFChars获取或释放指向 UTF-8 格式的字符串内容指针。可能会返回字符串的副本JDK 1.1
GetStringLength返回字符串中 Unicode 字符的数量JDK 1.1
GetStringUTFLength返回以 UTF-8 格式表示字符串所需的字节数(不包括尾数 0)。JDK 1.1
NewString创建拥有和给定的 Unicode 格式 C 式字符串相同字符序列的 java.lang.String 实例JDK 1.1
NewStringUTF创建拥有和给定的 UTF-8 格式 C 式字符串相同字符序列的 java.lang.String 实例JDK 1.1
GetStringCritical\ReleaseStringCritical获取指向 Unicode 格式的字符串内容的指针。 可能会返回字符串的副本。本地代码不能在 Get/ReleaseStringCritical 调用中间阻塞Java 2 JDK 1.2
GetStringRegion\SetStringRegion以 Unicode 格式将字符串的内容复制到预分配的 C 缓冲器到或从预分配的 C 缓冲区中复制。Java 2 JDK 1.2
GetStringUTFRegion\SetStringUTFRegion以 UTF-8 格式将字符串的内容复制到预分配的 C 缓冲区中或从预分配的 C 缓冲区中复制Java 2 JDK 1.2

3.2.7 选择合适的字符串函数

图 3.2 中表明程序员应该如何在 JDK release 1.1 和 Java 2 SDK release 1.2 中选择合适字符串相关函数。

(译文) JNI编程指南与规范1~4章Java 本地接口(JNI)是 JAVA 平台中的一个强大功能。使用 JNI 编程

如果你使用的是 1.1 或者 1.1 和 1.2 发行版的 JDK,那么除了 Get/ReleaseStringChars 和 Get/ReleaseStringUTFChars 外没有其余的选择了。

如果你是使用 Java 2 JDK release 1.2 及以上的版本进行编程,并且想字符串的内容复制到已经分配了 C 缓冲区中,使用 GetStringRegion 或者 GetStringUTFRegion。

对于小型固定大小的字符串,Get/SetStringRegion 和 Get/SetStringUTFRegion 几乎总是首选函数,因为 C 缓冲区在 C 堆栈上进行分配是开销是非常小的。在字符串中复制少量字符的开销是微不足道的。

Get/SetStringRegion 和 Get/SetStringUTFRegion 的一个优点是它们不执行内存分配,因此不会引起意外的内存不足异常。 如果确保不能发生索引溢出,则不需要进行异常检查。Get/SetStringRegion 和 Get/SetStringUTFRegion 的另一个优点是您可以指定起始索引和字符数。 如果本地代码仅需要访问长字符串中的字符子集,那么这些函数是合适的。

使用 GetStringCritical 函数必须非常小心。你必须确保当持有一个通过 GetStringCritical 返回的指针时,本地代码在 Java 虚拟机中不会创建新对象或者会引起系统死锁的阻塞性调用。

下面是一个实例,演示了使用 GetStringCritical 产生的微妙问题。下面的代码获取字符串的内容,并调用 fprintf 函数将字符写入到文件句柄 fd 中:

/* This is not safe! */
const char *c_str = (*env)->GetStringCritical(env, j_str, 0);
if (c_str == NULL) {
.../* error handling */
}
fprintf(fd, "%s\n", c_str);
(*env)->ReleaseStringCritical(env, j_str, c_str);

上述代码的问题是当当前线程禁用垃圾收集时,写入文件句柄并不总是安全的。假设,例如,另一个线程 T 等待从 fd 文件句柄读取。 让我们进一步假设操作系统缓冲的设置方式使得 fprintf 调用等待,直到线程 T 完成从 fd 读取所有挂起的数据。我们已经构建了可能的死锁场景:如果线程 T 不能分配足够的内存 作为从文件句柄读取的缓冲区,它必须请求垃圾回收。 垃圾回收请求将被阻止,直到当前线程执行 ReleaseStringCritical,直到 fprintf 调用返回为止。 然而,fprintf 调用正在等待线程 T 从文件句柄中完成读取。

以下代码虽然与上述示例类似,但几乎肯定是无死锁的:

/* This code segment is OK. */
const char *c_str = (*env)->GetStringCritical(env, j_str, 0);
if (c_str == NULL) {
... /* error handling */
}
DrawString(c_str);
(*env)->ReleaseStringCritical(env, j_str, c_str);

DrawString 是一个能直接将字符串写到屏幕上的系统调用。除非屏幕显示驱动程序也是在同一虚拟机中运行的 Java 应用程序,否则 DrawString 函数将不会无限期地阻止等待垃圾收集发生。

总而言之,您需要考虑一对 Get/ReleaseStringCritical 调用之间所有可能的阻塞行为。

3.3 访问数组

JNI 以不同的方式对待基本数据类型数组和对象数组。基本数据类型数组包含基本数据类型,例如 int 和 boolean。对象数据包含引用类型元素,例如类实例或其他数组。例如下面使用 Java 编程语言编写的代码中:

int[] iarr;
float[] farr;
Object[] oarr;
int[][] arr2;

iarr 和 farr 是基本数据类型数组,而 oarr 和 arr2 是对象数组。

在本地代码中访问基本数据类型数组所需要的方法和访问字符串所需要的本地方法类似。让我们看一个基本例子,以下程序调用本地方法 sumArray,它将 int 数组的内容相加。

class IntArray {
    private native int sumArray(int[] arr);
    public static void main(String[] args) {
        IntArray p = new IntArray();
        int arr[] = new int[10];
        for (int i = 0; i < 10; i++) {
            arr[i] = i;
        }
        int sum = p.sumArray(arr);
        System.out.println("sum = " + sum);
    }
    static {
        System.loadLibrary("IntArray");
    }
}

3.3.1 在 C 中访问数组

数据由 jarray 引用类型及其“子类型”(如 jintArray)表示。正如 jstring 不是 C 式字符串一样,jarray 也不是 C 式数据。你不能直接访问 jarray 引用来完成 Java_IntArray_sumArray 本地方法的编写。下面的 C 代码是非法的也不会获取到想要的结果:

/* This program is illegal! */
JNIEXPORT jint JNICALL Java_IntArray_sumArray(JNIEnv *env, jobject obj, jintArray arr) {
    int i, sum = 0;
    for (i = 0; i < 10; i++) {
        sum += arr[i];
    }
}

你应该使用恰当的 JNI 函数来访问基本数据类型数组中的元素,就像下面展示的正确的例子一样:

JNIEXPORT jint JNICALL Java_IntArray_sumArray(JNIEnv *env, jobject obj, jintArray arr) {
    jint buf[10];
    jint i, sum = 0;
    (*env)->GetIntArrayRegion(env, arr, 0, 10, buf);
    for (i = 0; i < 10; i++) {
        sum += buf[i];
    }
    return sum;
}

3.3.2 访问基本数据类型数组

前面的例子中使用 GetIntArrayRegion 函数来复制整型数组中的所有元素到 C 缓冲区中。第三个参数是需要复制的元素的起始索引,第四个参数表示需要复制的元素的总数。一旦元素存储在 C 缓冲区中,我们就能够在本地代码中访问他们了。异常检查是不需要的,因为在这个例子中,我们知道数组的长度为 10,因此不会引发索引越界问题。

JNI 支持相应的 SetIntArrayRegion 函数,该函数允许本机代码修改 int 类型的数组元素。 还支持其他原始类型的数组(如 boolean、short 和 float 类型)。

JNI 支持一系列 Get/Release<Type>ArrayElements(博主注<Type> 表示的是基本类型,例如 int、float 等,因为博客 Markdown 解析不好,实在没办法弄好,各位看官就将就看了,下同)函数(包括例如 Get/ReleaseIntArrayElements),允许本机代码获得对原始数组元素的直接指针。因为底层垃圾收集器不支持固定,所以虚拟机可能会返回指向基本数据类型数组的副本的指针。我们可以使用 GetIntArrayElements 来重写 3.3.1 节中的本地代码实现函数(包括例如 Get/ReleaseIntArrayElements),允许本机代码获得对原始数组元素的直接指针。因为底层垃圾收集器不支持固定,所以虚拟机可能会返回指向基本数据类型数组的副本的指针。我们可以使用 GetIntArrayElements 来重写 3.3.1 节中的本地代码实现:

JNIEXPORT jint JNICALL Java_IntArray_sumArray(JNIEnv *env, jobject obj, jintArray arr) {
    jint *carr;
    jint i, sum = 0;
    carr = (*env)->GetIntArrayElements(env, arr, NULL);
    if (carr == NULL) {
        return 0; /* exception occurred */
    }
    for (i=0; i<10; i++) {
        sum += carr[i];
    }
    (*env)->ReleaseIntArrayElements(env, arr, carr, 0);
    return sum;
}

GetArrayLength 方法返回基本数据类型数组或对象数组中元素的个数。当第一次分配数组的时候,其长度就固定了。

Java 2 SDK 1.2 中介绍了 Get/ReleasePrimitiveArrayCritical 函数。当本地代码访问基本数据类型数组的时候,这些函数允许虚拟机禁用垃圾回收器。程序员注意使用这两个函数必须跟使用 Get/ReleaseStringCritical 函数一样小心。在 Get/ReleasePrimitiveArrayCritical 函数对中的本地代码不能随意调用 JNI 方法,不能进行可能导致死锁的阻塞操作。

3.3.3 访问基本数据类型数组总结

表 3.2 中列出了访问基本数据类型数据的相关 JNI 方法,Java 2 JDK 1.2 版本中增加了一些增加特定数组操作性能的函数,增加的函数没有提供新的操作,只是做了操作性能的提升而已:

表 3.2 访问基本数据类型数组总结

JNI 函数描述从哪个版本开始
Get<Type>ArrayRegion\Set<Type>ArrayRegion复制基本数据类型数组的内容到 C 缓冲区或者将 C 缓冲区的内容复制出来JDK 1.1
Get<Type>ArrayElements\Release<Type>ArrayElements获取一个指向基本数据类型数组内容的指针,可能会返回该数组的副本 JDK 1.1
GetArrayLength返回数组中元素的个数JDK 1.1
New<Type>Array创建一个给定长度的数组JDK 1.1
GetPrimitiveArrayCriticalReleasePrimitiveArrayCritical获取一个指向基本数据类型数组内容的指针,可能禁用垃圾收集器或者返回该数组的副本Java 2 JDK 1.2

3.3.4 选择合适的基本类型数组函数

图 3.3 表明,在 JDK 1.1 和 Java 2 JDK 1.2 版本中,程序员应如何选择恰当的 JNI 函数来访问基本数据类型数组。

(译文) JNI编程指南与规范1~4章Java 本地接口(JNI)是 JAVA 平台中的一个强大功能。使用 JNI 编程

如果你需要将数组内容复制到 C 缓冲区或者从 C 缓冲区中将内容复制到数组中,应当使用 Get/Set<Type>ArrayRegion 家族函数。这些函数会进行边界检查,并且如果有必要的话会抛出 ArrayIndexOutOfBoundsException 异常。第 3.3.1 节中的本地方法实现中使用 GetIntArrayRegion 方法从 jarray 引用中复制 10 个元素。

对于小型固定大小的阵列,Get/Set<Type>ArrayRegion 几乎总是首选函数,因为 C 缓冲区可以非常方便的从 C 堆栈中分配。复制少量数组元素的开销是微不足道的。

Get/Set<Type>ArrayRegion 函数允许您指定起始索引和元素数量,因此如果本地代码只需要访问大型数组中的元素的一个子集,则它们是首选函数。

如果没有预分配的 C 缓冲区,则原始数组的大小不确定,并且本机代码在持有指向数组元素的指针时不发出阻塞调用,请使用 Java 2 SDK 版本 1.2 中的 Get/ReleasePrimitiveArrayCritical 函数。 就像 Get/ReleaseStringCritical 函数一样,必须非常小心地使用 Get/ReleasePrimitiveArrayCritical 函数,以避免死锁。

使用 Get/Release<Type>ArrayElements 系列函数总是安全的。 虚拟机或者返回指向数组元素的直接指针,或者返回一个保存数组元素副本的缓冲区。

3.3.5 访问对象数组

JNI 提供了一对单独的函数来访问对象数组。GetObjectArrayElement 返回给定索引处的元素,而 SetObjectArrayElement 更新给定索引处的元素。与原始数组类型的情况不同,您不能一次获取所有对象元素或复制多个对象元素。字符串和数组是引用类型,你可以使用 Get/SetObjectArrayElememt 访问字符串数组和数组的数组。

下面的代码调用一个本地函数来创建一个 int 型二维数组,然后打印给数组的内容。

class ObjectArrayTest {
    private static native int[][] initInt2DArray(int size);
    public static void main(String[] args) {
        int[][] i2arr = initInt2DArray(3);
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                System.out.print(" " + i2arr[i][j]);
            }
            System.out.println();
        }
    }

    static {
        System.loadLibrary("ObjectArrayTest");
    }
}

本地方法 initInt2DArray 根据给定的大小创建一个二维数组,该本地方法分配和创建二维数组的代码可能如下所示:

JNIEXPORT jobjectArray JNICALL
Java_ObjectArrayTest_initInt2DArray(JNIEnv *env, jclass cls, int size)
{
    jobjectArray result;
    int i;
    jclass intArrCls = (*env)->FindClass(env, "[I");
    if (intArrCls == NULL) {
        return NULL; /* exception thrown */
    }
    result = (*env)->NewObjectArray(env, size, intArrCls, NULL);
    if (result == NULL) {
        return NULL; /* out of memory error thrown */
    }
    for (i = 0; i < size; i++) {
        jint tmp[256]; /* make sure it is large enough! */
        int j;
        jintArray iarr = (*env)->NewIntArray(env, size);
        if (iarr == NULL) {
            return NULL; /* out of memory error thrown */
        }
        for (j = 0; j < size; j++) {
            tmp[j] = i + j;
        }
        (*env)->SetIntArrayRegion(env, iarr, 0, size, tmp);
        (*env)->SetObjectArrayElement(env, result, i, iarr);
        (*env)->DeleteLocalRef(env, iarr);
    }
    return result;
}

initInt2DArray 方法首先调用 JNI 函数 FindClass 来获取一个对二维 int 类型数组的元素类的引用。FindClass 的参数“[I”是一个对于与 Java 编程语言中 int[]类型的 JNI 类描述符(12.3.2 节)。如果类型查询失败,FindClass 会返回 NULL 并抛出异常(例如由于缺少类文件或者内存不足的情况)。

下一步,NewObjectArray 函数分配一个数组,其元素类型由 intArrCls 类应用决定。NewObjectArray 仅仅分配第一个维度,我们仍然需要填写构成第二个维度的数组元素。Java 虚拟机中没有特殊的数据类型来表示多维数组。一个二维数组其实就是一个数组。

创建第二维数组的代码是简单易懂的。NewIntArray 分配独立的数组元素,SetIntArrayRegion 将 tmp 缓冲区的内容复制到新分配的一维数组中。完成 SetObjectArrayElement 调用后,第 i 个一维数组的第 j 个元素的值为 i+j。执行 ObjectArrayTest.main 方法可以获得如下输出:

0   1   2
1   2   3
2   3   4

在循环结尾调用 DeleteLocalRef 确保虚拟机不会因为保存 JNI 引用例如 iarr,而导致内存耗尽。5.2.1 节将解释为什么以及何时需要调用 DeleteLocalRef。

第四章 字段和方法

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

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

4.1 访问字段

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

class InstanceFieldAccess {
    private String s;
    private native void accessField();
    public static void main(String args[]) {
        InstanceFieldAccess c = new InstanceFieldAccess();
        c.s = "abc";
        c.accessField();
        System.out.println("In Java:");
        System.out.println(" c.s = \"" + c.s + "\"");
    }
    static {
        System.loadLibrary("InstanceFieldAccess");
    }
}

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

JNIEXPORT void JNICALL
Java_InstanceFieldAccess_accessField(JNIEnv *env, jobject obj) {
    jfieldID fid; /* store the field ID */
    jstring jstr;
    const char *str;

    /* Get a reference to obj’s class */
    jclass cls = (*env)->GetObjectClass(env, obj);
    printf("In C:\n");

    /* Look for the instance field s in cls */
    fid = (*env)->GetFieldID(env, cls, "s","Ljava/lang/String;");
    if (fid == NULL) {
        return; /* failed to find the field */
    }

    /* Read the instance field s */
    jstr = (*env)->GetObjectField(env, obj, fid);
    str = (*env)->GetStringUTFChars(env, jstr, NULL);
    if (str == NULL) {
        return; /* out of memory */
    }
    printf(" c.s = \"%s\"\n", str);
    (*env)->ReleaseStringUTFChars(env, jstr, str);

    /* Create a new string and overwrite the instance field */
    jstr = (*env)->NewStringUTF(env, "123");
    if (jstr == NULL) {
        return; /* out of memory */
    }
    (*env)->SetObjectField(env, obj, fid, jstr);
}

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

In C:
    c.s = "abc"
In Java:
    c.s = "123"

4.1.1 访问实例字段的过程

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

fid = (*env)->GetFieldID(env, cls, "s", "Ljava/lang/String;");

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

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

jstr = (*env)->GetObjectField(env, obj, fid);

因为字符串和数组是特殊类型的对象,我们使用 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编程指南与规范1~4章Java 本地接口(JNI)是 JAVA 平台中的一个强大功能。使用 JNI 编程

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

javap -s -p InstanceFieldAccess

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

s Ljava/lang/String;

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

4.1.3 访问静态字段

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

class StaticFielcdAccess {
    private static int si;
    private native void accessField();
    public static void main(String args[]) {
        StaticFieldAccess c = new StaticFieldAccess();
        StaticFieldAccess.si = 100;
        c.accessField();
        System.out.println("In Java:");
        System.out.println(" StaticFieldAccess.si = " + si);
    }
    static {
        System.loadLibrary("StaticFieldAccess");
    }
}

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

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

JNIEXPORT void JNICALL
Java_StaticFieldAccess_accessField(JNIEnv *env, jobject obj) {
    jfieldID fid; /* store the field ID */
    jint si;
    /* Get a reference to obj’s class */
    jclass cls = (*env)->GetObjectClass(env, obj);
    printf("In C:\n");

    /* Look for the static field si in cls */
    fid = (*env)->GetStaticFieldID(env, cls, "si", "I");
    if (fid == NULL) {
        return; /* field not found */
    }

    /* Access the static field si */
    si = (*env)->GetStaticIntField(env, cls, fid);
    printf(" StaticFieldAccess.si = %d\n", si);
    (*env)->SetStaticIntField(env, cls, fid, 200);
}

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

In C:
    StaticFieldAccess.si = 100
In Java:
    StaticFieldAccess.si = 200

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

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

4.2 调用方法

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

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

class InstanceMethodCall {
    private native void nativeMethod();
    private void callback() {
        System.out.println("In Java");
    }
    public static void main(String args[]) {
        InstanceMethodCall c = new InstanceMethodCall();
        c.nativeMethod();
    }
    static {
        System.loadLibrary("InstanceMethodCall");
    }
}

下面是本地代码的实现:

JNIEXPORT void JNICALL
Java_InstanceMethodCall_nativeMethod(JNIEnv *env, jobject obj) {
    jclass cls = (*env)->GetObjectClass(env, obj);
    jmethodID mid = (*env)->GetMethodID(env, cls, "callback", "()V");
    if (mid == NULL) {
        return; /* method not found */
    }
    printf("In C\n");
    (*env)->CallVoidMethod(env, obj, mid);
}

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

In C
In 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 方法:

jobject thd = ...; /* a java.lang.Thread instance */
jmethodID mid;
jclass runnableIntf =(*env)->FindClass(env, "java/lang/Runnable");
if (runnableIntf == NULL) {
    ... /* error handling */
}
mid = (*env)->GetMethodID(env, runnableIntf, "run", "()V");
if (mid == NULL) {
    ... /* error handling */
}
(*env)->CallVoidMethod(env, thd, mid);
... /* check for possible exceptions */

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

4.2.2 生成方法描述符

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

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

native private String getLine(String);

其方法描述符为

(Ljava/lang/String;)Ljava/lang/String;

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

javap -s -p InstanceMethodCall

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

private callback ()V
public static main ([Ljava/lang/String;)V
private native nativeMethod ()V

-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 有一些不同:

class StaticMethodCall {
    private native void nativeMethod();
    private static void callback() {
        System.out.println("In Java");
    }
    public static void main(String args[]) {
        StaticMethodCall c = new StaticMethodCall();
        c.nativeMethod();
    }
    static {
        System.loadLibrary("StaticMethodCall");
    }
}

下面是本地方法的实现:

JNIEXPORT void JNICALL
Java_StaticMethodCall_nativeMethod(JNIEnv *env, jobject obj) {
    jclass cls = (*env)->GetObjectClass(env, obj);
    jmethodID mid = (*env)->GetStaticMethodID(env, cls, "callback", "()V");
    if (mid == NULL) {
        return; /* method not found */
    }
    printf("In C\n");
    (*env)->CallStaticVoidMethod(env, cls, mid);
}

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

In C
In Java

4.2.4 调用父类的实例方法

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

  • 使用 GetMethodID 而不是 GetStaticMethodID 从超类引用中获取方法 ID
  • 将对象、超类、方法 ID 和参数传给非虚调用函数系列之一,例如 CallNonvirtualVoidMethod、CallNonvirtualBooleanMethod 等。    你需要调用超类的实例方法的机会相对较少,该工具类似于使用 Java 编程语言中的以下构造来调用覆盖的超类方法(如 f):
super.f();

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

4.3 调用构造方法

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

jstring MyNewString(JNIEnv *env, jchar *chars, jint len) {
    jclass stringClass;
    jmethodID cid;
    jcharArray elemArr;
    jstring result;

    stringClass = (*env)->FindClass(env, "java/lang/String");
    if (stringClass == NULL) {
        return NULL; /* exception thrown */
    }

    /* Get the method ID for the String(char[]) constructor */
    cid = (*env)->GetMethodID(env, stringClass, "<init>", "([C)V");
    if (cid == NULL) {
        return NULL; /* exception thrown */
    }

    /* Create a char[] that holds the string characters */
    elemArr = (*env)->NewCharArray(env, len);
    if (elemArr == NULL) {
        return NULL; /* exception thrown */
    }
    (*env)->SetCharArrayRegion(env, elemArr, 0, len, chars);

    /* Construct a java.lang.String object */
    result = (*env)->NewObject(env, stringClass, cid, elemArr);

    /* Free local references */
    (*env)->DeleteLocalRef(env, elemArr);
    (*env)->DeleteLocalRef(env, stringClass);

    return result;
}

这个例子是复杂的,值得进行仔细的分析。首先,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 调用

result = (*env)->NewObject(env, stringClass, cid, elemArr);

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

result = (*env)->AllocObject(env, stringClass);
if (result) {
    (*env)->CallNonvirtualVoidMethod(env, result, stringClass, cid, elemArr);
    /* we need to check for possible exceptions */
    if ((*env)->ExceptionCheck(env)) {
        (*env)->DeleteLocalRef(env, result);
        result = NULL;
    }
}

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

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

4.4 缓存字段和方法 ID

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

4.4.1 在使用时执行缓存

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

JNIEXPORT void JNICALL
Java_InstanceFieldAccess_accessField(JNIEnv *env, jobject obj) {
    static jfieldID fid_s = NULL; /* cached field ID for s */
    jclass cls = (*env)->GetObjectClass(env, obj);
    jstring jstr;
    const char *str;

    if (fid_s == NULL) {
        fid_s = (*env)->GetFieldID(env, cls, "s", "Ljava/lang/String;");
        if (fid_s == NULL) {
            return; /* exception already thrown */
        }
    }

    printf("In C:\n");
    jstr = (*env)->GetObjectField(env, obj, fid_s);
    str = (*env)->GetStringUTFChars(env, jstr, NULL);
    if (str == NULL) {
        return; /* out of memory */
    }

    printf(" c.s = \"%s\"\n", str);
    (*env)->ReleaseStringUTFChars(env, jstr, str);
    jstr = (*env)->NewStringUTF(env, "123");
    if (jstr == NULL) {
        return; /* out of memory */
    }

    (*env)->SetObjectField(env, obj, fid_s, jstr);
}

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

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

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

jstring
MyNewString(JNIEnv *env, jchar *chars, jint len) {
    jclass stringClass;
    jcharArray elemArr;
    static jmethodID cid = NULL;
    jstring result;

    stringClass = (*env)->FindClass(env, "java/lang/String");
    if (stringClass == NULL) {
        return NULL; /* exception thrown */
    }

    /* Note that cid is a static variable */
    if (cid == NULL) {
        /* Get the method ID for the String constructor */
        cid = (*env)->GetMethodID(env, stringClass, "<init>", "([C)V");
        if (cid == NULL) {
            return NULL; /* exception thrown */
        }
    }

    /* Create a char[] that holds the string characters */
    elemArr = (*env)->NewCharArray(env, len);
    if (elemArr == NULL) {
        return NULL; /* exception thrown */
    }
    (*env)->SetCharArrayRegion(env, elemArr, 0, len, chars);

    /* Construct a java.lang.String object */
    result = (*env)->NewObject(env, stringClass, cid, elemArr);

    /* Free local references */
    (*env)->DeleteLocalRef(env, elemArr);
    (*env)->DeleteLocalRef(env, stringClass);
    return result;
}

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

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

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

class InstanceMethodCall {
    <b>private static native void initIDs();</b>
    private native void nativeMethod();
    private void callback() {
        System.out.println("In Java");
    }
    public static void main(String args[]) {
        InstanceMethodCall c = new InstanceMethodCall();
        c.nativeMethod();
    }
    static {
        System.loadLibrary("InstanceMethodCall");
        <b>initIDs();</b>
    }
}

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

jmethodID MID_InstanceMethodCall_callback;

JNIEXPORT void JNICALL Java_InstanceMethodCall_initIDs(JNIEnv *env, jclass cls) {
    MID_InstanceMethodCall_callback = (*env)->GetMethodID(env, cls, "callback", "()V");
}

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

JNIEXPORT void JNICALL
Java_InstanceMethodCall_nativeMethod(JNIEnv *env, jobject obj) {
    printf("In C\n");
    (*env)->CallVoidMethod(env, obj, MID_InstanceMethodCall_callback);
}

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 字段访问的开销是可以忽略不计的,因为函数调用只需要几个周期。

转载自:https://juejin.cn/post/6930972583848312846
评论
请登录