编者按:随着移动设备硬件能力的提升,Android系统开放的特质开始显现,各种开发的奇技淫巧、黑科技不断涌现,InfoQ特联合《深入理解Android》系列图书作者邓凡平,开设深入理解Android专栏,探索Android从框架到应用开发的奥秘。
这个选题很大,但并不是一开始就有这么高大上的追求。最初之时,只是源于对Xposed的好奇。Xposed几乎是定制ROM的神器软件技术架构或者说方法了。它到底是怎么实现呢?我本意就是想搞明白Xposed的实现原理,但随着代码研究的深入,我发现如果不了解虚拟机的实现,而仅简单停留在Xposed的调用流程之上,那真是对Xposed最大的不敬了。另外,歪果仁为什么能写出Xposed?Android上的Java虚拟机对他们来说应该也是相对陌生的,何以他们能做而我们没有人搞出这样的东西?
所以,在研究Xposed之后,我决定把虚拟机方面的东西也来研究一番。诚如我在很多场合中提到的关于Android学习的三个终极问题(其实对其他各科学习也适用):学什么?怎么学?学到什么程度为止?关于这三个问题,以本次研究的情况来看,回答如下:
除了这三个问题,其实还有一个隐含的疑问,学完之后有什么用呢?
言归正传,现在开始正式介绍dalvik,请牢记关于它的学习目标和学习程度。
你也可以下载本专题对应的demo代码用于学习。
Class文件是理解Vm实现的关键。关于Class文件的结构,这里介绍的内容直接参考JVM规范,因为它是最权威的资料。
Oracle的JVM SE7官方规范:https://docs.oracle.com/javase/specs/jvms/se7/html/
还算很有良心,纯网页版的,也可以下载PDF版。另外,周志明老师曾经翻译过中文版的JVM规范,网上可搜索到。
作为分析Class文件的入口,我在Demo示例中提供了一个特别简单的例子,代码如图1所示:
TestMain类的代码简单到不行,此处也不拟多说,因为没有特殊之处。
当我们用eclipse编译这个类后,将得到bin/com/test/TestMain.class。这个TestMain.class就是我们要分析的Class文件了。
Class文件到底是什么东西?我觉得一种通俗易懂的解释就是:
在某种哲学意义上看,java源文件和处理得到的class文件是同一种东西......
那么,这个给VM使用的class文件,其内部结构是怎样的呢?Jvm规范很聪明,它通过一个C的数据结构表达了class文件结构。这个数据结构如图2所示:
请大家务必驻足停留片刻,因为搞清楚图2的内容对后续的学习非常关键。图2的ClassFile这个数据结构真得是太容易理解了。相比那些native的二进制程序而言,ClassFile的组织结构和Java源码的组织结构匹配度非常高,以致于我第一眼看到这个结构体时,我觉得自己差不多就理解了它:
Class文件用javap工具可以很好得解析成图2那样的格式,我这里替大家解析了一把,结果如图3所示(先显示部分内容):
注意,解析方法为:javap -verbose xxxx.class
先来看看常量池。
常量池看起来陌生,其实简单得要死。注意,count_pool_count是常量池数组长度+1。比如,假设某个Class文件常量池只有4个元素,那么count_pool_count=5)。
javap解析class文件的时候,常量池的索引从1算起,0默认是给VM自己用得,一般不显示0这一项。这也是为什么图3中常量池第一个元素以#1开头。所以,如果count_pool_count=5的话,真正有用的元素是从count_pool[1]到count_pool[4]。
常量池数组的元素类型由下面的代码表示:
cp_info { //特别注意,这是介绍的cp_info是相关元素类型的通用表达。
u1 tag; //tag为1个字节长。不论cp_info具体是哪种,第一个字节一定代表tag
u1 info[]; //其他信息,长度随tag不同而不同
}
//tag取值,先列几个简单的:
tag=7 <==info代表这个cp_info是CONSTANT_Class_info结构体
tag=9<==info代表CONSTANT_Fieldrefs_info结构体
tag=10<==info代表CONSTANT_Methodrefs_info结构体
tag=8<==info代表CONSTANT_String_info结构体
tag=1<==info代表CONSTANT_Utf8_info结构体
在JVM规范中,真正代表字符串的数据结构是CONSTANT_Utf8_info结构体,它的结构如下代码所示:
CONSTANT_Utf8_info {
u1 tag;
u2 length; //下面就是存储UTF8字符串的地方了
u1 bytes[length];
}
大家看图3中常量池的内容,比如#2=Utf8 com/test/TestMain 这行表示:
数组第二个元素的类型是CONSTANT_Utf8_info,字符串为“com/test/TestMain”
下面我们看几个常用的常量池元素类型
这个类型是用于描述类信息的,此处的类信息很简单,就是类名(也就是代表类名的字符串)
CONSTANT_Class_info {
u1 tag; //tag取值为7,代表CONSTANT_Class_info
u2 name_index; //name_index表示代表自己类名的字符串信息位于于常量池数组中哪一个,也就是索引
}
唉,够懒的,name_index对应的那个常量池元素必须是CONSTANT_Utf8_info,也就是字符串。图3中的例子,咱们再看看:
#1 = Class #2 //com/test/TestMain
#2 = Utf8 com/test/TestMain
这说明:
这个结构也是常量池数据结构中中比较重要的一个,干什么用得呢?恩,它用来描述方法/成员名以及类型信息的。有点JNI基础的童鞋相信不难明白,在JNI中,一个类的成员函数或成员变量都可以由这个类名字符串+函数名字符串+参数类型字符串+返回值类型来确定(如果是成员变量,就是类名字符串+变量名字符串+类型字符串)来表达。既然是字符串,那么NameAndType_Info也就是存储了对应字符串在常量池数组中的索引:
CONSTANT_NameAndType_info {
u1 tag;
u2 name_index; //方法名或域名对应的字符串索引
u2 descriptor_index; //方法信息(参数+返回值),或者成员变量的信息(类型)对应的字符串索引
}
//还是来看图3中的例子吧
#13 = Utf8 ()V
#15 = NameAnType #16.#13 //合起来就是test.()V 函数名是test,参数和返回值是()V
#16=Utf8 test
太简单了,都不惜得说...,请大家自行解析#25这个常量池元素的内容,一定要做喔!
注意,对于构造函数和类初始化函数来说,JVM要求函数名必须是<init>和<cinit>。当然,这两个函数是编译器生成的。
Methodref_Info还有两个兄弟,分别是Fieldref_Info,InterfaceMethodref_Info,他们三用于描述方法、成员变量和接口信息。刚才的NameAndType_Info其实已经描述了方法和成员变量信息的一部分,唯一还缺的就是没有地方描述它们属于哪个类。而咱这三兄弟就补全了这些信息。他们三的数据结构如图4所示:
如此直白简单,不解释了。不放心的童鞋们请对照图3的例子自行玩耍!
常量池先介绍到这,它还有一些有用的信息,不过要等到后面我们碰到具体问题时再分析
刚才在常量池介绍中有提到Methodref_Info和Fieldref_Info,不过这两个Info无非是描述了函数或成员变量的名字,参数,类型等信息。但是真正的方法、成员变量信息还包括比如访问权限,注解,源代码位置等。对于方法来说,更重要的还包括其函数功能(即这个函数对应的字节码)。
在Java VM中,方法和成员变量的完整描述由如图5所示的数据结构来表达的:
attribute_info结构体很简单,如下代码所示:
attribute_info {//特别注意,这里描述的attribute_info结构体也是具体属性数据结构的通用表达
u2 attribute_name_index; //attribute_info的描述,指向常量池的字符串
u4 attribute_length; //具体的内容由info数组描述
u1 info[attribute_length];
}
Java VM规范中,attribute类型比较多,我们重点介绍几个,先来看代表一个函数实际内容的Code属性。
代表Code属性的数据结构如图6所示:
来看个实际例子吧,如图7所示(接着图3的例子):
图7中:
请大家自行解析图7中最后一行,看看能搞明白LocalVariableTable的含义不...
另外,Android SDK build Tools中的dx工具dump class文件得到的信息更全,大家可以试试。
使用方法是:dx --dump --debug xxx.class。
Class文件先介绍到这,下面我们来看看Android平台上的dex文件。
Android平台中没有直接使用Class文件格式,因为早期的Anrdroid手机内存,存储都比较小,而Class文件显然有很多可以优化的地方,比如每个Class文件都有一个常量池,里边存储了一些字符串。一串内容完全相同的字符串很有可能在不同的Class文件的常量池中存在,这就是一个可以优化的地方。当然,Dex文件结构和Class文件结构差异的地方还很多,但是从携带的信息上来看,Dex和Class文件是一致的。所以,你了解了Class文件(作为Java VM官方Spec的标准),Dex文件结构只不过是一个变种罢了(从学习到什么程度为止的问题来看,如果不是要自己来解析Dex文件,或者反编译/修改dex文件,我觉得大致了解下Dex文件结构的情况就可以了)。图8所示为Dex文件结构的概貌:
有一点需要说明:传统Class文件是一个Java源码文件会生成一个.Class文件,而Android是把所有Class文件进行合并,优化,然后生成一个最终的class.dex,如此,多个Class文件里如果有重复的字符串,当把它们都放到一个dex文件的时候,只要一份就可以了嘛。
dex头部信息中的magic取值为“dex\n035\0”
proto_ids:描述函数原型信息,包括返回值,参数信息。比如“test:()V”
methods_ids:函数信息,包括所属类及对应的proto信息。比如
"Lcom.test.TestMain. test:()V",.前面是类信息,后面属于proto信息
下面我们将示例TestMain.class转换成dex文件,然后再用dexdump工具看看它的结果,如图9所示:
具体方法:
图9中的dexdump结果其实比图3还要清晰易懂。我们重点关注code段的内容(图中红框的部分):
Android官方文档:https://source.android.com/devices/tech/dalvik/dex-format.html
说实话,写完这一小节的时候,我又反复看了官方文档还有其他一些参考文档。很痛苦,主要是东西太多,而我们目前又没有实际的问题,所以基本上是一边看一边忘!
恩。至少在这个阶段,先了解到这个程度就好。后面会随着学习的深入,有更多的深入知识,到时候根据需求再加进来。
再来看odex。odex是Optimized dex的简写,也就是优化后的dex文件。为什么要优化呢?主要还是为了提高Dalvik虚拟机的运行速度。但是odex不是简单的、通用的优化,而是在其优化过程中,依赖系统已经编译好的其他模块,简单点说:
图10给出了图1所示示例代码得到的test.dex,然后利用dexopt得到test.odex,接着利用dexdump得到其内容,最后利用Beyond Compare比较这两个文件的差异。
图10中,绿色框中是test.dex的内容,红色框中是test.odex的内容,这也是两个文件的差异内容:
vtable是虚表的意思,一般在OOP实现中用得很多。vtable一定比methodtable快么?那倒是有可能。我个人猜测:
1 http://mylifewithandroid.blogspot.com/2009/05/about-quick-method-invocation.html介绍了vtable的生成,大家可以看看
2 http://pallergabor.uw.hu/androidblog/dalvik_opcodes.html 详细描述了dex/odex指令的格式,大家有兴趣可以做参考。
前面曾经提到过,odex文件的生成依赖于BOOTCLASSPATH提供的系统核心库。以我们这个简单的例子而言,core.jar是必须的(java基础类大部分封装在core.jar中)。另外,core.jar对应的core.odex文件也需要。所有这些文件我都已经上传到示例代码仓库的javavmtest/odex-test目录下。然后执行dextest.sh脚本。此脚本内容如下:
#!/bin/sh
#在根目录下建立/data/dalvik-cache目录,这是因为odex往往是在机器上生成的,所有这些目录都是
#设备上才有。我们模拟一下罢了
sudo mkdir -p /data/dalvik-cache/
#core.dex文件名:这也是模拟了机器上的情况。系统将dex文件的绝对路径名换成了@来唯一标示
#一个dex文件。由于我在制作core.dex的时候,该core.jar包放在了/home/innost/workspace/my-projects/
#javavmtest/odex-test下,生成的core.dex就应该命名为home@innost@workspace@my-projects@javavmtest@[email protected]@classes.dex
CORE_TARGET_DEX="home@innost@workspace@my-projects@javavmtest@[email protected]@"
CURRENT_PATH=`pwd`
#为了减少麻烦,我这里做了一个链接,将需要的dex文件链接到此目录下的core.dex
sudo ln -sf ${CURRENT_PATH}/core.dex /data/dalvik-cache/${CORE_TARGET_DEX}classes.dex
rm test.odex
#设置BOOTCLASSPATH变量
export BOOTCLASSPATH=${CURRENT_PATH}/core.jar
/home/innost/workspace/android-4.4.4/out/host/linux-x86/bin/dexopt --preopt ${CURRENT_PATH}/test.jar test.odex "m=y u=n"
#删掉/data目录
sudo rm -rf /data
odex文件由dexopt生成,这个工具在SDK里没有,只能由源码生成。odex文件的生成有三种方式:
实际上dex转odex是利用了dalvik vm,里边也会运行dalvik vm的相关方法。
本节主要介绍了Class文件,以及在Android平台上的变种dex和odex文件。以标准角度来看,Class文件是由Java VM规范定义的,所以通用性更广。dex或者是odex只不过是规范在Android平台上的一种具体实现罢了,而且dex/odex在很多地方也需要遵守规范。因为dex文件的来源其实还是Class文件。
对于初学者而言,我建议了解Class文件的结构为主。另外,关于dex/odex的文件结构,除非有明确需求(比如要自己修改字节码等),否则以了解原理就可以。而且,将来我们看到dalvik vm的实际代码后,你会发现dex的文件内容还是会转换成代码里的那些你很熟悉的类型,数据结构。比如dex存储字符串是一种优化后的方法,但是到vm代码中,还不是只能用字符串来表示吗?
另外,你还会发现,Class、dex还是odex文件都存储了很多源码中的信息,比如类名、函数名、参数信息、成员变量信息等,而且直接用得是字符串。这和Native的二进制比起来,就容易看懂多了。
下面我们来讲讲字节码的执行。很多人对Java字节码到底是怎么运行的比较好奇。Java字节码的运行和操作系统上(比如Linux)一个进程是如何执行其代码,从理论上说是一致的。只不过Java字节码的执行是JVM,而操作系统上一个进程其代码的执行是由CPU来完成。当然,现在JVM也可以把Java字节码直接转成机器码,然后交给CPU来执行。这样可以显著提高运行速度。
本节我们将介绍Android平台上Java字节码的执行。当然,我并不会具体分析每一行代码都是怎么执行的(比如函数参数的入栈,寄存器的使用),而只是想向大家介绍大体的流程,满足大家的好奇心。如果有更深次的学习需求,你就可以在本节基础上自行开展了!
下面所讲内容的源码全部位于AOSP源码/dalvik/vm/mterp/out目录下
mterp/out目录下有好些个源码文件,如图11所示:
这个目录中的文件就是不同平台上,Java字节码处理的代码。每一个平台包含一个汇编文件和一个C文件。
下面我们看对于new操作,portable、arm平台的处理。
在InterpC-portable.cpp中,有几处关键代码,先来看图12:
在这段代码中:
那么,handlerTable是怎么定义的呢?来看图13:
图13中:
那么,new操作符对应的goto label在哪里呢?来看图14:
你看,portable.cpp中通过HANDLE_OPCODE(OP_NEW_INSTANCE)定义了new操作符的处理逻辑。这段逻辑中,真正分配内存的操作是由红框的dvmAllocObject来处理的。
看到这里,你会发现JVM执行Java字节码还是比较容易理解的。其实对于arm等平台也是这样。
和portable下dvmInterpretPortable函数(Java字节码执行的入口函数)相对应的,其他模式下的入口函数是dvmMterpStd,其代码如图15所示:
dvmMterpStd中最重要的是dvmMterpStdRun,这个函数是由各平台对应的xxx.S汇编文件定义的。InterpAsm-armv7-a-neon.S对应的dvmMterpStdRun函数以及对new的处理逻辑如图16所示:
图16中:
这一节我们介绍了JVM是怎么执行Java字节码的,主要以揭秘性质为主,大家也以掌握原理为首要任务。其中,portable模式下,操作码是一条一条解释执行的。而具体CPU平台上,则是由相关汇编代码来处理。二者实际上大同小异。但是由CPU来执行,显然处理要快,比如对于+这种操作,用portable的解释执行当然比直接转换成机器指令来执行要慢很多。
到此,我们了解了Class文件结构,以及Java字节码到底是怎么执行的。下一步,我们就开始正式分析Dalvik虚拟机了。
Android平台中,第一个虚拟机是通过app_process进程启动的,这个进程也就是大名鼎鼎的Zygote(含义是受精卵)。Zygote的启动我在《深入理解Android卷I》第四章深入理解Zygote中有详细分析,这里我们简单回顾下。图17所示为zygote启动的触发机制:
上述代码是位于init.rc中,当Linux天字号第一进程init启动后,将执行init.rc中的内容。此处的zygote的一个Service,对应的进程是/system/bin/app_process,后面的--zygote...等是该进程的参数。
zygote,也就是app_process,其源码位于frameworks/base/cmds/app_process里,源码比较少,主要是一个App_main.cpp。其main函数如下:
int main(int argc, char* const argv[])
{
.......
AppRuntime runtime; //AppRuntime是关键数据结构
const char* argv0 = argv[0];
int i = runtime.addVmArguments(argc, argv);//添加参数,不重要
// Parse runtime arguments. Stop at first unrecognized option.
.......
if (zygote) {//我是zygote
runtime.start("com.android.internal.os.ZygoteInit",
startSystemServer ? "start-system-server" : "");
} ......
}
runtime是核心对象,其类型是AppRuntime,是定义在app_process中的一个Class,它从AndroidRuntime派生。start函数就是AndroidRuntime中的,用于启动VM的入口。
start函数我们分两部分讲,第一部分如图18所示:
第一部分包含三个主要函数:
该函数内容如图19所示:
该函数:
所以,以后调用比如JNI_CreateVM_函数的时候,我们知道它的真实实现其实是位于libdvm.so中的JNI_CreateVM就好。
比较简单,Nothing more....
startVM属于Android Runtime start函数的第一部分,不过该函数内容比较多,我们单独搞一大节来讲它!
startVM此函数前面一大段都是参数处理,所以对本文有意义的内容其实只有图20所示的部分:
核心内容还是在libdvm.so中的JNI_CreateVM函数中,这个函数定义在dalvik/vm/jni.cpp中。来看它!
图21所示为此函数的主要代码:
图21中,首先扑面而来的就是Dalvik VM中的几个重量级数据结构:
图22所示为JavaVMExt和JNIEnvExt的内容:
图22中可知:
再来看gDvm的内容,它自己其实就是一大仓库,里边有很多成员变量,每个成员变量都有各自的用途。其内部如图23所示:
图23中:
这里要特别说明虚拟机中对类唯一性的确定方法:
1 对我们而言,类的唯一性由包名+类名表示,比如java.lang.Class这个类,就是唯一的。但实际上,根据Java VM规范,类的唯一性由全路径类名+定义它的ClassLoader两者唯一确定。
2 对一个类的加载而言,ClassLoader有两种情况。一种是直接创建目标类,这种loader叫Define Loader(定义加载器)。另外一种情况是一个ClassLoader创建了Class,但它可以自己直接创建,也可以是委托给比如父加载器创建的,这种Loader叫Initiating Loader(初始加载器)。
3 类的唯一性是由全路径类名+定义加载器唯一决定。
下面来看JNIEnvExt的创建,这是由图21中的dvmCreateJNIEnv函数完成的。
图21中的调用方法如下:
JNIEnvExt* pEnv = (JNIEnvExt*) dvmCreateJNIEnv(NULL);
该函数的相关代码如图24所示:
图24中,Dalvik虚拟机里JNI的所有函数都封装在gNativeInterface中。这个结构体包含了JNI定义的所有函数。注意,在使用sourceInsight的时候会有一些函数无法被解析。因为这些函数使用了类似图右下角的CALL_VIRTUAL宏方式定义。
我确认了下,应该所有函数的定义其实都在jni.cpp这一个文件里。
到此,我们为主线程创建和初始化了gDvm和JNI环境。下面来看dvmStartup。
去掉dvmStartup函数中一些判断代码后,该函数整个执行流程可由图25表示:
图25中,dvmStartup的执行从左到右。由于本章我只是想讨论dalvik是怎么执行的Java代码的,所以这里有一些函数(比如GC相关的,就不拟讨论)。
dvmStartup首先是解析参数,这些参数信息可能会传给gDvm相关的成员变量。解析参数是由setCommandLineDefaults和processOptions来完成的。具体代码就不看了,最终设置的几个重要的参数是:
图26为Nexus 7 Wi-Fi版4.4.4的BOOTCLASSPATH值:
图26可知,system/framework下几乎所有的jar包都被放在了BOOT CLASSPATH里。这意味这zygote进程加载了所有framework的包,这进一步意味着App也加载了所有framework的包.....。
下面来分析几个和本章目标相关的函数:
图27所示为dvmThreadStartup的一些关键代码和解释:
Thread是Dalvik中代表和管理一个线程的重要结构。注意,这里的Thread不简单是我们在Java层中的线程。在那里,我们只需要在线程里执行要干得活就可以了。而这里的Thread几乎模拟了一个CPU(或者说CPU上的一个核)是怎么执行代码的。比如Thread中为函数调用要设置和维护一个栈,还要要有一个变量指向当前正在执行的指令(大名鼎鼎的PC)。这一块我不想浪费时间介绍,有兴趣的童鞋们可以此为契机进行深入研究。
dvmInlineNativeStartup主要是将一些常用的函数搞成inline似的。这里的inline,其实就是将某些Java函数搞成JNI。比如String类的charAt、compareTo函数等。相关代码如图28所示:
注意,在上面函数中,gDvm.inlineMethods只不过是分配了一个内存空间,该空间大小和gDvmInlineOpsTable一样。而gDvm.inlineMethods数组元素并未和gDvmInlineOpsTable挂上钩。当然,最终是会挂上的,但是不在这里。此处暂且不表。
下面我们跳到dvmClassStartup,这个函数很重要。图29是其代码:
图29中:
下面来看processClassPath这个函数,它要加载所有的Boot Class,由于它涉及到类的加载,所以它也是本文的重点内容。先来看图30:
processClassPath主要是处理BOOTCLASSPATH,也就是图26中的那些位于system/framework/下的jar包。图31展示了prepareCpe的代码,该函数处理一个一个的文件:
prepareCpe倒是很简单:
这里我们看dvmJarFileOpen函数,如图32所示:
图32介绍了dvmJarFileOpen的主要内容,其中:
到此dvmClassStartup就介绍完了。下面来看一个重要函数,dvmFindRequiredClassesAndMembers。
dvmFindRequiredClassesAndMembers初始化一些重要类和函数。其代码如图33所示:
dvmFindRequiredClassesAndMembers就是初始化一些类,函数,虚函数等等。我们重点关注它是怎么初始化的。一共有三个重要函数:
重点是findClassNoInit,代码如图34所示:
图34中,有几个关键点:
注意:我们在编写代码的时候,对于类的唯一性往往只知道全路径类名,很少关注ClassLoader的重要性。实际上,我之前曾经碰到过一个问题:通过两个不同ClassLoader加载的相同的Class居然不相等。当时很不明白为什么要这么设计, 直到我碰到一个真实事情:有一天我在等车,听见一个路人大声叫着“李志刚,李志刚”。我回头一看,以为他是在找人,结果发现他的宠物狗跑了出来。原来他的 宠物狗就叫李志刚。这就说明,两个具有相同名字的东西,实际上很能是完全不同的事物。所以,简单得以两个类是否同名来判断唯一性肯定是不行得了。
下面来看最重要的loadClassFromDex,这个函数其实就是把odex文件中的信息转换成ClassObject。我们来看它:loadClassFromDex代码如图34所示:
其中主要的加载函数是loadClassFromDex0,其代码如图35所示:
以上是loadClassFromDex0的第一部分内容,这这一块比较简单,也就是设置一些东西。下面看图36
图36中:
其实loadClassFromDex0后面的工作也类似,比如解析成员函数信息,成员变量信息等。我们直接看相关函数吧:
图37展示了解析成员变量和解析函数用的两个函数。
注意native函数的处理,此处是先用dvmResolveNativeMethod顶着。我们以后分析JNI的时候再来讨论它。
上面的findClassNoInit是用于搜索Class的,下面我们来看dvmFindDirectMethodByDescriptor函数,它是用来搜索方法的,代码如图38所示:
对compareMethodHelper好奇的读者,我在图40里展示了如何从dex文件中获取一个函数的返回值信息。
好像感觉我们一直和字符串在玩耍。
说实话,讲到现在,其实虚拟机启动的流程差不多就完了。当然,本节所说的这个流程是很粗犷的,主要内容还是集中在Class的加载上,然后浮光掠影看了下一些重要的数据结构。Anyway,上述流程,我建议读者结合代码反复走几个来回。下面我们将开始介绍一些细节性的内容:
JVM中,一个Class首先被使用的时候会调用它的<clinit>函数。<clinit>函数是一个由编译器生成的函数,当类有static成员变量或者static语句块的时候,编译器就会为它生成这个函数。那么,我们要搞清楚这个函数在什么时候被调用,以什么样的方式被调用。
先来看一段示例代码,如图41所示:
示例代码中:
问题来了:TestAnother的<clinit>什么时候被调用?我一开始思考这个问题的时候:这个函数是编译器自动生成的,那么调用它的地方是不是也由编译器控制呢?
要确认这一点,只需要看dexdump的结果,如图42所示:
图42中:
当然,根据图41的日志输出,我们知道<clinit>是在TestAnother的构造函数之前调用的,那唯一有可能的地方会不会是new-instance呢?
我们在3.1节portable的纯解释执行一节中提到过new-instance,下面我们将以portable为主要讲解对象来介绍。
其实,不管是portable还是arm、x86方式,最终都会变成机器指令来执行。相对arm、x86的汇编代码,portable是以C语言实现的Java字节码解释器,非常方便我们理解。
图43为new-instance指令对应的代码:
第六节会介绍portable模式下Java函数是如何执行的,所以这里大家先不用管HANDLE_OPCODE这样的宏是干什么用的。图43中:
我们重点介绍dvmResolveClass和dvmInitClass。
图44是dvmResolveClass的代码:
图44中:
图45是findClassFromLoaderNoInit的代码,出奇的简单:
代码真是简洁啊,居然调用java/lang/ClassLoader的loadClass函数来加载类。当然,dalvik中调用Java函数是通过dvmCallMethod来实现的。这个函数我们下一节再介绍。然后,我们把loader存储到目标clazz的初始加载loader链表中。初始加载链表在决定类唯一性的时候很有帮助(不记得初始加载器和定义加载器的同学们,请回顾图23后的说明和图33)。
Anyway,到此,目标类就算加载成功了。类加载成功到底意味这什么?前面讲过loadClassFromDex等函数,类加载成功意味着dalvik虚拟机从dex字节码文件中成功得到了一个代表该类的ClassObject对象,里边该填的信息在这里都填好了!
加载成功,下一步工作是初始化,来看下一节:
图46为dvmInitClass的代码:
终于,在dvmInitClass中,我们看到了<clinit>的执行。其他感觉没什么特别需要说的了。
再次强调,本章是整个虚拟机旅程中一次浮光掠影般的介绍,先让大家,包括我自己看看虚拟机是个什么样子,有一个粗略的认识即可。后续有打算搞一个完整的,严谨的,基于ART的虚拟机分析系列。
JVM规范定义了JVM应该怎么执行一个函数,东西较碎,但和其他语言一样,无非是如下几个要点:
函数执行肯定是在一个线程里来做的,栈帧则理所当然就会和某个线程相关联。我们先来看dalvik是怎么创建线程及对应栈的。
Dalvik中,allocThread用于创建代表一个线程的线程对象,其代码如图47所示:
图47是dalvik虚拟机为一个线程创建代表对象的处理代码,其中,它为每个线程都创建了一个线程栈。线程栈大小默认为16KB,并设置了相关的栈顶和栈底指针,如图中右下角所示:
每个线程都分配16KB,会不会耗费内存呢?不会,这是因为mmap只是在内核里建立了一个内存映射项,这个项覆盖16KB内存。注意,它只是告诉kernel,这块区域最大能覆盖16KB内存。如果一直没有使用这块内存的话,那么内存并不会真正分配。所以,只有我们真正操作了这块内存,系统才会为它分配内存。
dalvik中,如果需要调用某个函数,则会调用dvmCallMethod(嗯嗯?不对吧,Java字节码里的invoke-direct指令难道也是调用这个么?别急,待会再说invoke-direct的实现。)
dvmCallMethod第一步主要是调用callPrep准备栈帧,这是函数调用的关键一步,马上来看:
当调用一个Java函数时,JVM需要为它搞一个新的栈帧,图49展示了dvmPushInterpFrame的代码
图49中:
1 注意:registersSize包括函数输入参数和函数内部本地变量的个数
2 dvmPushJNIFrame,这个函数是当Java要调用JNI函数时的压栈处理,该函数和dvmPushInterpFrame几乎一样,只是在计算所需栈空间时,没有加上outsSize*4,因为native函数所需栈是由Native自己控制的。此函数代码很简单,请童鞋们自己学习
好了,栈已经准备好了,我们看看函数到底怎么执行。
图48中dvmCallMethodV调用callPrep之后,有一段代码我们还没来得及展示,如图50所示:
参数入栈,您看明白了吗?
接着看dvmCallMethodV调用函数部分,如图51所示
对于java函数,其处理逻辑由dvmInterpret完成,对于Native函数,则由对应的nativeFunc完成。JNI我们放到后面讲,先来处理dvmInterpret。如图52所示:
图52中:
下面我们来看dvmInterpretPortable的处理:
dvmInterpretPortable位于dalvik/vm/mterp/out/InterpC-portable.cpp里,这个InterpC-portable.cpp是用工具生成的,将分散在其他地方的函数合并到最终这一个文件里。我们先来看该函数的第一段内容,如图53所示:
第一部分中,我们发现dvmInterpretPortable通过DEFINE_GOTO_TABLE定义了一个handlerTable[kNumPackedOpcodes]数组,这个数组里的元素通过H宏定义。H宏使用了&&操作符来获取某个goto label的位置。比如图中的H(OP_RETURN_VOID),展开这个宏后得到&&op_OP_RETURN_VOID,这表示op_OP_RETURN_VOID的位置。
那么,这个op_OP_RETURN_VOID标签是谁定义的呢?恩,图中的HANDLE_OPCODE宏定义的,展开后得到op_OP_RETURN_VOID:。
最后:
来看portable模式下Java字节码的处理,这也是最精妙的一部分,如图54所示:
请先认真看图54的内容,然后再看下面的总结,portable模式下:
好了,portable模式下dalvik如何运行java指令就是这样的,就是这么任性,就是这么简单。下面,我们来看Invoke-direct指令又是如何被解析然后执行的。
刚才你看到了portable模式下指令的执行,就是解析指令的操作码然后跳转到对应的label。假设我们现在碰到了invoke-direct指令,这是用来调用函数的。我们看看dvmInterpretPortable怎么处理它。一个图就可以了,如图55所示:
就是跳来跳去麻烦点,其实和dvmCallMethod一样一样。
一切尽在图56。
函数返回后,还需要pop栈帧,代码在stack.cpp的dvmPopFrame中。此处略过不讨论了。
这一节你真得要好好思考,函数调用,不论是Java、C/C++,python等等,都有这类似的处理:
这好像是程序设计的基础知识,这回你真正明白了吗?
关于JNI,我打算介绍下面几个内容:
native库中,如果某个线程需要调用java函数,它会先创建一个JNIEnv环境,然后callXXMethod来调用Java层函数。这部分内容请大家自行研究吧....
把这几个步骤讲清楚的话,JNI内容就差不多了。
APP中,如果要使用JNI的话,native函数必须封装在动态库里,Windows平台叫DLL,Linux平台叫so。然后,我们要在APP中通过System.loadLibrary方法把这个so加载进来。所以,入口是System的loadLibrary函数。相关代码如图57所示:
图57是System.loadLibrary的相关代码。这里主要介绍了so加载路径的问题:
这里再明确解释下,loadLibrary只是指定了so文件的名字,而没有指定绝对路径。所以虚拟机得知道去哪个目录搜索这个文件。传统做法是搜索LD_LIBRARY_PATH环境变量所表明的文件夹(AOSP默认是/vendor/lib和/system/lib)这两个目录。但是我刚才讲,如果使用传统方法,APP A有so要加载的话,得把自己的路径加到LD_LIBRARY_PATH里去。比如LD_LIBRARY_PATH=/vendor/lib:/system/lib:/data/data/pkg-of-app-A/libs,这种方法将导致任何APP都可以加载A的so。
真正的加载由doLoad函数完成。这个函数相关的代码如图58所示:
没什么太多可说的,无非就是dlopen对应的so,然后调用JNI_OnLoad(如果该so定义了这个函数的话)。另外,dalvik虚拟机会保存自己加载的so项。
注意,图58里左边有两个笑脸,当然是很“阴险”的笑脸。什么意思呢?请童鞋们看看nativeLoad和它对应的Dalvik_java_lang_Runtime_nativeLoad函数。你会发现Runtime_nativeLoad的函数参数声明好奇怪,完全不符合JNI规范。并且,Runtime_nativeLoad的函数返回是void,但是Java中的nativeLoad却是有返回值的。怎么回事???此处不表,下文接着说。
我们在JNI里,往往会自行注册java中native函数和native层对应函数的关系。这样,Java层调用native函数时候就会转到native层对应函数来执行。注册,是通过JNIEnv的RegisterNatives函数来完成的。我们来看看它的实现。如图59所示:
RegisterNatives里有几个比较重要的点:
被动注册,也就是JNI里不调用RegisterNatives函数,而是让虚拟机根据一定规则来查找native函数的实现。一般的JNI教科书都是介绍被动注册,不过我从《深入理解Android卷1》开始就建议直接上主动注册方法。
dalvik中,当最开始加载类并解析其中的函数时,如果标记为native函数,则会把Method->nativeFunc设置为dvmResolveNativeMethod(请回头看图37)。我们来看这个函数的内容,如图60所示:
被动注册的方式是在该native函数第一次调用的时候被处理。童鞋们主要注意native函数的匹配规则。Anyway,不建议使用被动注册的方法,因为native层设置的函数名太长,搞起来很不方便。
6.2节专门讲过如何调用java函数,故事还得从dvmCallMethodV说起,如图61所示:
整个流程如下:
图62是X86平台上关于dvmPlatformInvoke注释:
也就是解析参数嘛,不多说了。和前面讲的Java准备栈帧类似,无非是用汇编写得罢了。
fastJni,唉,可惜代码里有这个,但是好像没地方用。干啥的呢?还记得我们前面图58里的两个笑脸吗?
实话告诉大家,fastJni如果真正实现的话,可以加快JNI层函数的调用。为什么?我先给你看个东西,如图63所示:
图63需要好好解释下:
这种做法会造成什么后果呢?
注意喔,这两个函数的参数一个是四个参数,一个是两个参数。不过注释中说了,给一个只有两个参数的函数传4个参数没有问题.....
等等,这么做的好处是什么?
当然,fastJni模式是有要求的,比如是静态,而且非synchronized函数。Anyway,目前这么高级的功能还是只有虚拟机自己用,没放开给应用层。
本篇是我第一次细致观察Android上Java虚拟机的实现,起因是想知道xposed的原理。我们下一篇会分析xposed的原理,其实蛮简单。因为xposed只涉及到了函数调用,hook之类的东西,没有虚拟机里什么内存管理,线程管理之类的。所以,我们这两篇文章都不会涉及内存管理,线程管理之类的高级玩意儿。
简单点说,本章介绍得和dalvik相关的内容还是比较好理解。希望各位先看看,有个感性认识,为将来我们搞更深入的研究而打点基础。