學習 Android(十五)NDK進階及性能優化
對 NDK 相關知識有了初步的了解之后,我們可以更加深入的去學習 NDK 相關知識,接下來,我們將按照以下步驟進行深入學習:
- 深入理解JNI調用過程和性能消耗
- 常見 JNI 坑(比如頻繁創建Java對象、內存泄漏)
- 掌握 Native 內存管理,避免泄漏和崩潰
- 學習 pthread 多線程和同步機制,和 Android 線程的配合
- 多線程環境下調用 JNI 注意事項,跨線程回調技巧
1. 深入理解 JNI 調用過程和性能消耗
深入理解 JNI 調用過程和性能消耗,是掌握 Android NDK 開發的關鍵,有助于寫出高效、穩定的混合代碼。
1.1 JNI 調用過程詳解
JNI(Java Native Interface) 是 Java虛擬機(JVM)與本地(Native)代碼交互的橋梁,Android 上是 JVM 的子集 ———— ART(Android Runtime)。JNI 允許 Java 調用 C/C++,也允許 Native 調用 Java 方法。
1.1.1 Java 調用 Native 的典型流程
-
聲明 Native 方法
Java 代碼通過
native
關鍵字聲明本地方法,并加載 native 庫:class MyClass {static {System.loadLibrary("myLib");}public native int nativeMethod(int arg); }
-
Native 端實現(C/C++)
使用 JNI 約定的函數簽名實現:
extern "C" JNIEXPORT jint JNICALL Java_com_example_MyClass_nativeMethod(JNIEnv* env, jobject thiz, jint arg) { }
-
調用流程
-
Java 層調用 native 方法時,ART 會查找與方法簽名匹配的本地實現
-
通過 JNI 函數指針跳轉到本地實現
-
本地代碼通過傳入的
JNIEnv*
環境指針訪問 JVM 提供的接口(操作對象、數組、調用 Java 方法) -
執行完畢返回結果,JNI 自動轉換回 Java 層
-
1.1.2 Native 回調 Java
-
本地代碼通過
JNIEnv
指針調用CallVoidMethod
、CallIntMethod
等 JNI 函數,訪問 Java 對象。 -
使用
FindClass
查找 Java 類,GetMethodID
獲取方法 ID 等。
1.1.3 JNIEnv 和線程關聯
-
每個線程都必須有自己的
JNIEnv*
指針,不能跨線程使用。 -
Java 線程進入 Native 代碼時,Java 虛擬機會傳入
JNIEnv
-
Native 線程調用 Java 方法前,必須附加到 JVM(
AttachCurrentThred
)獲取JNIEnv
1.2 JNI 調用的性能消耗來源分析
雖然 JNI 是實現 Java 和 C/C++ 互操作的唯一通道,但調用代價較高,性能損耗主要來自以下方面:
1.2.1 調用開銷
每次 Java 調用 Native 方法,都涉及 JNI 橋接、參數轉換、堆棧切換等,成為跨語言調用開銷。
-
方法查找
使用
GetMethodID
、FindClass
等接口查找類/方法都會引發字符串查找和反射操作,建議提前緩存 ID。 -
參數轉換
JNI 參數和返回值往往需要進行轉換,比如 Java 數組轉 native 數組(
GetIntArrayElements
),這會產生內存拷貝和映射。 -
堆棧切換
從 Java 虛擬機切換到 native 運行環境,也涉及上下文切換開銷。
1.2.2 頻繁調用和跨界面層傳遞大量數據
-
若調用 JNI 設計不合理,頻繁調用小粒度函數,開銷累計顯著。
-
大量數據傳遞(如大數組、復雜對象)通過 JNI 參數傳輸,會產生內存復制,影響性能。
1.2.3 內部管理和局部引用開銷
-
JNI 會在 native 層為 Java 對象創建局部引用,如果不及時釋放會導致局部引用表溢出。
-
使用
NewGlobalRef
增加全局引用也帶來額外管理成本。
1.2.4 異常檢測
每次 JNI 調用之后,JNI 環境會檢測是否有 Java 異常,需要額外執行異常處理流程,若異常頻發也影響性能。
1.3 JNI 性能優化使用技巧
1.3.1 減少 JNI 調用次數
-
設計合理的接口,盡量減少 Java 和 Native 之間的頻繁小函數調用,更傾向于批量調用。
-
把一些需要循環調用的邏輯放到 Native 層一次處理完。
1.3.2 緩存方法ID和類引用
-
緩存
jclass
和方法IDjmethodId
,避免頻繁使用FindClass
、GetMethodID
。 -
注意緩存的類引用要全局引用(NewGlobalRef),避免被 GC 回收。
1.3.3 優化數組和字符串操作
-
對數組,優先使用
GetPrimitiveArrayCritical
,減少復制(但要注意對代碼穩定性和互斥性的影響)。 -
傳遞大數組時,盡量避免復制,改為操作指針/緩沖區。
-
對于 String 類型,避免頻繁轉換,盡量在 native 一側使用 UTF-8 編碼(
GetStringUTFChars
)。
1.3.4 縮短本地代碼運行時間/減少局部引用
-
本地代碼不要做耗時操作后立刻回到 Java,減少跨界調用壓。
-
使用
DeleteLocalRef
顯式釋放局部引用,防止泄漏;對于大循環內產生大量局部引用更要注意。
1.3.5 線程相關優化
- 避免頻繁調用
AttachCurrentThread
和DetachCurrentThread
,一般線程周期內只調用一次。
1.3.6 異常判斷與處理要有選擇性
- JNI 異常檢測開銷不算太大,但頻繁觸發異常檢查會影響性能。
- 合理判斷并只在需要時檢查異常,如無異常預期場景可優化。
2. 常見JNI坑(比如頻繁創建Java對象、內存泄漏)
在 Android NDK 開發中,JNI 是 Java 與 Native 代碼交互的橋梁,但不當使用很容易出現問題,導致性能問題、內存泄漏甚至程序崩潰。接下來我們分析一些常見的 JNI 坑,尤其是頻繁創建 Java 對象、內存泄漏,并研究如何規避。
2.1 頻繁創建 Java 對象的坑
2.1.1 現象與原因
-
JNI 代碼中頻繁通過
NewObject
、NewStringUTF
、NewObjectArray
等接口創建 Java 對象,尤其是在循環內。 -
這會導致:
-
JVM 頻繁進行對象分配和 GC,嚴重影響性能。
-
由于所有新建對象均為局部引用,未及時釋放可能導致局部引用表溢出
-
2.1.2 典型示例
for (int i = 0; i < n; i++) {jstring str = env.NewStringUTF("hello");// 使用 str// 如果這里不調用 DeleteLocalRef, str 累積導致局部引用溢出
}
2.1.3 解決方案
-
避免在循環中頻繁創建 Java 對象,盡量批量創建或復用。
-
及時釋放局部引用
JNI 代碼中局部引用默認在函數返回時釋放,但對于長時間運行的循環應手動調用:
env.DeleteLocalRef(str);
-
如果對象只在 Native 層使用,盡量用 Native 數據結構存儲,減少Java對象轉換。
-
使用全局引用緩存對象,但需注意手動釋放,以避免全局內存泄漏。
2.2 內存泄漏問題
JNI 內存泄漏主要有兩大來源:
2.2.1 局部引用不釋放導致局部引用表溢出
- 每個 JNI 本地方法有一個局部引用表,容量有限(一般512個引用)。
- 如果 JNI 方法創建或獲取大量局部引用,但不及時釋放,且方法運行時間較長,局部引用表會溢出,導致崩潰。
解決方法:
- 盡量縮短本地方法運行時長,分批處理任務。
- 循環內顯式調用?
DeleteLocalRef
?釋放局部引用。 - 對大批量 Java 對象操作時,使用
PushLocalFrame
?和?PopLocalFrame
?管理局部引用。
示例:
for (int i = 0; i < bigNum; i++) {jstring str = env.NewStringUTF("test");// 業務邏輯env.DeleteLocalRef(str);
}
2.2.2 全局引用未釋放導致全局內存泄漏
- 使用?
NewGlobalRef
?創建的全局引用不會被 GC 自動回收。 - 如果程序中全局引用被創建后沒有被釋放,導致內存泄漏。
解決方法:
- 對不再使用的全局引用調用?
DeleteGlobalRef
?釋放。
示例
jobject globalObj = env.NewGlobalRef(localObj);
// 業務使用
env.DeleteGlobalRef(globalObj);
2.2.3 字符串和數組 Get/Release 不匹配
JNI中很多接口都需要用戶主動釋放資源,如:
GetStringUTFChars
?與?ReleaseStringUTFChars
GetIntArrayElements
?與?ReleaseIntArrayElements
如果不調用釋放接口,可能會導致內存泄漏或者數據未同步。
示例:
const char* nativeStr = env.GetStringUTFChars(jstr, 0);
// 使用 nativeStr,但忘了調用釋放
// env.ReleaseStringUTFChars(jstr, nativeStr);
3. 掌握 Native 內存管理,避免泄漏和崩潰
在 Android NDK 及其他使用 C/C++ 開發的 Native 代碼中,內存管理是開發穩定、高效應用的根本技能。相比 Java,Native 代碼需要開發者手動管理內存,一旦失誤可能導致內存泄漏、野指針、崩潰等嚴重問題。接下來我們進行全面理解和掌握 Native 內存管理,避免內存相關的坑。
3.1 內存泄漏的根本原因與規避策略
場景 | 描述 | 避免策略 |
---|---|---|
未釋放 malloc/new 的內存 | 使用 malloc/new 分配后未 free/delete | 采用智能指針(C++)或顯式成對調用;如 unique_ptr |
分配的對象被提前返回/異常中斷 | 出現 early return 或異常路徑,未釋放 | 使用 RAII 模式自動釋放資源 |
JNI New* 函數未 Delete* | 創建局部/全局引用后未釋放 | 使用 DeleteLocalRef / DeleteGlobalRef |
多線程共享對象未同步釋放 | 多線程訪問同一對象導致重復釋放/未釋放 | 加鎖保護共享資源,避免野指針 |
3.2 JNI 資源管理核心規則
3.2.1 GetStringUTFChars
/ ReleaseStringUTFChars
-
這兩個 API 不會復制 Java 字符串內存,而是返回指針(有時會)。
什么叫做有時會呢?
關于
GetStringUTFChars
是否復制 Java 字符串內存的問題,確實存在「有時會,有時不會」的情況,這是由 JVM 的實現細節 和 字符串內容 共同決定的。先看官方文檔定義
const char * GetStringUTFChars(JNIEnv *env, jstring string, jboolean *isCopy);
-
返回一個指向 UTF-8 編碼字符串的指針。
-
*isCopy
會被設置為:-
JNI_TRUE
:表示 JVM 復制了一份內存。 -
JNI_FALSE
:表示返回的是 JVM 內部的只讀緩存指針(不是拷貝)。
-
什么時候會復制?
-
場景 | 解釋 |
---|---|
Java 字符串包含 非 ASCII 字符 | JVM 需要將 UTF-16 編碼轉換為 UTF-8 |
JVM 無法保證返回區域是連續內存 | 比如字符串被壓縮存儲時 |
字符串內容被壓縮/混淆存儲 | JVM 無法零拷貝轉換 |
特定 JVM 實現本身策略就是安全第一 | 比如 Android ART 通常直接復制 |
使用多線程共享字符串訪問 | JVM 會返回副本保證線程安全 |
什么時候不會復制?
場景 | 解釋 |
---|---|
字符串內容是 ASCII,且結構簡單 | 無需轉換,JVM 可以提供只讀指針 |
使用的是 HotSpot VM,且 JDK 版本較低 | 在某些平臺上,HotSpot 優化路徑中可能避免復制 |
單線程訪問,JVM 優化已緩存字符串 | JVM 內部可能已有 UTF-8 緩存區 |
-
用完后必須
ReleaseStringUTFChars
,否則會占用 JVM 內部緩存區。為什么必須
ReleaseStringUTFChars
?即使 JVM 沒有復制,也要調用
ReleaseStringUTFChars
,因為:-
你不知道是否復制了(依賴運行時行為);
-
JVM 可能會在你釋放之前鎖定該字符串區域;
-
不釋放可能導致 內存泄漏 或 阻塞 JVM 垃圾回收;
-
有些 JVM 會記錄這個指針的使用情況,未釋放可能造成崩潰。
-
3.2.2 NewLocalRef
和 DeleteLocalRef
-
JNI 局部引用存在于調試棧幀中,方法退出自動釋放
-
若創建大量局部引用,應主動
DeleteLocalRef
,避免Local reference table overflow
3.2.3 全局引用需手動釋放
jobject g_obj = (*env)->NewGlobalRef(env, obj);
// ...使用
(*env)->DeleteGlobalRef(env, g_obj);
3.3 調試與診斷工具
工具 | 用途 |
---|---|
Valgrind(Native) | 檢查 C/C++ 內存泄漏、越界訪問 |
ASan(AddressSanitizer) | 更適合 Android NDK,用于 native 崩潰和越界 |
Perfetto / systrace | 查找 native 層卡頓和資源濫用 |
Android Studio Profiler | 追蹤 JNI 調用和內存泄漏情況 |
logcat 日志分析 | 搭配 __android_log_print 分析生命周期 |
3.4 防止崩潰的工程實踐
問題 | 防范措施 |
---|---|
空指針解引用 | 嚴格檢查 null ,使用智能指針封裝訪問 |
野指針/重復釋放 | 避免裸指針,釋放后設置為 nullptr |
多線程并發訪問 | 線程同步+生命周期管理 |
Java 調用 native 后釋放對象 | 使用全局引用保護生命周期,或采用 WeakGlobalRef |
4. 學習 pthread 多線程和同步機制,和 Android 線程的配合
pthread
在 NDK 中是繞不開的核心技術,接下來我們來快速的學習和了解 pthread
多線程和同步機制,并且如何和 Android 線程的配合
4.1 pthread
在 Android 中的使用
Android 的 Native 層(C/C++)并不支持 Java 的 Thread
,因此如果需要多線程,就用 POSIX 線程庫(pthread),它在 Android SDK 中完全可用:
-
pthread_create
:創建線程 -
pthread_join
:等待線程結束 -
pthread_mutex_t
:互斥鎖 -
pthread_cond_t
:條件變量 -
pthread_rwlock_t
:讀寫鎖 -
pthread_once
:單次初始化
在 Android 上,pthread
的 ABI 與 Linux 一樣,因為 Android 本質也是基于 Linux 內核。
4.2 Android JAVA 線程與 pthread 的配合
Java 線程 和 Native 線程 之間是可以共存的,但要注意幾點:
-
Java 層啟動的線程:如果在 Native 中執行,需要從
JNIEnv
傳入,或者通過AttachCurrentThread
重新附著(因為每個線程都有自己唯一的 JNIEnv)。 -
Native 啟動的線程:用
pthread_create
,如果需要調用 Java 方法,同樣必須先AttachCurrentThread
,否則會崩潰。
示例
void* thread_func(void* arg) {JNIEnv* env;JavaVM* javaVm = (JavaVM*)arg;javaVm->AttachCurrentThread(&env, nullptr);// 這里就可以用 env 調用 Java 方法// ...javaVm->DetachCurrentThread();return nullptr;
}
這段代碼是一個典型的在 Native 線程中通過 JavaVM 獲取 JNIEnv 并調用 Java 方法的示例。我來分析一下關鍵點:
-
函數原型:
-
這是一個標準的 POSIX 線程函數,返回 void,接收 void 參數
-
參數 arg 被強制轉換為 JavaVM* 指針
-
-
關鍵操作:
-
AttachCurrentThread()
:將當前 native 線程附加到 JVM,獲取 JNIEnv 指針 -
DetachCurrentThread()
:線程結束時解除與 JVM 的關聯
-
-
重要細節:
-
每個線程都需要通過 AttachCurrentThread 獲取自己的 JNIEnv,不能跨線程使用
-
必須成對調用 Attach/Detach,否則會導致內存泄漏
-
在 Android 上,不 Detach 會導致 app 崩潰(DEBUG 模式下)
-
-
使用場景:
-
當在非 Java 創建的線程(如 pthread)中需要調用 Java 方法時
-
常見于 Native 異步回調到 Java 層的場景
-
4.3 常用的同步原語
同步方式 | 說明 |
---|---|
pthread_mutex_t | 最常用的互斥鎖 |
pthread_cond_t | 條件變量 |
pthread_rwlock_t | 讀寫鎖 |
pthread_spinlock_t | 自旋鎖 |
pthread_barrier_t | 屏障(同步多個線程) |
在 Android Native 開發里,最常用的依然是互斥鎖 + 條件變量。舉個常見場景:
-
一個生產者線程寫數據
-
一個消費者線程讀取數據
-
通過
pthread_mutex_t
和pthread_cond_t
同步
4.4 與 Java 層線程的區別
-
Java 的
Thread
實際上由 Android Runtime (ART) 或 Dalvik 管理
由pthread
實現,但對你透明 -
Java 線程有
Looper
/Handler
/MessageQueue
等機制
Native 沒有這些機制,需要你手動管理隊列 + 鎖
4.5 Android 中最佳實踐
-
避免在 Native 層大量啟動線程,因為調試復雜
-
如果需要高并發,優先考慮 Java 層線程池
-
在確實需要硬件交互、實時音視頻等高性能 Native 線程時,用
pthread
并且記得:-
AttachCurrentThread
-
正確釋放
DetachCurrentThread
-
JNIEnv 只能在當前線程使用
-
5. 多線程環境下調用 JNI 注意事項,跨線程回調技巧
在多線程環境下使用 JNI(Java Native Interface)時,必須非常小心,否則會導致 崩潰、內存泄漏、線程掛起 等嚴重問題。以下是實戰經驗總結與跨線程安全調用 Java 的技巧。
5.1 JNI 多線程環境下的基本準則
5.1.1 JNIEnv*
是線程私有的
-
每個線程都必須使用自己綁定的
JNIEnv*
-
不能跨線程傳遞
JNIEnv*
指針,否則會崩潰或產生不確定行為
5.1.2 子線程中使用 JNI 必須先附加線程
-
使用
JavaVM*
中的AttachCurrentThread()
獲取當前線程的中JNIEnv*
-
線程退出前必須執行
DetachCurrentThread()
,否則 JVM 會泄漏線程資源
5.2 JNI 跨線程回調 Java 的正確方式
場景:Native 中開啟一個線程,任務完成后回調 Java 的方法
步驟:
-
緩存
JavaVM*
和 Java 層對象的jobject
(用NewGlobalRef()
防止被 GC) -
在 Native 線程中通過
AttachCurrentThread()
獲取JNIEnv*
-
調用 Java 方法(例如回調)
-
調用完畢后
DetachCurrentThread()
示例代碼:
Kotlin / Java
class MainActivity : AppCompatActivity() {private lateinit var binding: ActivityMainBindingoverride fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)binding = ActivityMainBinding.inflate(layoutInflater)setContentView(binding.root)startNativeTask()}external fun startNativeTask()companion object {// Used to load the 'hello' library on application startup.init {System.loadLibrary("hello")}}fun onNativeTaskComplete() {runOnUiThread {Toast.makeText(this, "任務完成", Toast.LENGTH_SHORT).show()}}}
Native
JavaVM *g_vm = nullptr;
std::atomic<jobject> g_callback_obj{nullptr};jint JNI_OnLoad(JavaVM *vm, void *) {g_vm = vm;return JNI_VERSION_1_6;
}void* thread_func(void*) {JNIEnv* env;if (g_vm->AttachCurrentThread(&env, nullptr) != JNI_OK) {__android_log_print(ANDROID_LOG_ERROR, "NativeThread", "Attach failed");return nullptr;}if (g_callback_obj == nullptr) {__android_log_print(ANDROID_LOG_ERROR, "NativeThread", "Callback object is null");g_vm->DetachCurrentThread();return nullptr;}jclass cls = env->GetObjectClass(g_callback_obj);if (cls == nullptr) {__android_log_print(ANDROID_LOG_ERROR, "NativeThread", "Class not found");g_vm->DetachCurrentThread();return nullptr;}jmethodID methodID = env->GetMethodID(cls, "onNativeTaskComplete", "()V");if (methodID == nullptr) {__android_log_print(ANDROID_LOG_ERROR, "NativeThread", "Method not found");env->DeleteLocalRef(cls);g_vm->DetachCurrentThread();return nullptr;}env->CallVoidMethod(g_callback_obj, methodID);if (env->ExceptionCheck()) {env->ExceptionDescribe();env->ExceptionClear();}env->DeleteLocalRef(cls);g_vm->DetachCurrentThread();return nullptr;
}extern "C"
JNIEXPORT void JNICALL
Java_com_example_hello_MainActivity_startNativeTask(JNIEnv *env, jobject thiz) {jobject old_ref = g_callback_obj.exchange(env->NewGlobalRef(thiz));if (old_ref != nullptr) {env->DeleteGlobalRef(old_ref);}pthread_t thread;pthread_create(&thread, nullptr, thread_func, nullptr);pthread_detach(thread); // 避免內存泄漏
}
示例代碼分析:
關鍵組件解析:
變量/函數 | 作用 |
---|---|
g_vm | 全局?JavaVM* ,用于跨線程 Attach/Detach JNIEnv |
g_callback_obj | 全局引用(jobject ),保存 Java 層的回調對象(MainActivity ?實例) |
JNI_OnLoad | 動態庫加載時初始化?g_vm |
thread_func | Native 線程函數,執行任務并回調 Java 方法 |
startNativeTask | JNI 入口,啟動 Native 線程并設置回調對象 |
內存管理分析:
-
全局引用 (
g_callback_obj
)-
正確做法:
-
使用?
env->NewGlobalRef()
?將局部引用提升為全局引用(避免被 GC 回收)。 -
每次更新回調對象時,先刪除舊引用(
DeleteGlobalRef
)。
-
-
代碼驗證:
jobject old_ref = g_callback_obj.exchange(env->NewGlobalRef(thiz)); if (old_ref != nullptr) {env->DeleteGlobalRef(old_ref); // 釋放舊引用 }
優點:避免了全局引用泄漏。
-
-
局部引用(
cls
)-
正確做法:
-
GetObjectClass
?返回的?jclass
?是局部引用,需手動釋放(DeleteLocalRef
)。 -
代碼中在?
DetachCurrentThread
?前正確釋放:env->DeleteLocalRef(cls);
-
-
線程安全設計
g_callback_obj
?的原子操作
-
問題:多線程可能同時讀寫?
g_callback_obj
。 -
解決方案:
-
使用?
std::atomic<jobject>
?確保原子性。 -
通過?
exchange
?方法安全更新引用:jobject old_ref = g_callback_obj.exchange(env->NewGlobalRef(thiz));
-
- JNIEnv 的線程隔離
-
規則:
JNIEnv*
?是線程局部的,不能跨線程共享。 -
代碼驗證:
-
每個線程通過?
AttachCurrentThread
?獲取自己的?env
。 -
線程退出前調用?
DetachCurrentThread
(即使在異常情況下也通過?try-catch
?保證執行)。
-