JNI的一些基础

1,117 阅读41分钟

Cmake配置

以下介绍全部基于C++ 11

android {
  		......
			externalNativeBuild {
            cmake {
                //设置 C++ flag,启用 C++11 可选配置,-frtti 表示项目支持RTTI;(-fno-rtti 表示禁用)
                // -fexceptions 表示当前项目支持C++异常处理
                cppFlags "-std=c++11 -frtti -fexceptions"
                //arguments 语法:-D + 变量,更多变量:https://developer.android.com/ndk/guides/cmake.html
                arguments "-DANDROID_ARM_NEON=TRUE"

            }

        }
        // 指定 ABI
        ndk {

            abiFilters 'arm64-v8a', 'armeabi-v7a','x86'
        }
  .....
}

CMake 编译NDK 所支持的参数配置

变数名引数描述
ANDROID_TOOLCHAINclang (default) gcc (deprecated)指定 Cmake 编译所使用的工具链。示例:arguments “-DANDROID_TOOLCHAIN=clang”
ANDROID_PLATFORMAPI版本指定 NDK 所用的安卓平台的版本是多少。示例:arguments “-DANDROID_PLATFORM=android-21”
ANDROID_STLgnustl_static(default)指定 Cmake 编译所使用的标准模版库。使用示例:arguments “-DANDROID_STL=gnustl_static”
ANDROID_PIEON (android-16以上预设为ON) OFF (android-15以下预设为OFF)使得编译的elf档案可以载入到记忆体中的任意位置就叫pie(position independent executables)。 出于安全保护,在Android 4.4之后可执行档案必须是采用PIE编译的。使用示例:arguments “-DANDROID_PIE=ON”
ANDROID_CPP_FEATURES空(default) rtti(支持RTTI) exceptions(支持C异常)指定是否需要支持 RTTI(RunTime Type Information)和 C 的异常,预设为空。使用示例:arguments “-DANDROID_CPP_FEATURES=rtti exceptions”
ANDROID_ALLOW_UNDEFINED_SYMBOLSTRUE FALSE(default)指定在编译时,如果遇到未定义的引用时是否抛出错误。如果要允许这些型别的错误,请将该变数设定为 TRUE。使用示例:arguments “-DANDROID_ALLOW_UNDEFINED_SYMBOLS=TRUE”
ANDROID_ARM_MODEarm thumb (default)如果是 thumb 模式,每条指令的宽度是 16 位,如果是 arm 模式,每条指令的宽度是 32 位。示例:arguments “-DANDROID_ARM_MODE=arm”
ANDROID_ARM_NEONTRUE FALSE(default)指定在编译时,是否使用NEON对程式码进行优化。NEON只适用于armeabi-v7a和x86 ABI,且并非所有基于ARMv7的Android装置都支持NEON,但支持的装置可能会因其支援标量/向量指令而明显受益。 更多参考:developer.android.com/ndk/guides/… “-DANDROID_ARM_NEON=TRUE”
ANDROID_DISABLE_NO_EXECUTETRUE FALSE(default)指定在编译时是否启动 NX(No eXecute)。NX 是一种应用于 CPU 的技术,帮助防止大多数恶意程式的攻击。如果要禁用 NX,请将该变数设定为 TRUE。示例:arguments “-DANDROID_DISABLE_NO_EXECUTE=TRUE”
ANDROID_DISABLE_RELROTRUE FALSE(default)RELocation Read-Only (RELRO) 重定位只读,它能够保护库函式的呼叫不受攻击者重定向的影响。如果要禁用 RELRO,请将该变数设定为 TRUE。使用示例:arguments “-DANDROID_DISABLE_RELRO=FALSE”
ANDROID_DISABLE_FORMAT_STRING_CHECKSTRUE FALSE(default)在类似 printf 的方法中使用非常量格式字串时是否抛出错误。如果为 TRUE,即不检查字串格式。示例:arguments “-DANDROID_DISABLE_FORMAT_STRING_CHECKS=FALSE”

C库支持

名称说明功能
libstdc预设最小系统 C 执行时库不适用
gabi _staticGAbi 执行时(静态)。C 异常和 RTTI
gabi _sharedGAbi 执行时(共享)。C 异常和 RTTI
stlport_staticSTLport 执行时(静态)。C 异常和 RTTI;标准库
stlport_sharedSTLport 执行时(共享)。C 异常和 RTTI;标准库
gnustl_staticGNU STL(静态)。C 异常和 RTTI;标准库
gnustl_sharedGNU STL(共享)。C 异常和 RTTI;标准库
c _staticLLVM libc 执行时(静态)。C 异常和 RTTI;标准库
c _sharedLLVM libc 执行时(共享)。C 异常和 RTTI;标准库
参考:developer.android.com/ndk/guides/…

数据类型

从一个简单例子开始,声明 native 方法如下:

object NDKLibrary {
    init {
        //加载动态库,这里对应 CMakeLists.txt 里的 add_library NDKSample
        System.loadLibrary("NDKSample")
    }

    //使用 external 关键字指示以原生代码形式实现的方法
    external fun plus(a: Int, b: Int): Int
}

c++:

cppextern "C"
JNIEXPORT jint JNICALL
Java_tt_reducto_ndksample_NDKLibrary_plus(JNIEnv *env, jobject thiz, jint a, jint b) {
    jint sum = a + b;
    return sum;
}

这是一个简单的计算 a+b 的 native 方法,在 C++ 层接收来自 kotlin 方法的参数,并转换成 C++ 层的数据类型,计算之后再返回成 应用层的数据类型。

(*env)->方法名(env,参数列表)  //C的语法
env->方法名(参数列表)         //C++的语法

C语言没有对象的概念,因此要将env指针作为形参传入到JNIEnv方法中。

C++中const描述的都是一些“运行时常量性”的概念,即具有运行时数据的不可更改性。这与编译时期的常量性要区别开。

C++11中对编译时期常量的回答是constexpr,即常量表达式(constant expression)

基本数据类型转换
Java 类型Kotlin类型Native 类型符号属性字长
booleankotlin.Booleanjboolean无符号8位
bytekotlin.Bytejbyte无符号8位
charkotlin.Charjchar无符号16位
shortkotlin.Shortjshort有符号16位
intkotlin.Intjnit有符号32位
longkotlin.Longjlong有符号64位
floatkotlin.Floatjfloat有符号32位
doublekotlin.Doublejdouble有符号64位
引用数据类型转换
Java 引用类型Native 类型
All objectsjobject
java.lang.Classjclass
java.lang.Stringjstring
Object[]jobjectArray
boolean[]jbooleanArray
byte[]jbyteArray
java.lang.Throwablejthrowable
char[]jcharArray
short[]jshortArray
int[]jintArray
long[]jlongArray
float[]jdoubleArray

除了 Java 中基本数据类型的数组、Class、String 和 Throwable 外,其余所有 Java 对象的数据类型在 JNI 中都用 jobject 表示。

在 kotlin 方法中只有两个参数,在 C++ 代码就有四个参数了,至少都会包含前面两个参数

JNI 定义了两个关键数据结构,即“JavaVM”和“JNIEnv”。两者本质上都是指向函数表的二级指针。(在 C++ 版本中,它们是一些类,这些类具有指向函数表的指针,并具有每个通过该函数表间接调用的 JNI 函数的成员函数。)JavaVM 提供“调用接口”函数,可以利用此类来函数创建和销毁 JavaVM。理论上,每个进程可以有多个 JavaVM,但 Android 只允许有一个。

JNIEnv 提供了大部分 JNI 函数。原生函数都会收到 JNIEnv 作为第一个参数。

该 JNIEnv 将用于线程本地存储。因此,无法在线程之间共享 JNIEnv。如果一段代码无法通过其他方法获取自己的 JNIEnv,应该共享相应 JavaVM,然后使用 GetEnv 发现线程的 JNIEnv。

JNIEnv*

定义任意 native 函数的第一个参数,是一个指针,通过它可以访问虚拟机内部的各种数据结构,同时它还指向 JVM 函数表的指针,函数表中的每一个入口指向一个 JNI 函数,每个函数用于访问 JVM 中特定的数据结构。

JNIEnv类型是一个指向全部JNI方法的指针。该指针只在创建它的线程有效,不能跨线程传递。其声明如下:

struct _JNIEnv;
struct _JavaVM;
typedef const struct JNINativeInterface* C_JNIEnv;

#if defined(__cplusplus)
typedef _JNIEnv JNIEnv;
typedef _JavaVM JavaVM;
#else
typedef const struct JNINativeInterface* JNIEnv;
typedef const struct JNIInvokeInterface* JavaVM;
#endif

JNIEnv在C语言环境和C++语言环境中的实现是不一样的在C环境下其中方法的声明方式为:

struct JNINativeInterface {
    void*       reserved0;
    void*       reserved1;
    void*       reserved2;
    void*       reserved3;
    
    jint        (*GetVersion)(JNIEnv *);
    ...
};

C++中对其进行了封装:

struct _JNIEnv {
    /* do not rename this; it does not seem to be entirely opaque */
    const struct JNINativeInterface* functions;

#if defined(__cplusplus)

    jint GetVersion()
    { return functions->GetVersion(this); }

    .........  
#endif /*__cplusplus*/
};

返回值是宏定义的常量,可以使用获取到的值与下列宏进行匹配来知道当前的版本:

#define JNI_VERSION_1_1 0x00010001
#define JNI_VERSION_1_2 0x00010002
#define JNI_VERSION_1_4 0x00010004
#define JNI_VERSION_1_6 0x00010006

JavaVM

JavaVM是虚拟机在JNI中的表示,一个JVM中只有一个JavaVM对象,而且对象是线程共享的。

通过JNIEnv我们可以获取一个Java虚拟机对象,其函数如下:

jint **GetJavaVM**(JNIEnv *env, JavaVM **vm);
  • vm:用来存放获得的虚拟机的指针的指针。
  • return:成功返回0,失败返回其他。

JNI中JVM的声明:

/*
 * JNI invocation interface.
 */
struct JNIInvokeInterface {
    void*       reserved0;
    void*       reserved1;
    void*       reserved2;

    jint        (*DestroyJavaVM)(JavaVM*);
    jint        (*AttachCurrentThread)(JavaVM*, JNIEnv**, void*);
    jint        (*DetachCurrentThread)(JavaVM*);
    jint        (*GetEnv)(JavaVM*, void**, jint);
    jint        (*AttachCurrentThreadAsDaemon)(JavaVM*, JNIEnv**, void*);
};

JVM的创建:

/*
 * VM initialization functions.
 *
 * Note these are the only symbols exported for JNI by the VM.
 */
jint JNI_GetDefaultJavaVMInitArgs(void*);
jint JNI_CreateJavaVM(JavaVM**, JNIEnv**, void*);
jint JNI_GetCreatedJavaVMs(JavaVM**, jsize, jsize*);

其中JavaVMInitArgs是存放虚拟机参数的结构体,定义如下:

/*
 * JNI 1.2+ initialization.  (As of 1.6, the pre-1.2 structures are no
 * longer supported.)
 */
typedef struct JavaVMOption {
    const char* optionString;
    void*       extraInfo;
} JavaVMOption;

typedef struct JavaVMInitArgs {
    jint        version;    /* use JNI_VERSION_1_2 or later */

    jint        nOptions;
    JavaVMOption* options;
    jboolean    ignoreUnrecognized;
} JavaVMInitArgs;

JNI_CreateJavaVM() 函数给 JavaVM *指针 和 JNIEnv *指针进行赋值。得到这两个指针就可以操纵java了。

示例

#include <dlfcn.h>
#include <jni.h>
typedef int (*JNI_CreateJavaVM_t)(void *, void *, void *);
typedef jint (*registerNatives_t)(JNIEnv* env, jclass clazz);
static int init_jvm(JavaVM **p_vm, JNIEnv **p_env) {
  // https://android.googlesource.com/platform/frameworks/native/+/ce3a0a5/services/surfaceflinger/DdmConnection.cpp
  JavaVMOption opt[4];
  opt[0].optionString = "-Djava.class.path=/data/local/tmp/shim_app.apk";
  opt[1].optionString = "-agentlib:jdwp=transport=dt_android_adb,suspend=n,server=y";
  opt[2].optionString = "-Djava.library.path=/data/local/tmp";
  opt[3].optionString = "-verbose:jni"; // may want to remove this, it's noisy
  JavaVMInitArgs args;
  args.version = JNI_VERSION_1_6;
  args.options = opt;
  args.nOptions = 4;
  args.ignoreUnrecognized = JNI_FALSE;
  void *libdvm_dso = dlopen("libdvm.so", RTLD_NOW);
  void *libandroid_runtime_dso = dlopen("libandroid_runtime.so", RTLD_NOW);
  if (!libdvm_dso || !libandroid_runtime_dso) {
    return -1;
  }
  JNI_CreateJavaVM_t JNI_CreateJavaVM;
  JNI_CreateJavaVM = (JNI_CreateJavaVM_t) dlsym(libdvm_dso, "JNI_CreateJavaVM");
  if (!JNI_CreateJavaVM) {
    return -2;
  }
  registerNatives_t registerNatives;
  registerNatives = (registerNatives_t) dlsym(libandroid_runtime_dso, "Java_com_android_internal_util_WithFramework_registerNatives");
  if (!registerNatives) {
    return -3;
  }
  if (JNI_CreateJavaVM(&(*p_vm), &(*p_env), &args)) {
    return -4;
  }
  if (registerNatives(*p_env, 0)) {
    return -5;
  }
  return 0;
}

......
#include <stdlib.h>
#include <stdio.h>
JavaVM * vm = NULL;
JNIEnv * env = NULL;
int status = init_jvm( & vm, & env);
if (status == 0) {
  printf("Initialization success (vm=%p, env=%p)\n", vm, env);
} else {
  printf("Initialization failure (%i)\n", status);
  return -1;
}
jstring testy = (*env)->NewStringUTF(env, "this should work now!");
const char *str = (*env)->GetStringUTFChars(env, testy, NULL);
printf("testy: %s\n", str);

上面说了JNIEnv指针仅在创建它的线程有效。如果需要在其他线程访问JVM,那么必须先调用AttachCurrentThread将当前线程与JVM进行关联,然后才能获得JNIEnv对象。然后在必要时需要调用DetachCurrentThread来解除链接。

 jint AttachCurrentThread(JNIEnv** p_env, void* thr_args)
    { return functions->AttachCurrentThread(this, p_env, thr_args); }

解除与虚拟机的连接:

 jint DetachCurrentThread()
    { return functions->DetachCurrentThread(this); }

卸载虚拟机:

 jint DestroyJavaVM()
    { return functions->DestroyJavaVM(this); }

还有动态加载本地方法的两个函数:

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved);
JNIEXPORT void JNICALL JNI_OnUnload(JavaVM* vm, void* reserved);

这个以后讲利用JNI保护私密字符串会用到....

C++11

简单说下需要用到的一些点..

Unicode编码

看资料经常有人这么说:

Java 默认使用 Unicode 编码,而 Native 层是 C/C++ ,默认使用 UTF 编码。

这是因为一般人常常把UTF-16和Unicode混为一谈,我们在阅读各种资料的时候要注意区别。

Dalvik 中,String 对象编码方式为 utf-16 编码;

ART 中,String 对象编码方式为 utf-16 编码,但是有一个情况除外:如果 String 对象全部为 ASCII 字符并且 Android 系统为 8.0 及之上版本,String 对象的编码则为 utf-8;

我们称ISO/Unicode所定义的字符集为Unicode。在Unicode中,每个字符占据一个码位(Code point)。Unicode字符集总共定义了1114 112个这样的码位,使用从0到10FFFF的十六进制数唯一地表示所有的字符。不过不得不提的是,虽然字符集中的码位唯一,但由于计算机存储数据通常是以字节为单位的,而且出于兼容之前的ASCII、大数小段数段、节省存储空间等诸多原因,通常情况下,我们需要一种具体的编码方式来对字符码位进行存储。比较常见的基于Unicode字符集的编码方式有UTF-8、UTF-16及UTF-32。以UTF-8为例,其采用了1~6字节的变长编码方式编码Unicode,英文通常使用1字节表示,且与ASCII是兼容的,而中文常用3字节进行表示。UTF-8编码由于较为节约存储空间,因此使用得比较广泛。

UTF-8的编码方式:

Unicode符号范围(十六进制)UTF-8编码范围(二进制)byte数
0000 0000——0000 007F0xxxxxxx1
0000 0080——0000 07FF110xxxxx 10xxxxxx2
0000 0800——0000 FFFF1110xxxx 10xxxxxx 10xxxxxx3
0010 0000——0010 FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx4

单字节有效位数为7,第一位始终为0。对于以ASCII编码的字符串可以直接当做UTF-8字符串使用。

对于空字符其表示为\u0000

双字节字符在UTF-8中使用两个字节存放,且字符的开头为11 表示这个一个双字节字符:

  • 高位: 110xxxxx
  • 低位: 10xxxxxx

对于需要三个字节表示的字符,其最高位使用111表示该字符的字节数:

  • 高位: 1110xxxx
  • 中位: 10xxxxxx
  • 低位: 10xxxxxx

GB2312的出现先于Unicode。早在20世纪80年代,GB2312作为简体中文的国家标准被颁布使用。GB2312字符集收入6763个汉字和682个非汉字图形字符,而在编码上,是采用了基于区位码的一种编码方式,采用2字节表示一个中文字符。GB2312在中国大陆地区及新加坡都有广泛的使用。

BIG5俗称“大五码”。是长期以来的繁体中文的业界标准,共收录了13060个中文字,也采用了2字节的方式来表示繁体中文。BIG5在中国台湾、香港、澳门等地区有着广泛的使用。

在C++98标准中,为了支持Unicode,定义了“宽字符”的内置类型wchar_t。在Windows上,多数wchar_t被实现为16位宽,而在Linux上,则被实现为32位。事实上,C++98标准定义中,wchar_t的宽度是由编译器实现决定的。理论上,wchar_t的长度可以是8位、16位或者32位。这样带来的最大的问题是,程序员写出的包含wchar_t的代码通常不可移植。

C++11为了解决了Unicode类型数据的存储问题而引入以下两种新的内置数据类型来存储不同编码长度的Unicode数据。

  • char16_t:用于存储UTF-16编码的Unicode数据。
  • char32_t:用于存储UTF-32编码的Unicode数据。

至于UTF-8编码的Unicode数据,C++11还是使用8字节宽度的char类型的数组来保存。而char16_t和char32_t的长度则犹如其名称所显示的那样,长度分别为16字节和32字节,对任何编译器或者系统都是一样的。此外,C++11还定义了一些常量字符串的前缀。在声明常量字符串的时候,这些前缀声明可以让编译器使字符串按照前缀类型产生数据。

C++11一共定义了3种这样的前缀:

  • u8表示为UTF-8编码。
  • u表示为UTF-16编码。
  • U表示为UTF-32编码。

对于Unicode编码字符的书写,C++11中还规定了一些简明的方式,即在字符串中用'\u'加4个十六进制数编码的Unicode码位(UTF-16)来标识一个Unicode字符。比如'\u4F60'表示的就是Unicode中的中文字符“你”,而'\u597D'则是Unicode中的“好”。此外,也可以通过'\U'后跟8个十六进制数编码的Unicode码位(UTF-32)的方式来书写Unicode字面常量。需要看更多Unicode码位的编码可以去找下免费提供中文转Unicode的在线转换服务的网站。

看个简单例子:

#include <iostream>
using namespace std;

int main(int argc, const char * argv[]) {
    

    // 不同编码下的Unicode字符串的大小
    
    // 中文 你好啊
    char utf8[]  = u8"\u4F60\u597D\u554A";
    
    char16_t utf16[]  = u"\u4F60\u597D\u554A";
    // 输出中文
    cout << utf8 <<endl;
    //打印长度
    cout << sizeof(utf8) <<endl;
    cout << sizeof(utf16) <<endl;
    //
    cout << utf8[1] <<endl;
    cout << utf16[1] <<endl;
    return 0;
}

输出:

你好啊
10
8
\275
22909
Program ended with exit code: 0

可以看到由于utf-8采用了变长编码,每个中文字符编码为3字节,再加上'\0'的字符串终止符,所以UTF-8变量大小为10字节,而UTF-16采用的是定长编码,所以占了8字节空间。

这里看到utf8[1]输出不正确,因为UTF-8是不能直接数组式访问。这里直接指向了第一个UTF-8字符3字节的中的第二位。

UTF-8的优势在于支持更多的Unicode码位,另外变长的设定更多是为了序列化的时候节省存储空间,定长的UTF-16或者UTF-32更适合在内存环境中操作。在现有的C++编程中多数倾向于在即将进行I/O读写操作才将定长的UTF-16编码转化成UTF-8编码使用。

指针空值—nullptr

一般编程习惯中,声明一个变量的同时,总是需要在合适的代码位置将其初始化。

对于指针类型的变量,未初始化的悬挂指针通常会是一些难于调试的用户程序的错误根源。典型的初始化指针是将其指向一个“空”的位置,比如0。

由于大多数计算机系统不允许用户程序写地址为0的内存空间,倘若程序无意中对该指针所指地址赋值,通常在运行时就会导致程序退出。虽然程序退出并非什么好事,但这样一来错误也容易被程序员找到。因此在大多数的代码中,我们常常能看见指针初始化的语法如下:

int *ptr1 = NULL;
// 
int *ptr2 = 0;

一般情况下,NULL是一个宏定义。在JNI的C头文件(stddef.h)里我们可以找到如下代码:

#undef NULL
#ifdef __cplusplus
#  if !defined(__MINGW32__) && !defined(_MSC_VER)
#    define NULL __null
#  else
#    define NULL 0
#  endif

可以看到,NULL可能被定义为字面常量0,也可能预处理转换为编译器内部标识(__null),其实这是经过改进的,在C++98标准中,字面常量0的类型既可以是一个整型,也可以是一个无类型指针(void*),我们经常称之为字面常量0的二义性

在C++11中,出于兼容性的考虑,字面常量0的二义性并没有被消除。但标准还是为二义性给出了新的答案,就是nullptr。在C++11中,nullptr并非整型类别,甚至也不是指针类型,但是能转换成任意指针类型。指针空值类型被命名为nullptr_t,我们可以在__nullptr中找出如下定义:

namespace std
{
    typedef decltype(nullptr) nullptr_t;
}

nullptr也是一个nullptr_t的对象,nullptr是有类型的,且仅可以被隐式转化为指针类型。就是说nullptr到任何指针的转换是隐式的

另外C++11中规定用户不能获得nullptr的地址。其原因主要是因为nullptr被定义为一个右值常量,取其地址并没有意义。但是nullptr_t对象的地址可以被用户使用

运行时常量与编译时常量

在C++中,我们常常会遇到常量的概念。常量表示该值不可修改,通常是通过const关键字来修饰的。比如jni中的获取jchar:

 const jchar *mStr = env->GetStringChars(str, nullptr);

const还可以修饰函数参数、函数返回值、函数本身、类等。在不同的使用条件下,const有不同的意义,不过大多数情况下,const描述的都是一些“运行时常量性”的概念,即具有运行时数据的不可更改性。不过有的时候,我们需要的却是编译时期的常量性,这是const关键字无法保证的。

C++11中可以在函数返回类型前加入关键字constexpr来使其成为常量表达式函数。不过并非所有的函数都有资格成为常量表达式函数。事实上,常量表达式函数的要求非常严格,总结起来,大概有以下几点:

  • 函数体只有单一的return返回语句。
  • 函数必须返回值(不能是void函数)。
  • 在使用前必须已有定义。
  • return返回语句表达式中不能使用非常量表达式的函数、全局数据,且必须是一个常量表达式。
constexpr int data(){return 1;}

常量表达式实际上可以作用的实体不仅限于函数,还可以作用于数据声明,以及类的构造函数等.

const放在号前,表示指针指向的内容不能被修改,const放在*号后,表示指针不能被修改。

*号前后都有const关键字表示指针和指向的内容都不能被修改。

constexpr关键字只能放在号前面,并且表示指针的内容不能被修改。

但是constexpr关键字是不能用于修饰自定义类型的定义:

#include <iostream>
using namespace std;

struct DataType{
    constexpr  DataType(int data):x(data){}
    int x;
};

constexpr DataType mData = {10};

int main(int argc, const char * argv[]) {
    
    cout <<    "mData= "<< mData.x <<endl;
   
    return 0;
}

对DataType的构造函数进行了定义,加上了constexpr关键字。

智能指针与垃圾回收

单独起一篇写。

String 字符串操作

JNI把Java中的所有对象当作一个C指针传递到本地方法中,指针指向JVM中的内部数据结构,而内部的数据结构在内存中的存储方式是不可见的。只能从JNIEnv指针指向的函数表中选择合适的JNI函数来操作JVM中的数据结构,String在Java是一个引用类型,所以要使用合适的 JNI 函数来将 jstring 转成 C/C++ 字符串,例如用GetStringUTFChars这样的JNI函数来访问字符串的内容。当然我们也可以获得 Java 字符串的直接指针,不需要把它转换成 C 风格的字符串。

C/C++ 中的基本类型用 typedef 重新定义了一个新的名字,在 JNI 中可以直接访问。Java 层的字符串到了 JNI 就成了 jstring 类型的,但 jstring 指向的是 JVM 内部的一个字符串,它不是 C 风格的字符串 char*,所以不能像使用 C 风格字符串一样来使用 jstring 。

获得字符串

JNI 支持将 jstring 转换成 UTF 编码和 Unicode 编码两种。

  • GetStringUTFChars(jstring string, jboolean* isCopy)

将 jstring 转换成 UTF 编码的字符串

  • GetStringChars(jstring string, jboolean* isCopy)

其中,jstring 类型参数就是我们需要转换的字符串,而 isCopy 参数的值为 JNI_TRUE 或者 JNI_FALSE ,代表是否返回 JVM 源字符串的一份拷贝。如果为JNI_TRUE 则返回拷贝,并且要为产生的字符串拷贝分配内存空间;如果为JNI_FALSE 就直接返回了 JVM 源字符串的指针,意味着可以通过指针修改源字符串的内容,但这就违反了 Java 中字符串不能修改的规定,在实际开发中,直接填 nullptr 。

当调用完 GetStringUTFChars 方法时需要做完全检查。因为 JVM 需要为产生的新字符串分配内存空间,如果分配失败就会返回 nullptr,并且会抛出 OutOfMemoryError 异常,所以要对 GetStringUTFChars 结果进行判断。JNI 的异常和 Java 中的异常处理流程是不一样的,Java 遇到异常如果没有捕获,程序会立即停止运行。而 JNI 遇到未决的异常不会改变程序的运行流程,也就是程序会继续往下走,这样后面针对这个字符串的所有操作都是非常危险的,因此,我们需要用 return 语句跳过后面的代码,并立即结束当前方法。

当使用完 UTF 编码的字符串时,需要调用 ReleaseStringUTFChars 方法释放所申请的内存空间。

..... 
const char *chars = env->GetStringUTFChars((jstring) str1, nullptr);
 if (chars == nullptr) {
      return nullptr;
 }
 env->ReleaseStringUTFChars((jstring) str1, chars);

直接字符串指针

如果一个字符串内容很大,有 1 M 多,而我们只是需要读取字符串内容,这种情况下再把它转换为 C 风格字符串,不仅多此一举(通过直接字符串指针也可以读取内容),而且还需要为 C 风格字符串分配内存。

为此,JNI 提供了 GetStringCriticalReleaseStringCritical 函数来返回字符串的直接指针,这样只需要分配一个指针的内存空间。

不过这对函数有一个很大的限制,在这两个函数之间的本地代码不能调用任何会让线程阻塞或等待 JVM 中其它线程的本地函数或 JNI 函数。因为通过 GetStringCritical 得到的是一个指向 JVM 内部字符串的直接指针,获取这个直接指针后会导致暂停 GC 线程,当 GC 被暂停后,如果其它线程触发 GC 继续运行的话,都会导致阻塞调用者。所以在 Get/ReleaseStringCritical 这对函数中间的任何本地代码都不可以执行导致阻塞的调用或为新对象在 JVM 中分配内存,否则,JVM 有可能死锁。另外一定要记住检查是否因为内存溢出而导致它的返回值为 nullptr,因为 JVM 在执行 GetStringCritical 这个函数时,仍有发生数据复制的可能性,尤其是当 JVM 内部存储的数组不连续时,为了返回一个指向连续内存空间的指针,JVM 必须复制所有数据。

extern "C"
JNIEXPORT jstring JNICALL
Java_tt_reducto_ndksample_StringTypeOps_splicingStringCritical(JNIEnv *env,
                                                               jobject thiz,
                                                               jstring str) {
    const jchar *c_str = nullptr;
    char buf[128] = "hello ";
    char *pBuff = buf + 6;

    c_str = env->GetStringCritical(str, nullptr);
    if (c_str == nullptr) {
        // error handle
        return nullptr;
    }
    while (*c_str) {
        *pBuff++ = *c_str++;
    }
    //
    env->ReleaseStringCritical(str, c_str);
    //
    return env->NewStringUTF(buf);
}

获得字符串的长度

前面说了由于 UTF-8 编码的字符串以 \0 结尾,而 Unicode 字符串不是,所以对于两种编码获得字符串长度的函数也是不同的。

获得 Unicode 编码的字符串的长度:

  • GetStringLength

获得 UTF-8 编码的字符串的长度,或者使用 C 语言的 strlen 函数:

  • GetStringUTFLength

获得指定范围的字符串内容

JNI 提供了函数来获得字符串指定范围的内容,这里的字符串指的是 Java 层的字符串。函数会把源字符串复制到一个预先分配的缓冲区内。

  • GetStringRegion(获得 Unicode 编码的字符串指定内容)

  • GetStringUTFRegion(获得 UTF-8 编码的字符串指定内容)

/**
 *  截取字符串
 */
extern "C"
JNIEXPORT jstring JNICALL
Java_tt_reducto_ndksample_StringTypeOps_splitString(JNIEnv *env, jobject thiz, jstring str) {
    // 获取长度
    jsize len = env->GetStringLength(str);

    jchar outputBuf[len / 2];
    // 截取一部分内容放到缓冲区
    env->GetStringRegion(str, 0, len / 2, outputBuf);
    // 从缓冲区中获取 Java 字符串
    return env->NewString(outputBuf, len / 2);
}

中文处理

看到有很多在JNI中对gbk与UTF-8做编码转换,这个是比较麻烦的,因为UTF-8编码,GBK解码,要看UTF-8编码的二进制是否都能符合GBK的编码规则,但GBK编码,UTF-8解码,GBK编出的二进制,是很难匹配上UTF-8的编码规则。

”安卓“这两个字的UTF-8编码 与 GBK编码下的二进制为:

11100101 10101110 10001001 11100101 10001101 10010011 // UTF-8
10110000 10110010 11010111 10111111	// GBK 

GBK编码的二进制数据,完全匹配不了UTF-8的编码规则,只能被编码成��׿

这个符号都是为找不到对应规则随意匹配的一个特殊字符。

然后��׿的UTF-8二进制位为:

11101111 101111111 0111101 11101111 10111111 10111101 11010111 10111111

这个二进制和之前二进制不相同,所以转化不到最初的字符串,按照GBK的编码规则,“11101111 10111111”编码成“锟”,“10111101 11101111” 编码成“斤”,“10111111 10111101”编码成“拷”,“11010111 10111111”编码成“卓”。

字符串函数汇总

JNI 函数描述
GetStringChars / ReleaseStringChars获得或释放一个指向 Unicode 编码的字符串的指针(指 C/C++ 字符串)
GetStringUTFChars / ReleaseStringUTFChars获得或释放一个指向 UTF-8 编码的字符串的指针(指 C/C++ 字符串)
GetStringLength返回 Unicode 编码的字符串的长度
getStringUTFLength返回 UTF-8 编码的字符串的长度
NewString将 Unicode 编码的 C/C++ 字符串转换为 Java 字符串
NewStringUTF将 UTF-8 编码的 C/C++ 字符串转换为 Java 字符串
GetStringCritical / ReleaseStringCritical获得或释放一个指向字符串内容的指针(指 Java 字符串)
GetStringRegion获取或者设置 Unicode 编码的字符串的指定范围的内容
GetStringUTFRegion获取或者设置 UTF-8 编码的字符串的指定范围的内容

数组操作

基本数据类型数组

对于基本数据类型数组,JNI 都有和 Java 相对应的结构,在使用起来和基本数据类型的使用类似。

在 Android JNI 基础知识篇提到了 Java 数组类型对应的 JNI 数组类型。比如,Java int 数组对应了 jintArray,boolean 数组对应了 jbooleanArray。

如同 String 的操作一样,JNI 提供了对应的转换函数:GetArrayElements、ReleaseArrayElements。

1    intArray = env->GetIntArrayElements(int_array, nullptr);
2    env->ReleaseIntArrayElements(int_array, intArray, 0);

另外,JNI 还提供了如下的函数:

  • GetTypeArrayRegion / SetTypeArrayRegion(将数组内容复制到 C 缓冲区内,或将缓冲区内的内容复制到数组上)

  • GetArrayLength(得到数组中的元素个数,也就是长度)

  • NewTypeArray(返回一个指定数据类型的数组,并且通过 SetTypeArrayRegion 来给指定类型数组赋值)

  • GetPrimitiveArrayCritical / ReleasePrimitiveArrayCritical(返回一个指定基础数据类型数组的直接指针,这两个操作之间不能做任何阻塞的操作。)

// Java 传递 数组 到 Native 进行数组求和
external fun intArraySum(intArray: IntArray): Int

对应的 C++ 代码如下:

/**
 *  计算遍历数组求和。
 */
extern "C"
JNIEXPORT jint JNICALL
Java_tt_reducto_ndksample_StringTypeOps_intArraySum(JNIEnv *env, jobject thiz,
                                                    jintArray int_array) {
    // 声明
    jint *intArray;
    //
    int sum = 0;
    //
    intArray = env->GetIntArrayElements(int_array, nullptr);
    if (intArray == nullptr) {
        return 0;
    }
    // 得到数组的长度
    int length = env->GetArrayLength(int_array);
    for (int i = 0; i < length; ++i) {
        sum += intArray[i];
    }

    // 也可以通过 GetIntArrayRegion 获取数组内容
    jint  buf[length];
    //
    env->GetIntArrayRegion(int_array, 0, length, buf);
    // 重置
    sum = 0;
    for (int i = 0; i < length; ++i) {
        sum += buf[i];
    }
    // 释放内存
    env->ReleaseIntArrayElements(int_array, intArray, 0);

    return sum;
}

这里使用了两种方式获取数组中内容:

如果我们对包含1,000个元素的数组调用GetIntArrayElements(),则可能会导致分配和复制至少4,000个字节(1,000 * 4)。 然后,当使用ReleaseIntArrayElements()通知JVM更新数组的内容时,可能会触发另一个4,000字节的拷贝来更新数组。

即使您使用较新版本 GetPrimitiveArrayCritical(),规范仍允许JVM复制整个数组。

GetTypeArrayRegion()SetTypeArrayRegion() 方法允许我们只获取或者更新数组的一部分,而不是整个数组。通过使用这些方法,可以确保应用程序只操作所需要的部分数据,从而提高执行效率。

释放基本数据类型数组:

void Release<PrimitiveType>ArrayElements(JNIEnv *env,ArrayType array, NativeType *elems, jint mode);
mode行为
0copy back the content and free the elems buffer
JNI_COMMITcopy back the content but do not free the elems buffer
JNI_ABORTfree the buffer without copying back the possible changes

对象数组

即引用类型数组,数组中的每个类型都是引用类型,JNI 只提供了如下函数来操作:

  • GetObjectArrayElement / SetObjectArrayElement

与本数据类型不同,不能一次得到数据中的所有对象元素或者一次复制多个对象元素到缓冲区。只能通过以上函数来访问或者修改指定位置的元素内容。

字符串和数组都是引用类型,因此也只能通过上面的方法来访问。

我们通过 JNI生成一个对象数组:

kotlin:

data class JniArray(var msg: String)
....
// 获取JNI中创建的对象数组
external fun getNewObjectArray():Array<JniArray>

对应JNI方法:

extern "C"
JNIEXPORT jobjectArray JNICALL
Java_tt_reducto_ndksample_StringTypeOps_getNewObjectArray(JNIEnv *env, jobject thiz) {
    // 声明一个对象数组
    jobjectArray result;
    // 设置 数组长度
    int size = 5;
    //
    static jclass cls = nullptr;
    // 数组中对应的类
    if (cls == nullptr) {

        jclass localRefs = env->FindClass("tt/reducto/ndksample/JniArray");
        if (localRefs == nullptr) {
            return nullptr;
        }
        cls = (jclass) env->NewGlobalRef(localRefs);
        env->DeleteLocalRef(localRefs);
        if (cls == nullptr) {
            return nullptr;
        }
    } else{
        LOGD("use GlobalRef cached")
    }

    // 初始化一个对象数组,用指定的对象类型
    result = env->NewObjectArray(size, cls, nullptr);
    if (result == nullptr) {
        return nullptr;
    }

    static jmethodID mid = nullptr;
    if (mid == nullptr) {
        mid = env->GetMethodID(cls, "<init>", "(Ljava/lang/String;)V");
        if (mid == nullptr) {
            return nullptr;
        }
    } else {
        LOGD("use method cached")
    }
    char buf[64];
    for (int i = 0; i < size; ++i) {
        sprintf(buf,"%d",i);
        //
        jstring nameStr = env->NewStringUTF(buf);
        // 创建
        jobject jobjMyObj = env->NewObject(cls, mid, nameStr);
        env->SetObjectArrayElement(result, i, jobjMyObj);
        env->DeleteLocalRef(jobjMyObj);
    }

    return result;
}

数组截取

void GetArrayRegion(JNIEnv *env, ArrayType array,jsize start, jsize len, NativeType *buf);

范围设置数组


// 给数组的部分赋值
void Set<PrimitiveType>ArrayRegion(JNIEnv *env, ArrayType array,
jsize start, jsize len, const NativeType *buf);

操作基本数据类型数组的直接指针

在某些情况下,我们需要原始数据指针来进行一些操作。调用GetPrimitiveArrayCritical后,我们可以获得一个指向原始数据的指针,但是在调用ReleasePrimitiveArrayCritical函数之前,我们要保证不能进行任何可能会导致线程阻塞的操作。由于GC的运行会打断线程,所以在此期间任何调用GC的线程都会被阻塞。

void * GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy);
void ReleasePrimitiveArrayCritical(JNIEnv *env, jarray array, void *carray, jint mode);
jint len = (*env)->GetArrayLength(env, arr1);
  jbyte *a1 = (*env)->GetPrimitiveArrayCritical(env, arr1, 0);
  jbyte *a2 = (*env)->GetPrimitiveArrayCritical(env, arr2, 0);
  /* We need to check in case the VM tried to make a copy. */
  if (a1 == NULL || a2 == NULL) {
    ... /* out of memory exception thrown */
  }
  memcpy(a1, a2, len);
  (*env)->ReleasePrimitiveArrayCritical(env, arr2, a2, 0);
  (*env)->ReleasePrimitiveArrayCritical(env, arr1, a1, 0);

类型签名

这里的签名指的是在 JNI 中去查找 Java 中对应的数据类型、对应的方法时,需要将 Java 中的签名转换成 JNI 所能识别的。

例如查看String的函数签名:

javap -s java.lang.String

结果:

  ........
  public java.lang.String(byte[], int, int, java.lang.String) throws java.io.UnsupportedEncodingException;
    descriptor: ([BIILjava/lang/String;)V
    ......
  public byte[] getBytes(java.lang.String) throws java.io.UnsupportedEncodingException;
    descriptor: (Ljava/lang/String;)[B

  public byte[] getBytes(java.nio.charset.Charset);
    descriptor: (Ljava/nio/charset/Charset;)[B

  public byte[] getBytes();
    descriptor: ()[B

    .......

对于类的签名转换

对于 Java 中类或者接口的转换,需要用到 Java 中类或者接口的全限定名,把 Java 中描述类或者接口的 . 换成 / 就好了,比如 String 类型对应的 JNI 描述为:

java/lang/String     // . 换成 / 

对于数组类型,则是用 [ 来表示数组,然后跟一个字段的签名转换:

[I         // 代表一维整型数组,I 表示整型
[[I        // 代表二维整型数组
[Ljava/lang/String;      // 代表一维字符串数组, 

对应基础类型字段的转换

Java 类型JNI 对应的描述转
booleanZ
byteB
charC
shortS
intI
longJ
floatF
doubleD

对于引用类型的字段签名转换

大写字母 L 开头,然后是类的签名转换,最后以 ; 结尾:

Java 类型JNI 对应的描述转换
StringLjava/lang/String;
ClassLjava/lang/Class;
ThrowableLjava/lang/Throwable
int[]"[I"
Object[]"[Ljava/lang/Object;"

对于方法的签名转换

首先是将方法内所有参数转换成对应的字段描述,并全部写在小括号内,然后在小括号外再紧跟方法的返回值类型描述。

Java 类型JNI 对应的描述转换
String f();()Ljava/lang/String;
long f(int i, Class c);(ILjava/lang/Class;)J
String(byte[] bytes);([B)V

这里要注意的是在 JNI 对应的描述转换中不要出现空格。

了解并掌握这些转换后,就可以进行更多的操作了,实现 Java 与 C++ 的相互调用。

比如,有一个自定义的 data class,然后再 Native 中打印类的对象数组的某一个字段值:

data class JniArray(var msg: String)

看下对应的字段的Bytecode

 public final class tt/reducto/ndksample/JniArray {
 ....
 L0
    LINENUMBER 15 L0
    ALOAD 0
    GETFIELD tt/reducto/ndksample/JniArray.msg : Ljava/lang/String;
    ARETURN
   L1
   
   ......
 }

方法:

 external fun getObjectArrayElement(jniArray: Array<JniArray>):String?

具体 C++ 代码如下:

extern "C"
JNIEXPORT jstring JNICALL
Java_tt_reducto_ndksample_StringTypeOps_getObjectArrayElement(JNIEnv *env, jobject thiz,
                                                              jobjectArray jni_array) {
    jobject arr;
    // 数组长度
    int size = env->GetArrayLength(jni_array);
    // 数组中对应的类
    jclass cls = env->FindClass("tt/reducto/ndksample/JniArray");
   
    // 类对应的字段描述
    jfieldID fid = env->GetFieldID(cls, "msg", "Ljava/lang/String;");
   
    // 类的字段具体的值
    jstring jstr;
    const char *str = nullptr;
  	// 拼接
    string tmp;
    for (int i = 0; i < size; ++i) {
        // 得到数组中的每一个元素
        arr = env->GetObjectArrayElement(jni_array, i);
        // 每一个元素具体字段的值
        jstr = (jstring) (env->GetObjectField(arr, fid));
        str = env->GetStringUTFChars(jstr, nullptr);
        if (str == nullptr) {
            continue;
        }
        tmp += str;
        LOGD("str is %s", str)
        env->ReleaseStringUTFChars(jstr, str);
    }

    return env->NewStringUTF(tmp.c_str());
}

缓存方式

初始化时缓存

在类加载时,进行缓存。当类被加载进内存时,会先调用类的静态代码块,所以可以在类的静态代码块中进行缓存。

public class StringTypeOps {
  	static {
      	// 静态代码块中进行缓存
        nativeInit();
    }
		private static native void nativeInit();   
   
}
public class StringTypeOps {
  	companion object {
        
        private external fun nativeInit()

        init {
            nativeInit()
        }
    }
   
}

在执行 ID 查找的 C/C++ 代码中创建 nativeClassInit 方法。初始化类时,该代码会执行一次。如果要取消加载类之后再重新加载,该代码将再次执行。

如果要通过原生代码访问对象的字段,以下操作:

  • 使用 FindClass 获取类的类对象引用
  • 使用 GetFieldID 获取字段的字段 ID
  • 使用适当函数获取字段的内容,例如 GetIntField

同样,如需调用方法,首先要获取类对象引用,然后获取方法 ID。方法 ID 通常只是指向内部运行时数据结构的指针。查找方法 ID 可能需要进行多次字符串比较,但一旦获取此类 ID,便可以非常快速地进行实际调用以获取字段或调用方法。

如果性能很重要,一般建议查找一次这些值并将结果缓存在原生代码中。由于每个进程只能包含一个 JavaVM,因此将这些数据存储在静态本地结构中是一种合理的做法。

在取消加载类之前,类引用、字段 ID 和方法 ID 保证有效。只有在与 ClassLoader 关联的所有类可以进行垃圾回收时,系统才会取消加载类,这种情况很少见,但在 Android 中并非不可能。但请注意,jclass 是类引用,必须通过调用 NewGlobalRef 来保护

如果您在加载类时缓存方法 ID,并在取消加载类后重新加载时自动重新缓存方法 ID,那么初始化方法 ID 的正确做法是,将与以下类似的一段代码添加到相应类中:

// 全局变量作为缓存
// Java字符串的类和获取方法ID
jclass gStringClass;

jmethodID gmidStringInit;

jmethodID gmidStringGetBytes;

......
extern "C"
JNIEXPORT jbyteArray JNICALL
Java_tt_reducto_ndksample_StringTypeOps_chineseString(JNIEnv *env, jobject thiz,
                                                      jstring str) {
    .....                                                 
    gStringClass = env->FindClass("java/lang/String");
    if (gStringClass == nullptr) {
        return nullptr;
    }
    //  public byte[] getBytes(java.lang.String) throws java.io.UnsupportedEncodingException;
    gmidStringGetBytes = (env)->GetMethodID(gStringClass, "getBytes", "(Ljava/lang/String;)[B");
    if (gmidStringGetBytes == nullptr) {
        return nullptr;
    }                                                
   ....                                                   
}                                                      

要访问Java对象的字段或者调用它们的方法,本地代码必须调用FindClass()、GetFieldID()、GetMethodId()和GetStaticMethodID()来获取对应的ID。在的通常情况下,GetFieldID()、GetMethodID()和 GetStaticMethodID()为同一个类返回的ID在JVM进程的生命周期内都不会更改。但是获取字段或方法的ID可能需要在JVM中进行大量工作,因为字段和方法可能已经从超类继承,JVM不得不在类继承结构中查找它们。因为给定类的ID是相同的,所以应该查找它们一次,然后重复使用它们。同样的,查找类对象也可能很耗时,因此它们也应该被缓存起来进行复用。

这里 在 JNI 中直接将方法 id 缓存成全局变量了,这样再调用时,避免了多个线程同时调用会多次查找的情况,提升效率。

使用时缓存

使用时缓存,就是在调用时查找一次,然后将它缓存成 static 变量,这样下次调用时就已经被初始化过了。

直到内存释放了,才会缓存失效。

extern "C"
JNIEXPORT jstring JNICALL
Java_tt_reducto_ndksample_StringTypeOps_getObjectArrayElement(JNIEnv *env, jobject thiz,
                                                              jobjectArray jni_array) {
    .....
    static jfieldID fid = nullptr;
    // 类对应的字段描述
    // 从缓存中查找
    if (fid == nullptr) {
        fid = env->GetFieldID(cls, "msg", "Ljava/lang/String;");
        if (fid == nullptr) {
            return nullptr;
        }
    }
		....

    return env->NewStringUTF(tmp.c_str());
}

通过声明为 static 变量进行缓存。但这种缓存方式有弊端,多个调用者同时调用时,就会出现缓存多次的情况,并且每次调用时都要检查是否缓存过。

如果不能预先知道方法和字段所在类的源码,那么在使用时缓存比较合理。但如果知道的话,在初始化时缓存优点较多,既避免了每次使用时检查,还避免了在多线程被调用的情况。

引用管理

Native 代码并不能直接通过引用来访问其内部的数据接口,必须要通过调用 JNI 接口来间接操作这些引用对象,并且 JNI 还提供了和 Java 相对应的引用类型,因此,我们就需要通过管理好这些引用来管理 Java 对象,避免在使用时被 GC 回收。

JNI 提供了三种引用类型:

  • 局部引用
  • 全局引用
  • 弱全局引用

在Native的环境,同时要注意内存问题,因为Native的代码都是要手动的申请内存,手动的释放。

当然,业务逻辑里面的申请和释放用标准的new/delete或者malloc/free,或者用智能指针之类的。JNI部分是有封装好的方法的,比如NewGlobalRef,NewLocalRef, DeleteGlobalRef, DeleteLocalRef等。

需要注意的是用这些方法创建出来的引用要及时的删除。因为这些引用都是在JVM中一个表中存放的,而这个表是有容量限制,当到达一定数量后就不能再存放了,就会报出异常。所以要及时删除创建出来的引用。

局部引用

传递给原生方法的每个参数,以及 JNI 函数返回的几乎每个对象都属于“局部引用”。这意味着,局部引用在当前线程中的当前原生方法运行期间有效。在原生方法返回后,即使对象本身继续存在,该引用也无效。

比如:NewObject、FindClass、NewObjectArray 函数等等。

局部引用会阻止 GC 回收所引用的对象,同时,它不能在本地函数中跨函数传递,不能跨线程使用。

在之前 JNI 调用时缓存字段和方法 ID,把字段 ID 通过 static 变量缓存起来。

jfieldIDjmethodID 属于不透明类型,不是对象引用。而如果把 FindClass 函数创建的局部引用也通过 static 变量缓存起来,那么在函数退出后,局部引用被自动释放了,static 静态变量中存储的就是一个被释放后的内存地址,成为了一个野指针,再次调用时就会引起程序崩溃了。

创建的任何局部引用都必须手动删除。如果在循环中创建局部引用的任何原生代码可能需要执行某些手动删除操作:

  for (int i = 0; i < len; ++i) {
        jstring jstr = (*env)->GetObjectArrayElement(env, arr, i);
        ... /* process jstr */
        (*env)->DeleteLocalRef(env, jstr);
    }
  • 循环体中创建了大量的局部引用对象时,会造成 JNI 局部引用表的溢出,所以需要及时释放局部引用,防止溢出。
  • 局部引用使用完了就删除,不必要等到函数结尾。
  • 如果Native 方法不会返回,那么自动释放局部引用就失效了,这时候就必须要手动释放。比如,在某个一直等待的循环中,如果不及时释放局部引用,很快就会溢出。

**记住“不过度分配”局部引用。**JNI 的规范指出,JVM 要确保每个 Native 方法至少可以创建 16 个局部引用,因此如果需要更多,则应该按需删除,或使用 EnsureLocalCapacity/PushLocalFrame 保留。

例如 :

// Use EnsureLocalCapacity
    int len = 20;
    if (env->EnsureLocalCapacity(len) < 0) {
        // 创建失败,outof memory
    }
    for (int i = 0; i < len; ++i) {
        jstring  jstr = env->GetObjectArrayElement(arr,i);
        // 处理 字符串
        // 创建了足够多的局部引用,这里就不用删除了,显然占用更多的内存
    }

这样在循环体中处理局部引用时可以不进行删除了,但是显然会消耗更多的内存空间。

PushLocalFrame 与 PopLocalFrame 是配套使用的函数对。

PushLocalFrame为函数中需要用到的局部引用创建了一个引用堆栈,如果之前调用PushLocalFrame已经创建了Frame,在当前的本地引用栈中仍然是有效的,例如每次遍历中调用env->GetObjectArrayElement(arr, i);

返回一个局部引用时,JVM会自动将该引用压入当前局部引用栈中。而PopLocalFrame负责销毁栈中所有的引用。它们可以为局部引用创建一个指定数量内嵌的空间,在这个函数对之间的局部引用都会在这个空间内,直到释放后,所有的局部引用都会被释放掉,不用再担心每一个局部引用的释放问题。

// Use PushLocalFrame & PopLocalFrame
   for (int i = 0; i < len; ++i) {
       if (env->PushLocalFrame(len)) { // 创建指定数据的局部引用空间
           //outof  memory
       }
       jstring jstr = env->GetObjectArrayElement(arr, i);
       // 处理字符串
       // 期间创建的局部引用,都会在 PushLocalFrame 创建的局部引用空间中
       // 调用 PopLocalFrame 直接释放这个空间内的所有局部引用
       env->PopLocalFrame(nullptr); 
   }

全局引用

全局引用也会阻止它所引用的对象被回收。但是它不会在方法返回时被自动释放,必须要通过手动释放才行,而且,全局引用可以跨方法、跨线程使用。

全局引用只能通过 NewGlobalRef函数来创建,然后通过 DeleteGlobalRef 函数来手动释放。

JNIEXPORT jstring JNICALL
Java_tt_reducto_ndksample_StringTypeOps_getObjectArrayElement(JNIEnv *env, jobject thiz,
                                                              jobjectArray jni_array) {
    jobject arr;
    // 数组长度
    int size = env->GetArrayLength(jni_array);
    static jclass cls = nullptr;
    // 数组中对应的类
    if (cls == nullptr) {
        jclass localRefs = env->FindClass("tt/reducto/ndksample/JniArray");
        if (localRefs == nullptr) {
            return nullptr;
        }
        cls = (jclass) env->NewGlobalRef(localRefs);
        env->DeleteLocalRef(localRefs);
        if (cls == nullptr) {
            return nullptr;
        }
    }
    ........
}

谨慎使用全局引用。

虽然使用全局引用不可避免,但它们很难调试,并且可能会导致难以诊断的内存(不良)行为。在所有其他条件相同的情况下,全局引用越少,解决方案的效果可能越好。

弱全局引用

弱全局引用有点类似于 Java 中的弱引用,它所引用的对象可以被 GC 回收,并且它也可以跨方法、跨线程使用。

使用 NewWeakGlobalRef 方法创建,使用 DeleteWeakGlobalRef 方法释放。

extern "C"
JNIEXPORT jstring JNICALL
Java_tt_reducto_ndksample_StringTypeOps_getObjectArrayElement(JNIEnv *env, jobject thiz,
                                                              jobjectArray jni_array) {
    jobject arr;
    // 数组长度
    int size = env->GetArrayLength(jni_array);
    static jclass cls = nullptr;
    // 数组中对应的类
    if (cls == nullptr) {
        jclass localRefs = env->FindClass("tt/reducto/ndksample/JniArray");
        if (localRefs == nullptr) {
            return nullptr;
        }
        cls = (jclass) env->NewWeakGlobalRef(localRefs);
      
        if (cls == nullptr) {
            return nullptr;
        }
    }

    static jfieldID fid = nullptr;
    // 类对应的字段描述
    // 从缓存中查找
    if (fid == nullptr) {
        fid = env->GetFieldID(cls, "msg", "Ljava/lang/String;");
        if (fid == nullptr) {
            return nullptr;
        }
    }
    jboolean isGC = env->IsSameObject(cls, nullptr);
    if(isGC ){
        LOGD("weak reference has been gc")
        return nullptr;
    } else{

        jstring jstr;
        // 类的字段具体的值
        // 类字段具体值转换成 C/C++ 字符串
        const char *str = nullptr;
        string tmp;
        for (int i = 0; i < size; ++i) {
            // 得到数组中的每一个元素
            arr = env->GetObjectArrayElement(jni_array, i);
            // 每一个元素具体字段的值
            jstr = (jstring) (env->GetObjectField(arr, fid));

            str = env->GetStringUTFChars(jstr, nullptr);
            if (str == nullptr) {
                continue;
            }
            tmp += str;
            LOGD("str is %s", str)
            env->ReleaseStringUTFChars(jstr, str);
        }
        return env->NewStringUTF(tmp.c_str());
    }


}

引用比较

如需比较两个引用是否引用同一对象,必须使用 IsSameObject 函数。

切勿在原生代码中使用 == 比较各个引用。

因为JNI env不是指向Java对象的直接指针。在垃圾回收期间,Java对象可以在Heap上移动。它们的内存地址可能会更改,但是JNI env必须保持有效。

JNI env对用户是不透明的,也就是说,env的实现是特定于JVM的。IsSameObject提供了抽象层。

在HotSpot中,JVM env是指向可变对象引用的指针。

如果使用此符号,您就不能假设对象引用在原生代码中是常量或唯一值

同时,还可以用 isSameObject 来比较弱全局引用所引用的对象是否被 GC 了,返回 JNI_TRUE 则表示回收了,JNI_FALSE 则表示未被回收。

env->IsSameObject(obj1, obj2) // 比较局部引用 和 全局引用是否相同
env->IsSameObject(obj, nullptr)  // 比较局部引用或者全局引用是否为 NULL
env->IsSameObject(wobj, nullptr) // 比较弱全局引用所引用对象是否被 GC 回收

函数返回的 GetStringUTFCharsGetByteArrayElements 等原始数据指针不属于对象。这些指针可以在线程之间传递,并且在匹配的 Release 调用完成之前一直有效。

异常处理

一般我们在JNI中需要处理的两种异常:

  • Native 代码调用 Java 层代码时发生了异常要处理
  • Native 代码自己抛出了一个异常让 Java 层去处理

JNI没有像Java一样有try…catch…final这样的异常处理机制,面且在本地代码中调用某个JNI接口时如果发生了异常,后续的本地代码不会立即停止执行,而会继续往下执行后面的代码。

jint        (*Throw)(JNIEnv*, jthrowable);
jint        (*ThrowNew)(JNIEnv *, jclass, const char *);
jthrowable  (*ExceptionOccurred)(JNIEnv*);
void        (*ExceptionDescribe)(JNIEnv*);
void        (*ExceptionClear)(JNIEnv*);
void        (*FatalError)(JNIEnv*, const char*);

还有一个 单独的:

jboolean    (*ExceptionCheck)(JNIEnv*);
  • ExceptionCheck:检查是否发生了异常,若有异常返回JNI_TRUE,否则返回JNI_FALSE
  • ExceptionOccurred:检查是否发生了异常,若有异常返回该异常的引用,否则返回NULL
  • ExceptionDescribe:打印异常的堆栈信息
  • ExceptionClear:清除异常堆栈信息
  • ThrowNew:在当前线程触发一个异常,并自定义输出异常信息
  • Throw:丢弃一个现有的异常对象,在当前线程触发一个新的异常
  • FatalError:致命异常,用于输出一个异常信息,并终止当前VM实例(即退出程序)

Native 调用 Java 方法时的异常

我们拿上面getObjectArrayElement代码举例:

故意写错字段:

   .....
   // 类对应的字段描述
   jfieldID fid = env->GetFieldID(cls, "ms", "Ljava/lang/String;");
   jthrowable mjthrowable = env->ExceptionOccurred();
    if (mjthrowable) {
        // 打印异常日志
        env->ExceptionDescribe();
        // 清除异常不产生崩溃
        env->ExceptionClear();
        // 清除引用
        env->DeleteLocalRef(cls);
    }
    ....

这样 log就会输出:

java.lang.NoSuchFieldError: no "Ljava/lang/String;" field "ms" in class "Ltt/reducto/ndksample/JniArray;" or its superclasses

ExceptionClear 方法则是关键的不会让应用直接崩溃的方法,类似于 Java 的 catch 捕获异常处理,它会消除这次异常。

这样就把由 Native 调用 Java 时的一个异常进行了处理,当处理完异常之后,别忘了释放对应的资源。

不过,我们这样仅仅是消除了这次异常,还应该让调用者有异常的发生,那么就需要通过 Native 来抛出一个异常告诉 Java 调用者了。

Native 抛出 Java 中的异常

有时在 Native 代码中进行一些操作,需要抛出异常到 Java ,交由上层去处理。

比如 Java 调用 Native 方法传递了某个参数,而这个参数有问题,那么 Native 就可以抛出异常让 Java 去处理这个参数异常的问题。

Native 抛出异常的代码大致都是相同的,可以抽出一个通用函数来:

JNI中抛异常工具代码:

void
JNI_ThrowByName(JNIEnv *env, const char *name, const char *msg)
{
	//查找异常类
	jclass cls = env->FindClass(name);
	//判断是否找到该异常类
	if (cls != NULL) {
		env->ThrowNew(cls, msg);//抛出指定名称的异常
	}
	//释放局部变量
	env->DeleteLocalRef(cls);
}

JNI中检测工具代码:

int checkExecption(JNIEnv *env) {
    if(env->ExceptionCheck()) {//检测是否有异常
        env->ExceptionDescribe(); // 打印异常信息
        env->ExceptionClear();//清除异常信息
        return 1;
    }
    return -1;
}
java.lang.ArithmeticException: divide by zero

写个简单的例子:

class ExcTest {
    fun getNum() = 2 / 0
}

对应JNI函数:

extern "C"
JNIEXPORT  void JNICALL
Java_tt_reducto_ndksample_StringTypeOps_exception
        (JNIEnv *env, jobject jobj) {

    jclass cls = env->FindClass("tt/reducto/ndksample/ExcTest");

    jmethodID mid = env->GetMethodID(cls, "<init>", "()V");
    jobject obj = env->NewObject(cls, mid);
    mid = env->GetMethodID(cls, "getNum", "()I");
    // 先初始化一个类,然后调用类方法,就如博客中描述的那样
    env->CallIntMethod(obj, mid);
    // 检查是否发生了异常,若用异常返回该异常的引用,否则返回NULL
    jthrowable exc;
    exc = env->ExceptionOccurred();

    if (exc) {
        // 打印异常调用异常对应的Java类的printStackTrace()函数
        env->ExceptionDescribe();
        //清除引发的异常,在Java层不会打印异常的堆栈信息
        env->ExceptionClear();
        env->DeleteLocalRef(cls);
        env->DeleteLocalRef(obj);

        // 抛出一个自定义异常信息
        throwByName(env, "java/lang/ArithmeticException", "divide by zero");
    }

}

这样我们在kotlin中捕获就可以了:

  try {
        StringTypeOps.exception()
       }catch (e:ArithmeticException) {
           e.printStackTrace()
       }

注意事项:

调用ThrowNew方法手动抛出异常后,native方法会继续执行但是返回值会被忽略。

most JNI methods cannot be called with a pending exception.

尽量不要在抛出异常后再去执行逻辑。否则会crash.

JNI DETECTED ERROR IN APPLICATION: JNI ThrowNew called with pending exception java.lang.IllegalArgumentException:

信号量捕获

这里不是Java并发中的信号量Semaphore.

具体可以参考腾讯Bugly的这篇文章

另外关于爱奇艺的xCrash有兴趣也可以看看。

参考

developer.android.com/training/ar…

  • 《深入理解C++11》