学习 Android(十五)NDK进阶及性能优化
学习 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
保证执行)。
-