一.从问题出发
jvm学习的过程实际上是理解java文件如何被加载,编译,执行的过程,其中执行的过程最为关键;在执行过程中就涉及到数据存储,指令解析等关键问题。
本篇主要回答二个问题:
- java文件如何被编译加载 ?
- 指令在哪,指令如何被执行 ?
- 指令都一样吗?基于栈的指令与基于寄存器的指令有什么不同?
二.java文件如何被编译加载
为了区分与jvm的差异,android中的虚拟机如dalvik/art,我们用android virtual machine来表示,简称为avm.
1.java文件 到 class文件
java文件通过javac编译成class文件,我们新建空的Test.java
public class Test {
}
执行命令,会在当前目录生成同名文件Test.class
javac Test.java
通过010Editor查看它的结构信息发现,它是一个类c的structure结构体,包含魔数,版本号,常量池,变量信息,方法信息等,关于class文件的详细结构我在Java虚拟机系列二: class字节码详细分析做了说明。
2.class文件到dex文件
class 文件是什么
在普通java虚拟机中,class文件中存放的是jvm的指令集,它可以直接被jvm解释执行,解释执行的过程就是将jvm指令翻译成特定平台指令的过程。
dex文件是什么
在android虚拟机avm上,class通过工具dx再次编译成dex文件,它存放的是机遇特定虚拟机的dex指令集,同样被解释执行。
dex出现的目的与意义
dex出现的意义:dex结构更加紧凑,相比于class文件,dex能存储更多的class信息,减少jvm io的次数。
dex 格式与说明
android sdk提供了工具d8(支持java 8 特性,编译速度快,体积小,它一般在android sdk 安装目录下的build-tools目录下),可以将class文件转化为dex文件,dex是虚拟机最终用的可执行文件。
运行命令,在同级目录下可以得到class.dex文件
d8 Test.class
通过010editor,我们可以看到它也是一个类struct结构,相比与class文件,一个dex中可以描述多个class,结构更加紧凑,因此在加载中可以减少io次数。
如何查看dex中包含的指令
虚拟机的执行过程都包含在指令中,我们可以通过smali来查看,baksmali.jar可以帮助我们反汇编dex,生成smali指令,这些指令能被avm解释执行,它与dex的关系是:
- dex 一种存储格式,包含class信息和指令信息
- smali 一种语法,可用于书写能被dalvik/ark执行的指令
android studio 中可以通过插件java2smali直接查看amv指令
关于指令集的详细解释我们在另外的篇幅说明
dex文件如何被加载
在android平台上,dex也是通过双亲委托机制来完成dex文件的加载与解析过程。
负责加载class的类叫做ClassLoader,它的层次结构是这样的
ClassLoader 抽象类,类加载器的父类
BootClassLoader 主要用来加载AndroidFramework中的类;
BaseDexClassLoader
PathClassLoader 加载系统目录dex
DexCLassLoader 加载外部dex
InMemoryDexClassLoader 加载内存中的dex
双亲委托机制采用装饰器模式来解决委托问题,所有的子类都持有相同的基类ClassLoader作为parent,子类的loaderClass之前都会先查询父类是否已经加载过,如果没有加载过才加载对应的class,加载class的过程,涉及到loadDex - 转化dexElements - 转化dexFile - 查找class,每个class中包含了要被avm执行的指令,也是一个比较复杂的过程,而且跟随版本的变化也在变化,后续篇幅再分析。
三.指令在哪,指令如何被执行 ?
指令在哪?
从二的加载过程,我们可以看出,dex文件被加载到内存后,解析成为Elements的数组,里面包含dexFile,用来查找class文件,指令就存放在class文件中。
指令如何被执行
当我们调用一个方法时,这个方法所在类的字节码会被加载到内存中,并在栈区开辟一块空间存储一个叫做栈帧的结构体,它包含了方法执行时的局部变量表,操作数栈,方法调用的动态链接以及方法出口地址,然后执行引擎会根据pc指令计数器指向的位置,读取指令,解释执行。
我们看看一个简单的Java方法,它生成的指令情况:
public int add(int a ,int b){
a = 100;
b= 300;
return a+b;
}
生成的指令集看起来通俗易懂:
# virtual methods
.method public add(II)I
.registers 4
.param p1, "a" # I
.param p2, "b" # I
.prologue
.line 7
const/16 p1, 0x64
.line 8
const/16 p2, 0x12c
.line 9
const/16 v0, 0x190
return v0
.end method
按照指令的顺序,我们分析执行流程
# virtual methods
方法分为两类:
- direct method ,即是private方法,使用 invoke-direct 调用
- virtual method ,非private方法,使用 invoke-virtual 调用
.method public add(II)I
.end method
方法的定义开始与结束 .method 定义方法开始,它的定义如下
.method public/private [static][final] methodName()<类型>
定义一个public方法,名字叫add,包含两个参数,返回值是int
.registers 4
使用4个寄存器,android指令集是基于寄存器的,寄存器速度远高于内存读取速度,这个在操作系统课程中有详细说明,也可以参考 为什么寄存器速度远高于内存
.param p1, "a" # I
.param p2, "b" # I
使用两个参数寄存器p1 , p2 ,分别存储 a,b, 后面的#是注释,表示存储内容为int
.prologue
.line 7
const/16 p1, 0x64
.line 8
const/16 p2, 0x12c
.line 9
const/16 v0, 0x190
.prologue 表示代码段开始
.line 7 java源文件中的行数
const/16 p1, 0x64 将一个16位的常量放到p1寄存器中,它的值为0x64(100)
const/16 p2, 0x12c 将一个16位的常量放到p2寄存器中,它的值为0x12c(300)
const/16 v0, 0x190 将一个16位的常量放到变量寄存器v0中,它的值为0x190(400)
return v0
返回寄存器v0中的值
当方法返回时,pc计数器中的地址会为设置为栈帧中的方法返回地址索引,同时将当前方法出栈,这样虚拟机栈中的栈顶变为该方法的调用者的栈帧,并且pc会适当自增指向调用者的下一条指令。
四.基于栈的指令集与基于寄存器的指令集
JAVA hotspot指令流是基于栈的指令集架构,而android darlvik是基于寄存器的指令集架构。
指令的设计一般都是 :
操作码 操作数1...操作数n (n一般小于3)
Java的操作码是单字节(8位),因此最多支持256种操作。
基于栈的指令集的特点:
- 设计和实现简单,适用于资源受限制的系统。
- 可移植性强
- 同样的代码指令数多,多次内存访问,运行效率低
- 操作数基于出栈与入栈,无地址指令
基于寄存器的指令集的特点:
- 设计和实现依托于硬件,可以充分利用系统资源
- 可移植性差
- 同样的代码指令数少,减少内存访问,运行效率高
- 操作数放在寄存器中 ,一般包含1-3个地址
来个例子对比下:
public void get(T t){
int a = 10;
int b = a + 20;
}
基于栈的指令
0 bipush 10 //将常量10推送到操作数栈顶
2 istore_2 /将栈顶元素存放到局部变量表 第二个位置
3 iload_2 //加载第二个局部变量,放到操作数栈顶
4 bipush 20 // 将常量20推送到操作数栈顶
6 iadd //将栈顶20 ,10 挨个出栈,执行加法
7 istore_3 //将结果存放到局部变量表三的位置
8 return //返回 栈帧中存放的出口地址
基于寄存器的指令
const/16 v0, 0xa //将常量10 放到变量寄存器v0中
add-int/lit8 v1, v0, 0x14 //读取v0中的值,与0x14相加,结果放到寄存器v1中
return-void //返回 栈帧中存放的出口地址
从指令来看,基于寄存器指令数少,因此io操作更快,同时基于寄存器的指令会使用寄存器中的地址作为操作数,而基于栈的指令没有地址指令,它通过指令助记符来获取地址,比如istore_2,iload_2等。
片尾彩蛋
1.为什么反射比对象调用耗时?
反射的一般用法,
Class cls = Class.forName("com.hch.Test");
Object ob = cls.newInstance();
Method method = cls.getDeclaredMethod("test");
method.invoke(ob ,xxxx );
- 1.Class.forName 需要从dex中查找并加载字节码到内存
- 2.cls.getDeclaredMethod class中将所有的方法转化为ArtMethod 的列表,ArtMethod是一个类,它包含了方法的指令内容以及指令热度等。getDeclaredMethod每次会从列表中根据方法的名称,参数查找和匹配对应的方法,是一个主要的耗时操作
- 3.method.invoke 会将参数转化为具体的类型,同时invoke 内部会进行运行时权限检查。反射在达到阈值(15),会动态编写字节码并加载到内存中,这个字节码没有经过编译器优化,也不能享受JIT优化
反射优化方法,大家都能想到:
- 1.缓存Class.forName的结果
- 2.缓存cls.getDeclaredMethod的结果
- 3.method.setAccessible(true);关闭访问权限检查
2.对象直接调用方法为什么快?
这个根据调用的方法分为两种情况,
- 1.类中的静态方法(static),私有方法(private)和内置的init方法会在编译阶段,被静态绑定直接转化为方法字节码地址,因此对象调用此类方法几乎无耗时。在字节码中私有方法用invoke-direct {p0, v1, v2}, LTest;->add(II)I;静态方法用invoke-static {}, LTest;->mStatic()V
- 2.类中的其他方法(protected , public)需要在运行阶段动态确定,这个过程叫动态链接,动态方法根据调用的对象,查找运行时常量池中方法的索引来定位字节码地址。这个调用会比静态方法调用慢,但是比反射查找还是要快很多。
看一个简单的调用例子
public class Test {
public int del(int a ,int b){
return a-b;
}
private int add(int a ,int b){
a = del(a , b);
return a+b;
}
public final void mFinal(){
int a= 10;
return;
}
public static void mStatic(){
int a= 100;
return;
}
public void get(){
add(30 ,40);
mFinal();
mStatic();
Person person = new Person();
person.print();
return ;
}
}
编译后的字节码
.class public LTest;
.super Ljava/lang/Object;
.source "Test.java"
# direct methods
.method public constructor <init>()V
.registers 1
.prologue
.line 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
.method private add(II)I
.registers 4
.param p1, "a" # I
.param p2, "b" # I
.prologue
.line 7
invoke-virtual {p0, p1, p2}, LTest;->del(II)I
move-result p1
.line 8
add-int v0, p1, p2
return v0
.end method
.method public static mStatic()V
.registers 1
.prologue
.line 17
const/16 v0, 0x64
.line 18
.local v0, "a":I
return-void
.end method
# virtual methods
.method public del(II)I
.registers 4
.param p1, "a" # I
.param p2, "b" # I
.prologue
.line 3
sub-int v0, p1, p2
return v0
.end method
.method public get()V
.registers 4
.prologue
.line 22
const/16 v1, 0x1e
const/16 v2, 0x28
invoke-direct {p0, v1, v2}, LTest;->add(II)I
.line 23
invoke-virtual {p0}, LTest;->mFinal()V
.line 24
invoke-static {}, LTest;->mStatic()V
.line 25
new-instance v0, LPerson;
invoke-direct {v0}, LPerson;-><init>()V
.line 26
.local v0, "person":LPerson;
invoke-virtual {v0}, LPerson;->print()Ljava/lang/String;
.line 27
return-void
.end method
.method public final mFinal()V
.registers 2
.prologue
.line 12
const/16 v0, 0xa
.line 13
.local v0, "a":I
return-void
.end method
其中get方法调用中是这样的
.method public get()V
.registers 4
.prologue
.line 22
const/16 v1, 0x1e
const/16 v2, 0x28
invoke-direct {p0, v1, v2}, LTest;->add(II)I
.line 23
invoke-virtual {p0}, LTest;->mFinal()V
.line 24
invoke-static {}, LTest;->mStatic()V
.line 25
new-instance v0, LPerson;
invoke-direct {v0}, LPerson;-><init>()V
.line 26
.local v0, "person":LPerson;
invoke-virtual {v0}, LPerson;->print()Ljava/lang/String;
.line 27
return-void
.end method
可以看到mStatic方法采用invoke-static方法调用,add私有方法采用invoke-direct方法调用,而mFinal方法和Person中的print方法采用invoke-virtual方式调用。