(译文) JNI编程指南与规范9~11章节

877 阅读1小时+

第九章 利用现有的本地库

JNI 的一个应用就是利用已存在的本地库中现有的代码来编写本地方法。本章介绍的一个典型方法是生成一个封装了一系列本地方法的类库。

本章首先介绍编写包装类最直接的方法–一对一映射。然后我们介绍一项技术,共享桩,来简化编写封装类的任务。

一对一映射和共享桩都是封装本地方法的技术。在本章的最后,我们还将讨论如何使用 peer 类来封装本地数据结构。

本章中描述的方法直接使用本地方法公开本地库,因此具有使得调用这种本地方法的应用程序依赖于本地库的缺点。这样一个应用程序只能够运行在提供了该本地库的操作系统上。一个比较好的方法是声明操作系统无关的本地方法。只有实现这些本地方法的本地函数直接使用本地库,限制了移植到本地函数的需要。包括原生方法声明在内的应用程序不需要移植。(这最后一句不是很明白什么意思,原文:A preferred approach is to declare operating system-independent native methods. Only the native functions implementing those native methods use the native libraries directly, limiting the need for porting to those native functions. The application, including the native method declarations, does not need to be ported.

9.1 一对一映射

让我们从一个简单的例子开始。假设我们想编写一个封装类来暴露标准 C 库中的 atol 方法:

long atol(const char *str);

atol 方法解析一个字符串并返回该字符串代表的十进制值。可能没有什么理由需要在实践在定义这样一个函数,因为 Java API 已经提供了类型的方法,Integer.parseInt。例如,atol(“100”)的结果是一个 100 的整型值。我们定义的封装类如下:

public class C {
    public static native int atol(String str);
    ...
}

为了演示使用 C++来进行 JNI 编程,我们将会在这一章中使用 C++来实现本地方法。使用 C++实现的 C.atol 本地方法如下:

JNIEXPORT jint JNICALL
Java_C_atol(JNIEnv *env, jclass cls, jstring str)
{
    const char *cstr = env->GetStringUTFChars(str, 0);
    if (cstr == NULL) {
        return 0; /* out of memory */
    }
    int result = atol(cstr);
    env->ReleaseStringUTFChars(str, cstr);
    return result;
}

方法的实现是非常直接的。我们使用 GetStringUTFChars 转换 Unicode 字符串,因为十进制数值是 ASCII 字符。

让我们来测试第一个更复杂的例子,在例子中我们将传递一个结构指针给一个 C 函数。假设我们想编写这样一个封装类,这个类暴露出 Win32 平台的 CreateFile API 函数:

typedef void * HANDLE;
typedef long DWORD;
typedef struct {...} SECURITY_ATTRIBUTES;
HANDLE CreateFile(
    const char *fileName, // file name
    DWORD desiredAccess, // access (read-write) mode
    DWORD shareMode, // share mode
    SECURITY_ATTRIBUTES *attrs, // security attributes
    DWORD creationDistribution, // how to create
    DWORD flagsAndAttributes, // file attributes
    HANDLE templateFile // file with attr. to copy
);

CreateFile 函数支持一些 Win32 特有的而 Java 独立平台上的文件 API 不可用的特性。例如,CreateFile 方法可能会被用来指定特殊的访问模式和文件属性来打开 Win32 命名管道,和处理串口通信。

这这本书中,我们不会讨论 CreateFile 函数的太多细节。我们的关注点将会在如何将 CreateFile 函数映射到一个定义在名为 Win32 的封装类的本地方法中:

public class Win32 {
    public static native int CreateFile(
        String fileName, // file name
        int desiredAccess, // access (read-write) mode
        int shareMode, // share mode
        int[] secAttrs, // security attributes
        int creationDistribution, // how to create
        int flagsAndAttributes, // file attributes
        int templateFile); // file with attr. to copy
    ...
}

从 char 型指针到 String 类型是明显的。我们将本地 Win32 类型 long(DWORD)映射为 Java 编程语言的 int 型。Win32 类型 HANDLE,一个不透明的 32 位指针类型,也映射为 int 型。

因为字段是如何在内存在排列的潜在不同,我们不能将 C 结构体映射到 Java 编程语言的类中。作为替代,我们使用一个数组来存储 C 结构体 SECURITY_ATTRIBUTES 的内容。调用者也可能传递 null 作为 secAttrs 的参数来指定 Win32 安全属性的默认值。我们不会讨论 SECURITY_ATTRIBUTES 结构体的内容和如何在一个 int 数组中编码它们。

上面的本地方法的一个 C++实现如下所示:

JNIEXPORT jint JNICALL Java_Win32_CreateFile(
    JNIEnv *env,
    jclass cls,
    jstring fileName, // file name
    jint desiredAccess, // access (read-write) mode
    jint shareMode, // share mode
    jintArray secAttrs, // security attributes
    jint creationDistribution, // how to create
    jint flagsAndAttributes, // file attributes
    jint templateFile) // file with attr. to copy
{
    jint result = 0;
    jint *cSecAttrs = NULL;
    if (secAttrs) {
        cSecAttrs = env->GetIntArrayElements(secAttrs, 0);
        if (cSecAttrs == NULL) {
            return 0; /* out of memory */
        }
    }
    char *cFileName = JNU_GetStringNativeChars(env, fileName);
    if (cFileName) {
        /* call the real Win32 function */
        result = (jint)CreateFile(cFileName,
                    desiredAccess,
                    shareMode,
                    (SECURITY_ATTRIBUTES *)cSecAttrs,
                    creationDistribution,
                    flagsAndAttributes,
                    (HANDLE)templateFile);
        free(cFileName);
    }
    /* else fall through, out of memory exception thrown */
    if (secAttrs) {
        env->ReleaseIntArrayElements(secAttrs, cSecAttrs, 0);
    }
    return result;
}

首先我们将存储在 int 数组中的安全属性转换成一个 jint 数组。如果 secAttrs 参数是一个 NULL 引用,我们将 NULL 作为安全属性传递给 Win32 CreateFile 方法。接下来,我们调用一个辅助函数 JNU_GetStringNativeChars(8.2.2 节)来获取表示为特定语言环境的 C 字符串文件名。一旦我们完成安全属性和文件名的转换,我们将转换后的结果以及剩余的参数传递到 Win32 CreateFile 函数。

我们主要检查抛出的异常以及释放虚拟机资源(例如 CSecAttrs)。

C.atol 和 Win32.CreateFile 例子演示了一个通常的编写封装类和本地方法的方式。每一个本地函数(例如,CreateFile)映射到一个单独的本地桩函数(例如,Java_Win32_CreateFile),然后映射到一个定义的本地方法(例如,Win32.CreateFile)。在一对一映射中,桩函数有两个目的:

  • 这个桩将本地方法传递约定调整为 Java 虚拟机所期望的。虚拟机期望本地方法实现遵循给定的命名约定,并接受两个额外的参数(JNIEnv 指针和“this”指针)。
  • 这个桩在 Java 编程语言类型和本地类型间转换。例如,Java_Win32_CreateFile 函数将 jstring 文件名转换成一个特地语言环境的 C 字符串。

9.2 共享桩

一对一映射方法需要你为每一个你想封装的本地方法编写一个桩函数。当你面对为大量本地方法编写封装类的任务时,这将会变得很乏味。在这一节,我们将介绍共享桩的概念以及演示共享桩是如何用来简化编写封装类的任务的。

一个共享桩是一个派生到其他本地方法的本地方法。共享桩负责将从调用者提供的参数类型转换成本地函数接收的类型。

我们将要介绍的一个共享桩类 CFunction,但是首先让我们演示它是如何简化 C.atol 方法的实现的。

public class C {
    private static CFunction c_atol =
        new CFunction("msvcrt.dll", // native library name
                "atol", // C function name
                "C"); // calling convention
    public static int atol(String str) {
        return c_atol.callInt(new Object[] {str});
    }
    ...
}

C.atol 不再是一个本地方法(从而不再需要一个桩函数)。作为替代的,C.atol 被定义为使用 CFunction 类。CFunction 类内部实现了一个共享桩。静态变量 C.c_atol 存储了一个对应于在 msvcrt.dll 库(Win32 上的多线程 C 库)中的 C 函数 atol 的 CFunction 实例对象。CFunction 构造函数调用还指定 atol 遵循 C 调用约定。一旦 c_atol 字段被初始化,调用 C.atol 方法只需要通过 c_atol.callInt(共享桩)来重新调度。

CFunction 类属于我们将要建立和使用的类层次结构:

  CFunction 类的实例表示一个指向 C 函数的指针。 CFunction 是 CPointer 的一个子类,它表示任意的 C 指针.

public class CFunction extends CPointer {
    public CFunction(String lib, // native library name
                String fname, // C function name
                String conv) { // calling convention
    ...
    }
    public native int callInt(Object[] args);
    ...
}

callInt 方法将一个 java.lang.Object 数组作为它的参数。它检查元素中的数组类型、转换它们(例如,从 jstring 到 char *)并将它们作为参数传递给底层 C 函数。然后将 callInt 方法返回底层 C 函数的结果作为 int 型值。CFunction 类能够定义诸如 callFloat 或者 callDouble 的方法来处理具有其他返回类型的 C 函数。

CPointer 类定义如下:

public abstract class CPointer {
    public native void copyIn(
        int bOff, // offset from a C pointer
        int[] buf, // source data
        int off, // offset into source
        int len); // number of elements to be copied
    public native void copyOut(...);
    ...
}

CPointer 是一个支持任意访问 C 指针的抽象类。例如,copyIn 方法,从一个 int 数组中复制一些元素到 C 指针指向的位置。这个方法必须小心使用,因为它很容易被用来破坏地址空间中的任意内存位置。诸如 CPointer.copyIn 之类的本地方法与 C 中的直接指针操作一样不安全。

CMalloc 是 CPointer 的子类,它指向在 C 堆中使用 malloc 分配的一大块内存空间。

public class CMalloc extends CPointer {
    public CMalloc(int size) throws OutOfMemoryError { ... }
    public native void free();
    ...
}

CMalloc 构造函数在 C 堆中分配给定大小的一片内存区域,而 CMalloc.free 方法释放这片内存区域。

使用 CFunction 和 CMalloc 类,我们可以重新实现 Win32.CreateFile,如下所示:

public class Win32 {
    private static CFunction c_CreateFile =
        new CFunction ("kernel32.dll", // native library name
                "CreateFileA", // native function
                "JNI"); // calling convention

    public static int CreateFile(
        String fileName, // file name
        int desiredAccess, // access (read-write) mode
        int shareMode, // share mode
        int[] secAttrs, // security attributes
        int creationDistribution, // how to create
        int flagsAndAttributes, // file attributes
        int templateFile) // file with attr. to copy
    {
        CMalloc cSecAttrs = null;
        if (secAttrs != null) {
            cSecAttrs = new CMalloc(secAttrs.length * 4);
            cSecAttrs.copyIn(0, secAttrs, 0, secAttrs.length);
        }
        try {
            return c_CreateFile.callInt(new Object[] {
                        fileName,
                        new Integer(desiredAccess),
                        new Integer(shareMode),
                        cSecAttrs,
                        new Integer(creationDistribution),
                        new Integer(flagsAndAttributes),
                        new Integer(templateFile)});
        } finally {
            if (secAttrs != null) {
                cSecAttrs.free();
            }
        }
    }
    ...
}

我们在一个静态变量中缓存 CFunction 对象。Win32 API CreateFile 作为 CreateFileA 方法从 kernel32.dll 导出。另一个导出的入口,CreateFileW,将一个 Unicode 字符串作为文件名参数。这个函数遵循 JNI 调用约定,这是标准的 Win32.CreateFile 调用约定(stdcall)。

Win32.CreateFile 实现首先在 C 堆中分配一块足够容纳安全属性参数的内存区域。然后将所有参数打包到数组中,并通过共享调度程序调用底层 C 函数 CreateFileA。最后 Win32.CreateFile 方法释放用来暂时存放安全属性参数的内存块。我们在 finally 子句中调用 cSecAttrs.free 来确保临时 C 内存被释放,即使 c_CreateFile.callInt 调用引发异常。

9.3 一对一映射和共享桩对比

一对一映射和共享桩是为本地库建立封装类的两种方式。每一种都有各自的优势。

共享桩方式的优势是程序员不需要在本地代码中编写大量的桩函数。一旦一个共享桩实现例如 CFunction,可用,程序员可能就不需要在多写一行本地代码来建立封装类。

但是必须小心的使用共享桩。通过共享桩,程序员在本质上可以使用 Java 编程语言编写 C 代码。这破坏了 Java 编程语言的类型安全性。错误的使用共享桩可能会导致破坏内存排布错误和应用程序奔溃。

一对一映射的优势在于,它在转换 Java 虚拟机和本地代码之间的数据类型传输时通常更加高效。另一方面,共享桩最多处理一组预定义的参数类型,即使对于这些参数类型也不能实现最佳的性能。CFunction.callInt 的调用者经常需要为每一个 int 参数创建 Integer 对象。这增加了共享桩方案的内存和时间开销。

在实践中,你需要平衡性能、可移植性和短期生产力。共享桩可能适合利用本质上不可移植的代码,这些代码可以容忍轻微的性能下降,而在需要最高性能和可移植性的情况下,应该使用一对一映射。

9.4 实现共享桩

到目前为止,我们已经将 CFunction、CPointer 和 CMalloc 类视为黑盒子。本节介绍如何使用基本的 JNI 功能来实现他们。

9.4.1 CPointer 类

我们想看看 CPointer 类,因为它是 CFunction 和 CMalloc 的超类。虚类 CPointer 包含一个 64 位的域,peer,存储着底层 C 指针:

public abstract class CPointer {
    protected long peer;
    public native void copyIn(int bOff, int[] buf, int off,int len);
    public native void copyOut(...);
    ...
}

本地方法例如 copyIn 的实现是相当直接的:

JNIEXPORT void JNICALL
Java_CPointer_copyIn__I_3III(JNIEnv *env, jobject self, jint boff,
    jintArray arr, jint off, jint len)
{
    long peer = env->GetLongField(self, FID_CPointer_peer);
    env->GetIntArrayRegion(arr, off, len, (jint *)peer + boff);
}

FID_CPointer_peer 是一个为 CPointer.peer 预先计算的字段 ID。本地代码实现使用长名称命名方案(11.3 节)来解决在 CPointer 类中与其他数组类型的重载 copyIn 本地方法实现的冲突。

9.4.2 CMalloc 类

CMalloc 类添加两个本地方法,用来分配是释放 C 内存块:

public class CMalloc extends CPointer {
    private static native long malloc(int size);
    public CMalloc(int size) throws OutOfMemoryError {
        peer = malloc(size);
        if (peer == 0) {
            throw new OutOfMemoryError();
        }
    }
    public native void free();
    ...
}

CMalloc 构造函数调用本地方法 CMalloc.malloc,并且如果 CMalloc.malloc 在 C 堆栈空间中分配内存块失败的话,那么它会抛出一个异常。我们可以将 CMallo.malloc 和 CMalloc.free 方法实现如下:

JNIEXPORT jlong JNICALL
Java_CMalloc_malloc(JNIEnv *env, jclass cls, jint size) {
    return (jlong)malloc(size);
}

JNIEXPORT void JNICALL
Java_CMalloc_free(JNIEnv *env, jobject self) {
    long peer = env->GetLongField(self, FID_CPointer_peer);
    free((void *)peer);
}

9.4.3 CFunction 类

CFunction 类的实现要求在操作系统中使用动态链接支持以及 CPU 特定的汇编代码。下面介绍的实现是针对 Win32/Intel X86 环境。一旦你理解了实现 CFunction 类的原理,你可以按照相同的步骤在其他平台上实现它。

CFunction 类实现如下:

public class CFunction extends CPointer {
    private static final int CONV_C = 0;
    private static final int CONV_JNI = 1;
    private int conv;
    private native long find(String lib, String fname);

    public CFunction(String lib,// native library name
            String fname, // C function name
            String conv) { // calling convention
        if (conv.equals("C")) {
            conv = CONV_C;
        } else if (conv.equals("JNI")) {
            conv = CONV_JNI;
        } else {
            throw new IllegalArgumentException( "bad calling convention");
        }
        peer = find(lib, fname);
    }

    public native int callInt(Object[] args);
    ...
}

CFunction 类声明了一个私有字段 conv,用来存储 C 函数的调用规则。CFunction.find 本地方法实现如下:

JNIEXPORT jlong JNICALL
Java_CFunction_find(JNIEnv *env, jobject self, jstring lib, jstring fun)
{
    void *handle;
    void *func;
    char *libname;
    char *funname;

    if ((libname = JNU_GetStringNativeChars(env, lib))) {
        if ((funname = JNU_GetStringNativeChars(env, fun))) {
            if ((handle = LoadLibrary(libname))) {
                if (!(func = GetProcAddress(handle, funname))) {
                    JNU_ThrowByName(env,
                        "java/lang/UnsatisfiedLinkError",
                        funname);
                }
            } else {
                JNU_ThrowByName(env,
                    "java/lang/UnsatisfiedLinkError",
                    libname);
            }
            free(funname);
        }
        free(libname);
    }
    return (jlong)func;
}

CFunction.find 将库名和函数名转换为特定语言环境的 C 字符串,然后调用 Win32 API 函数 LoadLibrary 和 GetProcAddress 来定位在命名的本地库中 C 函数的位置。

callInt 方法,实现如下,执行重新调度到底层 C 语言的任务:

JNIEXPORT jint JNICALL
Java_CFunction_callInt(JNIEnv *env, jobject self, jobjectArray arr)
{
#define MAX_NARGS 32
    jint ires;
    int nargs, nwords;
    jboolean is_string[MAX_NARGS];
    word_t args[MAX_NARGS];

    nargs = env->GetArrayLength(arr);
    if (nargs > MAX_NARGS) {
        JNU_ThrowByName(env,
            "java/lang/IllegalArgumentException",
            "too many arguments");
        return 0;
    }

    // convert arguments
    for (nwords = 0; nwords < nargs; nwords++) {
        is_string[nwords] = JNI_FALSE;
        jobject arg = env->GetObjectArrayElement(arr, nwords);

        if (arg == NULL) {
            args[nwords].p = NULL;
        } else if (env->IsInstanceOf(arg, Class_Integer)) {
            args[nwords].i =
                env->GetIntField(arg, FID_Integer_value);
        } else if (env->IsInstanceOf(arg, Class_Float)) {
            args[nwords].f =
                env->GetFloatField(arg, FID_Float_value);
        } else if (env->IsInstanceOf(arg, Class_CPointer)) {
            args[nwords].p = (void *)
                env->GetLongField(arg, FID_CPointer_peer);
        } else if (env->IsInstanceOf(arg, Class_String)) {
            char * cstr =
                JNU_GetStringNativeChars(env, (jstring)arg);
            if ((args[nwords].p = cstr) == NULL) {
                goto cleanup; // error thrown
            }
            is_string[nwords] = JNI_TRUE;
        } else {
            JNU_ThrowByName(env,
                "java/lang/IllegalArgumentException",
                "unrecognized argument type");
            goto cleanup;
        }
        env->DeleteLocalRef(arg);
    }
    void *func =
        (void *)env->GetLongField(self, FID_CPointer_peer);
    int conv = env->GetIntField(self, FID_CFunction_conv);

    // now transfer control to func.
    ires = asm_dispatch(func, nwords, args, conv);

cleanup:
    // free all the native strings we have created
    for (int i = 0; i < nwords; i++) {
        if (is_string[i]) {
            free(args[i].p);
        }
    }
    return ires;
}

我们假设我们已经设置了一系列的全局变量来缓存适当的类引用和字段 ID。例如,全局引用 FID_CPointer_peer 缓存 CPointer.peer 的字段 ID 和全局引用 Class_String 是引用 java.lang.String 的全局引用。word_t 类型代表一个机器字,定义如下:

typedef union {
    jint i;
    jfloat f;
    void *p;
} word_t;

Java_CFunction_callInt 函数遍历参数数组,并检查每一个元素的类型:

  • 如果元素是 null 引用,它将 NULL 指针传递给 C 函数
  • 如果元素是 java.lang.Integer 类的实例,将提取其整数值并传递给 C 函数
  • 如果元素时 java.lang.Float 类的实例,将提取其浮点值并传递给 C 函数
  • 如果元素是 CPointer 类的实例,将提取其 peer 指针并传递给 C 函数
  • 如果参数是 java.lang.String 的实例,它将被转换为本地语言环境的 C 字符串并传递给 C 方法
  • 否则,IllegalArgumentException 将会被抛出 在从 Java_CFunction_callInt 方法返回前,我们需要认真的检查在转换参数和释放 C 字符串分配的临时存储可能产生的错误。

将参数从临时缓冲区参数传递给 C 函数的代码需要直接操作 C 堆栈。它是用内联程序集编写的:

int asm_dispatch(void *func, // pointer to the C function
            int nwords, // number of words in args array
            word_t *args, // start of the argument data
            int conv)  // calling convention 0: C
                        // 1: JNI
{
    __asm {
        mov esi, args
        mov edx, nwords
        // word address -> byte address
        shl edx, 2
        sub edx, 4
        jc args_done

        // push the last argument first
args_loop:
        mov eax, DWORD PTR [esi+edx]
        push eax
        sub edx, 4
        jge SHORT args_loop

args_done:
        call func
        // check for calling convention
        mov edx, conv
        or edx, edx
        jnz jni_call

        // pop the arguments
        mov edx, nwords
        shl edx, 2
        add esp, edx
jni_call:
        // done, return value in eax
    }
}

汇编例程将参数复制到 C 堆栈,然后重新分派到 C 函数 func。func 返回后,asm_dispatch 例程检查 func 的调用约定。如果 func 遵循 C 调用约定,asm_dispatch 将弹出传递给 func 的参数。 如果 func 遵循 JNI 调用约定,则 asm_dispatch 不会弹出参数; func 在返回之前弹出参数。

9.5 Peer 类

一对一映射和共享桩都解决了封装本地函数的问题。 在构建共享存根实现过程中,也遇到了包装本地数据结构的问题。 回想一下 CPointer 类的定义:

public abstract class CPointer {
    protected long peer;
    public native void copyIn(int bOff, int[] buf, int off, int len);
    public native void copyOut(...);
    ...
}

它包含一个引用本地数据结构(在本例中是 C 地址空间中的一块内存)的 64 位 peer 字段。CPointer 的子类为 peer 字段赋予特定的含义。例如,CMalloc 类使用 peer 字段来指向 C 堆中的一块内存:

   与本地数据结构(如 CPointer 和 CMalloc)直接对应的类称为 peer 类。您可以为各种本地数据结构构建 peer 类,例如:

  • 文件描述符
  • 套接字描述符
  • 窗口或其他图形用户界面组件

9.5.1 Java 平台中的 Peer 类

当前 Java 2 SDK 1.2 版本在内部使用 peer 类来实现 java.io、java.net 和 java.awt 包。例如,java.io.FileDescriptor 类,包含一个代表本地文件描述符的私有字段 fd。

// Implementation of the java.io.FileDescriptor class
public final class FileDescriptor {
    private int fd;
    ...
}

假设你想执行一个 Java 平台不支持的文件操作。你可能会尝试使用 JNI 来查找 java.io.FileDescriptor 实例的底层文件描述符。只要你知道字段的名字和类型,JNI 就允许你访问一个私有字段。你可能想你可以直接在本地文件描述符上直接操作。但是,这种方法存在一些问题:

  • 首先,你依赖在一个名为 fd 的私有字段中存储本地文件描述符的 java.io.FileDescriptor 实现。但是,不能保证将来 Sun 公司或 java.io.FileDescriptor 类的第三方实现的实现仍将使用相同的私有字段名称 fd 作为本机文件描述符。假定 peer 字段名称的本地代码可能无法与 Java 平台的不同实现一起使用。
  • 其次,直接在本机文件描述符上执行的操作可能会破坏 peer 类的内部一致性。例如,java.io.FileDescriptor 实例维护一个内部状态,指示底层本机文件描述符是否已关闭。如果你使用本地代码绕过 peer 类并直接关闭底层文件描述符,则在 java.io.FileDescriptor 实例中维护的状态将不再与本地文件描述符的真实状态保持一致。peer 类实现通常假定它们具有对底层本地数据结构的独占访问权。    解决这些问题的唯一办法是定义你自己的封装了本地数据结构的 peer 类。在上面例子中,你可以定义你自己的瞒住需求操作的文件描述符 peer 类。这种方法不是让你使用你自己的 peer 类来实现 Java API 类。例如,您不能将自己的文件描述符实例传递给期望 java.io.FileDescriptor 实例的方法。但是,你可以在 Java API 中实现标准接口中轻松定义自己的 peer 类。这是基于接口而不是类来设计 API 的有力论据。

9.5.2 释放本地数据结构

Peer 类使用 Java 编程语言来定义,尽管 peer 类的实例会被垃圾收集器自动回收。但是你要确保底层本地数据结构也会被同时释放。

回想一下,CMalloc 类包含一个用于显式释放 malloc 的 C 内存的 free 方法:

public class CMalloc extends CPointer {
    public native void free();
    ...
}

你必须记住调用 CMalloc 类的实例的 free 方法,否则,CMalloc 类实例可能会被垃圾收集器回收,但是他对应的 malloc 分配的 C 内存去不会被回收。

一些程序员喜欢在 peer 类中加入一个 finalize 方法,例如 CMalloc:

public class CMalloc extends CPointer {
    public native synchronized void free();
    protected void finalize() {
        free();
    }
    ...
}

虚拟机调用 finalize 方法,在它垃圾回收 CMalloc 实例前。及时你忘记调用 free 方法,finalize 方法会帮你释放 malloc 申请的 C 内存区。

你需要对 CMalloc.free 原生方法实现进行一些小改动,以说明可能被多次调用的可能性。你还需要使 CMalloc.free 成为一个同步方法以避免线程竞争条件:

JNIEXPORT void JNICALL
Java_CMalloc_free(JNIEnv *env, jobject self)
{
    long peer = env->GetLongField(self, FID_CPointer_peer);
    if (peer == 0) {
        return; /* not an error, freed previously */
    }
    free((void *)peer);
    peer = 0;
    env->SetLongField(self, FID_CPointer_peer, peer);
}

我们使用两条语句来设置 peer 字段:

peer = 0;
env->SetLongField(self, FID_CPointer_peer, peer);

而不是一条语句:

env->SetLongField(self, FID_CPointer_peer, 0);

因为 C++编译器会将文字 0 视为 32 位整数,而不是 64 位整数。一些 C++编译器允许你指定 64 位整数文字,但使用 64 位文字不会那么便于使用。

定义一个 finalize 方法是一个适当的保护措施,但是你永远不应该依靠 finalizer 作为释放原生数据结构的唯一手段。原因是本地数据结构可能比对等实例消耗更多的资源。Java 虚拟机在垃圾收集和 finalize 实例时,可能无法以足够快的速度释放本地资源。

定义一个 finalize 方法可能会带来性能方面的问题。通常创建和回收带 finalize 方法的实例要比创建和回收不带 finalize 方法的实例要慢。

如果你能确保你能够为 peer 类手动回收本地数据结构,你就不需要定义一个 finalize 方法。当时你必须确保在所有执行路径上释放了本地数据结构;否则你可能导致了资源泄漏。请特别注意在使用 peer 实例的过程中可能引发的异常。始终在 finally 子句中释放本地数据结构:

CMalloc cptr = new CMalloc(10);
try {
    ... // use cptr
} finally {
    cptr.free();
}

finally 子句确保 cptr 会被释放,即使在 try 块中发生了异常。

9.5.3 Peer 实例的返回点

我们已经表明 peer 类通常包含一个指向底层本地数据结构的私有字段。在某些情况下,还希望将来自本地数据结构的引用包括在 peer 类的实例中。例如,当本地代码需要启动 peer 类中实例方法的回调时,就会出现这种情况。

假设我们正在构建一个名为 KeyInput 的假想用户界面组件。KeyInput 的本地 C++组件,key_input,在用户按下一个键时从操作系统中接收一个 key_pressed C++函数调用事件。key_input C++组件通过调用 KeyInput 实例上的 keyPressed 方法将操作系统事件报告给 KeyInput 实例。下图中的箭头指示按键事件是如何由用户按键发起的,并从 key_input C ++组件传播到 KeyInput 对等实例:

KeyInput peer 类定义如下:

class KeyInput {
    private long peer;
    private native long create();
    private native void destroy(long peer);

    public KeyInput()   {
        peer = create();
    }

    public destroy() {
        destroy(peer);
    }

    private void keyPressed(int key) {
        ... /* process the key event */
    }
}

create 本地方法实现分配一个 C++结构体 key_input 的实例。C++结构体类似与 C++类,仅有的不同是所有的成员在结构体中是公有的,而在类中是私有的。在这个例子中,我们使用一个 C++结构体而不是一个 C++类主要是毕淼和 Java 编程语言中的类混淆。

// C++ structure, native counterpart of KeyInput
struct key_input {
    jobject back_ptr;         // back pointer to peer instance
    int key_pressed(int key); // called by the operating system
};

JNIEXPORT jlong JNICALL Java_KeyInput_create(JNIEnv *env, jobject self)
{
    key_input *cpp_obj = new key_input();
    cpp_obj->back_ptr = env->NewGlobalRef(self);
    return (jlong)cpp_obj;
}

JNIEXPORT void JNICALL Java_KeyInput_destroy(JNIEnv *env, jobject self, jlong peer)
{
    key_input *cpp_obj = (key_input*)peer;
    env->DeleteGlobalRef(cpp_obj->back_ptr);
    delete cpp_obj;
    return;
}

create 本地方法分配一个 C++结构体并初始化它的 back_ptr 字段为一个指向 KeyInput peer 实例的全局引用。destroy 本地方法删除指向 peer 实例的全局引用和由 peer 实例应用的 C++结构体。keyInput 构造函数调用 create 方法设置 peer 实例和本地实例之间的连接:

   当用户点击一个按键,操作系统调用 C++的成员函数 key_input::key_pressed。此成员函数通过对 KeyInput peer 实例上的 keyPressed 方法执行回调来响应事件。

// returns 0 on success, -1 on failure
int key_input::key_pressed(int key) {
    jboolean has_exception;
    JNIEnv *env = JNU_GetEnv();
    JNU_CallMethodByName(env, &has_exception, java_peer, "keyPressed", "()V", key);
    if (has_exception) {
        env->ExceptionClear();
        return -1;
    } else {
        return 0;
    }
}

key_press 成员函数在回调之后清除任何异常,并使用-1 返回码将错误条件返回给操作系统。有关 JNU_CallMethodByName 和 JNU_GetEnv 实用程序函数的定义,分别参见 6.2.3 节和 8.4.1 节。

让我们在结束本节之前讨论最后一个问题。 假设您在 KeyInput 类中添加了 finalize 方法,以避免潜在的内存泄漏:

class KeyInput {
    ...
    public synchronized destroy() {
        if (peer != 0) {
            destroy(peer);
            peer = 0;
        }
    }

    protect void finalize() {
        destroy();
    }
}

destroy 方法检查 peer 字段是否为零,并在调用重载的 destroy 本地方法后将 peer 字段设置为零。它被定义为避免竞争条件的同步方法。

但是,上面的代码不会像你所期望的那样工作。虚拟机将永远不会垃圾收集任何 KeyInput 实例,除非你显式调用 destroy。KeyInput 构造函数创建 KeyInput 实例的 JNI 全局引用。全局引用可防止垃圾收集 KeyInput 实例。你可以通过使用弱全局引用而不是全局引用来克服此问题:

JNIEXPORT jlong JNICALL
Java_KeyInput_create(JNIEnv *env, jobject self)
{
    key_input *cpp_obj = new key_input();
    cpp_obj->back_ptr = env-><b>NewWeakGlobalRef</b>(self);
    return (jlong)cpp_obj;
}

JNIEXPORT void JNICALL
Java_KeyInput_destroy(JNIEnv *env, jobject self, jlong peer) {
    key_input *cpp_obj = (key_input*)peer;
    env-><b>DeleteWeakGlobalRef</b>(cpp_obj->back_ptr);
    delete cpp_obj;
    return;
}

第十章 陷阱与缺陷

为了突显前面几章中介绍的重要技术,本章涵盖了 JNI 程序员长犯的一些错误。这里描述的每个错误都发生在现实世界的项目中。

10.1 错误检查

编写本地方法最常见的错误是忘记检查是否发生了错误情况。与 Java 编程语言不同,本地语言不提供标准的异常机制。JNI 不依赖于任何特定的本地异常机制(例如 C++异常)。因此,在执行每一个可能会引起异常的调用后,程序员都需要执行显式检查。并不是所有的 JNI 函数都会引起异常,但是大多数都可以检查。异常检测是单调的,但是为了确保使用本地方法的应用程序的健壮性,确实有必须的。

错误检查的繁琐工作极大地强调了将本地代码限制在需要使用 JNI 的应用程序的那些定义良好的子集(第 10.5 节)的必要性。

10.2 传递错误的参数给 JNI 函数

JNI 函数不会尝试检测或从无效参数中恢复。 如果将 NULL 或(jobject)0xFFFFFFFF 传递给期望得到一个引用的 JNI 函数,则结果的行为是未定义的。实际上,这可能导致错误的结果或虚拟机崩溃。Java 2 SDK 版本 1.2 为你提供了命令行选项-Xcheck:jni。此选项指示虚拟机检测并报告许多(尽管不是全部)本机代码将非法参数传递给 JNI 函数的情况。检查参数的有效性会导致大量开销,因此默认情况下不会启用。

不检查参数的有效性是 C 和 C++库中的常见做法。使用库的代码有责任确保传递给库函数的所有参数都是有效的。但是,如果你习惯了 Java 编程语言,则可能需要适应 JNI 编程中缺乏安全性检查的特定方面。

10.3 混淆 jclass 和 jobject

第一次使用 JNI 时,实例引用(jobject 类型的值)和类引用(jclass 类型的值)之间的区别可能会引起混淆。

实例引用对应于数组、java.lang.Object 实例或其一个子类。 类引用对应于表示类类型的 java.lang.Class 实例。

像 GetFieldID 这样的接受 jclass 的操作是一个类操作,因为它从类中获取字段描述符。相反,使用 jobject 的 GetIntField 是一个实例操作,因为它从一个实例中获取一个字段的值。jobject 与实例操作以及 jclass 与类操作的关联在所有的 JNI 函数中是一致的,所以很容易记住类操作与实例操作是不同的。

10.4 阶段 jboolean 参数

jboolean 是一个 8 位无符号 C 类型,可以存储 0 到 255 之间的值。值 0 对应于常量 JNI_FALSE,1 到 255 之间的值对应于 JNI_TRUE。但是,32 位或 16 位值大于 255 的而低 8 位为 0 的数会造成问题。

假设你已经定义了一个函数 print,它带有一个类型为 jboolean 的参数条件:

void print(jboolean condition) {
    /* C compilers generate code that truncates condition to its lower 8 bits. */
    if (condition) {
        printf("true\n");
    } else {
        printf("false\n");
    }
}

以前的定义没有错。然而,一下合法的调用会产生一些意想不到的结果:

int n = 256; /* the value 0x100, whose lower 8 bits are all 0 */
print(n);

我们传递一个非零值(256)来打印,期望它代表真实。但是因为除了低位 8 以外的其他所有位都被截断了,所以参数的计算结果为 0.程序打印出“假”,与预期相反。

将强制类型(如 int)强制转换为 jboolean 时,一个好的经验法则是始终评估整型类型的条件,从而避免强制过程中的无意错误。您可以重写 print 方法如下:

n = 256;
print (n ? JNI_TRUE : JNI_FALSE);

10.5 Java 应用程序与本地代码之间的界限

设计本地代码所支持的 Java 应用程序时,常见的问题是“本机代码中应该包含哪些和多少内容?”本机代码与以 Java 编程语言编写的其他应用程序之间的界限是特定于应用程序的, 但有一些普遍适用的原则:

  • 保持边界简单。 在 Java 虚拟机和本机代码之间来回复杂的控制流可能很难调试和维护。这种控制流程也阻碍了高性能虚拟机实现的优化。例如,虚拟机实现内联在 Java 编程语言中定义的方法要比在 C 和 C ++中定义的内联本机方法容易得多。
  • 尽量减少本地代码的代码。这样做有令人信服的理由。本机代码既不可移植,也不是类型安全的。在本地代码中检查错误是很乏味的(§10.1)。将这些部分保持在最低限度将是很好的软件工程。
  • 保持本地代码隔离。实际上,这可能意味着所有本地方法都在同一个包中,或者在同一个类中,与应用程序的其余部分分离。包或者包含本地方法的类本质上成为应用程序的“移植层”。   JNI 提供对虚拟机功能的访问,如类加载,对象创建,字段访问,方法调用,线程同步等等。有时候用本地代码来进行与 Java 虚拟机功能的复杂交互很有诱惑力,事实上,在 Java 编程语言中完成相同的任务更简单。下面的例子说明了为什么“在本机代码中进行 Java 编程”是不好的做法。考虑一个简单的语句来创建一个用 Java 编程语言编写的新线程:
new JobThread().start();

同样的代码可以用本地代码重写:

/* Assume these variables are precomputed and cached:
 *     Class_JobThread:  the class "JobThread"
 *     MID_Thread_init:  method ID of constructor
 *     MID_Thread_start: method ID of Thread.start()
 */
aThreadObject = (*env)->NewObject(env, Class_JobThread, MID_Thread_init);
if (aThreadObject == NULL) {
    ... /* out of memory */
}
(*env)->CallVoidMethod(env, aThreadObject, MID_Thread_start);
if ((*env)->ExceptionOccurred(env)) {
    ... /* thread did not start */
}

尽管我们已经省略了错误检查所需的代码行,但是本机代码比用 Java 编程语言编写的代码要复杂得多。

而不是编写一个复杂的操作 Java 虚拟机的本地代码片段,通常最好是用 Java 编程语言定义一个辅助方法,并让本机代码向辅助方法发出回调。

10.6 混淆 ID 和引用

JNI 将对象作为引用。类,字符串和数组是特殊类型的引用。JNI 将方法和字段作为 ID。一个 ID 不是一个参考。不要将类引用称为“类 ID”,也不要将方法 ID 称为“方法引用”。

引用是可以由本地代码显式管理的虚拟机资源。例如,JNI 函数 DeleteLocalRef 允许本地代码删除本地引用。相比之下,字段和方法 ID 由虚拟机管理并保持有效直到其定义的类被卸载。在虚拟机卸载定义的类之前,本机代码不能显式删除字段或方法 ID。

本机代码可能会创建多个引用同一个对象的引用。例如,全局引用和本地引用可能指的是同一个对象。 相反,为字段或方法的相同定义仅导出唯一的字段或方法 ID。如果 A 类定义方法 f,B 类从 A 继承 f,则以下代码中的两个 GetMethodID 调用总是返回相同的结果:

jmethodID MID_A_f = (*env)->GetMethodID(env, A, "f", "()V");
jmethodID MID_B_f = (*env)->GetMethodID(env, B, "f", "()V");

10.7 缓存字段和方法 ID

本地代码通过将字段或方法的名称和类型描述符指定为字符串(4.1 节,4.2 节)从虚拟机获取字段或方法 ID。使用名称和类型字符串的字段和方法查找速度很慢。缓存这些 ID 通常是有利的。未能缓存字段和方法 ID 是本机代码中的常见性能问题。

在某些情况下,缓存 ID 不仅仅是一个性能增益。缓存的 ID 可能是必要的,以确保本地代码访问正确的字段或方法。以下示例说明了缓存字段 ID 失败可能导致一个微妙的错误:

class C {
    private int i;
    native void f();
}

假设本地方法 f 需要在 C 的一个实例中获得字段 i 的值。一个不缓存一个 ID 的简单实现通过三个步骤完成:1)获得对象的类; 2)从类参考中查找 i 的字段 ID; 和 3)根据对象引用和字段 ID 访问字段值:

// No field IDs cached. JNIEXPORT
void JNICALL
Java_C_f(JNIEnv *env, jobject this) {
    jclass cls = (*env)->GetObjectClass(env, this);
    ... /* error checking */
    jfieldID fid = (*env)->GetFieldID(env, cls, "i", "I");
    ... /* error checking */
    ival = (*env)->GetIntField(env, this, fid);
    ... /* ival now has the value of this.i */
}

代码能够工作正常,直到我们将另一个类 D 定义为 C 的子类,并在 D 中也声明一个称为“i”的私有字段

// Trouble in the absence of ID caching
class D extends C {
    private int i;
    D() {
        f(); // inherited from C
    }
}

当 D 的构造函数调用 C.f 时,本地方法接收 D 的一个实例作为此参数,cls 指向 D 类,而 fid 代表 D.i. 在本地方法结束时,ival 包含 D.i 的值,而不是 C.i. 这可能不是你所期望的当实施本地方法 C.f.

解决方法是在确定对 C 具有类引用时,计算并缓存字段 ID,而不是 D.随后从此缓存 ID 进行的访问将始终引用正确字段 C.i. 这里是正确的版本:

// Version that caches IDs in static initializers
class C {
    private int i;
    native void f();
    private static native void initIDs();
    static {
        initIDs(); // Call an initializing native method
    }
}

修改后的本地代码是:

static jfieldID FID_C_i;
JNIEXPORT void JNICALL
Java_C_initIDs(JNIEnv *env, jclass cls) {
    /* Get IDs to all fields/methods of C that native methods will need. */
    FID_C_i = (*env)->GetFieldID(env, cls, "i", "I");
}

JNIEXPORT void JNICALL
Java_C_f(JNIEnv *env, jobject this) {
    ival = (*env)->GetIntField(env, this, FID_C_i);
    ... /* ival is always C.i, not D.i */
}

字段 ID 被计算并缓存在 C 的静态初始化器中。 这保证了 C.i 的字段 ID 将被缓存,因此本地方法实现 Java_C_f 将读取 C.i 的值,而不依赖于该对象的实际类。

一些方法调用也可能需要缓存。如果我们稍微改变上面的例子,以便类 C 和 D 每个都有自己的私有方法 g 的定义,则 f 需要缓存 C.g 的方法 ID,以避免不小心调用 D.g。进行正确的虚拟方法调用不需要缓存。虚拟方法根据定义动态绑定到调用该方法的实例。因此,您可以安全地使用 JNU_CallMethodByName 实用程序函数(第 6.2.3 节)来调用虚拟方法。前面的例子告诉我们,为什么我们不定义类似的 JNU_GetFieldByName 工具函数。

10.8 终止 Unicode 字符串

从 GetStringChars 或 GetStringCritical 获取的 Unicode 字符串不是以 NULL 结尾的。调用 GetStringLength 查找字符串中的 16 位 Unicode 字符数。某些操作系统(如 Windows NT)期望两个尾随的零字节值来终止 Unicode 字符串。你不能将 GetStringChars 的结果传递给需要 Unicode 字符串的 Windows NT API。你必须复制字符串的另一个副本,并插入两个尾随的零字节值。

10.9 违反访问控制规则

JNI 不执行类,字段和方法的访问控制限制,这些限制可以通过使用诸如 private 和 final 之类的修饰符在 Java 编程语言中进行表达。可以编写本地代码来访问或修改对象的字段,即使在 Java 编程语言级别这样做会导致 IllegalAccessException。由于本机代码可以访问和修改堆中的任何内存位置,所以 JNI 的宽容性是一个有意识的设计决定。

绕过源语言级访问检查的本地代码可能会对程序执行产生不良影响。例如,如果本地方法在即时(JIT)编译器内联访问该字段后修改最终字段,则可能会产生不一致。同样,本地方法不应该修改 java.lang.String 或 java.lang.Integer 实例中的不可变对象,如字段。这样做可能会导致 Java 平台实现中不变量的破坏。

10.10 无视国际化

Java 虚拟机中的字符串由 Unicode 字符组成,而本地字符串通常以特定于语言环境的编码形式存在。使用辅助函数(如 JNU_NewStringNative(8.2.1 节)和 JNU_GetStringNativeChars(8.2.2 节))在 Unicode 字符串和基于主机环境的特定语言环境的本机字符串之间进行转换。特别注意消息字符串和文件名,他们通常是国际化的。如果本地方法获取文件名作为 jstring,那么文件名必须在传递给 C 库例程之前转换为本地字符串。

以下本机方法 MyFile.open 打开一个文件并返回文件描述符作为其结果:

JNIEXPORT jint JNICALL
Java_MyFile_open(JNIEnv *env, jobject self, jstring name, jint mode)
{
    jint result;
    char *cname = JNU_GetStringNativeChars(env, name);
    if (cname == NULL) {
        return 0;
    }
    result = open(cname, mode);
    free(cname);
    return result;
}

我们使用 JNU_GetStringNativeChars 函数来转换 jstring 参数,因为开放系统调用期望文件名是特定于语言环境的编码。

10.11 保留虚拟机资源

本地方法中常见的错误是忘记释放虚拟机资源。程序员需要特比注意那些只有发生错误了才会执行的代码路径。下面的代码段(6.2.2 节中的一个例子稍作修改)会遗漏一个 ReleaseStringChars 调用:

JNIEXPORT void JNICALL
Java_pkg_Cls_f(JNIEnv *env, jclass cls, jstring jstr)
{
    const jchar *cstr = (*env)->GetStringChars(env, jstr, NULL);
    if (cstr == NULL) {
        return;
    }
    ...
    if (...) { /* exception occurred */
        /* misses a ReleaseStringChars call */
        return;
    }
    ...
    /* normal return */
    (*env)->ReleaseStringChars(env, jstr, cstr);
}

忘记调用 ReleaseStringChars 函数可能会导致无限期地固定 jstring 对象,导致内存碎片,或者 C 副本被无限期地保留,造成内存泄漏。

无论 GetStringChars 是否已经创建了字符串的副本,都必须有相应的 ReleaseStringChars 调用。 以下代码无法正确释放虚拟机资源:

/* The isCopy argument is misused here! */
JNIEXPORT void JNICALL
Java_pkg_Cls_f(JNIEnv *env, jclass cls, jstring jstr)
{
    jboolean isCopy;
    const jchar *cstr = (*env)->GetStringChars(env, jstr, &isCopy);
    if (cstr == NULL) {
        return;
    }
    ... /* use cstr */
    /* This is wrong. Always need to call ReleaseStringChars. */
    if (isCopy) {
        (*env)->ReleaseStringChars(env, jstr, cstr);
    }
}

即使 isCopy 为 JNI_FALSE,仍然需要调用 ReleaseStringChars,以便虚拟机将解除绑定 jstring 元素。

10.12 过量的本地引用创建

过多的本地引用创建会导致程序保留不必要地内存。不必要的本地引用会浪费引用对象和引用本身的内存。

要特别注意长时间运行的本地方法,循环中创建的本地引用以及辅助函数。利用 Java 2 SDK 1.2 版中新的 Push/PopLocalFrame 函数来更有效地管理本地引用。有关这个问题的更详细的讨论,请参阅第 5.2.1 节和第 5.2.2 节。

你可以在 Java 2 SDK 1.2 中指定-verbose:jni 选项来要求虚拟机检测并报告过多的本地引用创建。 假设你用这个选项运行一个 Foo 类:

% java -verbose:jni Foo

输出的内容包括下面的信息:

***ALERT: JNI local ref creation exceeded capacity
        (creating: 17, limit: 16).
    at Baz.g (Native method)
    at Bar.f (Compiled method)
    at Foo.main (Compiled method)

Baz.g 的本地方法实现可能无法正确管理本地引用。

10.13 使用不合法的本地引用

本地引用仅在本地方法的单个调用中有效。在本地方法调用中创建的本地引用会在实现该方法的本地函数返回后自动释放。本地代码不应该在全局变量中存储本地引用,并希望在以后的本地方法调用中使用它。

本地引用仅在创建它们的线程内有效。您不应该将一个线程的本地引用传递给另一个线程。在需要跨线程传递引用时创建全局引用。

10.14 在跨线程中使用 JNIEnv

作为每个本地方法的第一个参数传递的 JNIEnv 指针只能在与其关联的线程中使用。 缓存从一个线程获得的 JNIEnv 接口指针并在另一个线程中使用该指针是错误的。 8.1.4 节解释了如何获得当前线程的 JNIEnv 接口指针。

10.15 线程模型不匹配

只有主机本地代码和 Java 虚拟机实现共享相同的线程模型(8.1.5 节)时,JNI 才能工作。例如,程序员不能将本地平台线程附加到使用用户线程包实现的嵌入式 Java 虚拟机。

在 Solaris 上,Sun 提供了一个基于用户线程包(称为“绿色线程”)的虚拟机实现。如果你的本地代码依赖于 Solaris 本机线程支持,则它不适用于基于绿色线程的 Java 虚拟机实现。你需要一个旨在与 Solaris 本地线程一起工作的虚拟机实现。Solaris 中的本地线程支持 JDK 版本 1.1 需要单独下载。本地线程支持与 Solaris Java 2 SDK 版本 1.2 捆绑在一起。

Sun 在 Win32 上的虚拟机实现默认支持本地线程,并且可以很容易地嵌入到本机 Win32 应用程序中。

第十一章 JNI 设计概述

本章给出了 JNI 设计的概述,必要时,我们也提供底层技术的动机。设计概述作为关键 JNI 概念(如 JNIEnv 接口指针,本地和全局引用以及字段和方法 ID)的规范。技术动机旨在帮助读者了解各种设计的权衡。有几次,我们将讨论如何实现某些功能。这种讨论的目的不是提出一个实际的实施策略,而是要澄清微妙的语义问题。

桥接不同语言的编程接口的概念并不新鲜。例如,C 程序通常可以调用用 FORTRAN 和汇编语言编写的函数。同样,编程语言(如 LISP 和 Smalltalk)的实现也支持各种外部函数接口。

JNI 解决了类似于由其他语言支持的互操作性机制所解决的问题。然而,JNI 与许多其他语言中使用的互操作机制之间存在显着差异。JNI 不是为 Java 虚拟机的特定实现而设计的。相反,它是每个 Java 虚拟机实现都可以支持的本地接口。我们将在描述 JNI 设计目标时进一步阐述这一点。

11.1 设计目标

JNI 设计的最重要目标是确保它在给定的主机环境中提供不同 Java 虚拟机实现之间的二进制兼容性。相同的本地库二进制文件将运行在给定主机环境的不同虚拟机实现上,而不需要重新编译。

为了实现这个目标,JNI 设计不能对 Java 虚拟机实现的内部细节做任何假设。由于 Java 虚拟机实现技术正在迅速发展,所以我们必须小心,以避免引入任何可能会影响未来的高级实现技术的限制。

JNI 设计的第二个目标是效率。为了支持对时间要求严格的代码,JNI 强加了尽可能少的开销。但是,我们将看到,我们的第一个目标,即实现独立的需要,有时需要我们采用比我们其他方式效率稍低的设计。我们在效率和实现独立性之间达成妥协。

最后,JNI 必须功能完整。 它必须公开足够多的 Java 虚拟机功能,以使本地方法和应用程序能够完成有用的任务。

JNI 的目标不是成为给定的 Java 虚拟机实现所支持的唯一的本地编程接口。一个标准接口有利于程序员,他们希望将他们的本地代码库加载到不同的 Java 虚拟机实现中。但是,在某些情况下,较低级别的特定于实现的接口可能会获得更高的性能。在其他情况下,程序员可能使用更高级别的接口来构建软件组件。

11.2 加载本地库

在应用程序可以调用本地方法之前,虚拟机必须定位并加载包含本地方法实现的本地库。

11.2.1 类加载器

本地库位于类加载器中。 类加载器在 Java 虚拟机中有许多用途,例如加载类文件,定义类和接口,在软件组件之间提供命名空间分离,解决不同类和接口间的符号引用,最后定位本地库。 我们假设您对类加载器有基本的了解,所以我们不会详细介绍如何在 Java 虚拟机中加载和链接类。 关于类加载器的更多详细信息,你可以参阅由 Sheng Liang 和 Gilad Bracha 在 ACM 面向对象编程系统,语言和应用程序会议(OOPSLA)的会议记录中的记录的《Dynamic Class Loading in the Java Virtual Machine》。

类加载器提供了在同一虚拟机的一个实例中运行多个组件(比如从不同网站下载的 applet)所需的命名空间分离技术。类加载器通过在 Java 虚拟机中将类或接口的名称映射到对象的实际类或接口类型来维护单独的名称空间。每个类或接口类型都与其定义的加载程序相关联,该加载程序最初读取类文件并定义类或接口对象。只有两个类或接口类型具有相同的名称和相同的定义加载器时,才是相同的。 例如,在图 11.1 中,类加载器 L1 和 L2 都定义了一个名为 C 的类。这两个名为 C 的类是不一样的。确实,它们包含两种不同的 f 方法,它们具有不同的返回类型。

   上图中的虚线表示类加载器之间的委托关系。一个类加载器可能要求另一个类加载器代表它加载一个类或一个接口。例如,L1 和 L2 都委托给引导类加载器来加载系统类 java.lang.String。委托允许系统类在所有类加载器之间共享。这是必要的,因为例如,如果应用程序和系统代码对 java.lang.String 类型的内容有不同的概念,则会违反类型安全性。

11.2.2 类加载器和本地库

现在假设两个类 C 中的方法 f 都是本地方法。 虚拟机使用名称“C_f”定位两个 C.f 方法的本机实现。 为了确保每个 C 类与正确的本地函数链接,每个类加载器都必须维护自己的一组本机库,如图 11.2 所示。

   因为每个类加载器都维护着一组本地库,所以只要这些类具有相同的定义加载器,程序员可以使用一个库来存储任意数量的类所需的所有本地方法。

当相应的类加载器被垃圾收集时,本地库会自动被虚拟机卸载(11.2.5 节)。

11.2.3 定位本地库

本地库由 System.loadLibrary 方法加载。 在以下示例中,类 Cls 的静态初始化程序会加载一个特定于平台的本机库,其中定义了本机方法 f:

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

System.loadLibrary 的参数是程序员选择的库名。软件开发人员负责选择本地库名称,以最大限度地减少名称冲突的机会。虚拟机遵循一个标准的,但主机环境特定的约定将库名称转换为本地库名称。例如,Solaris 操作系统将名称 mypkg 转换为 libmypkg.so,而 Win32 操作系统将相同的 mypkg 名称转换为 mypkg.dll。

当 Java 虚拟机启动时,它会构建一个目录列表,这些目录将用于定位应用程序中类的本地库。列表的内容取决于主机环境和虚拟机的实现。例如,在 Win32 JDK 或 Java 2 SDK 版本下,目录列表由 Windows 系统目录,当前工作目录以及 PATH 环境变量中的条目组成。在 Solaris JDK 或 Java 2 SDK 发行版中,目录列表由 LD_LIBRARY_PATH 环境变量中的条目组成。

如果 System.loadLibrary 无法加载指定的本机库,则会抛出 UnsatisfiedLinkError。如果之前对 System.loadLibrary 的调用已经加载了相同的本地库,System.loadLibrary 将自动完成。如果底层操作系统不支持动态链接,则必须将所有本机方法预先链接到虚拟机。在这种情况下,虚拟机完成 System.loadLibrary 调用而不实际加载库。

虚拟机在内部为每个类加载器维护一个加载的本地库列表。它遵循三个步骤来确定哪个类加载器应该与新加载的本地库相关联:

  • 确定 System.loadLibrary 的直接调用者
  • 标识定义调用者的类 -获取调用者类的定义加载器 在以下示例中,本机库 foo 将与 C 的定义加载器相关联:

Java 2 SDK 版本 1.2 引入了新的 ClassLoader.findLibrary 方法,允许程序员指定特定于给定类加载器的自定义库加载策略。ClassLoader.findLibrary 方法将平台无关的库名称(如 mypkg)作为参数,并且:

  • 要么返回空指示虚拟机遵循默认的库搜索路径
  • 或返回库文件的主机环境相关的绝对路径(如“c:\mylibs\mypkg.dll”)。   ClassLoader.findLibrary 通常与 Java 2 SDK 版本 1.2 中添加的另一个方法 System.mapLibraryName 一起使用。System.mapLibraryName 将独立于平台的库名(例如 mypkg)映射到平台相关的库文件名(例如 mypkg.dll)。

你可以通过设置属性 java.library.path 来覆盖 Java 2 SDK 版本 1.2 中的默认库搜索路径。例如,下面的命令行启动一个程序 Foo,它需要在 c:\mylibs 目录中加载一个本地库:

java -Djava.library.path=c:\mylibs Foo

11.2.4 类型安全限制

虚拟机不允许给定的 JNI 本地库由多个类加载器加载。尝试通过多个类加载器加载相同的本机库会导致抛出 UnsatisfiedLinkError 异常。此限制的目的是确保基于类加载器的名称空间分离机制保留在本地库中。没有这个限制,通过本地方法错误地混合来自不同类加载器的类和接口将会变得更容易。考虑一个本地方法 Foo.f,它在全局引用中将自己的定义类 Foo 进行缓存:

JNIEXPORT void JNICALL
Java_Foo_f(JNIEnv *env, jobject self) {
    static jclass cachedFooClass; /* cached class Foo */
    if (cachedFooClass == NULL) {
        jclass fooClass = (*env)->FindClass(env, "Foo");
        if (fooClass == NULL) {
            return; /* error */
        }
        cachedFooClass = (*env)->NewGlobalRef(env, fooClass);
        if (cachedFooClass == NULL) {
            return; /* error */
        }
    }
    assert((*env)->IsInstanceOf(env, self, cachedFooClass)); ... /* use cachedFooClass */
}

我们期望断言成功,因为 Foo.f 是一个实例方法,而 self 是指 Foo 的一个实例。但是,如果两个不同的 Foo 类由类加载器 L1 和 L2 加载,并且两个 Foo 类都与前一个 Foo.f 实现链接,则断言可能会失败。cachedFooClass 全局引用将为首先调用 f 方法的 Foo 类创建。稍后调用另一个 Foo 类的 f 方法将导致断言失败。

JDK 版本 1.1 没有正确执行类加载器之间的本地库分离。这意味着不同类加载器中的两个类可以使用相同的本地方法进行链接。如前面的例子所示,JDK 版本 1.1 中的方法导致了以下两个问题:

  • 一个类可能会错误地链接到在不同类加载器中由具有相同名称的类加载的本地库。
  • 本地方法可以轻松地混合来自不同类加载器的类。这打破了类加载器提供的名称空间分离,并导致类型安全问题。

11.2.5 卸载本地库

在垃圾收集与本地库相关联的类加载器之后,虚拟机将卸载本机库。因为类引用了它们定义的加载器,这意味着虚拟机也卸载了在其静态初始化器中调用 System.loadLibrary 并加载了本地库(11.2.2 节)的类。

11.3 链接本地方法

虚拟机在第一次调用本地方法前,它会尝试去链接每一本地方法。本地方法 f 可以被链接的最早时间是方法 g 的第一次调用,其中 g 的方法体有对 f 的引用。虚拟机实现不应该尝试过早地链接本地方法。 这样做可能会导致意外的链接错误,因为实现本地方法的本地库可能尚未加载。

链接本地方法涉及以下步骤:

  • 确定定义本地方法的类的类加载器。

  • 搜索与这个类加载器相关联的本地库集,来定位实现这个本地方法的本地函数。

  • 设置内部数据结构,以便今后所有对本地方法的调用将直接跳转到本地函数。    虚拟机通过连接以下组件来从本机方法的名称中推导出本机函数的名称:

  • 前缀“java_”

  • 一个编码的全类名

  • 一个下划线分隔符

  • 一个编码的函数名

  • 对于重载的本地方法,两个下划线(“__”)后跟编码的参数描述符

虚拟机遍历与定义的加载器相关联的所有本机库,以适当的名称搜索本机函数。于每个本地库,虚拟机首先查找短名称,即不带参数描述符的名称。 然后它查找长名称,这是带有参数描述符的名称。 只有当本地方法被另一个本地方法重载时,程序员才需要使用长名称。 但是,如果使用非本地方法重载本机方法,则这不是问题。 后者不在本地库中。

在以下示例中,本机方法 g 不必使用长名称进行链接,因为其他方法 g 不是本机方法。

class Cls1 {
    int g(int i) { ... } // regular method
    native int g(double d);
}

JNI 采用简单的名称编码方案,以确保所有的 Unicode 字符都转换为有效的 C 函数名称。下划线(“_”)字符分隔完全格式的类名的组件。由于名称或类型描述符从不以数字开头,所以我们可以使用_0,…,_9 作为转义序列,如下所示:

   如果存在与多个本地库中编码的本机方法名称匹配的本机函数,则首先加载的本机库中的函数与本机方法链接。 如果没有函数匹配本地方法名称,则抛出 UnsatisfiedLinkError。

程序员也可以调用 JNI 函数 RegisterNatives 来注册与类关联的本地方法。 RegisterNatives 函数在静态链接函数中特别有用。

11.4 调用规则

调用约定确定本机函数如何接收参数并返回结果。 在各种本地语言之间或同一种语言的不同实现之间没有标准的调用约定。 例如,不同的 C ++编译器生成遵循不同调用约定的代码是很常见的。

如果不是不可能的话,要求 Java 虚拟机与各种本地调用约定进行互操作将是困难的。JNI 要求在一给定的主机环境中使用特定的标准调用规则来编写本地方法就。例如,JNI 遵循 UNIX 上的 C 调用约定和 Win32 上的 stdcall 约定。

当程序员需要调用遵循不同调用约定的函数时,他们必须编写将 JNI 调用约定调整为适合本地语言的调用约定。

11.5 JNIEnv 接口指针

本地代码通过调用通过 JNIEnv 接口指针导出的各种函数来访问虚拟机功能。

11.5.1 JNIEnv 接口指针的组织

一个 JNIEnv 接口指针是一个指向本地线程数据的指针,每个接口函数都在表中的预定义偏移处。NIEnv 接口像 C ++虚拟函数表一样组织,也像 Microsoft COM 接口。 图 11.3 展示了一组 JNIEnv 接口指针。

   实现本地方法的函数接收 JNIEnv 接口指针作为它们的第一个参数。虚拟机保证将相同的接口指针传递给从同一线程调用的本地方法实现函数。但是,可以从不同的线程调用本地方法,因此可以传递不同的 JNIEnv 接口指针。虽然接口指针是线程本地的,但双重间接 JNI 函数表是由多个线程共享的。

JNIEnv 接口指针引用线程本地结构的原因是有些平台没有高效的线程本地存储访问支持。通过传递一个线程本地指针,虚拟机内部的 JNI 实现可以避免许多线程本地存储访问操作,否则它将不得不执行。

因为 JNIEnv 接口指针是线程本地的,所以本地代码不能使用属于另一个线程中的一个线程的 JNIEnv 接口指针。本地代码可能会使用 JNIEnv 指针作为线程 ID,在线程的生命周期中保持唯一。

11.5.2 接口指针的好处

与硬连线函数条目相反.使用接口指针有几个优点:

  • 最重要的是,因为 JNI 函数表作为参数传递给每个本地方法,所以本地库不必与 Java 虚拟机的特定实现链接。 这是至关重要的,因为不同的供应商可能会以不同的方式命名虚拟机实现。在一个给定的主机环境中,然后每个本地库自成一体是让相同的本地库能够在不同厂商之间的虚拟机工作的先决条件。
  • 其次,通过不使用硬连线功能条目,虚拟机实现可以选择提供多个版本的 JNI 功能表。例如,虚拟机实现可能支持两个 JNI 函数表:一个执行彻底的非法参数检查,适合调试;另一个执行 JNI 规范所要求的最小量的检查,因此更高效。Java 2 SDK 版本 1.2 支持-Xcheck:jni 选项,可以选择打开对 JNI 函数的额外检查。
  • 最后,多个 JNI 函数表使将来能够支持多个类似 JNIEnv 的接口成为可能。虽然我们还没有预见到需要这样做,但除了 1.1 和 1.2 版本中 JNIEnv 接口所指向的版本之外,未来版本的 Java 平台还可以支持新的 JNI 函数表。Java 2 SDK 版本 1.2 引入了一个 JNI_Onload 函数,该函数可以由本地库定义,以指示本地库所需的 JNI 函数表的版本。Java 虚拟机的未来实现可以同时支持多个版本的 JNI 函数表,并根据需要将正确的版本传递给各个本地库。

11.6 传递数据

在 Java 虚拟机和本地代码之间能够复制原始数据类型,例如整数,字符等。另一方面,对象通过引用传递。每个引用都包含一个指向底层对象的直接指针。本地代码从不直接使用指向该对象的指针。从本地代码的角度来看,引用是不透明的。

传递引用而不是直接指向对象的指针可以使虚拟机以更灵活的方式管理对象。 图 11.4 说明了这种灵活性。 当本地代码持有引用时,虚拟机可能会执行垃圾回收,导致将对象从一个内存区域复制到另一个区域。 虚拟机可以自动更新引用的内容,以便虽然对象已经移动,但引用仍然有效。

11.6.1 全局和本地引用

JNI 为本地代码创建两种对象引用:本地和全局引用。本地引用在本地方法调用期间有效,并在本地方法返回后自动释放。全局引用在显式释放前保持有效。

对象作为本地引用传递给本地方法。大多数 JNI 函数返回本地引用。JNI 允许程序员从本地引用创建全局引用。将对象作为参数的 JNI 函数接受全局和本地引用。作为结果,本地方法可能会返回本地或全局引用到虚拟机。

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

JNI 中的 NULL 引用是指 Java 虚拟机中的空对象。其值不为 NULL 的本地或全局引用不引用空对象。

11.6.2 实现本地引用

958/5000 为了实现本地引用,Java 虚拟机为每个从虚拟机到本地方法的控制转换创建一个注册表。 注册表将不可移动的本地引用映射到对象指针。 注册中的对象不能被垃圾收集。 传递给本地方法的所有对象,包括那些作为 JNI 函数调用结果返回的对象,都会自动添加到注册表中。 本地方法返回后,注册表被删除,允许其条目被垃圾收集。 图 11.5 说明了如何创建和删除本地引用注册表。 对应于本地方法的 Java 虚拟机框架包含指向本地引用注册表的指针。 D.f 方法调用本地方法 C.g. C.g 由 C 函数 Java_C_g 实现。 虚拟机在输入 Java_C_g 之前创建本地参考注册表,并在 Java_C_g 返回后删除本地参考注册表。

   有不同的方法来实现注册表,例如使用堆栈,表,链表或哈希表。尽管可以使用引用计数来避免注册表中的重复条目,但 JNI 实现并不一定要检测和折叠重复的条目。

本地引用不能忠实地通过保守地扫描本地堆栈来实现。本机代码可能会将本地引用存储到全局或 C 堆数据结构中。

11.6.3 弱全局引用

Java 2 SDK 版本 1.2 引入了一种新的全局引用:弱全局引用。 与普通的全局引用不同,弱全局引用允许引用的对象被垃圾收集。 在底层对象被垃圾收集之后,清除弱全局引用。 本地代码可以通过使用 IsSameObject 来比较引用与 NULL 来测试弱全局引用是否被清除。

11.7 访问对象

JNI 为引用对象提供了丰富的访问函数。 这意味着无论虚拟机如何在内部表示对象,相同的本地方法实现都可以工作。 这是一个至关重要的设计决策,使 JNI 能够被任何虚拟机实现所支持。

通过不透明引用使用访问函数的开销高于直接访问 C 数据结构的开销。我们认为,在大多数情况下,本地方法执行不重要的任务,从而掩盖额外函数调用的成本。

11.7.1 访问基本数据类型数组

但是,函数调用的开销是不可接受的,因为重复访问大对象(如整型数组和字符串)中的基元数据类型的值。考虑用于执行向量和矩阵计算的本机方法。遍历一个整数数组并遍历一个函数调用将是非常低效的。

一种解决方案引入了“钉住”的概念,以便本地方法可以要求虚拟机不要移动数组的内容。本地方法然后接收到元素的直接指针。但是,这种方法有两个含义:

  • 垃圾回收器必须支持钉住。在许多实现中,钉住是不合需要的,因为它会使垃圾收集算法复杂化并导致内存碎片化。
  • 虚拟机必须在内存中连续放置原始数组。虽然这是大多数基本数组的自然实现,但布尔数组可以实现为压缩或解压缩。一个打包的布尔数组为每个元素使用一个位,而一个解压缩的数组通常为每个元素使用一个字节。因此,依赖布尔数组的确切布局的本机代码将不可移植。   JNI 采取了解决上述两个问题的妥协方案。

首先,JNI 提供了一组函数(例如,GetIntArrayRegion 和 SetIntArrayRegion)来复制原始数组的片段与原生内存缓冲区之间的原始数组元素。如果本地方法只需要访问大数组中的少量元素,或者本地方法需要复制数组,则可以使用这些函数。

其次,程序员可以使用另一组函数(例如,GetIntArrayElements)尝试获取固定版本的数组元素。但是,根据虚拟机的实现情况,这些功能可能会导致存储分配和复制。这些函数实际上是否复制数组取决于虚拟机的实现,如下所示:

  • 如果垃圾收集器支持固定,并且数组的布局与相同类型的本地数组的布局相同,则不需要复制。
  • 否则,将该数组复制到一个不可移动的内存块(例如,C 堆)中,并执行必要的格式转换。返回指向该副本的指针。    本地代码调用第三组函数(例如,ReleaseIntArrayElements)来通知虚拟机本地代码不再需要访问数组元素。当发生这种情况时,虚拟机要么取消数组,要么使原始数组与其不可移动的副本一致,并释放副本。

该方法提供了灵活性。垃圾收集器算法可以针对每个数组做出复制或固定的独立决定。在特定的实现方案下,垃圾收集器可能会复制小数组,但会插入大数组。

最后,Java 2 SDK 版本 1.2 引入了两个新的函数:etPrimitiveArrayCritical 和 ReleasePrimitiveArrayCritical。这些函数可以以类似于 GetIntArrayElements 和 ReleaseIntArrayElements 的方式使用。但是,在使用 GetPrimitiveArrayCritical 获取指向数组元素的指针之后,在使用 ReleasePrimitiveArrayCritical 释放指针之前,本地代码有很大的限制。在“关键区域”内,本机代码不应该无限期地运行,不能随意调用 JNI 函数,也不能执行可能导致当前线程阻塞并等待虚拟机中的另一个线程的操作。鉴于这些限制,虚拟机可以临时禁用垃圾回收,同时让本地代码直接访问数组元素。因为不需要固定支持,所以 GetPrimitiveArrayCritical 更有可能返回一个直接指向原始数组元素的指针,比如 GetIntArrayElements。

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

11.7.2 字段和方法

JNI 允许本地代码访问字段并调用 Java 编程语言中定义的方法。JNI 通过它们的符号名称和类型描述符来标识方法和字段。两个步骤的过程将是从其名称和描述符中找出字段或方法的成本。例如,要读取类 cls 中的整数实例字段 i,本机代码首先获取一个字段 ID,如下所示:

jfieldID fid = env->GetFieldID(env, cls, "i", "I");

然后,本机代码可以重复使用字段 ID,而不需要字段查找的代价,如下所示:

jint value = env->GetIntField(env, obj, fid);

字段或方法 ID 保持有效,直到虚拟机卸载定义相应字段或方法的类或接口。类或接口卸载后,方法或字段 ID 将变为无效。

程序员可以从解析相应字段或方法的类或接口派生一个字段或方法 ID。字段或方法可以在类或接口本身中定义,也可以从超类或超接口继承。Java™ 虚拟机规范包含解析字段和方法的精确规则。如果从这两个类或接口中解析相同的字段或方法定义,则 JNI 实现必须从两个类或接口派生相同的字段或方法 ID。例如,如果 B 定义了字段 fld,并且 C 从 B 继承了 fld,那么程序员保证从类 B 和 C 中获得字段名“fld”的相同字段 ID。

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

请注意,你需要字段名称和字段描述符以从给定的类或接口获取字段 ID。这看起来没有必要,因为字段不能用 Java 编程语言重载。但是,在类文件中重载字段并在 Java 虚拟机上运行这样的类文件是合法的。因此,JNI 能够处理由 Java 编程语言的编译器不生成的合法类文件。

只有程序员知道方法或字段的名称和类型,才能使用 JNI 调用方法或访问字段。相比之下,Java 核心反射 API 允许程序员确定给定类或接口中的字段和方法集合。在本地代码中反射类或接口类型有时也是有用的。Java 2 SDK 版本 1.2 提供了新的 JNI 函数,可以与现有的 Java 核心反射 API 配合使用。新函数包括一对在 JNI 字段 ID 和 java.lang.reflect.Field 类的实例之间转换,另一对在 JNI 方法 ID 和 java.lang.reflect.Method 类的实例之间转换。

11.8 错误和异常

JNI 编程中的错误与 Java 虚拟机实现中发生的错误不同。程序员错误是由于滥用 JNI 函数引起的。例如,程序员可能会错误地将对象引用而不是类引用传递给 GetFieldID。引发 Java 虚拟机异常,例如,当本机代码试图通过 JNI 分配一个对象时,出现内存不足的情况。

11.8.1 没有检查编程错误

JNI 函数不检查编程错误。将非法参数传递给 JNI 函数会导致未定义的行为。这个设计决定的原因如下:

  • 强制 JNI 函数检查所有可能的错误条件会降低所有(通常是正确的)本地方法的性能。
  • 在许多情况下没有足够的运行时类型信息来执行这种检查。    大多数 C 库函数不防范编程错误。例如,printf 函数通常会触发运行时错误,而不是在收到无效地址时返回错误代码。强制 C 库函数检查所有可能的错误条件可能会导致这种检查被重复,一次在用户代码中,然后再次在库中。

尽管 JNI 规范不要求虚拟机检查编程错误,但是鼓励虚拟机实现检查常见的错误。 例如,虚拟机可以在 JNI 函数表(第 11.5.2 节)的调试版本中执行更多的检查。

11.8.2 Java 虚拟机异常

JNI 不依赖于本地编程语言中的异常处理机制。原生代码可能会导致 Java 虚拟机通过调用 Throw 或 ThrowNew 引发异常。在当前线程中记录未决异常。与 Java 编程语言中抛出的异常不同,本机代码抛出的异常不会立即中断当前的执行。

本地语言没有标准的异常处理机制。因此,JNI 程序员需要在每个可能会抛出异常的操作之后检查并处理异常。JNI 程序员可以通过两种方式处理异常:

  • 本地方法可能会选择立即返回,导致在启动本机方法调用的代码中引发异常。
  • 本地代码可以通过调用 ExceptionClear 清除异常,然后执行自己的异常处理代码。    在调用任何后续的 JNI 函数之前,检查,处理和清除待处理的异常是非常重要的。调用带有未决异常的大多数 JNI 函数会导致未定义的结果。以下是当存在未决异常时可安全调用的 JNI 函数的完整列表:
ExceptionOccurred
ExceptionDescribe
ExceptionClear
ExceptionCheck

ReleaseStringChars
ReleaseStringUTFchars
ReleaseStringCritical
Release<Type>ArrayElements
ReleasePrimitiveArrayCritical
DeleteLocalRef
DeleteGlobalRef
DeleteWeakGlobalRef
MonitorExit

前四个函数与异常处理直接相关。剩下的是通用的,它们释放通过 JNI 公开的各种虚拟机资源。发生异常时通常需要释放资源。

11.8.3 异步异常

一个线程可能通过调用 Thread.stop 在另一个线程中引发异步异常。异步异常不会影响当前线程中本机代码的执行,直到:

  • 本地代码调用可能引发同步异常的 JNI 函数之一,或者
  • 本地代码使用 ExceptionOccurred 来显式检查同步和异步异常。    只有那些可能引发同步异常的 JNI 函数才会检查异步异常。

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

生成异步异常的 Java 线程 API Thread.stop 在 Java 2 SDK 版本 1.2 中已被弃用。强烈建议程序员不要使用 Thread.stop,因为它通常会导致不可靠的程序。这对于 JNI 代码尤其是个问题。例如,今天编写的许多 JNI 库不会仔细地遵循本节中描述的检查异步异常的规则。