美文网首页Android-NDK/JNI
NDK | 带你点亮 JNI 开发基石符文 (一)

NDK | 带你点亮 JNI 开发基石符文 (一)

作者: 彭旭锐 | 来源:发表于2021-06-11 22:34 被阅读0次

    点赞关注,不再迷路,你的支持对我意义重大!

    🔥 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_OnLoadJNI_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 编程的高级概念,例如引用、多线程操作、异常处理等。记得关注~


    参考资料

    创作不易,你的「三连」是丑丑最大的动力,我们下次见!

    相关文章

      网友评论

        本文标题:NDK | 带你点亮 JNI 开发基石符文 (一)

        本文链接:https://www.haomeiwen.com/subject/lfdnvktx.html