阿里架構師JVM精講篇——你真的瞭解JNI的運行機制嗎?

我們經常會遇見 Java 語言較難表達,甚至是無法表達的應用場景。比如我們希望使用匯編語言(如 X86_64 的 SIMD 指令)來提升關鍵代碼的性能;再比如,我們希望調用 Java 核心類庫無法提供的,某個體系架構或者操作系統特有的功能。

在這種情況下,我們往往會犧牲可移植性,在 Java 代碼中調用 C/C++ 代碼(下面簡述爲C 代碼),並在其中實現所需功能。這種跨語言的調用,便需要藉助 Java 虛擬機的 JavaNative Interface(JNI)機制。

關於 JNI 的例子,你應該特別熟悉 Java 中標記爲native的、沒有方法體的方法(下面統稱爲 native 方法)。當在 Java 代碼中調用這些 native 方法時,Java 虛擬機將通過 JNI,調用至對應的 C 函數(下面將 native 方法對應的 C 實現統稱爲 C 函數)中。

public class Object {
    public native int hashCode();
}

舉個例子,Object.hashCode方法便是一個 native 方法。它對應的 C 函數將計算對象的哈希值,並緩存在對象頭、棧上鎖記錄(輕型鎖)或對象監視鎖(重型鎖所使用的monitor)中,以確保該值在對象的生命週期之內不會變更。

一、native方法的鏈接

在調用 native 方法前,Java 虛擬機需要將該 native 方法鏈接至對應的 C 函數上。

鏈接方式主要有兩種。第一種是讓 Java 虛擬機自動查找符合默認命名規範的 C 函數,並且鏈接起來。

事實上,我們並不需要記住所謂的命名規範,而是採用javac -h命令,便可以根據 Java程序中的 native 方法聲明,自動生成包含符合命名規範的 C 函數的頭文件。

舉個例子,在下面這段代碼中,Foo類有三個 native 方法,分別爲靜態方法foo以及兩個重載的實例方法bar。

package org.example;
public class Foo {
    public static native void foo();
    public native void bar(int i, long j);
    public native void bar(String s, Object o);
}

通過執行javac -h . org/example/Foo.java命令,我們將在當前文件夾(對應-h後面跟着的.)生成名爲org_example_Foo.h的頭文件。其內容如下所示:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class org_example_Foo */
#ifndef _Included_org_example_Foo
#define _Included_org_example_Foo
#ifdef __cplusplus
extern "C" {
    #endif
    /*
* Class:     org_example_Foo
* Method:    foo
* Signature: ()V
*/
    JNIEXPORT void JNICALL Java_org_example_Foo_foo
    (JNIEnv *, jclass);
    /*
* Class:     org_example_Foo
* Method:    bar
* Signature: (IJ)V
*/
    JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
    (JNIEnv *, jobject, jint, jlong);
    /*
* Class:     org_example_Foo
* Method:    bar
* Signature: (Ljava/lang/String;Ljava/lang/Object;)V
*/
    JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
    (JNIEnv *, jobject, jstring, jobject);
    #ifdef __cplusplus
}
#endif
#endif

這裏我簡單講解一下該命名規範。

首先,native 方法對應的 C 函數都需要以Java爲前綴,之後跟着完整的包名和方法名。由於 C 函數名不支持/字符,因此我們需要將/轉換爲,而原本方法名中的符號,則需要__轉換爲1。

舉個例子,org.example包下Foo類的foo方法,Java 虛擬機會將其自動鏈接至名爲Java_org_example_Foo_foo的 C 函數中。

當某個類出現重載的 native 方法時,Java 虛擬機還會將參數類型納入自動鏈接對象的考慮範圍之中。具體的做法便是在前面 C 函數名的基礎上,追加__以及方法描述符作爲後綴。

方法描述符的特殊符號同樣會被替換掉,如引用類型所使用的;會被替換爲2,數組類型所使用的[會被替換爲3。

基於此命名規範,你可以手動拼湊上述代碼中,Foo類的兩個bar方法所能自動鏈接的 C 函數名,並用javac -h命令所生成的結果來驗證一下。

第二種鏈接方式則是在 C 代碼中主動鏈接。

這種鏈接方式對 C 函數名沒有要求。通常我們會使用一個名爲registerNatives的native 方法,並按照第一種鏈接方式定義所能自動鏈接的 C 函數。在該 C 函數中,我們將手動鏈接該類的其他 native 方法。

舉個例子,Object類便擁有一個registerNatives方法,所對應的 C 代碼如下所示:

//注:Object類的registerNatives方法的實現位於java.base模塊裏的C代碼中
static JNINativeMethod methods[] = {
{"hashCode",    "()I",                    (void *)&JVM_IHashCode},
{"wait",        "(J)V",                   (void *)&JVM_MonitorWait},
{"notify",      "()V",                    (void *)&JVM_MonitorNotify},
{"notifyAll",   "()V",                    (void *)&JVM_MonitorNotifyAll},
{"clone",       "()Ljava/lang/Object;",   (void *)&JVM_Clone},
};
JNIEXPORT void JNICALL
Java_java_lang_Object_registerNatives(JNIEnv *env, jclass cls)
{
(*env)->RegisterNatives(env, cls,
methods, sizeof(methods)/sizeof(methods[0]));
}

我們可以看到,上面這段代碼中的 C 函數將調用RegisterNativesAPI,註冊Object類中其他 native 方法所要鏈接的 C 函數。並且,這些 C 函數的名字並不符合默認命名規則。

當使用第二種方式進行鏈接時,我們需要在其他 native 方法被調用之前完成鏈接工作。因此,我們往往會在類的初始化方法裏調用該registerNatives方法。具體示例如下所示:

public class Object {
    private static native void registerNatives();
    static {
        registerNatives();
    }
}

下面我們採用第一種鏈接方式,並且實現其中的bar(String, Object)方法。如下所示:

// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
​
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
    (JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
    printf("Hello, Worldn");
    return;
}

然後,我們可以通過 gcc 命令將其編譯成爲動態鏈接庫:

#該命令僅適用於macOS
$ gcc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -o libfoo.dylib -shared foo.c

這裏需要注意的是,動態鏈接庫的名字須以lib爲前綴,以.dylib(或 Linux 上的.so)爲擴展名。在 Java 程序中,我們可以通過System.loadLibrary("foo")方法來加載libfoo.dylib,如下述代碼所示:

package org.example;
public class Foo {
    public static native void foo();
    public native void bar(int i, long j);
    public native void bar(String s, Object o);
​
    int i = 0xDEADBEEF;
​
    public static void main(String[] args) {
        try {
            System.loadLibrary("foo");
        }
        catch (UnsatisfiedLinkError e) {
            e.printStackTrace();
            System.exit(1);
        }
        new Foo().bar("", "");
    }
}

如果libfoo.dylib不在當前路徑下,我們可以在啓動 Java 虛擬機時配置java.library.path參數,使其指向包含libfoo.dylib的文件夾。具體命令如下所示:

$ java -Djava.library.path=/PATH/TO/DIR/CONTAINING/libfoo.dylib org.example.Foo  Hello,  World

二、JNI的API

在 C 代碼中,我們也可以使用 Java 的語言特性,如 instanceof 測試等。這些功能都是通過特殊的 JNI 函數(JNI Functions)來實現的。

Java 虛擬機會將所有 JNI 函數的函數指針聚合到一個名爲JNIEnv的數據結構之中。

這是一個線程私有的數據結構。Java 虛擬機會爲每個線程創建一個JNIEnv,並規定 C 代碼不能將當前線程的JNIEnv共享給其他線程,否則 JNI 函數的正確性將無法保證。

這麼設計的原因主要有兩個。一是給 JNI 函數提供一個單獨命名空間。二是允許 Java 虛擬機通過更改函數指針替換 JNI 函數的具體實現,例如從附帶參數類型檢測的慢速版本,切換至不做參數類型檢測的快速版本。

在 HotSpot 虛擬機中,JNIEnv被內嵌至 Java 線程的數據結構之中。部分虛擬機代碼甚至會從JNIEnv的地址倒推出 Java 線程的地址。因此,如果在其他線程中使用當前線程的JNIEnv,會使這部分代碼錯誤識別當前線程。

JNI 會將 Java 層面的基本類型以及引用類型映射爲另一套可供 C 代碼使用的數據結構。其中,基本類型的對應關係如下表所示:

引用類型對應的數據結構之間也存在着繼承關係,具體如下所示:

jobject
|- jclass (java.lang.Class objects)
|- jstring (java.lang.String objects)
|- jthrowable (java.lang.Throwable objects)
|- jarray (arrays)
    |- jobjectArray (object arrays)
    |- jbooleanArray (Boolean arrays)
    |- jbyteArray (byte arrays)
    |- jcharArray (char arrays)
    |- jshortArray (short arrays)
    |- jintArray (int arrays)
    |- jlongArray (long arrays)
    |- jfloatArray (float arrays)
    |- jdoubleArray (double arrays)

我們回頭看看Foo類 3 個 native 方法對應的 C 函數的參數。

JNIEXPORT void JNICALL Java_org_example_Foo_foo
(JNIEnv *, jclass);

JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
(JNIEnv *, jobject, jint, jlong);

JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2

靜態 native 方法foo將接收兩個參數,分別爲存放 JNI 函數的JNIEnv指針,以及一個jclass參數,用來指代定義該 native 方法的類,即Foo類。

兩個實例 native 方法bar的第二個參數則是jobject類型的,用來指代該 native 方法的調用者,也就是Foo類的實例。

如果 native 方法聲明瞭參數,那麼對應的 C 函數將接收這些參數。在我們的例子中,第一個bar方法聲明瞭 int 型和 long 型的參數,對應的 C 函數則接收 jint 和 jlong 類型的參數;第二個bar方法聲明瞭 String 類型和 Object 類型的參數,對應的 C 函數則接收jstring 和 jobject 類型的參數。

下面,我們繼續修改上文中的foo.c,並在 C 代碼中獲取Foo類實例的i字段。

// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
    jclass cls = (*env)->GetObjectClass(env, thisObject);
    jfieldID fieldID = (*env)->GetFieldID(env, cls, "i", "I");
    jint value = (*env)->GetIntField(env, thisObject, fieldID);
    printf("Hello, World 0x%xn", value);
return;
}

我們可以看到,在 JNI 中訪問字段類似於反射 API:我們首先需要通過類實例獲得FieldID,然後再通過FieldID獲得某個實例中該字段的值。不過,與 Java 代碼相比,上述代碼貌似不用處理異常。事實果真如此嗎?

下面我就嘗試獲取了不存在的字段j,運行結果如下所示:

$ java org.example.Foo
Hello, World 0x5
Exception in thread "main" java.lang.NoSuchFieldError: j
at org.example.Foo.bar(Native Method)
at org.example.Foo.main(Foo.java:20)

我們可以看到,printf語句照常執行並打印出Hello, World 0x5,但這個數值明顯是錯誤的。當從 C 函數返回至 main 方法時,Java 虛擬機又會拋出NoSuchFieldError異常。

實際上,當調用 JNI 函數時,Java 虛擬機便已生成異常實例,並緩存在內存中的某個位置。與 Java 編程不一樣的是,它並不會顯式地跳轉至異常處理器或者調用者中,而是繼續執行接下來的 C 代碼。

因此,當從可能觸發異常的 JNI 函數返回時,我們需要通過 JNI 函數ExceptionOccurred檢查是否發生了異常,並且作出相應的處理。如果無須拋出該異常,那麼我們需要通過 JNI 函數ExceptionClear顯式地清空已緩存的異常。

具體示例如下所示(爲了控制代碼篇幅,我僅在第一個GetFieldID後檢查異常以及清空異常):

// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
    jclass cls = (*env)->GetObjectClass(env, thisObject);
    jfieldID fieldID = (*env)->GetFieldID(env, cls, "j", "I");
    if((*env)->ExceptionOccurred(env)) {
        printf("Exception!n");
(*env)->ExceptionClear(env);
}
fieldID = (*env)->GetFieldID(env, cls, "i", "I");
jint value = (*env)->GetIntField(env, thisObject, fieldID);
// we should put an exception guard here as well.
printf("Hello, World 0x%xn", value);
return;
}

三、局部引用與全局引用

在 C 代碼中,我們可以訪問所傳入的引用類型參數,也可以通過 JNI 函數創建新的 Java 對象。

這些 Java 對象顯然也會受到垃圾回收器的影響。因此,Java 虛擬機需要一種機制,來告知垃圾回收算法,不要回收這些 C 代碼中可能引用到的 Java 對象。

這種機制便是 JNI 的局部引用(Local Reference)和全局引用(Global Reference)。垃圾回收算法會將被這兩種引用指向的對象標記爲不可回收。

事實上,無論是傳入的引用類型參數,還是通過 JNI 函數(除NewGlobalRef及NewWeakGlobalRef之外)返回的引用類型對象,都屬於局部引用。

不過,一旦從 C 函數中返回至 Java 方法之中,那麼局部引用將失效。也就是說,垃圾回收器在標記垃圾時不再考慮這些局部引用。

這就意味着,我們不能緩存局部引用,以供另一 C 線程或下一次 native 方法調用時使用。

對於這種應用場景,我們需要藉助 JNI 函數NewGlobalRef,將該局部引用轉換爲全局引用,以確保其指向的 Java 對象不會被垃圾回收。

相應的,我們還可以通過 JNI 函數DeleteGlobalRef來消除全局引用,以便回收被全局引用指向的 Java 對象。

此外,當 C 函數運行時間極其長時,我們也應該考慮通過 JNI 函數DeleteLocalRef,消除不再使用的局部引用,以便回收被引用的 Java 對象。

另一方面,由於垃圾回收器可能會移動對象在內存中的位置,因此 Java 虛擬機需要另一種機制,來保證局部引用或者全局引用將正確地指向移動過後的對象。

HotSpot 虛擬機是通過句柄(handle)來完成上述需求的。這裏句柄指的是內存中 Java對象的指針的指針。當發生垃圾回收時,如果 Java 對象被移動了,那麼句柄指向的指針值也將發生變動,但句柄本身保持不變。

實際上,無論是局部引用還是全局引用,都是句柄。其中,局部引用所對應的句柄有兩種存儲方式,一是在本地方法棧幀中,主要用於存放 C 函數所接收的來自 Java 層面的引用類型參數;另一種則是線程私有的句柄塊,主要用於存放 C 函數運行過程中創建的局部引用。

當從 C 函數返回至 Java 方法時,本地方法棧幀中的句柄將會被自動清除。而線程私有句柄塊則需要由 Java 虛擬機顯式清理。

進入 C 函數時對引用類型參數的句柄化,和調整參數位置(C 調用和 Java 調用傳參的方式不一樣),以及從 C 函數返回時清理線程私有句柄塊,共同造就了 JNI 調用的額外性能開銷。

四、總結與實踐

Java 中的 native 方法的鏈接方式主要有兩種。一是按照 JNI 的默認規範命名所要鏈接的 C函數,並依賴於 Java 虛擬機自動鏈接。另一種則是在 C 代碼中主動鏈接。

JNI 提供了一系列 API 來允許 C 代碼使用 Java 語言特性。這些 API 不僅使用了特殊的數據結構來表示 Java 類,還擁有特殊的異常處理模式。

JNI 中的引用可分爲局部引用和全局引用。這兩者都可以阻止垃圾回收器回收被引用的Java 對象。不同的是,局部引用在 native 方法調用返回之後便會失效。傳入參數以及大部分 JNI API 函數的返回值都屬於局部引用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章