JNI 定義了 Android 使用 Java 或 Kotlin 編程語言編的代碼編譯的字節碼與原生代碼(使用 C/C++ 編寫)互動的方式。
JNI 是一套標準的協議,不受硬件限制,支持從動態共享庫加載代碼,在一些情況對比直接使用Java高效。我們可以使用 Android Studio 3.2 及更高版本的內存性能剖析器中的 JNI 堆視圖來查看全局 JNI 引用,並查看這些引用創建和刪除的位置。
本文基於Android NDK官方文檔中的提示信息結合自己開發過程的思考,從性能、可維護性、魯棒性等角度總結JNI開發準則。
2. 一般準則我們要知道一點,JNI調用是耗費資源的,所以我們應該按重要程度(從最重要的開始)嘗試遵循以下準則:
儘量減少跨 Java層與native調用次數。跨 JNI 調用會產生資源消耗,我們在進行接口設計時儘量要減少Java層與native交互頻率。
儘量避免在Java層編寫的代碼與C++ 層編寫的代碼之間進行異步通信。這樣可使我們的JNI 接口更易於維護。一般情況,我們可以採用UI層的編程語言保持異步更新,以簡化異步UI更新。比如使用 Java 語言在兩個線程之間進行回調(其中一個線程發出阻塞 C++ 調用,然後在阻塞調用完成時通知界面線程),而不是通過 JNI 從使用 Java 代碼的UI線程調用 C++ 函數。
儘可能減少需要接觸 JNI 或被 JNI 接觸的線程數。如果我們確實需要使用 Java 和 C++ 這兩種語言的線程池,要儘量保持在池所有者之間(而不是各個工作器線程之間)進行 JNI 通信。
將接口代碼保存在少量易於識別的 C++ 和 Java 源位置,以便將來進行重構。把JNI層代碼儘可能放到一個文件裡面,比如放在javah 自動生成的文件中。
3. JavaVM 和 JNIEnvJNI 定義了兩個關鍵數據結構,即「JavaVM」和「JNIEnv」。兩者本質上都是指向函數表的二級指針。(在 C++ 版本中,它們是一些類,這些類具有指向函數表的指針,並具有通過該函數表間接調用的 JNI 函數的成員函數。)
JavaVM 提供「調用接口」函數,我們可以利用這些函數創建和銷毀 JavaVM。理論上,每個進程可以有多個 JavaVM,但 Android 只允許有一個。
JNIEnv 提供了大部分 JNI 函數。我們的原生函數都會收到 JNIEnv 作為第一個參數。
該 JNIEnv 將用於線程本地存儲。因此,我們無法在線程之間共享 JNIEnv。如果一段代碼無法通過其他方法獲取自己的 JNIEnv,我們可以共享相應 JavaVM,然後使用 GetEnv 來獲取線程的 JNIEnv。(如果該線程沒有包含 JNIEnv;則需要調用 AttachCurrentThread來關聯。)
JNIEnv 和 JavaVM 的 C 聲明與 C++ 聲明不同。"jni.h" 頭文件會提供不同的類型定義符,具體取決於該文件是包含在 C 還是 C++ 中。
因此,不建議包含兩種語言的的頭文件中添加 JNIEnv 參數。(換句話說:如果我們的的頭文件需要 #ifdef __cplusplus,且在該頭文件中任何位置引用了 JNIEnv,我們可能都必須進行一些額外操作。)
4. 線程所有線程都是 Linux 線程,由內核調度。線程通常從受管理代碼啟動(使用 Thread.start()),但也可以在其他位置創建,然後附加到 JavaVM。例如,通過 pthread_create() 或 std::thread 啟動的線程可以使用 AttachCurrentThread() 或 AttachCurrentThreadAsDaemon() 函數附加到JavaVM。在附加之前,線程不包含任何 JNIEnv,也無法調用 JNI。
通常,我們儘量使用 Thread.start() 創建需要在Java中調用的任何線程, 這樣做可以確保我們有足夠的堆棧空間、屬於正確的 ThreadGroup 且與我們的 Java 代碼使用相同的 ClassLoader。而且,在 Java 設置線程名稱來調試也比通過原生代碼更容易(如果我們用 pthread_t 或 thread_t,可以參閱 pthread_setname_np();如果我們使用 std::thread 且需要 pthread_t,可以參閱 std::thread::native_handle())。
附加原生創建的線程會構建 java.lang.Thread 對象並將其添加到「主」ThreadGroup,從而使調試程序能夠看到它。在已附加的線程上調用 AttachCurrentThread() 屬於空操作。
Android 不會掛起執行原生代碼的線程。如果正在進行垃圾回收,或者調試程序已發出掛起請求,則在線程下次調用 JNI 時才會將其掛起。
通過 JNI 附加的線程在退出之前必須調用 DetachCurrentThread()。如果直接對此進行編碼會很棘手,在 Android 2.0 (Eclair) 及更高版本中,您可以先使用 pthread_key_create() 定義將在線程退出之前調用的析構函數,在這裡調用 DetachCurrentThread()。(將該鍵與 pthread_setspecific() 搭配使用,將 JNIEnv 存儲在線程本地存儲中;這樣一來,這個鍵將作為參數傳遞到我們的的析構函數中。)
5. jclass、jmethodID 和 jfieldID如果要通過原生代碼訪問對象的字段,一般執行以下操作:
使用 FindClass 獲取類的類對象引用
使用 GetFieldID 獲取字段的字段 ID
使用適當函數獲取字段的內容,例如 GetIntField
同樣,如果需要調用方法,首先要獲取類對象引用,然後獲取方法 ID。方法 ID 通常只是指向內部運行時數據結構的指針。查找方法 ID 可能需要進行多次字符串比較,但當我們獲取此類 方法ID後,就可以非常快速地獲取字段或調用方法。
我們考慮性能的話,我們需要查找一次這些值並將結果緩存在原生代碼中。由於每個進程只能包含一個 JavaVM,因此可以將這些數據存儲在本地靜態結構。
類引用、字段 ID 和方法 ID 在類卸載之前要保證有效。只有在與 ClassLoader 關聯的所有類可以進行垃圾回收時,系統才會卸載類,這種情況很少見,但在 Android 中並非不可能。我們要注意,jclass 是類引用,必須通過調用 NewGlobalRef 來保護它,防止被釋放。
如果您想在加載類時緩存方法 ID,並在取消加載類後並重新加載時自動重新緩存方法 ID,我們可以參考下面代碼初始化方法 ID :
companionobject{/**Weuseastaticclassinitializertoallowthenativecodetocachesome*fieldoffsets.Thisnativefunctionlooksupandcachesinteresting*class/field/methodIDs.Throwsonfailure.*/privateexternalfunnativeInit()init{nativeInit()}}C/C++ 代碼中創建 nativeClassInit 方法來執行ID的查找。當初始化類時,這段代碼會執行一次。如果要取消加載類之後再重新加載,這段代碼將再次被執行。
6. 局部引用和全局引用傳遞給原生方法的每個參數,以及 JNI 函數返回的幾乎每個對象都屬於「局部引用」。局部引用只有在當前線程中的當前原生方法運行期間有效。**在原生方法返回後,即使對象本身繼續存在,該引用也無效。**局部引用只有在當前方法內有效。
這適用於 jobject 的所有子類,包括 jclass、jstring 和 jarray。(啟用擴展的 JNI 檢查時,運行時會針對大部分引用誤用問題發出警告。)
獲取非局部引用的唯一方法是通過 NewGlobalRef 和 NewWeakGlobalRef 函數來創建全局引用。
如果我們希望長時間保留某個引用,則必須使用「全局」引用。NewGlobalRef 函數將局部引用作為參數,然後返回全局引用。在調用 DeleteGlobalRef 之前,全局引用保證有效。
這種模式通常在緩存 FindClass 返回的 jclass 時使用,例如:
jclasslocalClass=env->FindClass("MyClass");jclassglobalClass=reinterpret_cast<jclass>(env->NewGlobalRef(localClass));所有 JNI 方法都接受局部引用和全局引用作為參數。對同一對象的引用可能具有不同的值。例如,對同一對象連續調用 NewGlobalRef 所返回的值可能有所不同。**如我們想知道兩個引用是否引用同一對象,必須使用 IsSameObject 函數。**在原生代碼中使用 == 比較各個引用會有問題。
我們不能假設對象引用在原生代碼中是常量或唯一值。在兩次調用同一個方法時,某個對象的 32 位值可能有所不同;相應的,兩個不同的對象可能具有相同的 32 位值。所以我們不能將 jobject 值用作鍵。
我們不能過度分配使用局部引用,這意味着如果我們需要要創建大量局部引用(也許是在運行對象數組時),應該在使用完成時使用 DeleteLocalRef 手動釋放它們,而不是讓 JNI在方法執行完成後自動幫我們回收。JNI默認實現僅有 16 個局部引用保留槽位,因此如果我們需要更多槽位,則應該刪除已經無用的,或者使用 EnsureLocalCapacity/PushLocalFrame 修改配置擴展更多槽位。
這裡注意:jfieldID 和 jmethodID 屬於不透明類型,不是對象引用,不應該傳遞給 NewGlobalRef。函數返回的 GetStringUTFChars 和 GetByteArrayElements 等原始數據指針也不屬於對象。(這些指針可以在線程之間傳遞,並且在匹配的 Release 調用完成之前一直有效。)
如果使用 AttachCurrentThread 附加原生線程,那麼在線程分離之前,我們運行的代碼絕不會自動釋放局部引用。我們創建的任何局部引用都必須手動刪除。通常,在循環中創建局部引用的任何原生代碼可能需要執行某些手動刪除操作。之前就遇到了在一個線程中開啟循環處理事件,局部引用沒有手動釋放,方法一直不結束,導致局部引用槽位不夠引起的jni local reference table overflow crash。
全局引用我們要謹慎使用。雖然全局引用不可避免,但它們很難調試,並且可能會導致難以診斷的內存問題。在所有其他條件相同的情況下,全局引用越少,解決方案的效果可能越好。
7. UTF-8 和 UTF-16 字符串Java 編程語言使用的是 UTF-16。為方便起見,JNI 還提供了使用修改後的 UTF-8 的方法。修改後的編碼對 C 代碼非常有用,因為它將 \u0000 編碼為 0xc0 0x80,而不是 0x00。
這樣做的好處是,我們可以依靠以零終止的 C 樣式字符串,非常適合與標準 libc 字符串函數配合使用。但缺點是,我們無法將任意 UTF-8 數據傳遞給 JNI 並期望它能夠正常工作。
如果可能,使用 UTF-16 字符串執行操作通常會更快。Android 目前不需要 GetStringChars 的副本,而 GetStringUTFChars 需要分配和轉換為 UTF-8。但是UTF-16 字符串不是以零終止的,並且允許使用 \u0000,因此我們需要保留字符串長度和 jchar 指針。
要記得 Release 我們Get 的字符串。字符串函數會返回 jchar* 或 jbyte*,它們是指向原始數據而非局部引用的 C 樣式指針。這些指針在調用 Release 之前保證有效,所以導致原生方法返回時不會釋放這些指針。
傳遞給 NewStringUTF 的數據必須採用修改後的 UTF-8 格式。一種常見的錯誤就是從文件或網絡數據流中讀取字符數據,並在未過濾的情況下將其傳遞給 NewStringUTF。除非我們確定數據是有效的 MUTF-8(或 7 位 ASCII,這是一個兼容子集),否則我們需要剔除無效字符或將它們相應轉換為修改後的 UTF-8 格式。
如果不這樣做,UTF-16 轉換可能會產生意外的結果。CheckJNI 默認狀態下為模擬器啟用,它會掃描字符串並且在收到無效輸入時會中止虛擬機。
8. 原始數組JNI 提供訪問數組對象內容的函數。雖然訪問對象數組時一次只能訪問一個條目,但可以直接讀寫原始類型的數組,就像它們是在 C 語言中聲明的一樣。
為了在不限制虛擬機實現的情況下使接口儘可能高效,Get<PrimitiveType>ArrayElements 系列調用允許運行時返回指向實際元素的指針,或者分配一些內存並進行複製。無論採用哪種方式,在發出相應的 Release 調用之前,返回的原始指針要保證有效(這意味着,如果沒有複製數據,數組對象的位置將固定不變,並且無法在壓縮堆期間重新調整位置)。**我們必須 Release 自己 Get 的每個數組。**此外,如果 Get 調用失敗,我們必須確保自己的代碼稍後不會試圖 Release NULL 指針,增加判空容錯。
我們可以通過傳入 isCopy 參數的非 NULL 指針來確定是否複製了數據。但其實這用處不大。
Release 調用採用的 mode 參數可為三個值中的一個。運行時執行的操作取決於其返回的指針是指向實際數據還是指向數據副本:
0
實際數據:數組對象未固定。
數據副本:已複製回數據。釋放了包含相應副本的緩衝區。
JNI_COMMIT
實際數據:不執行任何操作。
數據副本:已複製回數據。未釋放包含相應副本的緩衝區。
JNI_ABORT
實際數據:數組對象未固定。未中止早期的寫入數據。
數據副本:釋放了包含相應副本的緩衝區;對該副本所做的任何更改都會丟失。
檢查 isCopy 標記的其中一個原因是,了解我們對數組進行更改後是否需要使用 JNI_COMMIT 調用 Release。如果我們要交替進行更改和執行使用數組內容的代碼,則可以跳過空操作提交。
檢查這個標記的另一個原因是為了有效處理 JNI_ABORT。例如,當我們想要獲取一個數組、對其進行適當修改、將片段傳遞給其他函數,然後捨棄所做的更改。如果我們知道 JNI 要為我們創建新副本,則無需創建另一個「可修改」副本。
如果 JNI 要將原始數據傳遞給我們,那麼我們需要製作自己的副本。
想當然地認為在 *isCopy 為 false 時可以跳過 Release 調用是一種常見誤區)。事實上如果沒有分配任何副本緩衝區,則必須固定原始內存,並且不能由垃圾回收器移動。
另請注意,JNI_COMMIT 標記不會釋放數組,最終需要我們使用其他標記再次調用 Release。
9. 區域調用如果我們只是想複製數據,使用替代方法進行 Get<Type>ArrayElements 和 GetStringChars 等調用可能非常有方便。參考下面示例代碼:
jbyte*data=env->GetByteArrayElements(array,NULL);if(data!=NULL){memcpy(buffer,data,len);env->ReleaseByteArrayElements(array,data,JNI_ABORT);}這裡會從array總獲取前len個字節拷貝到buffer中,然後釋放數組。Get 調用會複製數組內容,具體取決於實現情況。代碼複製數據(可能是第二次),然後調用 Release;在這種情況下,JNI_ABORT 確保不會有機會進行第三次複製。
我們還可以用更簡單的方式完成相同操作:
env->GetByteArrayRegion(array,0,len,buffer);這種做法具有諸多優勢:
需要一個 JNI 調用而不是兩個,從而減少開銷。
不需要固定或額外複製數據。
降低程序員出錯風險,因為不存在操作失敗後忘記調用 Release 的風險。
同樣,我們可以使用 Set<Type>ArrayRegion 調用將數據複製到數組中,使用 GetStringRegion 或 GetStringUTFRegion 將字符複製到 String 中。
10. 異常**在放生異常時,不得調用大多數 JNI 函數。**我們的代碼應該要處理異常情況(通過函數的返回值 ExceptionCheck 或 ExceptionOccurred)並返回,或者清除異常後繼續執行。
在異常掛起時,我們只能調用以下 JNI 函數:
DeleteGlobalRef
DeleteLocalRef
DeleteWeakGlobalRef
ExceptionCheck
ExceptionClear
ExceptionDescribe
ExceptionOccurred
MonitorExit
PopLocalFrame
PushLocalFrame
ReleaseArrayElements
ReleasePrimitiveArrayCritical
ReleaseStringChars
ReleaseStringCritical
ReleaseStringUTFChars
許多 JNI 調用都會拋出異常,但通常會提供一種更簡單的方法來檢查失敗。例如,如果 NewString 返回非 NULL 值,則無需檢查異常。但是,如果要調用方法(使用 CallObjectMethod 等函數),則必須始終檢查異常,因為如果系統拋出異常,返回值將無效。
處理代碼拋出的異常不會展開原生堆棧幀,並且 Android 還不支持 C++ 異常。JNI Throw 和 ThrowNew 指令只是在當前線程中設置了異常指針。從原生代碼返回到受管理代碼後,這些指令會注意到異常並進行相應處理。
原生代碼可以通過調用 ExceptionCheck 或 ExceptionOccurred 來「捕獲」異常,然後使用 ExceptionClear 進行清除,在未經處理的情況下捨棄異常可能會出現問題。
因為沒有可用於操控 Throwable 對象本身的內置函數,所以如果我們想要獲取異常字符串,則需要找到 Throwable 類、查找 getMessage "()Ljava/lang/String;" 的方法 ID 並調用該方法;如果結果為非 NULL 值,則使用 GetStringUTFChars 獲取可以傳遞給 printf(3) 或等效函數的內容。
11. 擴展的檢查JNI 很少進行錯誤檢查,錯誤通常會導致崩潰。Android 還提供了一種名為 CheckJNI 的模式,其中 JavaVM 和 JNIEnv 函數表指針已切換為在調用標準實現之前執行一系列擴展的檢查的函數表。
額外檢查包括:
數組:嘗試分配大小為負值的數組。
錯誤指針:將錯誤的 jarray/jclass/jobject/jstring 傳遞給 JNI 調用,或者將 NULL 指針傳遞給帶有不可設為 null 的參數的 JNI 調用。
類名稱:將類名稱的「java/lang/String」樣式之外的所有內容傳遞給 JNI 調用。
關鍵調用:在「關鍵」get 及其相應 release 之間調用 JNI。
直接字節緩衝區:將錯誤參數傳遞給 NewDirectByteBuffer。
異常:在異常掛起時調用 JNI。
JNIEnv*:使用錯誤線程中的 JNIEnv*。
jfieldID:使用 NULL jfieldID,或者使用 jfieldID 將字段設置為錯誤類型的值(例如,嘗試將 StringBuilder 分配給 String 字段),或者使用靜態字段的 jfieldID 設置實例字段(反之亦然),或者將一個類中的 jfieldID 與另一個類的實例搭配使用。
jmethodID:在調用 Call*Method JNI 時使用錯誤類型的 jmethodID:返回類型不正確、靜態/非靜態不匹配、「this」類型錯誤(對於非靜態調用)或類錯誤(對於靜態調用)。
引用:對錯誤類型的引用使用 DeleteGlobalRef/DeleteLocalRef。
Release 模式:將錯誤的 release 模式傳遞給 release 調用(除 0、JNI_ABORT 或 JNI_COMMIT 之外的內容)。
類型安全:從原生方法返回不兼容的類型(例如,從聲明返回 String 的方法返回 StringBuilder)。
UTF-8:將無效的修改後的 UTF-8 字節序列傳遞給 JNI 調用。
(仍未檢查方法和字段的可訪問性:訪問限制不適用於原生代碼。)
我們可以通過以下幾種方法啟用 CheckJNI。
如果您使用的是模擬器,CheckJNI 默認處於啟用狀態。
如果您使用的是已取得 root 權限的設備,則可以使用以下命令序列重新啟動運行時,並啟用 CheckJNI:
adbshellstopadbshellsetpropdalvik.vm.checkjnitrueadbshellstart在以上任何一種情況下,當運行時啟動時,您將在 logcat 輸出中看到如下內容:
DAndroidRuntime:CheckJNIisON如果我們使用的是常規設備,則可以使用以下命令:
adbshellsetpropdebug.checkjni1這不會影響已經運行的應用,但從那時起啟動的任何應用都將啟用 CheckJNI。(將屬性更改為任何其他值,或者只是重新啟動應用都將再次停用 CheckJNI。)在這種情況下,當應用下次啟動時,您將在 logcat 輸出中看到如下內容:
DLate-enablingCheckJNI我們還可以在應用清單中設置 android:debuggable 屬性,以便為我們的應用啟用 CheckJNI,Android 構建工具會自動為某些構建類型執行此操作。
12.原生庫我們可以使用官方標準 System.loadLibrary 的方法從共享庫加載原生代碼。
實際上,舊版 Android 的 PackageManager 存在錯誤,導致原生庫的安裝和更新不可靠。ReLinker 項目能夠解決此問題及其他原生庫加載問題。
從靜態類初始化程序中調用 System.loadLibrary(或 ReLinker.loadLibrary)。參數是「未修飾」的庫名稱(比如,如果需加載 libfubar.so,則需要傳入 "fubar")。
如果我們只有一個類具有原生方法,那麼合理的做法是應該將對 System.loadLibrary 的調用置於該類的靜態初始化程序中。否則,我們可能需要從 Application 進行該調用,這樣我們就能始終都會加載該庫,而且總是會提前加載。
運行時可以通過兩種方式找到我們的原生方法。我們可以使用 RegisterNatives 顯示註冊原生方法,也可以讓運行時使用 dlsym 進行動態查找。RegisterNatives 的優勢在於,我們可以預先檢查符號是否存在,而且還可以通過只導出 JNI_OnLoad 來獲得規模更小、速度更快的共享庫。讓運行時發現函數的優勢在於,要編寫的代碼稍微少一些。
如需使用 RegisterNatives,可以按以下步驟操作:
提供 JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) 函數。
在 JNI_OnLoad 中,使用 RegisterNatives 註冊所有原生方法。
使用 -fvisibility=hidden 進行構建,以便只從我們的庫中導出我們的 JNI_OnLoad。這將生成速度更快且規模更小的代碼,並避免與加載到應用中的其他庫發生潛在衝突(但如果應用在原生代碼中崩潰,則創建的堆棧軌跡用處不大)。
靜態初始化程序應如下所示:
companionobject{init{System.loadLibrary("fubar")}}如果使用 C++ 編寫,JNI_OnLoad 函數應如下所示:
JNIEXPORTjintJNI_OnLoad(JavaVM*vm,void*reserved){JNIEnv*env;if(vm->GetEnv(reinterpret_cast<void**>(&env),JNI_VERSION_1_6)!=JNI_OK){returnJNI_ERR;}//Findyourclass.JNI_OnLoadiscalledfromthecorrectclassloadercontextforthistowork.jclassc=env->FindClass("com/example/app/package/MyClass");if(c==nullptr)returnJNI_ERR;//Registeryourclass'nativemethods.staticconstJNINativeMethodmethods[]={{"nativeFoo","()V",reinterpret_cast<void*>(nativeFoo)},{"nativeBar","(Ljava/lang/String;I)Z",reinterpret_cast<void*>(nativeBar)},};intrc=env->RegisterNatives(c,methods,sizeof(methods)/sizeof(JNINativeMethod));if(rc!=JNI_OK)returnrc;returnJNI_VERSION_1_6;}如需改為使用「discovery」原生方法,我們需要以特定方式為其命名(詳情請參閱 JNI 規範)。這意味着,如果方法簽名是錯誤的,我們要等到第一次實際調用該方法時才會知道。
從 JNI_OnLoad 進行的任何 FindClass 調用都會在用於加載共享庫的類加載器的上下文中解析類。
從其他上下文調用時,FindClass 會使用與 Java 堆棧頂部的方法相關聯的類加載器,如果沒有(因為調用來自剛剛附加的原生線程),則會使用「系統」類加載器。由於系統類加載器不知道應用的類,因此我們將無法在該上下文中使用 FindClass 查找我們自己的類。
這使得 JNI_OnLoad 成為查找和緩存類的便捷位置:一旦有了有效的 jclass,我們就可以從任何附加的線程使用它。
12. 64 位注意事項為了支持使用 64 位指針的架構,在 Java 字段中存儲指向原生結構的指針時,要使用 long 字段而不是 int。
推薦閱讀:
NDK | 帶你梳理 JNI 函數註冊的方式和時機
Android NDK 開發:JNI 基礎篇
Android NDK 開發:Java 與 Native 相互調用
Android NDK POSIX 多線程編程
NDK 開發中 Native 方法的靜態註冊與動態註冊
Android NDK 開發中快速定位 Crash 問題
Android JNI 中發送 Http 網絡請求
Android NDK 減少 so 庫體積方法總結
Android 引用三方庫導致 so 庫衝突的解決辦法
Android JNI原理分析