點贊關注,不再迷路,你的支持對我意義重大!
?? 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_OnLoad
與JNI_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 編程的高級概念,例如引用、多線程操作、異常處理等。記得關注~
參考資料
- JNI 提示 —— Google Developers
- 深入理解 JVM 字節碼(第 10 章) —— 張亞 著
- Java 性能權威指南(第 7、8 章) —— [美]Scott Oaks 著
- 《JNI 編程指南》
創作不易,你的「三連」是丑丑最大的動力,我們下次見!