Android NDK——必知必会之JNI和NDK基础全面详解(二)

版权声明:本文为CrazyMo_原创,转载请在显著位置注明原文链接 https://blog.csdn.net/CrazyMo_/article/details/82050526

引言

前一篇文章简单总结了下配置NDK的基本开发环境,从这篇文章开始,此系列文章是主要总结和讲解一些关于NDK的开发的基础的必要知识及实战使用NDK的经验总结,相信对于所有NDK开发初学者都会对JNI和NDK的有一些更深的理解和认知,不敢说完全知其所以然,至少略知一二,此系列文章基链接:

一、JNI和NDK 概述

或许在很多人的眼里认为NDK 就是JNI,JNI 就是NDK,其实不然。JNI是Java Native Interface的缩写,它提供了若干的API实现了Java和其他语言的通信(主要是C&C++)。从Java1.1开始,JNI标准成为Java平台的一部分,它允许Java代码和其他语言写的代码进行交互。JNI一开始是为了本地已编译语言,尤其是C和C++而设计的,但是它并不妨碍你使用其他编程语言,只要调用约定受支持就可以了。简而言之,JNI就是Java代码和本地语言之间的桥梁,是一套通用的框架,允许本地方法创建Java对象并使用Java 对象及其方法,也同样允许在Java代码中使用本地语言对象即其方法,最终在JVM的同一线程中运行JNI是一种本地编程接口,它允许运行在JVM中的JAVA代码与用其他编程语言(C语言、C++、汇编)写的应用和库之间的交互操作。JNI不仅仅是只能在Android环境下运行,它在Linux、Windows、Unix环境下都可以运行。而NDK全称是Android Native Development Kit,是Google 提供给我们Android开发者使用的一个开发套件包含了Android平台交叉编译器和快速开发C/C++的动态库(可以编译为ARM或x86 或MIPS等对应架构的so文件)并自动将对应的so和应用一起打包成 apk的各种工具 ,所以使用Android Studio开发NDK的时候,第一步就是配置各种NDK环境(具体参见Android NDK——配置NDK及使用Android studio开发Hello JNI并简单打包so库)。

二、JNI的优势和使用注意事项

1、JNI的优势

通过JNI Java程序员可以使用其他编程语言来解决用纯粹的Java代码不好处理的情况, 例如Java标准库不支持的平台相关功能或者程序库。也可改造已存在的用其它语言写的程序供Java程序调用。许多基于JNI的标准库以及平台相关的API实现提供了很多功能供Java程序员高效使用。JNI框架允许Native 层语言与Java 层语言双向交互给开发带来了极大的优势。

2、JNI的缺使用注意事项

  • 在使用JNI的过程中,可能因为某些微小的BUG对整个JVM造成很难重现和调试的错误

  • 依赖于JNI的应用失去了Java的平台移植性(一种解决办法是为每个平台编写专门的JNI代码,然后在Java代码中,根据操作系统载入正确的JNI代码)。

  • JNI框架并没有对 non-JVM 内存提供自动垃圾回收机制, Native代码(如汇编语言)分配的内存和资源,需要其自身负责进行显式的释放

  • NewStringUTF, GetStringUTFLength, GetStringUTFChars, ReleaseStringUTFChars, GetStringUTFRegion等编码函数处理的是一种修改的UTF-8,[3],实际上是一种不同的编码, 某些字符并不是标准的UTF-8。 null字符(U+0000)以及不在Unicode字符平面映射中的字符(codepoints 大于等于 U+10000 的字符,例如UTF-16中的代理对 surrogate pairs),在修改的UTF-8中的编码都有所不同。 许多程序错误地使用了这些函数,将标准UTF-8字符串传入或传出这些函数,实际上应该使用修改后的编码。程序应当先使用NewString, GetStringLength, GetStringChars, ReleaseStringChars, GetStringRegion, GetStringCritical与ReleaseStringCritical等函数,这些函数在小尾序机器上使用UTF-16LE编码,在大尾序机器上使用UTF-16BE编码,然后再通过程序将 UTF-16转换为 UTF-8。

  • JNI使用改进的UTF-8字符串来表示不同的字符类型。而Java使用UTF-16编码,其中UTF-8编码主要使用于C语言,因为它的编码用\u000表示为0xc0,而不是平常的0×00。非空ASCII字符改进后的字符串编码中可以用一个字节表示。

  • JNI不会检查NullPointerException、IllegalArgumentException这样的错误,原因是:导致性能下降。

  • JNI在以下情况下可能带来很大的开销和性能损失:

    • 调用 JNI 方法是很笨重的操作, 特别是在多次重复调用的情况下。
    • Native 方法不会被 JVM 内联, 也不会被 JIT compiled 优化 , 因为方法已经被编译过了.
    • Java 数组可能会被拷贝一份,以传递给 native 方法, 执行完之后再拷贝回去. 其开销与数组的长度是线性相关的
    • 如果传递一个对象给方法或者需要一个回调,那么 Native 方法可能会自己调用JVM。 访问Java对象的属性、方法和类型时, Native代码需要类似reflection的东西。签名由字符串指定,通常从JVM中查询,这非常缓慢并且容易出错。
    • Java 中的字符串(String) 是有编码过的 length 属性的对象,.读取或者创建字符串都需要一次时间复杂度为 O(n) 的复制操作.

三、利用JNI在本地Java环境调用C/C++代码

正如前面所说的,JNI是一套通用框架,可以在很多系统环境下运行,接下来就实现一个不依赖NDK,在本地Java环境下调用C/C++代码的例子。
这里写图片描述

1、首先在Java 项目中声明本地jni方法

随便打开一个Java IDE ,Android Studio、Eclipse、IDEA、MyEclipse、记事本等等都可以,无所谓目的是编写一个Java类,并在这个类中通过JNI 使用本地方法。

package com.crazymo.permission;

import org.junit.Test;

import static org.junit.Assert.*;

/**
 * Example local unit test, which will execute on the development machine (host).
 *
 * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
 */
public class ExampleUnitTest {
    @Test
    public void addition_isCorrect() throws Exception {
        assertEquals(4, 2 + 2);
        useJNI();
    }

    /**
     * 使用JNI通常包含两个步骤:
     * 1)引入库,是由System类提供的两个静态方法:load 需要传入的参数必须是绝对路径,而System.loadLibrary()则是传递动态库名称
     * 2)调用本地Java层的方法
     */
    public void useJNI(){
        //C:\Users\cmo\CMakeBuilds\1b5ff312-8a80-b73a-bf3a-7dfe1a7d08a8\build\x64-Debug
        System.load("C:\\Users\\cmo\\CMakeBuilds\\1b5ff312-8a80-b73a-bf3a-7dfe1a7d08a8\\build\\x64-Debug\\hellojni.dll");
        helloJNI("Hello JNI");
    }
    //声明本地java 层的jni方法
    native void helloJNI(String str);
}

2、开发编译对应的动态库

根据上面本地Java 类中的方法签名使用本地语言(代指C/C++)定义实现,此处使用Visual Studio来开发,在使用Visual Studio开发前需要进行一些必要的配置。

2.1、安装cmake 模块并配置Visual Studio JNI开发环境

此处并不是必须的步骤,你也可以自己去配置普通的VS 项目,但是使用cmake Project 更方便些,所以这里推荐先安装cmake 模块(工具——>获取工具和功能),然后在CMakeList里引入jni头文件

# CMakeList.txt: HelloC 的 CMake 项目,包括源和定义
# 此处特定于项目的逻辑。
#
cmake_minimum_required (VERSION 3.8)

# 将源添加到此项目的可执行文件,编译打包后默认生成的是可执行的文件
# add_executable (HelloC "hellojni.cpp")

#jni头文件是在jdk下的,所以需要把对应的jni头文件引入,否则会提示找不到jni.h文件
include_directories("C:/Program Files/Java/jdk1.8.0_91/include")
# windows系统下还需要引入win32下的头文件;而Mac系统需要引入darwin下的
include_directories("C:/Program Files/Java/jdk1.8.0_91/include/win32")
# 配置生成动态库,第一个参数是生成的动态库文件名;因为要给别人使用所以第二个参数必须是 SHARED ;第三个是对应的cpp文件名 多个话 可以使用空格符 连接起来
add_library(hellojni SHARED helloJNI.cpp)

2.2、实现JNI方法的具体逻辑

HelloJNI.h

#pragma once

#include <iostream>
#include <jni.h>

extern "C"
JNIEXPORT void JNICALL Java_com_crazymo_permission_ExampleUnitTest_helloJNI
(JNIEnv *env, jobject, jstring j);

HelloJNI.cpp

#include "helloJNI.h"
//c++中需要以c的方式编译,使用c++ 编写时必须添加
extern "C"
//JNIEnv: 由Jvm传入与线程相关的变量。定义了JNI系统操作、java交互等方法。
//jobject: 表示当前调用对象,即 this , 如果是静态的native方法,则获得jclass
JNIEXPORT void JNICALL Java_com_crazymo_permission_ExampleUnitTest_helloJNI(JNIEnv *env, jobject, jstring j)
{

	const char* str = env->GetStringUTFChars(j, JNI_FALSE);

	printf("C/C++ print:%s", str);
	env->ReleaseStringUTFChars(j, str);
}

编译并全部生成cmake 项目之后(cmake——>全部生成),就在C:\Users\cmo\CMakeBuilds\xxxxx路径下生成对应的dll文件,此处为什么是dll呢,因为这是在Windows平台下运行的,本质上来说和Linux 下的so 没啥区别,都是可以通过System的静态方法加载进来使用的。

这里写图片描述

2.3、在Java 项目中调用JNI

这里写图片描述

四、JNI的基本语法

1、通过javah 命令自动生成本地JNI的头文件

其实本地JNI头文件是根据Java层的JNI接口类方法的包名、类名、方法名以及方法签名来生成的,具有一个统一的格式:JNIEXPORT 返回值类型 JNICALL **Java_**com_crazymo_permission_ExampleUnitTest_helloJNI
*(JNIEnv env, jobject obj, jstring j),其中黑色部分为固定值,每一个JNI方法声明都必须包含的部分,然后再Java_后拼接上全限定的方法名(包括完整包名,再把 “.” 换成_),最后再填写上Java映射到JNI中对应的参数即可,当然如果你不想记忆的话,可以通过javah 命令来完成

javah -o [输出头文件名] [全限定名]

2、JNIEXPORT 和 JNICALL

JNIEXPORT和JNICALL本质上是C/C++ 定义在jni_md.h头文件中的宏。JNIEXPORT在Windows 中被定义为 _declspec(dllexport)。因为Windows编译 dll 动态库规定,如果动态库中的函数要被外部调用,需要在函数声明中添加此标识,表示将该函数导出在外部可以调用。而在 Linux/Unix/Mac os/Android 这种 Like Unix系统中,定义为_attribute ((visibility (“default”))),GCC 有个visibility属性, 启用这个属性gcc -fvisibility=xx时
当-fvisibility=hidden时,动态库中的函数默认是被隐藏的即 hidden, 除非显示声明为_attribute_((visibility(“default”)))。当-fvisibility=default时动态库中的函数默认是可见的.除非显示声明为__attribute__((visibility(“hidden”))).

注意:JNICALL:在类Unix中无定义,在Windows中定义为:_stdcall ,一种函数调用约定

3、JNIEnv

如下图所示,JNIEnv 是在C++环境下是JVM传入的一个结构体(C语言有所不同)它里面定义了JNI系统操作方法,是与线程相关的ThreadLocal线程局部变量,所以线程A不能使用线程B的 JNIEnv。其实可以把JNIEnv简单看成JVM提供给我们在本地语言操作Java对象的的“上下文”(包含了所有必须的函数与JVM交互、访问Java对象的方法),当JVM调用这些JNI函数,就传递一个JNIEnv指针,一个jobject的指针以及任何在Java方法中声明的Java参数。基本上Java程序可以做的任何事情都可以用JNIEnv做到。

这里写图片描述
另外,JNI环境指针(JNIEnv*)作为每个映射为Java方法的本地幔数的第一个参数,使得本地幔数可以与JNI环境交互。这个JNI指针可以存储,但仅在当前线程中有效。其它线程必须首先调用AttachCurrentThread()把自身附加到虚拟机以获得JNI指针。

//把当前线程附加到虚拟机并获取JNI指针:

JNIEnv *env;
(*g_vm)->AttachCurrentThread (g_vm, (void **) &env, NULL);
//当前线程脱离虚拟机:

(*g_vm)->DetachCurrentThread (g_vm);

本地代码不仅可以与Java交互,也可以在Java Canvas绘图,使用Java AWT Native Interface。

4、jobject

jobject表示当前在Java层调用这个非静态JNI方法的类对象即 this , 如果是静态的native方法,则获得对应jclass。

5、JNI与Java语言数据类型映射关系

本地数据类型与Java数据类型可以互相映射。对于复合数据类型诸如对象,数组,字符串、集合等,就必须用JNIEnv中的方法来显示转换操作。有些可以互换的如jint也可使用 int,不需任何类型转换。但是,有些则不能,比如Java字符串、数组与本地字符串、数组是不同的,如果在使用char *代替了jstring,程序可能会导致JVM崩溃

Java类型 本地类型 描述
boolean jboolean C/C++8位整型
byte jbyte C/C++带符号的8位整型
char jchar C/C++无符号的16位整型
short jshort C/C++带符号的16位整型
int jint C/C++带符号的32位整型
long jlong C/C++带符号的64位整型
float jfloat C/C++32位浮点型
double jdouble C/C++64位浮点型
void void
Object jobject 任何Java对象,或者没有对应java类型的对象
Class jclass Class对象
String jstring 字符串对象
Object[] jobjectArray 任何对象的数组
boolean[] jbooleanArray 布尔型数组
byte[] jbyteArray 比特型数组
char[] jcharArray 字符型数组
short[] jshortArray 短整型数组
int[] jintArray 整型数组
long[] jlongArray 长整型数组
float[] jfloatArray 浮点型数组
double[] jdoubleArray 双浮点型数组

注意:其实在JNI中,可以说万物基于jobject的,所以Object 下面的引用类型几乎都可以用jobject来表示。

6、方法签名

方法签名十分重要,因为在本地语言操作Java对象,基本都是通过反射机制来实现的,所以方法签名相当于是每一个方法的唯一Id,所以JNI给每种类型约定了各自的签名标识符。通用格式——(参数1类型签名参数2类型签名)返回值类型签名 ,每个类型签名之间是没有空格的比如:

///public string addTail(String tail, int index)
(Ljava/util/String;I)Ljava/util/String;

///public int addValue(int index, String value,int[] arr)
(ILjava/util/String;[I)I

6.1、Java基本类型及对应的类型签名

Java类型 类型签名(signature)
boolean Z
short S
float F
byte B
int I
double D
char C
long J
void V
引用类型 L + 全限定名 + ;(包含分号)
数组 [+类型签名

注意:签名 " L 全限定名(fully-qualified-class ); " 是由该名字对应的完整类名(包含分号)。而带前缀[的签名表示该类型的数组,多维数组则是 n个[ +该类型的类型签名 , N代表的是几维数组。多种类型的话依次拼接起来即可

String类型的签名为: Ljava/lang/String;    
  
数组的签名 :[ + 其类型签名 (引用类型需要再末尾添加上 分号)
int[ ][I  
float[ ][F  
int  [ ][ ][[I  
float[ ][ ][[F 
String[ ][Ljava/lang/String;  
Object[ ][Ljava/lang/Object;  
 

6.2、通过javap 命令查看指定JNI方法的签名

javap命令是操作Java本地接口类的class字节码文件的,所以第一步得先cd 进入 class所在的目录 ,然后再执行: javap -s 全限定类名,最后查看输出的 descriptor
这里写图片描述

//如果已经在当前包名文件夹下则不需要写完整的类名
javap -s 全限定类名

7、JVM内存泄漏的类型

JAVA 编程中的内存泄漏,从泄漏的内存位置角度可以分为两种:JVM 中 Java Heap 的内存泄漏JVM 内存中 native memory 的内存泄漏

7.1、Java Heap的内存泄漏

Java 对象是存储在 JVM 进程空间中的 Java Heap 中,且随着运行过程中动态变化。若 Java 对象越来越多,自然占据 Java Heap 的空间也越来越大,随之JVM 会在运行时相应地扩充 Java Heap 的容量。当Java Heap 容量扩充到上限且在 GC 后仍然没有足够空间分配新的 Java 对象,则会抛出 out of memory 异常进而导致 JVM 进程崩溃。通常程序过于庞大导致过多 Java 对象的同时存在或者程序编写的错误才会导致Java Heap的内存泄漏
###7.2、Native memory 的内存泄漏
JVM 中 native memory 的内存泄漏则是从操作系统角度看的。JVM 在运行时和其它进程没有本质区别,在系统级别上,它们都具有同样的调度机制,同样的内存分配方式和同样的内存格局。而JVM 进程空间中Java Heap 以外的内存空间则称为 JVM 的 Native memory区。(存储着进程的很多资源如载入的代码映像,线程的堆栈,线程的管理控制块,JVM 的静态数据、全局数据、 JNI 程序中 native code 分配到的资源等)一般在 JVM 运行中,多数进程资源从 Native memory 中动态分配的,当越来越多的资源在 Native memory 中分配,占据的Native memory 空间越来越大,当达到 Native memory 上限时,JVM 会抛出OOM导致 JVM 进程异常退出,而此时 Java Heap 往往还没有达到上限,以下几种为导致 JVM 的 native memory 内存泄漏的常见原因:

  • JVM 在运行中过多的线程被创建,并且在同时运行。
  • JVM 为线程分配的资源就可能耗尽 native memory 的容量。
  • JNI 编程错误也可能导致 native memory 的内存泄漏。
    除了以上两种之外,还有一种是不遵守Native Code 编程规范所造成的自身的内存泄漏,因为JNI 编程首先是一门具体的编程语言,每门编程语言环境都实现了自身的内存管理机制。因此JNI 程序开发者要遵循 native 语言本身的内存管理机制,避免造成内存泄漏。总之,所有在 native 语言编程中应当注意的内存泄漏规则,在 JNI 编程中依然适用。

8、JNI引用

为了更好的管理内存,在JNI 规范中定义了三种引用:局部引用(Local Reference)全局引用(Global Reference)弱全局引用(Weak Global Reference)。 JNI一般是以拷贝值的形式向Java层进行传递的,所以即使是在本地方法层通过DeleteXXX 释放对应的引用了也不会影响到Java层的使用。简而言之,Java 和 本地方法层的对象相对独立。比如说在本地方法层通过反射创建出一个对象即使是释放掉了之后,在Java层依然是可以使用的。

8.1、局部引用(Local Reference)

大多数JNI函数(比如NewObject、FindClass、NewStringUTF 等等)会自动创建局部引用。无法跨线程、跨方法使用局部引用因为它只有在创建它的本地方法返回前有效,本地方法返回后,局部引用会通过以下两种方式被释放

  • 本地方法执行完毕后JVM自动释放
  • 开发者通过DeleteLocalRef方法手动释放(在确认不再需要使用的时候,主动释放往往是一个非常好的编程习惯)

jclass clz;//这样写也有问题,局部引用就会有问题
extern "C"
JNIEXPORT jstring JNICALL
Java_com_crazymo_jnitest_MainActivity_testLocal(JNIEnv *env, jobject instance) {
   /**
   *这样使用局部引用,会存在问题:
   * 当第一次调用的时候 通过FindClass 找到对应的类并为jclass类型的局部引用进行初始化即clz 引用着Bean,而当第一次方法执行完毕之后,clz内部引用就被自动释放掉了(可以看成变成了悬空指针,指向的内存中的数据已经被释放掉了),第二次再执行时就会出问题,但是如果变成全局引用这样写就没有问题
   */
   /// static jclass clz;  有问题
    if(clz == NULL){
       clz= env->FindClass("com.crazymo.jnitest.Bean");
       //变成全局引用这样写就没有问题
///       clz=static_cast<jclass>(env->NewGlobalRef(clz);
///env->DeleLocalRef(clz);
    }
	...
}

8.2、全局引用(Global Reference)

全局引用可以跨方法、跨线程使用,直到它被手动释放才会失效 ,通常是由 NewGlobalRef 函数创建的。局部引用只在本地方法执行时有效,当本地方法执行完后则自动失效,它们所引用的 Java 对象的 引用计数会相应减 1,不会造成 Java Heap 中 Java 对象的内存泄漏。而全局引用则是对Java 对象的引用一直有效,因此它们引用的 Java 对象会一直存在 Java Heap 中。如果一定要使用全部引用务必确保在不用的时候释放。否则,全局引用的 Java 对象将永远停留在 Java Heap 中,造成 Java Heap 的内存泄漏。

/**
*extern "C" ————代表指定C语言代码用C++ 环境进行编译通俗来说就是在C++中运行C代码,也可使用{} 圈中代码块
*/
extern "C"
JNIEXPORT jstring JNICALL
Java_com_crazymo_jnitest_MainActivity_testGlobal(JNIEnv *env, jobject instance) {
   
    static jstring globalStr;
    if(globalStr == NULL){
        jstring str = env->NewStringUTF("C++字符串");
        //删除全局引用调用  DeleteGlobalRef
        globalStr = static_cast<jstring>(env->NewGlobalRef(str));//把局部引用str 存到全局引用中
        //可以释放,因为有了一个全局引用使用str,局部str也不会使用了
        env->DeleteLocalRef(str);
    }
    return globalStr;
}

8.3、弱全局引用(Weak Global Reference)

与全局引用类似,弱引用也可以跨方法、线程使用。与全局引用不同的是,弱引用不会阻止GC回收它所指向的JVM内部的对象 。所以对Class进行弱引用是非常合适,因为Class一般直到程序进程结束才会卸载。注意在使用弱引用时,必须先检查缓存过的弱引用是指向活动的对象,还是指向一个已经被GC的对象

extern "C"
JNIEXPORT jclass JNICALL
Java_com_crazymo_jnitest_MainActivity_testWeakGlobal(JNIEnv *env, jobject instance) {
    static jclass globalClazz = NULL;
    //对于弱引用 如果引用的对象被回收返回 true,否则为false
    //对于局部和全局引用则判断是否引用java的null对象
    jboolean isEqual = env->IsSameObject(globalClazz, NULL);
    if (globalClazz == NULL || isEqual) {
        jclass clazz = env->GetObjectClass(instance);
        //删除使用 DeleteWeakGlobalRef
        globalClazz = static_cast<jclass>(env->NewWeakGlobalRef(clazz));
        env->DeleteLocalRef(clazz);
    }
    return globalClazz;
}

未完待续……

猜你喜欢

转载自blog.csdn.net/CrazyMo_/article/details/82050526