从原理聊JVM(四):JVM中的方法调用原理

原理,jvm,方法,调用 · 浏览次数 : 99

小编点评

**静态分派** 1.找到操作数栈顶元素的对象的实际类型,记作C。 2.如果在C中找到与常量中描述符和简单名称都一样的方法,则进行访问权限校验,通过则返回方法的直接引用,否则抛出异常java.lang.IllegalAccessError。 3.否则,按照继承关系从下往上依次寻找C类的父类,来进行第二步中的方法查找和校验。 4.最后仍未能找到方法则抛出异常java.lang.AbastractMethodError。 **动态分派** 1.如果在C中找到与常量中描述符和简单名称都一样的方法,则进行访问权限校验,通过则返回方法的直接引用,否则抛出异常java.lang.IllegalAccessError。 2.否则,按照继承关系从下往上依次寻找C类的父类,来进行第二步中的方法查找和校验。 3.最后仍未能找到方法则抛出异常java.lang.AbastractMethodError。

正文

1 引言

多态是Java语言极为重要的一个特性,可以说是Java语言动态性的根本,那么线程执行一个方法时到底在内存中经历了什么,JVM又是如何确定方法执行版本的呢?

2 栈帧

JVM中由栈帧存储方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法的调用就是从入栈到出栈到过程。

2.1 局部变量表

局部变量表由变量槽组成,《Java虚拟机规范》指出:“每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据”。

这八种数据类型都可以使用32位或更小的物理内存来存储,如果是64位虚拟机环境下,虚拟机需要通过对齐填充来使变量槽与在32位虚拟机环境下外观一致。

如果是64位的数据类型,比如long和double,JVM会以高位对齐的方式为其分配两个连续的变量槽空间。且规定不允许以任何方式访问这两个变量槽的其中一个,类加载的校验阶段会针对违反规定的行为抛出异常。

类变量会有两次赋值,一次是准备阶段给赋值一个默认值,二是初始化阶段,赋予程序定义的值。但方法变量没有准备阶段,所以没赋值的方法变量不能被使用。

2.2 变量槽的复用

为了节省内存空间,变量槽是可以复用的。当程序计数器的值超过方法体中定义的变量的作用域时,这个变量的变量槽就可以被其他变量复用了。不过虽然这样可以节省内存空间,但对GC有一定影响。

举个例子,如果没有发生即时编译的前提下,在方法清单1中placeholder不会被回收。原因是,方法清单1中gc发生时,变量槽仍然保持着对placeholder的引用,所以不会被标记为可回收对象。而在方法清单2中国呢增加了int a = 0后,placeholder原有的变量槽被变量a复用了,也就不存在引用placeholder的变量槽了,所以placeholder就可以被回收了。

方法清单1:

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

方法清单2:

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

但是实际上,大部分程序都是运行在即时编译下的,所以编译器会对其进行优化,实际情况下方法清单1中placeholder也能被回收。

2.3 操作数栈

操作数栈主要作用有二:

1.作为计算过程中的所需变量的临时存储空间

2.存储系统运行过程中的计算中间结果

操作数栈不能通过指针访问,只能通过弹栈和压栈来操作其内部元素。当执行某项指令前会将所需变量压入栈顶,然后真正执行指令时从栈顶依次取出用来执行具体指令,执行完成后会将结果在压入操作数栈。

大多数虚拟机实现会有一些优化处理,将两个栈帧部分重叠:上一个栈帧的部分操作数栈和下一个栈帧的部分局部变量表。不仅节约空间,还让下面栈的操作可以直接使用上面栈的内容,减少了参数传递。

2.4 动态链接

Java文件被编译成Class文件后,变量和方法的引用都作为符号引用保存在Class文件中的常量池中。而对于方法的引用,某些可以在编译期就确定下来称为“直接引用”,而有些方法只能在运行期才能确定下来(比如方法的重载)。

动态链接的作用就是在运行期将符号引用转换为直接引用。

2.5 方法返回地址

一个方法执行完成后,有两种方式退出:正常完成和抛出异常。

当方法A中调用方法B时,A的栈帧中会保存程序计数器的值作为返回地址。而异常退出时,返回地址是要通过异常处理器表来确定的。

方法返回后还会进行几个操作:

1.恢复主调线程对应栈帧中的局部变量表和操作数栈

2.把返回值压入主调线程的栈帧中

3.调整程序计数器到方法调用指令的下一条指令

2.6 附加信息

不同虚拟机在实现时可以自定义一些例如调试、性能收集等信息放到栈帧之中。

3 方法调用

一切方法调用在Class文件里面存储的都只是符号引用,某些调用需要在类加载时甚至运行期间才能确定目标方法的直接引用,这是Java强大的动态扩展能力的基础。

3.1 方法调用指令

JVM共支持以下5种方法调用字节码指令:

•invokestatic调用静态方法

•invokespecial调用构造器()方法、私有方法和父类中的方法

•invokevirtual调用所有虚方法

•invokeinterface调用接口方法,运行期会确定具体实现该接口的对象

•invokedynamic调用运行期动态解析出具体调用的方法

其中,invokestatic和invokespecial指令调用的方法,都可以类加载的解析阶段确定调用的方法版本,Java中符合这个条件的方法共有五种:静态方法、私有方法、实例构造器、父类方法和final修饰的方法(它使用invokevirtual指令调用)。

这五种方法称为“非虚方法”(Non-Virtual Method),剩下的均为“虚方法”(Virtual Method)。

3.2 解析

如果一个方法在类加载的解析阶段就能确定方法的调用版本,那么这类方法的调用被称为解析(Resolution)。

Java中符合解析标准的主要是静态方法私有方法。前者与类型直接相关,后者对外不可见。

方法调用指令中,invokestaticinvokespecial指令调用的方法,再加上final修饰的方法,都被称作“非虚方法”,他们都可以在解析阶段确定唯一的调用版本。其他的方法都被称作“虚方法”。

3.3 分派

在编译阶段,依赖静态类型确定方法的调用版本,这就叫做“静态分派”

而在运行期,根据实际类型确定方法调用版本被称作“动态分派”

3.3.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,gentleman!");
  }
  public void sayHello (Woman guy) {
    System.out.println("hello,lady!");
  }
  public static void main (String[] args) {
    StaticDispatch sd = new StaticDispatch();
    Human man = new Man();
    Human woman = new Woman();
    sd.sayHello(man);
    sd.sayHello(woman);
  }
}

输出如下:

hello,guy!
hello,guy!

首先介绍一下静态类型和实际类型。

当声明变量man时:

Human man = new Man();

其中Human被称为“静态类型”,ManWoman叫做“实际类型”。静态类型在编译期可知,实际类型需要等到运行期才能确定。

回到上面的示例,为什么两次输出都是hello,guy!呢?

这是因为编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的。所以上面代码示例中,多次不同对象调用sayHello()均找到各自的静态类型对应的方法版本,即sayHello (Human guy)

方法版本的选择顺序

事实上,虽然编译器能确定方法的重载版本,但往往这并不是唯一的,仅仅只能确定一个“相对更合适的“版本。

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

输出如下:

hello char

如果删除掉sayHello (char arg)方法,则会匹配到sayHello (int arg)方法,编译器匹配的转型顺序是char > int > long > float > double,但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。

3.3.2 动态分派

区别于静态分派,如果需要在运行期根据对象类型来确定方法版本,则属于动态分派。

public class DynamicDispatch {
    static abstract class Human {
        public void speak() {
            System.out.println("I'm human");
        }
    }
    static class Man extends Human {
        @Override
        public void speak() {
            System.out.println("I'm man");
        }
    }
    static class Woman extends Human {
        @Override
        public void speak() {
            System.out.println("I'm woman");
        }
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.speak();
        woman.speak();
	      man = new Woman();
        man.speak();
    }
}

输出如下:

I'm man
I'm woman
I'm woman

在上面的例子中,对于speak()方法的调用在编译期完全无法确定,所以只能动态匹配对应的方法版本。那么JVM是如何进行匹配呢?答案就是invokevirtual指令。

invokevirtual指令运行过程

Java做到多态的根本原因是invokevirtual指令的执行逻辑,该命令的运行过程如下:

1.找到操作数栈顶元素的对象的实际类型,记作C。

2.如果在C中找到与常量中描述符和简单名称都一样的方法,则进行访问权限校验,通过则返回方法的直接引用,否则抛出异常java.lang.IllegalAccessError。

3.否则,按照继承关系从下往上依次寻找C类的父类,来进行第二步中的方法查找和校验。

4.最后仍未能找到方法则抛出异常java.lang.AbastractMethodError。

注意:由此也能看出,Java中只有虚方法,没有“虚字段”,如果子类和父类存在相同名称的字段,子类中的字段会覆盖父类中的字段。

3.3.3 静态分派和动态分派的对比

分派类型 原理 发生阶段 应用场景
静态分派 根据静态类型判断方法版本 编译期 重载
动态分派 根据实际类型判断方法版本 运行期 重写

作者:京东科技 康志兴

来源:京东云开发者社区

与从原理聊JVM(四):JVM中的方法调用原理相似的内容:

从原理聊JVM(四):JVM中的方法调用原理

多态是Java语言极为重要的一个特性,可以说是Java语言动态性的根本,那么线程执行一个方法时到底在内存中经历了什么,JVM又是如何确定方法执行版本的呢?

从原理聊JVM(一):染色标记和垃圾回收算法

本篇介绍了JVM中垃圾回收器相关的基础知识,后续会深入介绍CMS、G1、ZGC等不同垃圾收集器的运作流程和原理,欢迎关注。

从原理聊JVM(二):从串行收集器到分区收集开创者G1

随着Java的进化过程,涌现出各种不同的垃圾回收器,从串行执行到并行执行,从高吞吐到低延迟,终极目标就是让开发人员专注于程序的代码书写而无需关注内存管理。

从原理聊JVM(三):详解现代垃圾回收器Shenandoah和ZGC

现代的垃圾回收器为了低停顿的目标可谓将“并发”二字玩到极致,Shenandoah在G1基础上做了非常多的优化来使回收阶段并行,而ZGC直接采用了染色指针、NUMA等黑科技,目的都是为了让Java开发者可以更多的将精力放在如何使用对象让程序更好的运行,剩下的一切交给GC,我们所做的只需享受现代化GC技术带来的良好体验。

从原理聊 JVM(五):JVM 的编译过程和优化手段

# **一、前端编译** 前端编译就是将Java源码文件编译成Class文件的过程,编译过程分为4步: ## **1 准备** 初始化插入式注解处理器(Annotation Processing Tool)。 ## **2 解析与填充符号表** 将源代码的字符流转变为标记(Token)集合,构造出`

[转帖]为什么说JVM是黑盒子般存在,从Java 虚拟机原理内存开始

https://maimai.cn/article/detail?fid=1739907745&efid=ALbQzkwOvQr-0GryeUzRsw 为什么要学习 JVM 在很多 Java 程序员的开发生涯里,JVM 一直是黑盒子一般的存在,大家只知道运行 Java 程序需要依靠 JVM,千篇一律

消失的死锁:从 JSF 线程池满到 JVM 初始化原理剖析

在一次上线时,按照正常流程上线后,观察了线上报文、接口可用率十分钟以上,未出现异常情况,结果在上线一小时后突然收到jsf线程池耗尽的报警,并且该应用一共有30台机器,只有一台机器出现该问题,迅速下线该机器的jsf接口,恢复线上。然后开始排查问题。

[转帖]JVM优化之调整大内存分页(LargePage)

转自:http://cjjwzs.iteye.com/blog/1059381 本文将从内存分页的原理,如何调整分页大小两节内容,向你阐述LargePage对JVM的性能有何提升作用,并在文末点明了大内分页的副作用。OK,让我们开始吧! 内存分页大小对性能的提升原理 首先,我们需要回顾一小部分计算机

聊一聊领域驱动与贫血模型

写在前面 前段时间跟领导讨论技术债概念时不可避免地提到了代码的质量,而影响代码质量的因素向来都不是单一的,诸如项目因素、管理因素、技术选型、人员素质等等,因为是技术债务,自然就从技术角度来分析,单纯从技术角度来看代码质量,其实又细分很多原因,如代码设计、代码规范、编程技巧等等,但我个人觉得这些都是技

从原理到实战,详解XXE攻击

本文分享自华为云社区《【安全攻防】深入浅出实战系列专题-XXE攻击》,作者: MDKing。 1 基本概念 XML基础:XML 指可扩展标记语言(Extensible Markup Language),是一种与HTML类似的纯文本的标记语言,设计宗旨是为了传输数据,而非显示数据。是W3C的推荐标准。