学习 Android(十五)NDK进阶及性能优化

发布于:2025-08-08 ⋅ 阅读:(14) ⋅ 点赞:(0)

学习 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 指针调用 CallVoidMethodCallIntMethod 等 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 桥接、参数转换、堆栈切换等,成为跨语言调用开销。

  • 方法查找

    使用 GetMethodIDFindClass 等接口查找类/方法都会引发字符串查找和反射操作,建议提前缓存 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,避免频繁使用 FindClassGetMethodID

  • 注意缓存的类引用要全局引用(NewGlobalRef),避免被 GC 回收。

1.3.3 优化数组和字符串操作
  • 对数组,优先使用 GetPrimitiveArrayCritical ,减少复制(但要注意对代码稳定性和互斥性的影响)。

  • 传递大数组时,尽量避免复制,改为操作指针/缓冲区。

  • 对于 String 类型,避免频繁转换,尽量在 native 一侧使用 UTF-8 编码(GetStringUTFChars)。

1.3.4 缩短本地代码运行时间/减少局部引用
  • 本地代码不要做耗时操作后立刻回到 Java,减少跨界调用压。

  • 使用DeleteLocalRef显式释放局部引用,防止泄漏;对于大循环内产生大量局部引用更要注意。

1.3.5 线程相关优化
  • 避免频繁调用AttachCurrentThreadDetachCurrentThread,一般线程周期内只调用一次。
1.3.6 异常判断与处理要有选择性
  • JNI 异常检测开销不算太大,但频繁触发异常检查会影响性能。
  • 合理判断并只在需要时检查异常,如无异常预期场景可优化。

2. 常见JNI坑(比如频繁创建Java对象、内存泄漏)

在 Android NDK 开发中,JNI 是 Java 与 Native 代码交互的桥梁,但不当使用很容易出现问题,导致性能问题、内存泄漏甚至程序崩溃。接下来我们分析一些常见的 JNI 坑,尤其是频繁创建 Java 对象、内存泄漏,并研究如何规避。

2.1 频繁创建 Java 对象的坑

2.1.1 现象与原因
  • JNI 代码中频繁通过 NewObjectNewStringUTFNewObjectArray 等接口创建 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,因为:

    1. 你不知道是否复制了(依赖运行时行为);

    2. JVM 可能会在你释放之前锁定该字符串区域

    3. 不释放可能导致 内存泄漏阻塞 JVM 垃圾回收

    4. 有些 JVM 会记录这个指针的使用情况,未释放可能造成崩溃。

3.2.2 NewLocalRefDeleteLocalRef
  • 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_tpthread_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 的方法

步骤:

  1. 缓存 JavaVM* 和 Java 层对象的 jobject(用 NewGlobalRef() 防止被 GC)

  2. 在 Native 线程中通过 AttachCurrentThread() 获取 JNIEnv*

  3. 调用 Java 方法(例如回调)

  4. 调用完毕后 DetachCurrentThread()

示例代码:

Kotlin / Java

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override 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 线程并设置回调对象

内存管理分析:

  1. 全局引用 (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);  // 释放旧引用
      }
      

      优点:避免了全局引用泄漏。

  2. 局部引用(cls)

    • 正确做法:

      • GetObjectClass 返回的 jclass 是局部引用,需手动释放(DeleteLocalRef)。

      • 代码中在 DetachCurrentThread 前正确释放:

        env->DeleteLocalRef(cls);
        

线程安全设计

  1. g_callback_obj 的原子操作
  • 问题:多线程可能同时读写 g_callback_obj

  • 解决方案

    • 使用 std::atomic<jobject> 确保原子性。

    • 通过 exchange 方法安全更新引用:

      jobject old_ref = g_callback_obj.exchange(env->NewGlobalRef(thiz));
      
  1. JNIEnv 的线程隔离
  • 规则JNIEnv* 是线程局部的,不能跨线程共享。

  • 代码验证

    • 每个线程通过 AttachCurrentThread 获取自己的 env

    • 线程退出前调用 DetachCurrentThread(即使在异常情况下也通过 try-catch 保证执行)。


网站公告

今日签到

点亮在社区的每一天
去签到