NDK 系列(5):JNI 從入門到實踐,萬字爆肝詳解!

語言: CN / TW / HK

theme: jzman

攜手創作,共同成長!這是我參與「掘金日新計劃 · 8 月更文挑戰」的第2天,點選檢視活動詳情 

請點贊關注,你的支援對我意義重大 👍 👍

🔥 Hi,我是小彭。本文已收錄到 GitHub · Android-NoteBook 中。這裡有 Android 進階成長知識體系,有志同道合的朋友,關注公眾號 [彭旭銳] 帶你建立核心競爭力。

前言

  • 在 Android 生態中主要有 C/C++、Java、Kotlin 三種語言 ,它們的關係不是替換而是互補。其中,C/C++ 的語境是演算法和高效能,Java 的語境是平臺無關和記憶體管理,而 Kotlin 則融合了多種語言中的優秀特性,帶來了一種更現代化的程式設計方式;
  • JNI 是實現 Java 程式碼與 C/C++ 程式碼互動的特性, 思考一個問題 —— Java 虛擬機器是如何實現兩種毫不相干的語言的互動的呢? 今天,我們來全面總結 JNI 開發知識框架,為 NDK 開發打下基礎。本文部分演示程式碼可以從 DemoHall·HelloJni 下載檢視。

這篇文章是 NDK 系列文章第 5 篇,專欄文章列表:

一、語言基礎:

  • 1、NDK 學習路線:怎麼學 & 我的經驗
  • 2、C 語言基礎
  • 3、C ++ 語言基礎
  • 4、C/C++ 編譯過程:從原始碼到程式執行

二、NDK 開發:

三、基礎理論

四、計算機基礎


JNI 學習路線圖:


1. 認識 JNI

1.1 為什麼要使用 JNI?

JNI(Java Native Interface,Java 本地介面)是 Java 生態的特性,它擴充套件了 Java 虛擬機器的能力,使得 Java 程式碼可以與 C/C++ 程式碼進行互動。 通過 JNI 介面,Java 程式碼可以呼叫 C/C++ 程式碼,C/C++ 程式碼也可以呼叫 Java 程式碼。

這就引出第 1 個問題(為什麼要這麼做):Java 為什麼要呼叫 C/C++ 程式碼,而不是直接用 Java 開發需求呢?我認為主要有 4 個原因:

  • 原因 1 - Java 天然需要 JNI 技術: 雖然 Java 是平臺無關性語言,但執行 Java 語言的虛擬機器是執行在具體平臺上的,所以 Java 虛擬機器是平臺相關的。因此,對於呼叫平臺 API 的功能(例如開啟檔案功能,在 Window 平臺是 openFile 函式,而在 Linux 平臺是 open 函式)時,雖然在 Java 語言層是平臺無關的,但背後只能通過 JNI 技術在 Native 層分別呼叫不同平臺 API。類似的,對於有操作硬體需求的程式,也只能通過 C/C++ 實現對硬體的操作,再通過 JNI 呼叫;
  • 原因 2 - Java 執行效率不及 C/C++: Java 程式碼的執行效率相對於 C/C++ 要低一些,因此,對於有密集計算(例如實時渲染、音影片處理、遊戲引擎等)需求的程式,會選擇用 C/C++ 實現,再通過 JNI 呼叫;
  • 原因 3 - Native 層程式碼安全性更高: 反編譯 so 檔案的難度比反編譯 Class 檔案高,一些跟密碼相關的功能會選擇用 C/C++ 實現,再通過 JNI 呼叫;
  • 原因 4 - 複用現有程式碼: 當 C/C++ 存在程式需要的功能時,則可以直接複用。

還有第 2 個問題(為什麼可以這麼做):為什麼兩種獨立的語言可以實現互動呢?因為 Java 虛擬機器本身就是 C/C++ 實現的,無論是 Java 程式碼還是 C/C++ 程式碼,最終都是由這個虛擬機器支撐,共同使用一個程序空間。JNI 要做的只是在兩種語言之間做橋接。

1.2 JNI 開發的基本流程

一個標準的 JNI 開發流程主要包含以下步驟:

  • 1、建立 HelloWorld.java,並宣告 native 方法 sayHi();
  • 2、使用 javac 命令編譯原始檔,生成 HelloWorld.class 位元組碼檔案;
  • 3、使用 javah 命令匯出 HelloWorld.h 標頭檔案(標頭檔案中包含了本地方法的函式原型);
  • 4、在原始檔 HelloWorld.cpp 中實現函式原型;
  • 5、編譯原生代碼,生成 Hello-World.so 動態原生庫檔案;
  • 6、在 Java 程式碼中呼叫 System.loadLibrary(...) 載入 so 檔案;
  • 7、使用 Java 命令執行 HelloWorld 程式。

該流程用示意圖表示如下:

1.3 JNI 的效能誤區

JNI 本身本身並不能解決效能問題,錯誤地使用 JNI 反而可能引入新的效能問題,這些問題都是要注意的:

  • 問題 1 - 跨越 JNI 邊界的呼叫: 從 Java 呼叫 Native 或從 Native 呼叫 Java 的成本很高,使用 JNI 時要限制跨越 JNI 邊界的呼叫次數;
  • 問題 2 - 引用型別資料的回收: 由於引用型別資料(例如字串、陣列)傳遞到 JNI 層的只是一個指標,為避免該物件被垃圾回收虛擬機器會固定住(pin)物件,在 JNI 方法返回前會阻止其垃圾回收。因此,要儘量縮短 JNI 呼叫的執行時間,它能夠縮短物件被固定的時間(關於引用型別資料的處理,在下文會說到)。

1.4 註冊 JNI 函式的方式

Java 的 native 方法和 JNI 函式是一一對應的對映關係,建立這種對映關係的註冊方式有 2 種:

  • 方式 1 - 靜態註冊: 基於命名約定建立對映關係;
  • 方式 2 - 動態註冊: 通過 JNINativeMethod 結構體建立對映關係。

關於註冊 JNI 函式的更多原理分析,見 註冊 JNI 函式

1.5 載入 so 庫的時機

so 庫需要在執行時呼叫 System.loadLibrary(…) 載入,一般有 2 種呼叫時機:

  • 1、在類靜態初始化中: 如果只在一個類或者很少類中使用到該 so 庫,則最常見的方式是在類的靜態初始化塊中呼叫;
  • 2、在 Application 初始化時呼叫: 如果有很多類需要使用到該 so 庫,則可以考慮在 Application 初始化等場景中提前載入。

關於載入 so 庫的更多原理分析,見 so 檔案載入過程分析


2. JNI 模板程式碼

本節我們通過一個簡單的 HelloWorld 程式來幫助你熟悉 JNI 的模板程式碼。

JNI Demo

cpp JNIEXPORT void JNICALL Java_com_xurui_hellojni_HelloWorld_sayHi (JNIEnv *, jobject);

2.1 JNI 函式名

為什麼 JNI 函式名要採用 Java_com_xurui_HelloWorld_sayHi 的命名方式呢?—— 這是 JNI 函式靜態註冊約定的函式命名規則。Java 的 native 方法和 JNI 函式是一一對應的對映關係,而建立這種對映關係的註冊方式有 2 種:靜態註冊 + 動態註冊。

其中,靜態註冊是基於命名約定建立的對映關係,一個 Java 的 native 方法對應的 JNI 函式會採用約定的函式名,即 Java_[類的全限定名 (帶下劃線)]_[方法名] 。JNI 呼叫 sayHi() 方法時,就會從 JNI 函式庫中尋找函式 Java_com_xurui_HelloWorld_sayHi(),更多內容見 註冊 JNI 函式

2.2 關鍵詞 JNIEXPORT

JNIEXPORT 是巨集定義,表示一個函式需要暴露給共享庫外部使用時。JNIEXPORT 在 Window 和 Linux 上有不同的定義:

jni.h

```cpp // Windows 平臺 :

define JNIEXPORT __declspec(dllexport)

define JNIIMPORT __declspec(dllimport)

// Linux 平臺:

define JNIIMPORT

define JNIEXPORT attribute ((visibility ("default")))

```

2.3 關鍵詞 JNICALL

JNICALL 是巨集定義,表示一個函式是 JNI 函式。JNICALL 在 Window 和 Linux 上有不同的定義:

jni.h

```cpp // Windows 平臺 :

define JNICALL __stdcall // __stdcall 是一種函式呼叫引數的約定 ,表示函式的呼叫引數是從右往左。

// Linux 平臺:

define JNICALL

```

2.4 引數 jobject

jobject 型別是 JNI 層對於 Java 層應用型別物件的表示。每一個從 Java 呼叫的 native 方法,在 JNI 函式中都會傳遞一個當前物件的引用。區分 2 種情況:

  • 1、靜態 native 方法: 第二個引數為 jclass 型別,指向 native 方法所在類的 Class 物件;
  • 2、例項 native 方法: 第二個引數為 jobject 型別,指向呼叫 native 方法的物件。

2.5 JavaVM 和 JNIEnv 的作用

JavaVMJNIEnv 是定義在 jni.h 標頭檔案中最關鍵的兩個資料結構:

  • JavaVM: 代表 Java 虛擬機器,每個 Java 程序有且僅有一個全域性的 JavaVM 物件,JavaVM 可以跨執行緒共享;
  • JNIEnv: 代表 Java 執行環境,每個 Java 執行緒都有各自獨立的 JNIEnv 物件,JNIEnv 不可以跨執行緒共享。

JavaVM 和 JNIEnv 的型別定義在 C 和 C++ 中略有不同,但本質上是相同的,內部由一系列指向虛擬機器內部的函式指標組成。 類似於 Java 中的 Interface 概念,不同的虛擬機器實現會從它們派生出不同的實現類,而向 JNI 層遮蔽了虛擬機器內部實現(例如在 Android ART 虛擬機器中,它們的實現分別是 JavaVMExt 和 JNIEnvExt)。

jni.h

```groovy struct _JNIEnv; struct _JavaVM;

if defined(__cplusplus)

// 如果定義了 __cplusplus 巨集,則按照 C++ 編譯 typedef _JNIEnv JNIEnv; typedef _JavaVM JavaVM;

else

// 按照 C 編譯 typedef const struct JNINativeInterface JNIEnv; typedef const struct JNIInvokeInterface JavaVM;

endif

/ * C++ 版本的 _JavaVM,內部是對 JNIInvokeInterface 的包裝 / struct _JavaVM { // 相當於 C 版本中的 JNIEnv const struct JNIInvokeInterface functions;

// 轉發給 functions 代理
jint DestroyJavaVM()
{ return functions->DestroyJavaVM(this); }
...

};

/ * C++ 版本的 JNIEnv,內部是對 JNINativeInterface 的包裝 / struct _JNIEnv { // 相當於 C 版本的 JavaVM const struct JNINativeInterface functions;

// 轉發給 functions 代理
jint GetVersion()
{ return functions->GetVersion(this); }
...

}; ```

可以看到,不管是在 C 語言中還是在 C++ 中,JNINativeInterface*JNINativeInterface* 這兩個結構體指標才是 JavaVM 和 JNIEnv 的實體。不過 C++ 中加了一層包裝,在語法上更簡潔,例如:

示例程式

```cpp // 在 C 語言中,要使用 (env)-> // 注意看這一句:typedef const struct JNINativeInterface JNIEnv; (*env)->FindClass(env, "java/lang/String");

// 在 C++ 中,要使用 env-> // 注意看這一句:jclass FindClass(const char* name) //{ return functions->FindClass(this, name); } env->FindClass("java/lang/String"); ```

後文提到的大量 JNI 函式,其實都是定義在 JNINativeInterface 和 JNINativeInterface 內部的函式指標。

jni.h

```cpp / * JavaVM / struct JNIInvokeInterface { // 一系列函式指標 jint (DestroyJavaVM)(JavaVM); jint (AttachCurrentThread)(JavaVM, JNIEnv, void); jint (DetachCurrentThread)(JavaVM); jint (GetEnv)(JavaVM*, void, jint); jint (AttachCurrentThreadAsDaemon)(JavaVM, JNIEnv*, void); };

/ * JNIEnv / struct JNINativeInterface { // 一系列函式指標 jint (GetVersion)(JNIEnv ); jclass (DefineClass)(JNIEnv, const char, jobject, const jbyte, jsize); jclass (FindClass)(JNIEnv, const char*); ... }; ```


3. 資料型別轉換

這一節我們來討論 Java 層與 Native 層之間的資料型別轉換。

3.1 Java 型別對映(重點理解)

JNI 對於 Java 的基礎資料型別(int 等)和引用資料型別(Object、Class、陣列等)的處理方式不同。這個原理非常重要,理解這個原理才能理解後面所有 JNI 函式的設計思路:

  • 基礎資料型別: 會直接轉換為 C/C++ 的基礎資料型別,例如 int 型別對映為 jint 型別。由於 jint 是 C/C++ 型別,所以可以直接當作普通 C/C++ 變數使用,而不需要依賴 JNIEnv 環境物件;
  • 引用資料型別: 物件只會轉換為一個 C/C++ 指標,例如 Object 型別對映為 jobject 型別。由於指標指向 Java 虛擬機器內部的資料結構,所以不可能直接在 C/C++ 程式碼中操作物件,而是需要依賴 JNIEnv 環境物件。另外,為了避免物件在使用時突然被回收,在本地方法返回前,虛擬機器會固定(pin)物件,阻止其 GC。

另外需要特別注意一點,基礎資料型別在對映時是直接對映,而不會發生資料格式轉換。例如,Java char 型別在對映為 jchar 後舊是保持 Java 層的樣子,資料長度依舊是 2 個位元組,而字元編碼依舊是 UNT-16 編碼。

具體對映關係都定義在 jni.h 標頭檔案中,檔案摘要如下:

jni.h

```groovy typedef uint8_t jboolean; / unsigned 8 bits / typedef int8_t jbyte; / signed 8 bits / typedef uint16_t jchar; / unsigned 16 bits / / 注意:jchar 是 2 個位元組 / typedef int16_t jshort; / signed 16 bits / typedef int32_t jint; / signed 32 bits / typedef int64_t jlong; / signed 64 bits / typedef float jfloat; / 32-bit IEEE 754 / typedef double jdouble; / 64-bit IEEE 754 / typedef jint jsize;

ifdef __cplusplus

// 內部的資料結構由虛擬機器實現,只能從虛擬機器原始碼看 class _jobject {}; class _jclass : public _jobject {}; class _jstring : public _jobject {}; class _jarray : public _jobject {}; class _jobjectArray : public _jarray {}; class _jbooleanArray : public _jarray {}; ... // 說明我們接觸到到 jobject、jclass 其實是一個指標 typedef _jobject jobject; typedef _jclass jclass; typedef _jstring jstring; typedef _jarray jarray; typedef _jobjectArray jobjectArray; typedef _jbooleanArray jbooleanArray; ...

else / not __cplusplus /

...

endif / not __cplusplus /

```

我將所有 Java 型別與 JNI 型別的對映關係總結為下表:

| Java 型別 | JNI 型別 | 描述 | 長度(位元組) | | --- | --- | --- | --- | | boolean | jboolean | unsigned char | 1 | | byte | jbyte | signed char | 1 | | char | jchar | unsigned short | 2 | | short | jshort | signed short | 2 | | int | jint、jsize | signed int | 4 | | long | jlong | signed long | 8 | | float | jfloat | signed float | 4 | | double | jdouble | signed double | 8 | | Class | jclass | Class 類物件 | 1 | | String | jstrting | 字串物件 | / | | Object | jobject | 物件 | / | | Throwable | jthrowable | 異常物件 | / | | boolean[] | jbooleanArray | 布林陣列 | / | | byte[] | jbyteArray | byte 陣列 | / | | char[] | jcharArray | char 陣列 | / | | short[] | jshortArray | short 陣列 | / | | int[] | jinitArray | int 陣列 | / | | long[] | jlongArray | long 陣列 | / | | float[] | jfloatArray | float 陣列 | / | | double[] | jdoubleArray | double 陣列 | / |

3.2 字串型別操作

上面提到 Java 物件會對映為一個 jobject 指標,那麼 Java 中的 java.lang.String 字串型別也會對映為一個 jobject 指標。可能是因為字串的使用頻率實在是太高了,所以 JNI 規範還專門定義了一個 jobject 的派生類 jstring 來表示 Java String 型別,這個相對特殊。

jni.h

```groovy // 內部的資料結構還是看不到,由虛擬機器實現 class _jstring : public _jobject {}; typedef _jstring* jstring;

struct JNINativeInterface { // String 轉換為 UTF-8 字串 const char (GetStringUTFChars)(JNIEnv, jstring, jboolean); // 釋放 GetStringUTFChars 生成的 UTF-8 字串 void (ReleaseStringUTFChars)(JNIEnv, jstring, const char); // 構造新的 String 字串 jstring (NewStringUTF)(JNIEnv, const char); // 獲取 String 字串的長度 jsize (GetStringUTFLength)(JNIEnv, jstring); // 將 String 複製到預分配的 char 陣列中 void (GetStringUTFRegion)(JNIEnv, jstring, jsize, jsize, char); }; ```

由於 Java 與 C/C++ 預設使用不同的字元編碼,因此在操作字元資料時,需要特別注意在 UTF-16 和 UTF-8 兩種編碼之間轉換。關於字元編碼,我們在 Unicode 和 UTF-8是什麼關係? 這篇文章裡討論過,這裡就簡單回顧一下:

  • Unicode: 統一化字元編碼標準,為全世界所有字元定義統一的碼點,例如 U+0011;
  • UTF-8: Unicode 標準的實現編碼之一,使用 1~4 位元組的變長編碼。UTF-8 編碼中的一位元組編碼與 ASCII 編碼相容。
  • UTF-16: Unicode 標準的實現編碼之一,使用 2 / 4 位元組的變長編碼。UTF-16 是 Java String 使用的字元編碼;
  • UTF-32: Unicode 標準的實現編碼之一,使用 4 位元組定長編碼。

以下為 2 種較為常見的轉換場景:

  • 1、Java String 物件轉換為 C/C++ 字串: 呼叫 GetStringUTFChars 函式將一個 jstring 指標轉換為一個 UTF-8 的 C/C++ 字串,並在不再使用時呼叫 ReleaseStringChars 函式釋放記憶體;
  • 2、構造 Java String 物件: 呼叫 NewStringUTF 函式構造一個新的 Java String 字串物件。

我們直接看一段示例程式:

示例程式

```groovy // 示例 1:將 Java String 轉換為 C/C++ 字串 jstring jStr = ...; // Java 層傳遞過來的 String const char *str = env->GetStringUTFChars(jStr, JNI_FALSE); if(!str) { // OutOfMemoryError return; } // 釋放 GetStringUTFChars 生成的 UTF-8 字串 env->ReleaseStringUTFChars(jStr, str);

// 示例 2:構造 Java String 物件(將 C/C++ 字串轉換為 Java String) jstring newStr = env->NewStringUTF("在 Native 層構造 Java String"); if (newStr) { // 通過 JNIEnv 方法將 jstring 呼叫 Java 方法(jstring 本身就是 Java String 的對映,可以直接傳遞到 Java 層) ... } ```

此處對 GetStringUTFChars 函式的第 3 個引數 isCopy 做解釋:它是一個布林值引數,將決定使用拷貝模式還是複用模式:

  • 1、JNI_TRUE: 使用拷貝模式,JVM 將拷貝一份原始資料來生成 UTF-8 字串;
  • 2、JNI_FALSE: 使用複用模式,JVM 將複用同一份原始資料來生成 UTF-8 字串。複用模式絕不能修改字串內容,否則 JVM 中的原始字串也會被修改,打破 String 不可變性。

另外還有一個基於範圍的轉換函式:GetStringUTFRegion:預分配一塊字元陣列緩衝區,然後將 String 資料複製到這塊緩衝區中。由於這個函式本身不會做任何記憶體分配,所以不需要呼叫對應的釋放資源函式,也不會丟擲 OutOfMemoryError。另外,GetStringUTFRegion 這個函式會做越界檢查並丟擲 StringIndexOutOfBoundsException 異常。

示例程式

groovy jstring jStr = ...; // Java 層傳遞過來的 String char outbuf[128]; int len = env->GetStringLength(jStr); env->GetStringUTFRegion(jStr, 0, len, outbuf);

3.3 陣列型別操作

與 jstring 的處理方式類似,JNI 規範將 Java 陣列定義為 jobject 的派生類 jarray

  • 基礎型別陣列:定義為 jbooleanArrayjintArray 等;
  • 引用型別陣列:定義為 jobjectArray

下面區分基礎型別陣列和引用型別陣列兩種情況:

操作基礎型別陣列(以 jintArray 為例):

  • 1、Java 基本型別陣列轉換為 C/C++ 陣列: 呼叫 GetIntArrayElements 函式將一個 jintArray 指標轉換為 C/C++ int 陣列;
  • 2、修改 Java 基本型別陣列: 呼叫 ReleaseIntArrayElements 函式並使用模式 0;
  • 3、構造 Java 基本型別陣列: 呼叫 NewIntArray 函式構造 Java int 陣列。

我們直接看一段示例程式:

示例程式

cpp extern "C" JNIEXPORT jintArray JNICALL Java_com_xurui_hellojni_HelloWorld_generateIntArray(JNIEnv *env, jobject thiz, jint size) { // 新建 Java int[] jintArray jarr = env->NewIntArray(size); // 轉換為 C/C ++ int[] int *carr = env->GetIntArrayElements(jarr, JNI_FALSE); // 賦值 for (int i = 0; i < size; i++) { carr[i] = i; } // 釋放資源並回寫 env->ReleaseIntArrayElements(jarr, carr, 0); // 返回陣列 return jarr; }

此處重點對 ReleaseIntArrayElements 函式的第 3 個引數 mode 做解釋:它是一個模式引數:

| 引數 mode | 描述 | | --- | --- | | 0 | 將 C/C++ 陣列的資料回寫到 Java 陣列,並釋放 C/C++ 陣列 | | JNI_COMMIT | 將 C/C++ 陣列的資料回寫到 Java 陣列,並不釋放 C/C++ 陣列 | | JNI_ABORT | 不回寫資料,但釋放 C/C++ 陣列 |

另外 JNI 還提供了基於範圍函式:GetIntArrayRegionSetIntArrayRegion,使用方法和注意事項和 GetStringUTFRegion 也是類似的,也是基於一塊預分配的陣列緩衝區。

操作引用型別陣列(jobjectArray):

  • 1、將 Java 引用型別陣列轉換為 C/C++ 陣列: 不支援!與基本型別陣列不同,引用型別陣列的元素 jobject 是一個指標,不存在轉換為 C/C++ 陣列的概念;
  • 2、修改 Java 引用型別陣列: 呼叫 SetObjectArrayElement 函式修改指定下標元素;
  • 3、構造 Java 引用型別陣列: 先呼叫 FindClass 函式獲取 Class 物件,再呼叫 NewObjectArray 函式構造物件陣列。

我們直接看一段示例程式:

示例程式

cpp extern "C" JNIEXPORT jobjectArray JNICALL Java_com_xurui_hellojni_HelloWorld_generateStringArray(JNIEnv *env, jobject thiz, jint size) { // 獲取 String Class jclass jStringClazz = env->FindClass("java/lang/String"); // 初始值(可為空) jstring initialStr = env->NewStringUTF("初始值"); // 建立 Java String[] jobjectArray jarr = env->NewObjectArray(size, jStringClazz, initialStr); // 賦值 for (int i = 0; i < size; i++) { char str[5]; sprintf(str, "%d", i); jstring jStr = env->NewStringUTF(str); env->SetObjectArrayElement(jarr, i, jStr); } // 返回陣列 return jarr; }


4. JNI 訪問 Java 欄位與方法

這一節我們來討論如何從 Native 層訪問 Java 的欄位與方法。在開始訪問前,JNI 首先要找到想訪問的欄位和方法,這就依靠欄位描述符和方法描述符。

4.1 欄位描述符與方法描述符

在 Java 原始碼中定義的欄位和方法,在編譯後都會按照既定的規則記錄在 Class 檔案中的欄位表和方法表結構中。例如,一個 public String str; 欄位會被拆分為欄位訪問標記(public)、欄位簡單名稱(str)和欄位描述符(Ljava/lang/String)。 因此,從 JNI 訪問 Java 層的欄位或方法時,首先就是要獲取在 Class 檔案中記錄的簡單名稱和描述符。

Class 檔案的一級結構:

欄位表結構: 包含欄位的訪問標記、簡單名稱、欄位描述符等資訊。例如欄位 String str 的簡單名稱為 str,欄位描述符為 Ljava/lang/String;

方法表結構: 包含方法的訪問標記、簡單名稱、方法描述符等資訊。例如方法 void fun(); 的簡單名稱為 fun,方法描述符為 ()V

4.2 描述符規則

  • 欄位描述符: 欄位描述符其實就是描述欄位的型別,JVM 對每種基礎資料型別定義了固定的描述符,而引用型別則是以 L 開頭的形式:

| Java 型別 | 描述符 | | --- | --- | | boolean | Z | | byte | B | | char | C | | short | S | | int | I | | long | J | | floag | F | | double | D | | void | V | | 引用型別 | 以 L 開頭 ; 結尾,中間是 / 分隔的包名和類名。例如 String 的欄位描述符為 Ljava/lang/String; | - 方法描述符: 方法描述符其實就是描述方法的返回值型別和引數表型別,引數型別用一對圓括號括起來,按照引數宣告順序列舉引數型別,返回值出現在括號後面。例如方法 void fun(); 的簡單名稱為 fun,方法描述符為 ()V

4.3 JNI 訪問 Java 欄位

原生代碼訪問 Java 欄位的流程分為 2 步:

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

Java 欄位分為靜態欄位和例項欄位,相關方法如下:

  • GetFieldId:獲取例項方法的欄位 ID
  • GetStaticFieldId:獲取靜態方法的欄位 ID
  • GetField:獲取型別為 Type 的例項欄位(例如 GetIntField)
  • SetField:設定型別為 Type 的例項欄位(例如 SetIntField)
  • GetStaticField:獲取型別為 Type 的靜態欄位(例如 GetStaticIntField)
  • SetStaticField:設定型別為 Type 的靜態欄位(例如 SetStaticIntField)

示例程式

groovy extern "C" JNIEXPORT void JNICALL Java_com_xurui_hellojni_HelloWorld_accessField(JNIEnv *env, jobject thiz) { // 獲取 jclass jclass clz = env->GetObjectClass(thiz); // 示例:修改 Java 靜態變數值 // 靜態欄位 ID jfieldID sFieldId = env->GetStaticFieldID(clz, "sName", "Ljava/lang/String;"); // 訪問靜態欄位 if (sFieldId) { // Java 方法的返回值 String 對映為 jstring jstring jStr = static_cast<jstring>(env->GetStaticObjectField(clz, sFieldId)); // 將 jstring 轉換為 C 風格字串 const char *sStr = env->GetStringUTFChars(jStr, JNI_FALSE); // 釋放資源 env->ReleaseStringUTFChars(jStr, sStr); // 構造 jstring jstring newStr = env->NewStringUTF("靜態欄位 - Peng"); if (newStr) { // jstring 本身就是 Java String 的對映,可以直接傳遞到 Java 層 env->SetStaticObjectField(clz, sFieldId, newStr); } } // 示例:修改 Java 成員變數值 // 例項欄位 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, JNI_FALSE); // 釋放資源 env->ReleaseStringUTFChars(jStr, sStr); // 構造 jstring jstring newStr = env->NewStringUTF("例項欄位 - Peng"); if (newStr) { // jstring 本身就是 Java String 的對映,可以直接傳遞到 Java 層 env->SetObjectField(thiz, mFieldId, newStr); } } }

4.4 JNI 呼叫 Java 方法

原生代碼訪問 Java 方法與訪問 Java 欄位類似,訪問流程分為 2 步:

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

Java 方法分為靜態方法和例項方法,相關方法如下:

  • GetMethodId:獲取例項方法 ID
  • GetStaticMethodId:獲取靜態方法 ID
  • CallMethod:呼叫返回型別為 Type 的例項方法(例如 GetVoidMethod)
  • CallStaticMethod:呼叫返回型別為 Type 的靜態方法(例如 CallStaticVoidMethod)
  • CallNonvirtualMethod:呼叫返回型別為 Type 的父類方法(例如 CallNonvirtualVoidMethod)

示例程式

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

4.5 快取 ID

訪問 Java 層欄位或方法時,需要先利用欄位名 / 方法名和描述符進行檢索,獲得 jfieldID / jmethodID。這個檢索過程比較耗時,優化方法是將欄位 ID 和方法 ID 快取起來,減少重複檢索。

提示: 從不同執行緒中獲取同一個欄位或方法 的 ID 是相同的,快取 ID 不會有多執行緒問題。

快取欄位 ID 和 方法 ID 的方法主要有 2 種:

  • 1、使用時快取: 使用時快取是指在首次訪問欄位或方法時,將欄位 ID 或方法 ID 儲存在靜態變數中。這樣將來再次呼叫本地方法時,就不需要重複檢索 ID 了。例如:
  • 2、類初始化時快取: 靜態初始化時快取是指在 Java 類初始化的時候,提前快取欄位 ID 和方法 ID。可以選擇在 JNI_OnLoad 方法中快取,也可以在載入 so 庫後呼叫一個 native 方法進行快取。

兩種快取 ID 方式的主要區別在於快取發生的時機和時效性:

  • 1、時機不同: 使用時快取是延遲按需快取,只有在首次訪問 Java 時才會獲取 ID 並快取,而類初始化時快取是提前快取;
  • 2、時效性不同: 使用時快取的 ID 在類解除安裝後失效,在類解除安裝後不能使用,而類載入時快取在每次載入 so 動態庫時會重新更新快取,因此快取的 ID 是保持有效的。

5. JNI 中的物件引用管理

5.1 Java 和 C/C++ 中物件記憶體回收區別(重點理解)

在討論 JNI 中的物件引用管理,我們先回顧一下 Java 和 C/C++ 在物件記憶體回收上的區別:

  • Java: 物件在堆 / 方法區上分配,由垃圾回收器掃描物件可達性進行回收。如果使用區域性變數指向物件,在不再使用物件時可以手動顯式置空,也可以等到方法返回時自動隱式置空。如果使用全域性變數(static)指向物件,在不再使用物件時必須手動顯式置空。
  • C/C++: 棧上分配的物件會在方法返回時自動回收,而堆上分配的物件不會隨著方法返回而回收,也沒有垃圾回收器管理,因此必須手動回收(free/delete)。

而 JNI 層作為 Java 層和 C/C++ 層之間的橋接層,那麼它就會兼具兩者的特點:對於

  • 區域性 Java 物件引用: 在 JNI 層可以通過 NewObject 等函式建立 Java 物件,並且返回物件的引用,這個引用就是 Local 型的區域性引用。對於區域性引用,可以通過 DeleteLocalRef 函式手動顯式釋放(這類似於在 Java 中顯式置空區域性變數),也可以等到函式返回時自動釋放(這類似於在 Java 中方法返回時隱式置空區域性變數);
  • 全域性 Java 物件引用: 由於區域性引用在函式返回後一定會釋放,可以通過 NewGlobalRef 函式將區域性引用升級為 Global 型全域性變數,這樣就可以在方法使用物件(這類似於在 Java 中使用 static 變數指向物件)。在不再使用物件時必須呼叫 DeleteGlobalRef 函式釋放全域性引用(這類似於在 Java 中顯式置空 static 變數)。

提示: 我們這裡所說的 ”置空“ 只是將指向變數的值賦值為 null,而不是回收物件,Java 物件回收是交給垃圾回收器處理的。

5.2 JNI 中的三種引用

  • 1、區域性引用: 大部分 JNI 函式會建立區域性引用,區域性引用只有在建立引用的本地方法返回前有效,也只在建立區域性引用的執行緒中有效。在方法返回後,區域性引用會自動釋放,也可以通過 DeleteLocalRef 函式手動釋放;
  • 2、全域性引用: 區域性引用要跨方法和跨執行緒必須升級為全域性引用,全域性引用通過 NewGlobalRef 函式建立,不再使用物件時必須通過 DeleteGlobalRef 函式釋放。
  • 3、弱全域性引用: 弱引用與全域性引用類似,區別在於弱全域性引用不會持有強引用,因此不會阻止垃圾回收器回收引用指向的物件。弱全域性引用通過 NewGlobalWeakRef 函式建立,不再使用物件時必須通過 DeleteGlobalWeakRef 函式釋放。

示例程式

```cpp // 區域性引用 jclass localRefClz = env->FindClass("java/lang/String"); env->DeleteLocalRef(localRefClz);

// 全域性引用 jclass globalRefClz = env->NewGlobalRef(localRefClz); env->DeleteGlobalRef(globalRefClz);

// 弱全域性引用 jclass weakRefClz = env->NewWeakGlobalRef(localRefClz); env->DeleteGlobalWeakRef(weakRefClz); ```

5.3 JNI 引用的實現原理

在 JavaVM 和 JNIEnv 中,會分別建立多個表管理引用:

  • JavaVM 內有 globals 和 weak_globals 兩個表管理全域性引用和弱全域性引用。由於 JavaVM 是程序共享的,因此全域性引用可以跨方法和跨執行緒共享;
  • JavaEnv 內有 locals 表管理區域性引用,由於 JavaEnv 是執行緒獨佔的,因此區域性引用不能跨執行緒。另外虛擬機器在進入和退出本地方法通過 Cookie 資訊記錄哪些區域性引用是在哪些本地方法中建立的,因此區域性引用是不能跨方法的。

5.4 比較引用是否指向相同物件

可以使用 JNI 函式 IsSameObject 判斷兩個引用是否指向相同物件(適用於三種引用型別),返回值為 JNI_TRUE 時表示相同,返回值為 JNI_FALSE 表示不同。例如:

示例程式

cpp jclass localRef = ... jclass globalRef = ... bool isSampe = env->IsSamObject(localRef, globalRef)

另外,當引用與 NULL 比較時含義略有不同:

  • 區域性引用和全域性引用與 NULL 比較: 用於判斷引用是否指向 NULL 物件;
  • 弱全域性引用與 NULL 比較: 用於判斷引用指向的物件是否被回收。

6. JNI 中的異常處理

6.1 JNI 的異常處理機制(重點理解)

JNI 中的異常機制與 Java 和 C/C++ 的處理機制都不同:

  • Java 和 C/C++: 程式使用關鍵字 throw 丟擲異常,虛擬機器會中斷當前執行流程,轉而去尋找匹配的 catch{} 塊,或者繼續向外層丟擲尋找匹配 catch {} 塊。
  • JNI: 程式使用 JNI 函式 ThrowNew 丟擲異常,程式不會中斷當前執行流程,而是返回 Java 層後,虛擬機器才會丟擲這個異常。

因此,在 JNI 層出現異常時,有 2 種處理選擇:

  • 方法 1: 直接 return 當前方法,讓 Java 層去處理這個異常(這類似於在 Java 中向方法外層丟擲異常);
  • 方法 2: 通過 JNI 函式 ExceptionClear 清除這個異常,再執行異常處理程式(這類似於在 Java 中 try-catch 處理異常)。需要注意的是,當異常發生時,必須先處理-清除異常,再執行其他 JNI 函式呼叫。 因為當執行環境存在未處理的異常時,只能呼叫 2 種 JNI 函式:異常護理函式和清理資源函式。

JNI 提供了以下與異常處理相關的 JNI 函式:

  • ThrowNew: 向 Java 層丟擲異常;
  • ExceptionDescribe: 列印異常描述資訊;
  • ExceptionOccurred: 檢查當前環境是否發生異常,如果存在異常則返回該異常物件;
  • ExceptionCheck: 檢查當前環境是否發生異常,如果存在異常則返回 JNI_TRUE,否則返回 JNI_FALSE;
  • ExceptionClear: 清除當前環境的異常。

jni.h

cpp struct JNINativeInterface { // 丟擲異常 jint (*ThrowNew)(JNIEnv *, jclass, const char *); // 檢查異常 jthrowable (*ExceptionOccurred)(JNIEnv*); // 檢查異常 jboolean (*ExceptionCheck)(JNIEnv*); // 清除異常 void (*ExceptionClear)(JNIEnv*); };

示例程式

cpp // 示例 1:向 Java 層丟擲異常 jclass exceptionClz = env->FindClass("java/lang/IllegalArgumentException"); env->ThrowNew(exceptionClz, "來自 Native 的異常"); // 示例 2:檢查當前環境是否發生異常(類似於 Java try{}) jthrowable exc = env->ExceptionOccurred(env); if(exc) { // 處理異常(類似於 Java 的 catch{}) } // 示例 3:清除異常 env->ExceptionClear();

6.2 檢查是否發生異常的方式

異常處理的步驟我懂了,由於虛擬機器在遇到 ThrowNew 時不會中斷當前執行流程,那我怎麼知道當前已經發生異常呢?有 2 種方法:

  • 方法 1: 通過函式返回值錯誤碼,大部分 JNI 函式和庫函式都會有特定的返回值來標示錯誤,例如 -1、NULL 等。在程式流程中可以多檢查函式返回值來判斷異常。
  • 方法 2: 通過 JNI 函式 ExceptionOccurredExceptionCheck 檢查當前是否有異常發生。

7. JNI 與多執行緒

這一節我們來討論 JNI 層中的多執行緒操作。

7.1 不能跨執行緒的引用

在 JNI 中,有 2 類引用是無法跨執行緒呼叫的,必須時刻謹記:

  • JNIEnv: JNIEnv 只在所在的執行緒有效,在不同執行緒中呼叫 JNI 函式時,必須使用該執行緒專門的 JNIEnv 指標,不能跨執行緒傳遞和使用。通過 AttachCurrentThread 函式將當前執行緒依附到 JavaVM 上,獲得屬於當前執行緒的 JNIEnv 指標。如果當前執行緒已經依附到 JavaVM,也可以直接使用 GetEnv 函式。

示例程式

cpp JNIEnv * env_child; vm->AttachCurrentThread(&env_child, nullptr); // 使用 JNIEnv* vm->DetachCurrentThread();

  • 區域性引用: 區域性引用只在建立的執行緒和方法中有效,不能跨執行緒使用。可以將區域性引用升級為全域性引用後跨執行緒使用。

示例程式

cpp // 區域性引用 jclass localRefClz = env->FindClass("java/lang/String"); // 釋放全域性引用(非必須) env->DeleteLocalRef(localRefClz); // 區域性引用升級為全域性引用 jclass globalRefClz = env->NewGlobalRef(localRefClz); // 釋放全域性引用(必須) env->DeleteGlobalRef(globalRefClz);

7.2 監視器同步

在 JNI 中也會存在多個執行緒同時訪問一個記憶體資源的情況,此時需要保證併發安全。在 Java 中我們會通過 synchronized 關鍵字來實現互斥塊(背後是使用監視器位元組碼),在 JNI 層也提供了類似效果的 JNI 函式:

  • MonitorEnter: 進入同步塊,如果另一個執行緒已經進入該 jobject 的監視器,則當前執行緒會阻塞;
  • MonitorExit: 退出同步塊,如果當前執行緒未進入該 jobject 的監視器,則會丟擲 IllegalMonitorStateException 異常。

jni.h

cpp struct JNINativeInterface { jint (*MonitorEnter)(JNIEnv*, jobject); jint (*MonitorExit)(JNIEnv*, jobject); }

示例程式

```cpp // 進入監視器 if (env->MonitorEnter(obj) != JNI_OK) { // 建立監視器的資源分配不成功等 }

// 此處為同步塊 if (env->ExceptionOccurred()) { // 必須保證有對應的 MonitorExit,否則可能出現死鎖 if (env->MonitorExit(obj) != JNI_OK) { ... }; return; }

// 退出監視器 if (env->MonitorExit(obj) != JNI_OK) { ... }; ```

7.3 等待與喚醒

JNI 沒有提供 Object 的 wati/notify 相關功能的函式,需要通過 JNI 呼叫 Java 方法的方式來實現:

示例程式

```cpp static jmethodID MID_Object_wait; static jmethodID MID_Object_notify; static jmethodID MID_Object_notifyAll;

void JNU_MonitorWait(JNIEnv env, jobject object, jlong timeout) { env->CallVoidMethod(object, MID_Object_wait, timeout); } void JNU_MonitorNotify(JNIEnv env, jobject object) { env->CallVoidMethod(object, MID_Object_notify); } void JNU_MonitorNotifyAll(JNIEnv *env, jobject object) { env->CallVoidMethod(object, MID_Object_notifyAll); } ```

7.4 建立執行緒的方法

在 JNI 開發中,有兩種建立執行緒的方式:

  • 方法 1 - 通過 Java API 建立: 使用我們熟悉的 Thread#start() 可以建立執行緒,優點是可以方便地設定執行緒名稱和除錯;
  • 方法 2 - 通過 C/C++ API 建立: 使用 pthread_create() 或 std::thread 也可以建立執行緒

示例程式

```cpp // void thr_fn(void arg) { printids("new thread: "); return NULL; }

int main(void) { pthread_t ntid; // 第 4 個引數將傳遞到 thr_fn 的引數 arg 中 err = pthread_create(&ntid, NULL, thr_fn, NULL); if (err != 0) { printf("can't create thread: %s\n", strerror(err)); } return 0; } ```


8. 通用 JNI 開發模板

光說不練假把式,以下給出一個簡單的 JNI 開發模板,將包括上文提到的一些比較重要的知識點。程式邏輯很簡單:Java 層傳遞一個媒體檔案路徑到 Native 層後,由 Native 層播放媒體並回調到 Java 層。為了程式簡化,所有真實的媒體播放程式碼都移除了,只保留模板程式碼。

  • Java 層:start() 方法開始,呼叫 startNative() 方法進入 Native 層;
  • Native 層: 建立 MediaPlayer 物件,其中在子執行緒播放媒體檔案,並通過預先持有的 JavaVM 指標獲取子執行緒的 JNIEnv 物件回撥到 Java 層 onStarted() 方法。

MediaPlayer.kt

```groovy // Java 層模板 class MediaPlayer { companion object { init { // 注意點:載入 so 庫 System.loadLibrary("hellondk") } }

// Native 層指標
private var nativeObj: Long? = null

fun start(path : String) {
    // 注意點:記錄 Native 層指標,後續操作才能拿到 Native 的物件
    nativeObj = startNative(path)
}

fun release() {
    // 注意點:使用 start() 中記錄的指標呼叫 native 方法
    nativeObj?.let {
        releaseNative(it)
    }
    nativeObj = null
}

private external fun startNative(path : String): Long
private external fun releaseNative(nativeObj: Long)

fun onStarted() {
    // Native 層回撥(來自 JNICallbackHelper#onStarted)
    ...
}

} ```

native-lib.cpp

```groovy // 注意點:記錄 JavaVM 指標,用於在子執行緒獲得 JNIEnv JavaVM *vm = nullptr;

jint JNI_OnLoad(JavaVM vm, void args) { ::vm = vm; return JNI_VERSION_1_6; }

extern "C" JNIEXPORT jlong JNICALL Java_com_pengxr_hellondk_MediaPlayer_startNative(JNIEnv env, jobject thiz, jstring path) { // 注意點:String 轉 C 風格字串 const char path_ = env->GetStringUTFChars(path, nullptr); // 構造一個 Native 物件 auto helper = new JNICallbackHelper(vm, env, thiz); auto player = new MediaPlayer(path_, helper); player->start(); // 返回 Native 物件的指標 return reinterpret_cast(player); }

extern "C" JNIEXPORT void JNICALL Java_com_pengxr_hellondk_MediaPlayer_releaseNative(JNIEnv *env, jobject thiz, jlong native_obj) { auto * player = reinterpret_cast(native_obj); player->release(); } ```

JNICallbackHelper.h

```groovy

ifndef HELLONDK_JNICALLBACKHELPER_H

define HELLONDK_JNICALLBACKHELPER_H

include

include "util.h"

class JNICallbackHelper {

private: // 全域性共享的 JavaVM // 注意點:指標要初始化 0 值 JavaVM vm = 0; // 主執行緒的 JNIEnv JNIEnv env = 0; // Java 層的物件 MediaPlayer.kt jobject job; // Java 層的方法 MediaPlayer#onStarted() jmethodID jmd_prepared;

public: JNICallbackHelper(JavaVM vm, JNIEnv env, jobject job);

~JNICallbackHelper();

void onStarted();

};

endif //HELLONDK_JNICALLBACKHELPER_H

```

JNICallbackHelper.cpp

```groovy

include "JNICallbackHelper.h"

JNICallbackHelper::JNICallbackHelper(JavaVM vm, JNIEnv env, jobject job) { // 全域性共享的 JavaVM this->vm = vm; // 主執行緒的 JNIEnv this->env = env;

// C 回撥 Java
jclass mediaPlayerKTClass = env->GetObjectClass(job);
jmd_prepared = env->GetMethodID(mediaPlayerKTClass, "onPrepared", "()V");

// 注意點:jobject 無法跨越執行緒,需要轉換為全域性引用
// Error:this->job = job;
this->job = env->NewGlobalRef(job);

}

JNICallbackHelper::~JNICallbackHelper() { vm = nullptr; // 注意點:釋放全域性引用 env->DeleteGlobalRef(job); job = nullptr; env = nullptr; }

void JNICallbackHelper::onStarted() { // 注意點:子執行緒不能直接使用持有的主執行緒 env,需要通過 AttachCurrentThread 獲取子執行緒的 env JNIEnv * env_child; vm->AttachCurrentThread(&env_child, nullptr); // 回撥 Java 方法 env_child->CallVoidMethod(job, jmd_prepared); vm->DetachCurrentThread(); } ```

MediaPlayer.h

```groovy

ifndef HELLONDK_MEDIAPLAYER_H

define HELLONDK_MEDIAPLAYER_H

include

include

include "JNICallbackHelper.h"

class MediaPlayer { private: char path = 0; JNICallbackHelper helper = 0; pthread_t pid_start; public: MediaPlayer(const char path, JNICallbackHelper helper);

~MediaPlayer();

void doOpenFile();

void start();

void release();

};

endif //HELLONDK_MEDIAPLAYER_H

```

MediaPlayer.cpp

```groovy

include "MediaPlayer.h"

MediaPlayer::MediaPlayer(const char path, JNICallbackHelper helper) { // 注意點:引數 path 指向的空間被回收會造成懸空指標,應複製一份 // this->path = path; this->path = new char[strlen(path) + 1]; strcpy(this->path, path);

this->helper = helper;

}

MediaPlayer::~MediaPlayer() { if (path) { delete path; } if (helper) { delete helper; } }

// 在子執行緒執行 void MediaPlayer::doOpenFile() { // 省略真實播放邏輯... // 媒體檔案開啟成功 helper->onStarted(); }

// 在子執行緒執行 void task_open(void args) { // args 是 主執行緒 MediaPlayer 的例項的 this變數 auto *player = static_cast(args); player->doOpenFile();

return nullptr;

}

void MediaPlayer::start() { // 切換到子執行緒執行 pthread_create(&pid_start, 0, task_open, this); }

void MediaPlayer::release() { ... } ```


9. 總結

到這裡,JNI 的知識就講完了,你可以按照學習路線圖來看。下一篇,我們開始講 Android NDK 開發。關注我,帶你建立核心競爭力,我們下次見。


參考資料

都看到這裡了,就點贊 👍 👍 👍 支援吧!微信搜尋公眾號 [彭旭銳],你可以討論技術,找到志同道合的朋友,建立核心競爭力,我們下次見!

好的身體才是~~革命~~寫程式碼的本錢!

image.png