篇幅有限
完整内容及源码关注公众号:ReverseCode,发送 冲
JNI简述
JNI:Java Native Interface的缩写,通常翻译为JAVA本地 接口。从Java1.1开始,Java Native Interface(JNI)标准成为java平台的一部分,它允许Java代码和其他语言写的代码进行交互。JNI一开始是为本地已编译语言,尤其是 C和C++而设计的,但是它并不妨碍你使用其他语言,只要调用约定受支持就可以了。
JNI不是Android特有,windows、linux等凡是有JVM的地方都支持JNI。Android的Dalvik/ART虚拟机同样支持JNI标准。通过JNI,便可以打通Android里的两个世界:JAVA世界和Native世界。可以说,JNI是Java和Native世界的桥梁。而背后的一切都由Dalvik/ART虚拟机来驱动。
JAVA特点
特性:简单、面向对象、分布式、编译和解释性、健壮性、跨平台性(Write Once,Run Anywhere)、多线程、动态性等等
但是:性能较低、易于逆向分析等问题。
因此,如何能够提高java程序的性能以及如何能够实现对海量已有C/C++代码库的复用问题?
NDK简述
NDK 即Native Development Kit,因此称为“NDK”, 完成使用JNI提供的接口与java层的交互。
NDK是一系列工具的集合。
- NDK提供了工具链,帮助开发者快速开发以及调试C(或C++)的动态库。
NDK提供了一份稳定、功能有限的API库。
- 不同于linux的glibc,Android采用的是Google Bionic Libc,大部分api是一致的。
一些重要逻辑、算法可以采用C/C++、甚至是汇编的形式通过NDK的工具链最终编译生成动态库,最后通过JNI完成和Dalvik/ART虚拟机环境中的Java代码的交互
使用NDK开发的so不再具有跨平台特性,需要编译提供不同平台支持。ABI:ApplicationBinary Interface
优点
- 运行效率高:Android开发的原生代码不需要在Dalvik/ART虚拟机下运行,直接运行的汇编代码
- 代码安全性高:Java层代码很容易被反编译,而C/C++反编译难度较大,代码混淆比如使用ollvm,字符串加密等保护汇编代码。同时,相对于针对Java代码的混淆保护来说,可以运用当前一切C/C++代码保护技术,如控制流混淆,加壳等;
- 易于移植,可复用海量代码库:当时用第三方 C/C++开源库时便于移植,可以充分复用C/C++海量代码库
缺点
- 开发效率低:和Android的Java世界(Dalvik/ART)交互繁琐,无法直接使用Android系统框架提供的丰富API
- 开发难度大:对于当前市面的APP开发人员来说掌握JNI开发存在一定困难
- 稳定性难以保证:对开发人员要求较高,如存在内存泄漏出现的频率搞,修正bug困难。
对于Android中的java函数,在Dalvik时代分为解释模式和JIT模式,JIT根据代码运行过程中的频度进行编译提升运行效率,4.4后的art中新增dex2oat流程,直接对dex中所有的函数进行编译,而不是像4.4在运行过程中JIT对热点代码编译。在app在运行前将字节码编译成汇编代码,能够提升app运行效率,必然在安装时需要对dex中函数编译很耗时,耗电,因此生成代码量很大,占用很大的内存,安装耗时,如在dexclassloader动态加载时dex2oat编译过程很耗时,很耗电,很占存储。在7.0后结合AOT和AIT混合编译。
- 最初安装应用时不进行任何 AOT 编译。应用前几次运行时,系统会对其进行解译,并对经常执行的方法进行 JIT 编译。
- 当设备闲置和充电时,编译守护程序会运行,以便根据在应用前几次运行期间生成的配置文件对常用代码进行 AOT 编译。
- 下一次重新启动应用时将会使用配置文件引导型代码,并避免在运行时对已经过编译的方法进行 JIT 编译。在应用后续运行期间经过 JIT 编译的方法将会添加到配置文件中,然后编译守护程序将会对这些方法进行 AOT 编译。
Java函数运行模式
- 纯解释模式下执行
- JIT模式
- 经过dex2oat编译后在quick模式下运行
注意:Android 7.0(代号Nougat,简称N)开始结合用AOT,即时(JIT编译和配置文件引导型编译)。因此一个java函数可能运行在解释模式,JIT或者quick模式
JNI与NDK的关系
NDK开发1
as新建项目类型选择Native C++,项目名为ndk01
- extern C : 由于C++支持重载,C++类中的函数会有name mangling
- jni函数的参数问题:JNIEnv* 和jobject以及JNIEnv*和jclass
- JNICALL: 空宏
- JNIEXPORT: __attribute__((visibility(“default”))),代表当前函数符号需要导出,与之对应的为hidden隐藏符号信息
- AndroidStudio默认生成函数名很长,如:Java_com_kanxue_ndk01_MainActivity_stringFromJN
1 | cp /system/lib/libc.so /sdcard |
MainActivity.java
1 | int result = this.myfirstjni(10); 在onCreate中调用 |
一般函数
修改native-lib.cpp.cpp编译后打开libnative-lib.so
1 | // 对函数名称按照c++的name mangling编译后修改函数名 |
JNIEnv: 是一个JNI接口指针,指向了本地方法的函数表,该函数表中每一个成员指向了一个JNI函数。
静态函数
testc.h
1 | #ifndef NDK01_TESTC_H |
testc.c
1 | #include <stdbool.h> |
native-lib.cpp
1 | extern "C" { |
MainActivity.java
1 | String gbkStr = "中国人民"; //源码文件是GBK格式,或者这个字符串是从GBK文件中读取出来的, 转换为string 变成unicode格式 |
JNI类型映射
基础数据类型
Native Type这8种数据类型可以C/C++中直接使用,包括域描述符,类描述符斜杠区分,函数描述符(参数域描述符叠加)返回类型描述符
Java类型 | 本地类型(Native Type) | 描述 | 域描述符 |
---|---|---|---|
boolean | jboolean | C/C++无符号8位整型(unsigned char) | Z |
byte | jbyte | C/C++带符号8位整型(char) | B |
char | jchar | C/C++无符号8位整型(unsigned short) | C |
short | jshort | C/C++带符号8位整型(short) | S |
int | jint | C/C++带符号8位整型(int) | I |
long | jlong | C/C++带符号8位整型(long) | J |
float | jfloat | C/C++32位浮点型(float) | F |
double | jdouble | C/C++64位浮点型(double) | D |
引用数据类型为L+该类型的类描述符+;String
类型域描述符为Ljava/lang/String
, int[]
描述符为[I
, float[]
描述符为[F
,String[]
描述符为[Ljava/lang/String;
,Object[]
域描述符为[Ljava/lang/Object;
,int[][]
[][]描述符为[[I
,float[][]
描述符为[[F
引用数据类型
JNI数据类型 | java数据类型 |
---|---|
jobject | java.lang.Object |
jclass | java.lang.Class |
jstring | java.lang.String |
jthrowable | java.lang.Throwable |
MainActivity.java
1 | public native int myfirstjni(int a); |
native-lib.cpp
1 | extern "C" JNIEXPORT jint JNICALL |
JNI其他类型
1 | 成员域ID,成员方法ID |
jni字符串操作
jstring NewStringUTF( const char* bytes)):函数使用给定的C字符串创建一个新的JNI字符串( jstring),不能为构造的 java. lang String分配足够的内存, NewStringUTF会抛出一个 OutOfMemoryError异常,并返回一个NULL
const char* GetStringUTFChars(jstring string, boolean* is Copy):函数可用于从给定的Java的 jstring创建新的C字符串(char*)。如果无法分配内存,则该函数返回NULL。检査NULL是一个好习惯。不要忘记检查。因为该函数需要为新诞生的UTF8字符串分配内存,这个操作有可能因为内存太少而失败。失败时, GetStringUTFChars会返回NULL,并抛出一个 OutofMemoryError异常,在不使用 GetStringUTFChars()返回的字符串时,需要来释放内存和引用以便可以对其进行垃圾回收,因此之后应始终调用 ReleaseStringUTFChars()
jsize GetStringUTFLength(jstring string):用于获取 jstring的长度
MainActivity.java
1 | String resultString=testjstringapis("hello from java"); 在onCreate中调用 |
native-lib.cpp
1 | extern "C" JNIEXPORT jstring JNICALL |
api如下
1 | jstring(* NewString)(JNIEnv*, const char*,jsize);//新建一个 unicode编码字符串 |
NDK开发2
ANDROID: 使用OATDUMP反编译OAT文件:用来对dex2oat编译生成oat文件进行反汇编,看到dex2oat编译的结果
as新建项目类型选择Native C++,项目名为PerformanceTest01
MainActivity.java
1 | int result = java_add(1000000); // 在onCreate中调用 |
编译好后安装到手机上,ls /data/app/com.kanxue.performancetest01-*/oat/arm64
可以看到base.dex和base.vdex
ART 包括一个编译器(dex2oat
工具)和一个为启动 Zygote 而加载的运行时 (libart.so
)。dex2oat
工具接受一个 APK 文件,并生成一个或多个编译工件文件,然后运行时将会加载这些文件。文件的个数、扩展名和名称因版本而异,但在 Android O 版本中,将会生成以下文件:
.vdex
:其中包含 APK 的未压缩 DEX 代码,以及一些旨在加快验证速度的元数据。.odex
:其中包含 APK 中已经过 AOT 编译的方法代码。.art (optional)
:其中包含 APK 中列出的某些字符串和类的 ART 内部表示,用于加快应用启动速度。
1 | adb pull /sdcard/base.dex |
1 | oatdump --oat-file=base.dex >> /sdcard/oatdump1.txt |
说明还没有被dex2oat编译。nexus 6p默认Android7.0在最初安装时不进行AOT编译,通过查看7.0以前nexus 5的ART来对比编译后的汇编代码,安装完直接编译。编译好后安装到手机上,ls /data/app/com.kanxue.performancetest01-*/oat/arm
可以看到base.odex
1 | oatdump --oat-file=base.dex >> /sdcard/6.0oatdump.txt |
说明6.0时smali正常显示,CODE部分已经编译生成汇编代码,当6.0安装完该函数必然运行在quick下,并不会解释执行原有的java代码。
解释模式
MainActivity.java
1 | 在onCreate中调用两个函数对比时间效率 |
native-lib.cpp
1 | extern "C" JNIEXPORT jint JNICALL |
首先比较在纯解释模式下运行java函数和jni函数花销,自然不能让art下的dex2oat的编译流程完成对java函数的编译。在8.0后app安装完后的函数还是没有被编译,运行在解释模式下的。运行了一定时间后,某些函数就已经被编译了。自然导致在7.0以后函数运行在不同模式下都有可能。首先要让函数运行在解释模式下,可以通过禁用art下的dex2oat的编译流程, 或者使用4.4之前的dalvik的执行环境,必然是在一个解释模式下的。那么使用4.4的hammerhead安装该app,自然默认运行在dalvik下。
1
2 result:1784293664--23747499 smali指令在纯解释模式,jni大致是java函数的5倍
result:1784293664--4924895
quick模式
接下来对比art下经过dex2oat编译后的quick模式下java函数执行的时间花销和jni函数的时间花销,首先保证java函数被dex2oat编译,且函数运行在quick模式下,也就是编译后的汇编代码。7.0以后一开始是没有编译,是在解释模式下,只有在运行一段时间后,调用频率比较高时在充电或者闲置时会被编译。自然确保确实被编译运行在dex2oat的quick模式下,可以使用7.0以前的art测试,可以使用6.0的hammerhead,安装好app后这个时候运行的java函数就会被编译成了汇编了。
1
2 result:1784293664--5778594 java函数经过dex2oat编译以后运行在quick模式下时花销是jni花销的一倍多一点
result:1784293664--3798072
性能提升总结
以上分别比较相同代码逻辑的java函数和jni函数的时间代价。
java函数运行模式
- 在4.4以前的dalvik下运行或者art下利用hook禁用掉dex2oat过程强制让其运行在解释模式
- 使用Android6.0测试java函数在quick模式(运行dex2oat编译以后的汇编代码)
jni函数实现
- 和java函数相同的逻辑,纯C/C++实现
- 和java函数相同的逻辑,经过JNI提供的接口频繁调用java函数
Java反射
java反射:java反射机制是在运行状态中,对于任意类,都能够知道这个类的所有属性和方法;对于任意类的静态属性和方法,都能够完成对静态属性的获取和设置以及静态方法的调用;对于任意个对象,都能够调用它的任意方法和属性;这种动态获取信息以及动态调用对象方法的功能称为ava语言的反射机制。
在日常的第三方应用开发过程中,经常会遇到某类的某个成员变量、方法或是属性是私有的或是只对系统应用开放,这时候就可以利用Java的反射机制通过反射来获取所需的私有成员或是方法。而对于public类型的成员变量和方法和属性都可以使用反射来进行访问。
类名 | 用途 |
---|---|
Class类 | 代表类的实体,在运行的Java应用程序中表示类和接口 |
Field类 | 代表类的成员变量(成员变量也成为了类的属性) |
Method类 | 代表类的方法 |
Constructor类 | 代表类的构造方法 |
Class
代表类的实体,在运行的Java应用程序中表示类和接口。在类中提供了很多有用的方法。
获取类中属性相关方法
获取类中注解相关方法
获取类中构造器相关方法
获取类中方法的相关方法
获取类中其他重要的方法
Field
Field代表类的成员变量(成员变量也称为类的属性)
Method
Constructor
破坏本身的封装性和安全性,访问 private域和方法类 AccessibleObject中有函数 public void setAccessible( boolean flag),该函数在传入tue作为参数后,让访问 private修饰的域和函数成为可能。而Method、Feld和 Constructot类共同继承了 AccessibleObject类,该基类有两个 setAccessible方法能在运行时压制Java语言访问控制检查(Javalanguage access control checks),从而能任意调用被私有化保护的方法域和构造方法
新建C++项目ReflectionTest3
Test.java
1 | public class Test { |
MainActivity.java
1 | testField(); 在onCreate中调用 |
native-lib.cpp
1 | extern "C" JNIEXPORT jstring JNICALL |
JavaVM与JNIEnv
JNIEnv:指Java Native Interface Environment,是一个JNI接口指针,指向了本地方法 的一个函数表,该函数表中的每一个成员指向了一个JNI函数,本地方法通过JNI函数 来访问JVM中的数据结构。
JNIEnv 表示 Java 调用 native 语言的环境,是一个封装了几乎全部 JNI 方法的指针。
JNIEnv 只在创建它的线程生效,不能跨线程传递,不同线程的 JNIEnv 彼此独立。
native 环境中创建的线程,如果需要访问 JNI,必须要调用 AttachCurrentThread 关联,并使用 DetachCurrentThread 解除链接
JavaVM 和 JNIEnv 在 C 语言环境下和 C++ 环境下调用是有区别的,在C的定义中,env是一个两级指针,而在C++的定义中,env是个一级指针,主要表现在:
1 | C风格:(*env)->NewStringUTF(env, “Hellow World!”); |
JavaVM获取方式有两种,1-在JNI_Onload中作为参数获得,2-通过JNIEnv的GetJavaVM函数获得
1 | JavaVM *globalVM = nullptr; |
JNIEnv获取方式有三种,
1-如果当前线程绑定了一个JNIEnv,可以通过JavaVM获取比如主线程中的JNI_Onload内容
1 | JNIEnv* env = NULL; |
2-通过JNI函数的参数传入获取,对于任何一个JNI函数来说,第一个参数都是JNIEnv指针
3-在非主线程中时需要通过当前进程的JavaVM调用AttachCurrentThread(&env,NULL )来获取
注意:
JNIEnv是与一个ClassLoader绑定的,当时用env->FindClass()进行类的查询和加载时便是时用的这个ClassLoader
JNIEnv是当前Java线程的执行环境,一个JVM对应一个JavaVM结构,而一个JVM中可能创建多个Java线程,时用pthread_create新建的线程当时用AttachCurrentThread(&env, NULL)获取到JNIEnv后,该JNIEnv的ClassLoader并不是主线程的ClassLoader,因此也无法加载app自己的class。
JNIEnv 和 JavaVM 其实只是对 JNINativeInterface 和 JNIInvokeInterface 的一层封装,实际调用和操作的还是 JNINativeInterface 和 JNIInvokeInterface 里的方法。
C实现
1 | //extern "C" |
C++实现
1 | JavaVM *globalVM = nullptr; |
JavaVM 是虚拟机在 JNI 层的代表,一个进程只有一个 JavaVM,所有 的线程共用一个 JavaVM。
- JNIInvokeInterface_结构封装和JVM相关的功能函数,如销毁JVM,获得当前线程的Java执行环境。
- 在C和C++中JavaVM的定义有所不同,在C中JavaVM是JNIInvokeInterface_类型指针,而在C++中又对 JNIInvokeInterface_进行了一次封装。
- 推荐用C++来编写JNI代码,比C中少了一个参数
虚拟机是唯一的,但是可以有很多线程,所以使用JNIEnv和一个线程绑定。
1.1. 如果当前线程已绑定了一个JNIEnv,可以通过jint (JNICALL GetEnv)(JavaVM vm, voidpenv, jint version); 获取。
1.2. 如果未绑定,调用jint (JNICALL AttachCurrentThread)(JavaVM *vm,void *penv, void *args); 主动绑定,线程结束或者不需要的话解除绑定。
JNIEnv是当前Java线程的执行环境,一个JVM对应一个JavaVM结构,而一个JVM中可能创建多个Java线程,每个线程对应一个JNIEnv结构,它们保存在线程本地存储中。因此,不同的线程的JNIEnv是不同,也不能相互共享使用。
JNIEnv结构也是一个函数表(可以这么理解),在Native代码中通过JNIEnv的函数表来操作Java数据或调用Java方法。
子线程和主线程的JNIEnv并不是都在调用方的ClassLoader当中,因此,需要特别注意env->FindClass的用法
JNI新建对象
1 | extern "C" JNIEXPORT void JNICALL |
JNI访问函数
和Java反射类访问类属性的对比:
1.Java反射中获取属性时只需要传入属性名即可,而jni中还需要传入该属性的签名信息
2.Java反射中在对private类型属性访问时需要先取消安全检查,即调用setAccessible(true)才能访问,而jni中不需要
3.Java反射中对普通函数使用Method,而对构造函数使用Contructor,在jni中构造函数也是普通函数,依然使用jmethodID,只不过函数名是<init>
构造函数
对于构造函数的调用来说只能通过NewObject和AllocObject来调用,因为构造函数本身首先不是静态函数,自然不能通过类名进行调用;其次如果按照一般的函数进行调用,需要传入对象,而此时还没有对象,因此只能是一种特例。
Test testobj = (Test) callInit();
1 | extern "C" JNIEXPORT jobject JNICALL |
静态函数
1 | extern "C" JNIEXPORT void JNICALL |
非静态域
1 | extern "C" JNIEXPORT void JNICALL |
非静态函数
1 | extern "C" JNIEXPORT void JNICALL |
onCreate
1 | //protected native void onCreate(Bundle savedInstanceState); |
引用
Java当中的内存管理:透明的当新建类的实例时,值需要在创建完这个类的实例之后,拿着这个引用访问它的所有数据成员(属性及方法)就可以了,Java来说有一儿垃圾回收线程即GC线程负责将一些不在使用的对象回收。
C/C++中的内存管理:需要编码人员自己进行内存管理,如在C++中new一个对象,使用完了还要做一次delete操作,malloc一次同样也要调用free来释放响应的内存,否则就会有内存泄漏。
局部引用
通过NewLocalRef和各种JNI接口创建(FindClass,NewObject,GetObjectClass和NewCharArray等),会阻止GC回收所引用的对象。局部引用只能在当前函数中使用,函数返回后局部引用所引用的对象会被JVM自动释放,或调用DeleteLocalRef手动释放,因此,局部引用不能跨函数使用,不能跨线程使用。
- 函数返回时自动释放
- 手动调用deletelocalref释放
- 使用jni提供的一系列函数来管理局部引用的生命周期,ensurelocalcapacity,newlocalref,pushlocalframe,poplocalframe,deletelocalref
如果需要创建更多的引用,可以通过调用ensurelocalcapacity函数,确保当前线程中创建指定数量的局部引用,如果创建成功则返回0否则创建失败,并抛出outofmemoryerror异常
1 | for (int i = 0; i < 2048; i++) { |
全局引用
调用NewGlobalRef基于局部引用创建阻GC回收所引用的对象。全局引用可以跨函数,跨线程使用。ART不会自动释放,必须调用DeleteGlobalRef手动释放,DeleteGlobalRef(g_cls_string),否则会出现内存泄漏。
jclass tmpjclass = env->FindClass("com/kanxue/reflectiontest/Test");
testjclass = static_cast<jclass>(env->NewGlobalRef(tmpjclass));
弱全局引用
调用NewWeakGlobalRef基于局部引用或全局引用创建不会阻止GC回收所引用的对象,可以跨方法,跨线程使用。但与全局引用很重要不同的一点是,弱引用不会组织GC回收它引用的对象,但是引用也不会自动释放,在ART认为应该回收它的时候(比如内存紧张的时候),进行回收而被释放,或调用DeleteWeakGlobalRef手动释放。
Java层函数在调用本地jni代码的时候,会维护一个局部引用表(该引用表并不是无限的),一般jni函数调用结束后,ART会释放这个引用,如果是简单的函数就不需要注意这些问题,让他们自己释放,基本没什么问题,但是如果函数里面有注入大量的循环的操作的话,那么程序可能会因为局部引用太多而出现异常情况。
PushLocalFrame可以为当前函数中需要用到的局部引用创建一个引用堆栈,而PopLocalFrame负责销毁栈中所有的引用。因此push、poplocalframe函数对提供了对局部引用生命周期更方便的管理,而不需要时刻关注获取一个引用后,再调用deletelocalref来释放引用,在调用poplocalframe销毁当前frame中的所有引用前,如果第二个参数的result不为空,会有result生成一个新的局部引用,再把这个新生成的局部引用存储到上一个frame中。
1 | jint testPushAndPopLocalFrame(JNIEnv *env) { |
动态注册
extern “C” JNIEXPORT 静态注册时,IDA可直接反编译函数,且函数名很长。对于任意一个jni函数来说,在该函数被调用前,必须要完成java函数与so中地址的绑定,这个绑定过程是被动的,即由Dalvik、Art虚拟机在调用前查找并完成地址的绑定,所以静态函数名JAVA+包名+类名+方法名,简单明了单不够安全,名字过长,很容易被反编译软件直接定位地址。也可以是主动的,即由app自己完成地址的动态绑定。
动态注册通过RedisterNatives方法手动完成native方法和so中方法的绑定,虚拟机通过函数映射关系直接找到响应方法。通常我们在JNI_Onload方法中完成动态注册,事实上只需要在jni函数被调用前的任意时机完成注册即可,甚至多次注册到不同地址都可以。
RegisterNatives函数返回值成功则返回JNI_OK(0),失败则返回一个负值,需要三个参数:注册jni函数所属的jclass,JNINativeMethod数组指针,注册的jni函数个数。
MainActivity
1 | Test testobj=new Test(); |
重复绑定
1 | extern "C" void _init(void) { |
Dalvik
1 | 709static bool dvmRegisterJNIMethod(ClassObject* clazz, const char* methodName, |
method->nativeFunc = func;动态注册就是为了完成java函数在Dalvik中的method结构体中nativeFunc指针的绑定。
接下来通过手动修改aosp源码添加log
1 | 2459static jint RegisterNatives(JNIEnv* env, jclass jclazz, |
1 | 4559void dvmSetNativeFunc(Method* method, DalvikBridgeFunc func, |
编译好后,重新刷system.img即可。加固app查看日志信息查看Dalvik主动注册原理。
1 | source build/envsetup.sh |
ART
1 | 148 static jint RegisterNatives(JNIEnv* env, jclass java_class, const JNINativeMethod* methods, |
接下来通过手动修改aosp源码添加log
1 | 2148 static jint RegisterNatives(JNIEnv* env, jclass java_class, const JNINativeMethod* methods, |
编译刷机
1 | source build/envsetup.sh |
很多加壳时机就是在init/initarray或JNI_Onload中实现。init>initarray>JNI_Onload顺序