从字节码的角度看Java代码是如何运行的

321 阅读17分钟

一、简介

java是一门面向对象的高级语言,它从C++之上发展而来,从代码的编写风格上很相似。但是不同于C++,java编译后的产物不能被cpu直接运行,它是生成一种名为“字节码”的中间产物,然后再由不同机器上的JVM识别,翻译成本地cpu指令。

java之父詹姆斯·高斯林在设计java的时候,便有一个雄心勃勃的计划“一处编写,处处运行”。为了解决这个问题,于是高斯林提出了字节码的概念,并且借助于和平台无关的“字节码”,java便能实现一次编写,处处运行,实现跨平台。实际上,通过字节码 不仅能做到跨平台,还能做到跨语言相互调用(而Graal VM这个高科技虚拟机,在跨语言上,更进一步了,限于篇幅这里不多加赘述,有兴趣的可以自行搜索)。

二、字节码指令

1.栈指令集架构?寄存器指令集架构?

虚拟机常见的实现方式有2中:基于栈和基于寄存器,典型的基于的栈虚拟机有oracle的HotSpot以及微软的.net CLR,而基于寄存器的虚拟机有LuaVM以及谷歌的DalvikVM,JVM采用的是基于栈的指令集架构。实际上2者各有自己的优缺点:

  • 基于栈的指令集移植性更好,指令更短,实现简单。但是不能随机访问堆栈中的元素,完成相同功能往往比基于寄存器的架构要多,要频繁的执行入栈出栈操作,不利于代码优化
  • 基于寄存器的指令集速度快,可以充分利用寄存器,有利于程序运行优化,但是操作数需要显示指定,指令长

\

2.指令分类

java虚拟机指令包括一个字节的操作码以及与之相关的操作数。字节码的操作码限定为一个字节,这样做的话,就可以使得编译后的文件短小精悍,同时,这也是字节码名字的由来,但这么做也限制了整个JVM操作码的数目不能超过256个。

绝大部分的字节码操作是和类型相关的,例如ireturn用于返回一个int类型数据,freturn用于返回一个float类型的数据。根据字节码的用途,这里大概分为这么些种类:

  • 加载和存储,如iload将一个int类型数据从局部变量表中加载到操作数栈
  • 控制转移,如ifeq跳转指令
  • 对象操作,如new创建对象
  • 方法调用,目前有5条命令支持方法调用,均为invoke*,如invokevirtual用于调用虚方法
  • 执行运算,如iadd执行int类型的加法
  • 线程同步,如monitorenter以及monotorexit用于支持java中的关键字synchronized
  • 异常处理,如athrow用于抛出异常

\

三、运行时数据区

在java虚拟机规范(SE8版)定义内,java的内存被划分了如下5份:

  1. 方法区
  1. VM栈
  2. 本地栈
  1. 程序计数器

其中VM栈、本地方法栈以及程序计数器是线程私有的,也就是说每一条线程就有一份,并且不同线程的这些内存是隔离的,不能相互访问,但是堆和方法区是共享的,不同的线程都可以访问这些内存。

1.堆

堆在JVM启动的时候便已经创建好了,堆中的数据被各个线程共享。java中创建的对象,均存放在堆中,包括使用关键字new、反序列化、Object.clone、Unsafe.allocateInstance、反射等创建的对象。(当然,此处不考虑JIT的一些优化策略,因为如果考虑栈上分配、标量替换等优化策略,创建出来的对象就不一定是在堆中分配内存了)。堆中的对象,由JVM进行管理,JVM的GC组件负责回收不再使用的对象。

2.方法区

和堆一样,方法区也是被各个线程所共享的区域。关于方法区,还有另外一种叫法no-heap(非堆)。方法区和传统语言中的编译存储区或者操作系统进程的正文段很相似,它存储了每一个类的结构信息,例如:运行常量池、字段、方法数据、构造方法以及普通方法的字节码等。

3.VM栈

在java中,线程使用的是操作系统的线程,也就是说一条Java线程会和一条操作系统的线程做一一映射关系,一条java线程默认大小为1M,可以通过命令**java -XX:+PrintFlagsFinal -version | grep "ThreadStackSize" **查看。栈中方法调用一层套一层,每调用一层方法,便会创建一个栈帧,栈帧的大小在编译之后就确定了,这个栈帧的大小直接写入了类文件中,在运行时无需从新计算。在线程执行的某个时间点上,只有目前正在执行的那个方法的栈帧是活动的,这个栈帧也别称为当前栈帧,这个栈帧下的方法也被称为当前方法。对局部变量表和操作数栈的操作,都是针对于当前栈帧而言的。那么一个栈帧又具体包含那些数据呢?

i.局部变量表:局部变量表中包含方法参数以及声明的局部变量。关于局部变量表,我们就可以对比数组,把变量放在一个名为局部变量表的数组中。在方法执行的开始阶段,会执行“序幕”,所谓的序幕其实就是做一些准备工作,给局部变量表赋值。那么好比如下代码:

    public void localVar(int x, int y) {
        int s = 0;
        byte d = 123;
        Object o1 = new int[10];
    }

局部变量有s、d、o1,还有方法参数x、y,其实这里还漏了一个局部变量,那就是this,并且this总是位于局部变量表的0号位置,在每次发起方法调用的时候,this也需要当作参数传入方法,也就是说我们可以理解为:this引用其实相当于类的实例方法的隐藏的第一个方法参数 ** **这里,我们执行javap -c -p -l class文件路径。

查看字节码:

\

其中

  • Start:局部变量的作用域启始位置
  • Length:其作用域范围
  • Slot:变量在局部变量表中的索引
  • Signature是变量类型的类描述符,L开头的表明是引用类型,I是int类型,B是byte类型

局部变量表中,boolean、byte、char、short、int占用1个Slot,也就是说,长度低于int(4字节)的变量也和int类型变量一样,占用相同的内存大小,换句话说,就是boolean、byte、char、short在局部变量表中也是4个字节(它们不同于放在堆中内存大小,可不是1个字节或者2个字节哦~)。并且java操作码中,并没有对应这些类型专门用于计算的操作码,他们会被当作int类型一样,使用和int一样的操作码。double和long占用2个Slot,但是在访问这些变量的时候,使用的是他们第一个Slot的索引位置。引用类型有点特殊,因为在32位虚拟机和64位虚拟机中(启用压缩指针)的时候,占用1Slot,而在64位虚拟机,不开启压缩指针的时候,占用2个Slot(以下所有图例为了方便,引用类型按照1Slot处理)。

\

这里有个示意图,用来表明这些变量是怎么存储的,值是多少:

ii.操作数栈

前面也提到了字节码是基于栈的,而这个栈指的就是操作数栈。

在执行代码的时候,变量不能凭空计算(i++这种代码除外),必须借助另一个数据结构,这就是“操作数栈”。操作数栈,如其名,是一个FILO的“栈”。执行字节码之前,会将操作数执行压栈操作,计算之后,会弹出栈,把执行结果再压入栈。

操作数栈的大小也是提前就计算好的,在编译成字节码的时候,大小就已经写入了类文件。

在某些虚拟机中,操作数栈和局部变量表可以复用一部分,这样就可以节省一些变量拷贝的额外开销。如:

public void func1() {
        SomeClass param = new SomeClass();
        int x = 10;
        int y = 20;
        show(x, param.func2(y, x));
    }

    public static void show(int x, int y) {
        System.out.println(x + "|" + y);
    }

    public int func2(int param1, int param2) {
        int localVar = 10;
        return param1 - param2 + localVar;
    }

其结果:

iii.行号表

    public static void main(String[] args) {
        int x = 123;
    }

    public static int func1(int x) {
        return x + 1;
    }

    public void localVar(int x) {
        x = x + 10;
    }

行号表用于映射字节码位置和源代码(java源文件)的一一映射关系,通过这个映射关系,我们可以很方便的通过IDE来进行Debug。从上图也可以看出,编号为0字节码位置对应的源代码为13行,也就是方法定义的位置。编号为5的字节码位置对应源代码14行,也就是return的位置。

iv.异常表

    public static void main(String[] args) {
        try {
            int y = Integer.parseInt(args[0]);
        } catch (NumberFormatException e) {
            e.printStackTrace();
        } catch (Exception e) {
            System.out.println(e);
        }
    }

  • from:try的开始位置
  • to:try的结束位置
  • target:catch块字节码的启始位置
  • type:捕捉的异常类型

我们可以看到每个catch块的第一个字节码都是astore_1。这是因为如果触发异常,这个异常会压入栈顶,通过astore_1字节码,取栈顶的异常,放入局部变量表索引为1的位置。

v.常量池引用

观察字节码,不难发现,在操作数里面有类似于#1、#2这种标记,实际上通过记录当前方法所在类的常量池引用,便可以将这些标记转换为一个实际的符号引用。

4.本地栈

在java语言中需要调用一些native的本地方法,这些方法都不是用java语言编写,这个时候就会创建“本地栈”。

在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。 甚至有的虚拟机(比如:Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

5.程序计数器

程序计数器本身是一个记录着当前线程所执行的字节码的行号指示器。

JAVA代码编译后的字节码在未经过JIT(实时编译器)编译前,其执行方式是通过“字节码解释器”进行解释执行。简单的工作原理为解释器读取装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。

\

当然这5份也仅仅只是虚拟机规范内定义的部分,虚拟机可以根据自己的需求,有自己的其他内存空间,如HotSpot虚拟机还有本地堆、代码缓存、压缩类空间等的其他运行时内存空间。

\

四、代码执行过程

当我们写完代码后,有没有想过,我们的代码究竟是如何执行的?如何和内存交互的?

下面通过列举几个样例,来帮大家理解这一块。

1、加法运算

这里举一个很简单的例子,只有一行代码的方法,麻雀虽小,五脏俱全。我们可以通过JVM解释器的执行过程,来窥探从底层理解代码到底是如何运行的。

其对应的代码和字节码如下:

    public void localVar(int x) {
        x = x + 10;
    }

这里讲述这段代码是如何执行的,也就下编号为0~4的4行字节码执行过程(假设x=110)。

a.开始阶段:

 

b.执行编号为0的指令,iload_1,把局部变量表1号位置的值压栈,也就是变量x的值压入操作数栈。字节码中有很多类似于xxx_0,xxx_1这种指令,这种指令本身就携带操作数,等价于xxx 1,也就是说_1后面的1就是它的操作数。JVM这么干的目的就是减少类文件的体积,保证短小精悍!

    c.执行bipush 10,把常量10压入栈:

   d.执行iadd指令,将操作数栈里面栈顶的2个元素累加,并且出栈,将计算结果压栈:

  e.执行istore_1指令,将操作数栈内栈顶元素弹出,并且存入编号为1的局部变量表:

就此x=x+10代码便执行完毕,x的值此时从110->120;

2、创建对象

这里我们再来看一段比较复杂点的代码,对象是如何创建的,字段是如何赋值的。

    public static void main(String[] args) {
        People p = new People();
        p.money += 100;
    }

    public static class People {
        public int money;

        public void work(int year) {
            this.money += year * 10;
        }
    }

编译后的字节码:

具体的执行过程如下:

a.执行编号为0的new指令,有一个操作数,是一个符号引用,代表的是com/example/all/Test$Chinese

  • 如果类型Test$Chinese未被加载,那么这里会触发类加载动作,当然,同一个类加载器下,同一个类型,仅会被加载一次
  • 接下来要为对象分配内存,其内存大小运行时刻无需计算,在代码编译时刻已经计算完毕(计算大小方式,可以参考我写的另一篇文章java-对象内存布局,注,数组对象在运行时需要实时计算其大小),对象创建会依赖GC,不同的GC可以将对象放在不同的具体位置
  • 为对象的对象头、相关字段等赋值
  • 将新创建对象的地址压栈

此时,堆和方法区以及操作数栈如下图所示:

b.执行dup指令,将栈顶元素复制一份

c.执行invokespecial指令,这个指令带了一个操作数,#3,查找常量池,可以发现其名为,这是一个特殊的方法,对应的是Chinese类的构造方法。执行完毕后,栈顶元素出栈

d.执行astore_1指令,这个指令没有操作数,是弹出操作数栈顶,并且赋值到局部变量表中编号为1的位置,也就是给p赋值,此时p的值从null变成了堆中该对象地址

e.执行aload_1指令,将局部变量表中1号位置的值压入栈顶

f.执行dup指令,复制栈顶元素

g.执行getfield指令,这里有一个操作数,#4,com/example/all/Test$People.money:I,是People类的money字段。people对象是存放在堆中的,需要给people的字段money赋值,那么就需要计算其内存地址,计算方式=对象的首地址+字段偏移量。(关于偏移量计算,可以参考我写的另一篇文章java-对象内存布局

因为这个字段一直保持默认值,所以这个字段的值读取后结果为0。同时,栈顶元素出栈,0压栈。

h.执行bipush指令,将100这个常量压栈

i.iadd指令是将栈顶2个元素弹出栈,并且把结果压栈

j.putfield和前面讲的getfield类似,需要计算对象字段的地址,将栈顶的值写入堆中,并且将栈顶2个元素弹出栈

到此,people对象的创建,people.money字段赋值的逻辑就结束了,最后一行字节码,方法结束,返回结果。

3、调用虚方法

在java中有5条字节码可以用于调用一个方法

  • invokespecial,调用private修饰的方法、super父类方法、构造方法等
  • invokeinterface,调用接口方法
  • invokestatic,调用静态方法
  • invokedynamic,JDK7新加的字节码,执行动态调用,函数式接口和lambda表达式会用到
  • invokevirtual,调用非private标记的类的实例方法

\

上述中,invokeinterface以及invokevirtual(没有用final符号修饰)均为虚方法调用。那么啥是虚方法呢?简单点讲就是那些可以在子类重写的方法即为虚方法。虚方法的调用,有点特殊,因为JVM需要在运行时,根据调用者的实际类型来确定调用的到底是哪个方法,这个过程也被称之为动态绑定,那么相对于调用静态绑定,调用虚方法需要一些额外的开销,当然也就更加耗时了。

\

接下来我们来看下JVM是如何调用一个虚方法的。

    public static void main(String[] args) {
        People p = new Chinese();
        p.say();
    }

    public static class People {
        public void say() {
            System.out.println("我是人");
        }
    }

    public static class Chinese extends People {
        @Override
        public void say() {
            System.out.println("我是中国人");
        }
    }

对应的字节码如下:

考虑到0~8行字节码指令和前面讲的重复了,这里直接跳过。

a.这里用到的命令是invokevirtual,有一个参数#4,代表的是调用People.say方法。

虚方法的调用,需要查找方法表,而方法表本质上是一个数组,每个元素指向一个当前类以或者父类非私有实例方法。People和Chinese这2个类的方法表如下图所示(为了描述方便,这里省略部分Object类的方法):

父类和子类之间,相同的方法位置相同。这里Chinese类重写了say方法,所以其值和People不一样。

不同于前面的invokespecial指令,我们都知道,在类加载的解析阶段,符号引用会解析为直接引用,而对于invokespecial这种指令的方法而言,采用静态绑定,直接引用直接指向目标的方法。而invokevirtual采用的是动态绑定,直接引用其实是上面方法表中的索引值,所以说invokevirtual需要明确做到对象的实际类型,再加上这个方法表中的索引值才能定位到是调用那个方法。

就拿上面这个例子来说,符号引用记录的是People.say,但是因为这个对象p本身是Chinese类型的,所以其会查找Chinese的方法表,再定位到Chinese.say方法,再发起调用。也就是虚方法到底调用哪类个方法,是需要在运行时刻根据实际对象类型动态决定的。

当然,实际运行中查找方法表是需要一定额外开销的,JIT在这有一些优化策略,如引入“内联缓存”。它能够缓存虚方法调用中的调用者的动态类型,以及该类型对应的目标方法,如果下次发起调用,在缓存中命中,就无需查找,不过,如果没命中的话,还是会查找方法表。

b.定位到了具体的方法位置后,这儿会创建一个新的栈帧,之前的方法栈帧保留,当前栈帧处于激活状态,同时会创建新的操作数栈以及局部变量表。

say方法执行结束后,程序计数器重新指向main方法。

到此虚方法调用,就写到这里了。

\

五、推荐学习资料

  • 深入理解java字节码。作者:张亚
  • 深入理解java虚拟机(第三版)。作者:周志明
  • Java虚拟机规范 Java SE 8版。作者:Tim Lindholm、Frank Yellin、Gilad Bracha、Alex Buckley等