字节码执行细节

Java虚拟机执行Java字节码的时候,每一个方法都对应一个虚拟机栈中的栈帧,方法从调用开始执行到执行完成返回相应值的过程就对应着一个栈帧从入栈到出栈的过程。那么一个方法的栈帧需要多大的内存呢?早在方法表的code属性中做了相关规定:比如max_locals指明局部变量表的最大容量, max_stacks代表操作数栈的最大深度等。
对于一个栈帧,大概包括局部变量表、操作数栈、动态链接和方法返回地址等。

  • 局部变量表
    它是一组变量值的存储空间,主要用于存放方法参数和方法内部定义的局部变量。局部变量表以变量槽(slot)为最小单位,可以将局部变量表想象为一个数组,数组小标对应与slot的索引,每个索引对应一个数据,不同的是,一个slot一般规定为占用32位的数据类型(如Java中6个32为基本数据类型、reference和returnAddress)所占的内存空间,那么对于一个64位的数据类型(double和long)就需要连续两个slot来存放,而且这两个连续的slot是不允许单独访问的。
    和数组的访问方式一样,虚拟机是通过索引定位的方式使用局部变量表的。如果执行的是实例方法,一般来说,索引0的位置存放的是方法所属对象实例的引用(大概和this关键字的作用一样)。
    局部变量表中的Slot是可以重用的。原因是在方法中有的变量的生命周期是小于方法的生命周期的,那么如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的slot就可以给其他变量使用。但这会带来一个垃圾收集器无法回收的问题。如:
public static void main(String[] args) () {
	{
		byte[] placeholder = new byte[64*1024*1024];
	}
	System.gc();
}

这个代码并不会回收placeholder所占用的64MB的内存,即使placeholder的作用域已经结束。
但下面的代码却可以被回收

public static void main(String[] args) () {
	{
		byte[] placeholder = new byte[64*1024*1024];
	}
	int a = 0;
	System.gc();
}

那么这是为什么呢?
我们来看,按照垃圾收集器的工作原理,placeholder能否被回收的根本原因是:是否还有引用存在,即局部变量变量中的slot是否还有关于placeholder数组对象的引用。第一个代码,虽然代码已经执行过placeholder所在的作用域,但在此之后没有任何的读写操作,所以原本placeholder所占用的slot还没有被其它变量使用,那么placeholder仍然关联着某个GC Roots,所以不会被回收。但是如果placeholder所占用的Slot被其他变量占用,数组的内存就将会被回收。
推荐的Java技巧中就有“把不使用的对象手动赋值为null",这就是手动指明对象可以被回收。

  • 操作数栈
    首先举个例子,当我们计算a+b的值的时候,对应到字节码指令就是iadd,需要将操作数栈栈顶的两个数弹出,求和,将结果压入栈。那么栈帧中的操作数栈和例子中栈是一个道理。
    在一个方法执行的过程中,操作数栈不停的写入个提取内容,从无到有再到无,就对应着一个方法的执行到完成。

  • 动态链接
    常量池中的符号引用会有一部分在运行期间将转化为直接引用,这一部分称为动态链接。

  • 方法返回地址
    方法返回有两种方法

    1. 正常结束,方法执行完成,带着返回值返回
    2. 异常结束,方法内产生异常且本方法的异常表中没有匹配的异常处理器,不带返回值返回。

那么无论采用哪种方式返回,在方法退出之后,都需要返回到方法被调用的位置,那么返回地址就是用来标识这个位置。

方法调用
作用:确定应该调用哪个方法,并不涉及方法内部的运行过程。

  • 解析
    所有方法调用中的目标方法在class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。这些符号引用所引用的方法是在程序运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期不可改变。如静态方法、私有方法、父类方法和实例构造器init方法。
    对应到invokestatic和invokespecial两条字节码指令。(也就是说,只要能被上边两条指令调用的方法,都可以在解析阶段中确定唯一的调用版本,还有final方法。这是因为这些方法不能通过继承或其他的方法来重写其他版本)。
/**
 * 方法静态解析
 */
public class StaticResolution {
	public static void sayHello() {
		System.out.println("hello workd!");
	}
	
	public static void main(String[] args) {
		StaticResolution.sayHello();
	}
}

通过javap可以看到main方法中调用sayHello方法是通过invokestatic指令来调用的。
在这里插入图片描述
所以解析阶段是将没有其他版本的方法的符号引用变为直接引用。

  • 分派(Method Overload Resolution)
    分派是为了确定应该调用有多版本方法(重载、重写)的哪一个。
  1. 静态分派:依赖静态类型来定位方法执行版本的分派动作。
    首先来看一段代码:
public class StaticDispatch {
	static abstract class Human{}
	
	static class Man extends Human {}
	
	static class Woman extends Human {}
	
	public void sayHello (Human guy) {
		System.out.println("hello,guy!");
	}
	
	public void sayHello (Man guy){
		System.out.println("hello,man!");
	}
	
	public void sayHello (Woman guy){
		System.out.println("hello,laddy!");
	}
	
	public static void main(String[] args) {
		Human man = new Man();
		Human woman = new Woman();
		
		StaticDispatch sr = new StaticDispatch();
		sr.sayHello(man);
		sr.sayHello(woman);
	}
}

sayHello有三个不同的版本,那么应该调用哪一个呢?
关键点就是Human man = new Man(),这个代码中Human是变量的静态类型,Man是变量的实际类型,这两者的区别为:静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型实在编译期可知的;而实际类型变化的结果在运行期才可以确定,编译器在编译时并不知道一个对象的实际类型是什么。
所以在main函数的两次调用中,应该调用哪个重载版本,就完全取决于传入参数的数量和数据类型。虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据的。所以以上两个调用都是选择sayHello(Human)作为调用目标的,所以输出为:

hello,guy!
hello,guy!

利用javap查看可知,这两个方法调用是通过invokevirtual(调用所有的虚方法)指令完成。
在这里插入图片描述

虽然,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是"唯一的",往往只能确定一个“更加适合的版本”。

public class Overload {
	
	public static void sayHello(Object arg){
		System.out.println("hello Object");
	}
	public static void sayHello(int arg){
		System.out.println("hello Object");
	}
	public static void sayHello(long arg){
		System.out.println("hello Object");
	}
	public static void sayHello(Character arg){
		System.out.println("hello Object");
	}
	public static void sayHello(char arg){
		System.out.println("hello Object");
	}
	public static void sayHello(char... arg){
		System.out.println("hello Object");
	}
	public static void sayHello(Serializable arg){
		System.out.println("hello Object");
	}
	
	public static void main(String[] args) {
		sayHello('a');
	}
}

上述代码中有7个sayHello的重载方法,如果传入字符‘a’,程序输出hello char
删除sayHello(char arg)方法,程序输出hello int,这是因为对a进行了一次类型转换,如果删除sayHello(int arg),会输出hello long,道理一样,并且会一直持续下去。
具体为,char->int->long->Character->Serializable->object->char… 。
具体来说,静态分派就是就是选择重载版本的过程。

  • 动态分派
    动态分派用来解决应该调用重写的哪个方法。看代码
public class DynamicDispatch {	
	static abstract class Human{
		protected abstract void sayHello();
	}
	
	static class Man extends Human {
		@Override
		protected void sayHello() {
			System.out.println("man say hello");
		}
		
	}
	
	static class Woman extends Human {
		@Override
		protected void sayHello() {
			System.out.println("woman say hello");
		}
	}
	
	public static void main(String[] args) {
		Human man = new Man();
		Human woman = new Woman();
		man.sayHello();
		woman.sayHello();
		man = new Woman();
		man.sayHello();
	}
}

这个方法输出:

man say hello
woman say hello
woman say hello

利用javap来查看main函数的字节码

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class jvmTest/DynamicDispatch$Man
         3: dup
         4: invokespecial #3                  // Method jvmTest/DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #4                  // class jvmTest/DynamicDispatch$Woman
        11: dup
        12: invokespecial #5                  // Method jvmTest/DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #6                  // Method jvmTest/DynamicDispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #6                  // Method jvmTest/DynamicDispatch$Human.sayHello:()V
        24: new           #4                  // class jvmTest/DynamicDispatch$Woman
        27: dup
        28: invokespecial #5                  // Method jvmTest/DynamicDispatch$Woman."<init>":()V
        31: astore_1
        32: aload_1
        33: invokevirtual #6                  // Method jvmTest/DynamicDispatch$Human.sayHello:()V
        36: return
      LineNumberTable:
        line 25: 0
        line 26: 8
        line 27: 16
        line 28: 20
        line 29: 24
        line 30: 32
        line 31: 36
}

我们来简单解释下:
0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,并将这两个实例的引用存放在第1、2个局部变量表Slot之中,这个动作对应于代码中的这两句:

Human man = new Man();
Human woman = new Woman();

16~21句是关键部分,具体16、20两句分别把刚刚创建的两个对象的引用压到栈顶。
17和21句是方法调用指令,这两条指令看起来是完全一样的,但是最终执行的目标方法并不相同,这是为什么呢?
我们来看一条invokevirtual指令的运动解析过程的步骤:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  2. 如果类型C中有与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过就返回这个方法的直接引用,查找过程结束;如果不通过,则返回异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第二步。
  4. 如果没有找到合适的方法就抛出异常。

这样,我们就明白了为什么执行的目标方法不同,因为再执行指令的第一步就是找出方法所属的实际类型,那么目标方法必然不同。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值