NDK | 帶你點亮 JNI 開發基石符文 (一)

點贊關注,不再迷路,你的支持對我意義重大!

?? Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收錄,這里有 Android 進階成長路線筆記 & 博客,歡迎跟著彭丑丑一起成長。(聯系方式在 GitHub)

前言

  • 對于 Java / Android 工程師來說,native 開發是向高工進階的必經之路,也是面試中與競爭者 拉開差距 的利器!為了點亮 native 技能樹,首當其沖得是點亮 JNI(Java Native Interface,Java 本地接口)基石符文。
  • 在這篇文章里,我將帶你由淺入深地帶你探究 JNI 編程。如果能幫上忙,請務必點贊加關注,這真的對我非常重要。
  • 本文相關代碼可以從 DemoHall·HelloJni 下載查看。

目錄


前置知識

這篇文章的內容會涉及以下前置 / 相關知識,貼心的我都幫你準備好了,請享用~


1. 概述

1.1 JNI 解決了什么問題?

Java 設計 JNI 機制的目的是增強 Java 與本地代碼交互的能力。 先說一下 Java 和本地代碼的區別:我們知道程序的運行環境 / 平臺主要是操作系統 + CPU,每個平臺有自己的本地庫和 CPU 指令集。像 C/C++ 這樣的本地語言會變編譯為依賴于特定平臺的本地代碼,不具備跨平臺的性質。反觀 Java,在虛擬機和字節碼的加持下,Java 就具備了跨平臺的性質,但換個角度看,卻導致 Java 與本地代碼交互的能力較弱,本地平臺相關的特性無法充分發揮出來。因此,就有必要設計 JNI 機制來增強 Java 和本地代碼交互的能力。

提示: 本地代碼通常是 C/C++,但不限于 C/C++。

1.2 JNI 的優勢

  • 1、解決密集計算的效率問題,例如圖像處理、OpenGL、游戲等場景都是在 native 實現;
  • 2、復用現有的 C/C++ 庫,例如 OpenCV。

1.3 JNI 犧牲了什么?

  • 1、本地語言不具備跨平臺的特性,必須為不同運行環境編譯本地語言的部分;
  • 2、Java 和 native 互相調用的效率比 Java 調用 Java 的效率低(注意:是調用效率低,不要和執行效率混淆);
  • 3、增加了工程的復雜度。

2. 第一個 JNI 程序

本節我們通過一個簡單的 HelloWorld 程序來展示 JNI 編程的基本流程。

2.1 JNI 編程的基本流程

  • 1、創建 HelloWorld.java,并聲明 native 方法 sayHi();
  • 2、使用 javac 命令編譯源文件,生成 HelloWorld.class 字節碼文件;
  • 3、使用 javah 命令導出 HelloWorld.h 頭文件,頭文件里包含了本地方法的函數原型;
  • 4、使用 C/C++ 實現函數原型;
  • 5、編譯本地代碼,生成 Hello-World.so 動態庫文件;
  • 6、在 Java 代碼中調用 System.loadLibrary(...) 加載 so 文件;
  • 7、使用 Java 命令運行 HelloWorld 程序。

源碼不在這里展示了,你可以下載 Demo 查看,下載路徑:HelloJni。這里只展示 JNI 函數聲明:

JNIEXPORT void JNICALL Java_com_xurui_hellojni_HelloWorld_sayHi (JNIEnv *, jobject);

2.2 細節解釋

下面,我總結了新手容易疑惑的幾個問題:

  • 問題 1:頭文件為什么要加#ifndef #define #endif?
    答:避免頭文件被多個文件引用時重復編譯,所以把頭文件的內容放在 #ifndef 和 #endif 中間。常見模板如下:
#ifndef <宏>
#define <宏>
內容......
#endif
  • 問題 2:為什么要使用 extern "C" ?
    答:extern "C"表示即使處于 C++ 環境,也要全部使用 C 的標準進行編譯。我們可以在 jni.h 文件中找到答案:因為 JNI 方法中的 JavaVM 和 JNIEnv 最終都調用到了 C 中的 JNIInvokeInterface_ 和 JNINativeInterface_。(todo 論據不充分)

jni.h

struct JNIEnv_;
struct JavaVM_;

#ifdef __cplusplus
typedef JNIEnv_ JNIEnv;
typedef JavaVM_ JavaVM;
#else
typedef const struct JNINativeInterface_ *JNIEnv; // 結構體指針
typedef const struct JNIInvokeInterface_ *JavaVM; // 結構體指針
#endif

無論 C 還是 C++,最終調用到 C 的定義
struct JNIEnv_ {
    const struct JNINativeInterface_ *functions;

......
}

struct JavaVM_ {
    const struct JNIInvokeInterface_ *functions;

......
}
  • 問題 3:為什么 JNI 函數名是 Java_com_xurui_HelloWorld_sayHi?
    答:這是 JNI 函數靜態注冊約定的函數命名規則,當 Java 虛擬機調用 native 方法時,需要執行對應的 JNI 函數,而 JNI 函數注冊討論的就是如何確定 native 方法與 JNI 函數之間的映射關系,有兩種方法:靜態注冊和動態注冊。靜態注冊采用的是基于約定的命名規則,無重載時采用「短名稱」規則,有重載時采用「長名稱」規則。更多詳細的分析在我之前的一篇文章里討論過:NDK | 帶你梳理 JNI 函數注冊的方式和時機

  • 問題 4:關鍵詞 JNIEXPORT 是什么意思?
    答:JNIEXPORT 是一個宏定義,表示一個函數需要暴露給共享庫外部使用時。JNIEXPORT 在 Window 和 Linux 上有不同的定義:

Windows 平臺 :  
#define JNIEXPORT __declspec(dllexport)
#define JNIIMPORT __declspec(dllimport)

Linux 平臺:
#define JNIIMPORT
#define JNIEXPORT  __attribute__ ((visibility ("default")))
  • 問題 5:關鍵詞 JNICALL 是什么意思?
    答:JNICALL 是一個宏定義,表示一個函數是 JNI 函數。JNICALL 在 Window 和 Linux 上有不同的定義:
Windows 平臺 :  
#define JNICALL __stdcall // __stdcall 是一種函數調用參數的約定 ,表示函數的調用參數是從右往左。

Linux 平臺:
#define JNICALL

問題 6:第一個參數 JNIEnv* 是什么?
答:第一個參數是 JNIEnv 指針,指向一個 JNI 函數表。通過這些 JNI 函數可以讓本地代碼訪問 Java 虛擬機的內部數據結構。JNIEnv 指針還有一個作用,就是屏蔽了 Java 虛擬機的內部實現細節,使得本地代碼庫可以透明地加載到不同的 Java 虛擬機實現中去(犧牲了調用效率)。

問題 7:第二個參數 jobject 是什么?
答:第二個參數根據 native 方法是靜態方法還是實例方法有所不同。對于靜態 native 方法,第二個參數 jclass 代表 native 方法所在類的 Class 對象。對于實例 native 方法,第二個參數 jobject 代表調用 native 的對象。

2.3 類型的映射關系

Java 類型在 JNI 中都會映射為 JNI 類型,具體映射關系定義在 jni.h 文件中,jbyte, jint 和 jlong 和運行環境有關,定義在 jni_md.h 文件中。總結如下表:

Java 類型 JNI 類型 描述 長度(字節)
boolean jboolean unsigned char 1
char jchar unsigned short 2
short jshort signed short 2
float jfloat signed float 4
double jdouble signed double 8
int jint、jsize signed int 2 或 4
long jlong signed long 4 或 8(LP64)
byte jbyte signed char 1
Class jclass Java Class 類對象 /
String jstrting Java 字符串對象 /
Object jobject Java 對象 /
byte[] jbyteArray byte 數組 /

3. JNI 調用 Java 代碼

這一節我們來討論如何在 JNI 中訪問 Java 字段和方法,在本地代碼中訪問 Java 代碼,需要使用 ID 來訪問字段或方法。頻繁檢索 ID 的過程相對耗時,通常我們還需要緩存 ID 來優化性能的方法。

3.1 JNI 訪問 Java 字段

本地代碼訪問 Java 字段的流程分為兩步:

  • 1、通過 jclass 獲取字段 ID,例如:Fid = env->GetFieldId(clz, "name", "Ljava/lang/String;");
  • 2、通過字段 ID 訪問字段,例如:Jstr = env->GetObjectField(thiz, Fid);

需要注意:Ljava/lang/String;是實例字段name的字段描述符,嚴格來說,所謂「字段描述符」其實是 JVM 字節碼中描述字段的規則,和 JNI 無直接關系。使用 javap 命令也可以自動生成字段描述符和方法描述符,Android Studio 也會幫助自動生成。完整的字段描述符規則如下表:

Java 類型 字段描述符
boolean Z
byte B
char C
short S
int I
long J
float F
double D
void V
引用類型 以 L 開頭 ; 結尾,中間是 / 分隔的包名和類名。
例如 String 的字段描述符為 Ljava/lang/String;

Java 字段分為靜態字段和實例字段,本地代碼獲取或修改 Java 字段主要是使用以下 6 個方法:

  • GetFieldId:獲取實例方法的字段 ID
  • GetStaticFieldId:獲取靜態方法的字段 ID
  • Get<Type>Field:獲取類型為 Type 的實例字段(例如 GetIntField)
  • Set<Type>Field:設置類型為 Type 的實例字段(例如 SetIntField)
  • GetStatic<Type>Field:獲取類型為 Type 的靜態字段(例如 GetStaticIntField)
  • SetStatic<Type>Field:設置類型為 Type 的靜態字段(例如 SetStaticIntField)

native-lib.cpp

extern "C"
JNIEXPORT void JNICALL
Java_com_xurui_hellojni_HelloWorld_accessField(JNIEnv *env, jobject thiz) {
    // 獲取 jclass
    jclass clz = env->GetObjectClass(thiz);
    // 靜態字段 ID
    jfieldID sFieldId = env->GetStaticFieldID(clz, "sName", "Ljava/lang/String;");
    // 訪問靜態字段
    if (sFieldId) {
        jstring jStr = static_cast<jstring>(env->GetStaticObjectField(clz, sFieldId));
        // 轉換為 C 字符串
        const char *sStr = env->GetStringUTFChars(jStr, NULL);
        LOGD("靜態字段:%s", sStr);
        env->ReleaseStringUTFChars(jStr, sStr);
        jstring newStr = env->NewStringUTF("靜態字段 - Peng");
        if (newStr) {
            env->SetStaticObjectField(clz, sFieldId, newStr);
        }
    }
    // 實例字段 ID
    jfieldID mFieldId = env->GetFieldID(clz, "mName", "Ljava/lang/String;");
    // 訪問實例字段
    if (mFieldId) {
        jstring jStr = static_cast<jstring>(env->GetObjectField(thiz, mFieldId));
        // 轉換為 C 字符串
        const char *sStr = env->GetStringUTFChars(jStr, NULL);
        LOGD("實例字段:%s", sStr);
        env->ReleaseStringUTFChars(jStr, sStr);
        jstring newStr = env->NewStringUTF("實例字段 - Peng");
        if (newStr) {
            env->SetObjectField(thiz, mFieldId, newStr);
        }
    }
}

3.2 JNI 調用 Java 方法

本地代碼訪問 Java 方法與訪問 Java 字段類似,訪問流程分為兩步:

  • 1、通過 jclass 獲取「方法 ID」,例如:Mid = env->GetMethodID(jclass, "helloJava", "()V");
  • 2、通過方法 ID 調用方法,例如:env->CallVoidMethod(thiz, Mid);

需要注意:()V是實例方法helloJava的方法描述符,嚴格來說「方法描述符」是 JVM 字節碼中描述方法的規則,和 JNI 無直接關系。

Java 方法分為靜態方法和實例方法,本地代碼調用 Java 方法主要是使用以下 5 個方法:

  • GetMethodId:獲取實例方法 ID
  • GetStaticMethodId:獲取靜態方法 ID
  • Call<Type>Method:調用返回類型為 Type 的實例方法(例如 GetVoidMethod)
  • CallStatic<Type>Method:調用返回類型為 Type 的靜態方法(例如 CallStaticVoidMethod)
  • CallNonvirtual<Type>Method:調用返回類型為 Type 的父類方法(例如 CallNonvirtualVoidMethod)

native-lib.cpp

extern "C"
JNIEXPORT void JNICALL
Java_com_xurui_hellojni_HelloWorld_accessMethod(JNIEnv *env, jobject thiz) {
    // 獲取 jclass
    jclass clz = env->GetObjectClass(thiz);
    // 靜態方法 ID
    jmethodID sMethodId = env->GetStaticMethodID(clz, "sHelloJava", "()V");
    if (sMethodId) {
        env->CallStaticVoidMethod(clz, sMethodId);
    }
    // 實例方法 ID
    jmethodID mMethodId = env->GetMethodID(clz, "helloJava", "()V");
    if (mMethodId) {
        env->CallVoidMethod(thiz, mMethodId);
    }
}

3.3 緩存 ID

  • 為什么要緩存 ID:訪問 Java 層字段或方法時,需要先利用字段名 / 方法名和描述符進行檢索,獲得 jfieldID / jmethodID。這個檢索過程比較耗時,優化方法是將字段 ID 和方法 ID 緩存起來,減少重復檢索。

  • 緩存 ID 的方法:緩存字段 ID 和 方法 ID的方法主要有兩種:使用時緩存 + 初始化時緩存,主要區別在于緩存發生的時機和緩存 ID 的時效性。

使用時緩存:

使用時緩存是指在首次訪問字段或方法時,將字段 ID 或方法 ID 存儲在靜態變量中。這樣在將來再次調用本地方法時,就不需要重復檢索 ID 了。例如:

jstring MyNewString(JNIEnv* env, jchar* chars, jint len) {
        // 靜態字段
        static jmethodID cid = NULL;

        jclass stringClazz = (*env)->FindClass(env,"java/lang/String");
        if(NULL == cid) {
                cid = (*env)->GetMethodID(env,stringClazz,"<init>","([C)V");
        }
        jcharArray elemArr = (*env)->NewCharArray(env,len);
        (*env)->SetCharArrayRegion(env, elemArr, 0, len, chars);
        jstring result = (*env)->NewObject(env, stringClazz, cid, elemArr);
        (*env)->DeleteLocalRef(env,elemArr);
        (*env)->DeleteLocalRef(env,stringClazz);
        return result
}

提示: 多個線程訪問這個本地方法,會使用相同的緩存 ID,會出現問題嗎?不會,多個線程計算的字段 ID 或方法 ID 其實是相同的。

靜態初始化時緩存:

靜態初始化時緩存是指在 Java 類初始化的時候,提前緩存字段 ID 和方法 ID。例如:

private static native void initIDs();

static {
        // Java 類初始化
        System.loadLibrary("InstanceMethodCall");
        initIDs();
}
----------------------------------------------------
jmethodID cid;
jmethoidID stringId;

JNIEXPORT void JNICALL
Java_InstanceMethodCall_initIDs(JNIEnv *env, jclass cls) {
    cid = (*env)->GetMethodID(env, cls, "callback", "()V");
    jclass stringClazz = (*env)->FindClass(env,"java/lang/String");
    stringId = (*env)->GetMethodID(env,stringClazz,"<init>","([C)V");
}

3.4 兩種緩存 ID 方式的對比和使用場景

在大多數情況下,應該盡可能在靜態初始化時緩存字段 ID 和方法 ID,因為使用時緩存存在一些局限性:

  • 1、每次使用前都要檢查緩存有效;
  • 2、字段 ID 和方法 ID 在 Java 類卸載 (unload) 時會失效,因此需要確保類卸載之后不會繼續使用這個 ID。而靜態初始化時緩存在類加載 (load) 時重新檢索 ID,因此不用擔心 ID 失效。

當然,使用時緩存也不是一無是處。如果無法修改 Java 代碼源碼,使用時緩存是必然的選擇。另一個優勢在于,使用時緩存相當于懶初始化,可以按需檢索 ID,而靜態初始化時緩存相當于提前初始化,會一次性檢索所有 ID。盡管如此,大多數情況下還是會使用靜態初始化時緩存。

3.5 什么是 ID,什么是引用?

引用是通過本地代碼來管理 JVM 中的資源,可以同時創建多個引用指向相同對象;而字段 ID 和方法 ID 由 JVM 管理,同一個字段或方法的 ID 是固定的,只有在所屬類被卸載時會失效。


4. 加載 & 卸載 so 庫的過程

關于加載與卸載 so 庫的全過程,在我之前寫過的一篇文章里講過:《NDK | 說說 so 庫從加載到卸載的全過程》。這里我簡單復述下:

  • 1、so 庫加載到卸載的大體過程,主要分為:確定 so 庫絕對路徑、nativeLoad 加載進內存、ClassLoader 卸載時跟隨卸載
  • 2、搜索 so 庫的路徑,分為 App 路徑(/data/app/[packagename]/lib/arm64)和系統路徑(/system/lib64、/vendor/lib64);
  • 3、JNI_OnLoadJNI_OnUnLoad分別在 so 庫加載與卸載時執行。

5. 注冊 JNI 函數

關于 JNI 函數注冊的方式和時機,在我之前寫過的一篇文章里講過:《NDK | 帶你梳理 JNI 函數注冊的方式和時機》。這里我簡單復述下:

  • 1、調用 Java 類中定義的 native 方法時,虛擬機會調用對應的 JNI 函數,而這些 JNI 函數需要先注冊才能使用。
  • 2、注冊 JNI 函數的方式分為 靜態注冊 & 動態注冊
  • 3、注冊 JNI 函數有三種時機:
注冊的時機 對應的注冊方式
1、虛擬機第一次調用 native 方法時 靜態注冊
2、Android 虛擬機啟動時 動態注冊
3、加載 so 庫時 動態注冊

6. JNIEnv * 和 JavaVM

6.1 JNIEnv * 指針的作用

JNIEnv* 指針指向一個 JNI 函數表,在本地代碼中,可以通過這些函數來訪問 JVM 中的數據結構。從這個意義上說,可以理解為 JNIEnv* 指向了 Java 環境,但不能說 JNIEnv* 代表 Java 環境。

需要注意: 如果本地方法被不同的線程調用,傳入的 JNIEnv 指針是不同的。JNIEnv 指針只在它所在的線程中有效,不能跨線程(甚至跨進程)傳遞和使用。但 JNIEnv 間接指向的函數表在多個線程間是共享的。

6.2 JavaVM 的作用

JavaVM 表示 Java 虛擬機,一個 Java 虛擬機對應一個 JavaVM 對象,這個對象是線程間共享的。我們可以通過 JNIEnv* 來獲取一個 JavaVM 對象:

jint GetJavaVM(JNIEnv *env, JavaVM **vm);

- vm:用來存放獲得的虛擬機的指針的指針;
- return:成功返回0,失敗返回其它。

6.3 在任意位置獲取 JNIEnv* 指針

JNIEnv* 指針僅在創建它的線程有效,如果我們需要在其他線程訪問JVM,那么必須先調用 AttachCurrentThread 將當前線程與 JVM 進行關聯,然后才能獲得JNIEnv* 指針。另外,需要調用DetachCurrentThread 來解除鏈接。

jint AttachCurrentThread(JavaVM* vm , JNIEnv** env , JavaVMAttachArgs* args);

- vm:虛擬機對象指針;
- env:用來保存得到的 JNIEnv 指針的指針;
- args:鏈接參數,參數結構體如下所示;
- return:鏈接成功返回 0,連接失敗返回其它。
-----------------------------------
func() {
    JNIEnv *env;
    (*jvm)->AttachCurrentThread(jvm, (void **)&env, NULL);
}

7. 總結

今天我們主要討論了 JNI 編程的基本概念和使用步驟,也討論了本地代碼調用 Java 代碼的步驟,也介紹了提高調用效率的方法 —— 緩存 ID。另外,關于 “加載 & 卸載 so 庫的過程” 和 “JNI 函數注冊的方式和時機” 我們去年已經討論過了,希望能幫助你建立對 JNI 編程的系統認知。后面,我后續會發布更多文章來討論 JNI 編程的高級概念,例如引用、多線程操作、異常處理等。記得關注~


參考資料

創作不易,你的「三連」是丑丑最大的動力,我們下次見!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容