Java虚拟机系列一: Java文件如何被加载执行

1,165 阅读11分钟

Java虚拟机系列一: Java文件如何被加载执行

Java虚拟机系列二: class字节码详细分析

Java虚拟机系列二: 运行时数据区解析

一.从问题出发

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字节码详细分析做了说明。 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次数。

image

dex每个字段的官方说明参考

如何查看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方式调用。