Java类加载

1 类加载机制

1.1 类加载时机

Java类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using) 和 卸载(Unloading)七个阶段.其中准备、验证、解析3个部分统称为连接(Linking)

类的生命周期

加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定).以下陈述的内容都已HotSpot为基准.特别需要注意的是,类的加载过程必须按照这种顺序按部就班地"开始",而不是按部就班的"进行"或"完成",因为这些阶段通常都是相互交叉地混合式进行的,也就是说通常会在一个阶段执行的过程中调用或激活另外一个阶段.

对于初始化阶段,虚拟机规范严格规定了有且只有5 种情况必须立即对类进行"初始化",加载、验证、准备在初始化之前已经进行.这5种方式称为对类的主动引用.

  • 遇到new 、getstatic 、putstatic 或invokestatic 这4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化. 生成这4 条指令的最常见的Java 代码场景是: new关键字实例化对象、读取或设置一个类的静态字段(被final 修饰、已在编译期把结果放入常量池的静态字段除外),调用一个类的静态方法.
  • 使用java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,先触发其初始化.
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要初始化父类.
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main() 方法的那个类),虚拟机会先初始化这个主类.
  • 当使用JDK 1.7 的动态语言支持时,如果一个java.lang.invoke.MethodHandle 实例最后的解析结果REF_getStatic 、REF_putStatic 、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,要对其进行初始化.

类的被动引用不会引起初始化.下面几种类的被动引用不会引起类的初始化.

  • 通过子类引用父类的静态字段不会导致子类初始化.
  • 通过数组定义来定义的引用类不会触发此类的初始化.
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

接口的不同特点:

接口也有初始化过程,接口中不能使用"static {}"语句块,但编译器仍然会为接口生成"<clinit>()" 类构造器,用于初始化接口中所定义的接口常量.一个接口在初始化时,并不要求其父接口全部都完成了初始化, 只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化.

特别注意: 下面的情况对类和接口同样适用.

public static final int A = 1; // 使用不会引起类初始化
public static final int B = new Integer(1); // 使用会引起类初始化
public static final Integer C = new Integer(1); // 使用会引起类初始化
public static final Integer D = 1; // 使用会引起类初始化

其它七种基本变量类似

字符串:
public static final String S = "abc"; // 不会引起类初始化
public static final String S = new String("abc"); // 会引起类初始化

其它情况均会引起初始化.

1.2 类加载过程

1.2.1 加载(Loading)

加载阶段,虚拟机完成以下3 件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,譬如:网络、动态生成、数据库等).
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class 对象,作为方法区这个类的各种数据的访问人口

获取二进制流的方式:

  • 从ZIP,JAR、EAR、WAR 格式中读取.
  • 从网络中获取,如Applet .
  • 运行时计算生成,如动态代理技术,在java.lang.reflect.Proxy中, 就是用了ProxyGenerator.generateProxyClass来为特定的接口生成形式为"*$Proxy"的代理类的二进制字节流
  • 由其他文件生成,JSP文件生成对应的Class类.
  • 从数据库中读取
  • ...

数组类型的加载

数组类本身不通过类加载器创建,它是由Java 虚拟机直接创建.一个数组类(下面简称为C)创建过程要遵循以下规则:

  • 如果数组的组件类型(Component Type ,指的是数组去掉一个维度的类型)是引用类型,那就递归采用定义的加载过程去加载这个组件类型
  • 如果数组的组件类型不是引用类型(例如int[]数组), Java 虚拟机将会把数组C 标记为与引导类加载器关联
  • 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public
  • 字节码二进制字节流按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义.加载阶段与连接阶段的部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序.

1.2.2 验证(Verification)

确保Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全.对不同的虚拟机言,验证的实现可能会有所不同,但大多数会完成四个阶段的检验过程.

四个阶段的检验过程

  • 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理.验证内容主要包括:
    • 是否以魔数0xCAFEBABE 开头
    • 主、次版本号是否在当前虚拟机处理范围之内
    • 常量池的常量中是否有不被支持的常量类型(检查常量tag 标志)
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
    • CONSTANT_Utf8_info 型的常量中是否有不符合UTF8 编码的数据
    • Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息
    • ...
  • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java 语言规范的要求,主要是:
    • 类是否有父类(除了java.lang.Object 之外,所有的类都应当有父类)
    • 类是否继承了不允许被继承的类(被final 修饰的类)
    • 类不是抽象类,是否实现了父类或接口之中要求实现的所有方法
    • 类中的字段、方法是否与父类产生矛盾,(例如覆盖了父类的final 字段,或者出现不符合规则的方法重载,例如方法参数都一数,但返回值类型却不同等)
    • ...
  • 字节码验证:进行数据流和控制分析.对类的方法体进行校验分析.保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,主要是:
    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
    • 保证跳转指令不会跳转到方法体以外的字节码指令上
    • 保证方法体中的类型转换是有效的
    • ...
  • 符号引用验证:对类自身以外(常最池中的各种符号引用)的信息进行匹配性的校验,主要是:
    • 符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
    • 符号引用中的类、字段和方法的访问性是否可被当前类访问
    • ...

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响. 如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间.

1.2.3 准备(Preparation)

准备阶段是正式为类变量(static 成员变量)分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都将在方法区中进行分配.初始值指基本数据类型的零值,引用数据类型的null.如果类字段的字段属性表中存在ConstantValue 属性,那在准备阶段变量value 就会被初始化为ConstantValue 属性所指定的值.

基本数据类型的零值 | 数据类型 | 零值 | | --- | --- | | int | 0 | | long | 0L | | short | (short)0 | | char | '\u0000' | | byte | (byte)0 | | boolean | false | | float | 0.0f | | double | 0.0d | | reference | null |

1.2.4 解析(Resolution)

虚拟机将常量池内的符号引用替换为直接引用的过程.

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可.符号引用与虚拟机实现的内存布局无关, 引用的目标并不一定已经加载到内存中.
  • 直接引用(Direct References): 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄.直接引用与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同.如果有了直接引用,那引用的目标必定已经在内存中存在.

Java虚拟机指令anewarray、checkcast、getfield、getstatic、instanceof、nvokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic将符号引用指向运行时常量池.执行上述任何一条指令都需要对它的符号引用的进行解析.

解析有下面几种

  • 类与接口解析
  • 字段解析
  • 普通方法解析
  • 接口方法解析
  • 方法类型与方法句柄解析
  • 调用点限定符解析

1.2.5 初始化(Initialization)

初始化阶段是执行类构造器<clinit>()方法的过程.

()方法的特点和细节

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
  • <clinit>()方法与类的构造函数(或者说()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕.因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object .
  • 父类的<clinit>()方法先执行,父类中定义的静态语句块要优先于子类的变量赋值操作.
  • <clinit>()方法对于类或接口来说并不是必须的.如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法.
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成<clinit>()方法.但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法.只有当父接口中定义的变量被使用时,父接口才会被初始化.另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法.
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个统程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法.其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕.如果在一个类的<clinit>()方法中有耗时很长的操作, 就可能造成多个进程阻塞,在实际应用中这种阻塞是很隐蔽的.

注意:如果执行<clinit>()方法的那条线程执行完<clinit>()方法退出,其他线程唤醒之后不会再次进入<clinit>()方法.同一个类加载器下,一个类型只会初始化一次.

2.类加载器

2.1 类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一向确立其在Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间.比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等.这里所指的"相等",包括代表类的Class 对象的equals() 方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof 关键字做对象所属关系判定等情况.

从Java虚拟机的角度来讲,只存在两种不同的类加载器:

  • 一种是启动类加载器(Bootstrap ClassLoader),它C++语言实现,是虚拟机自身的一部分
  • 另一种就是所有其他的类加载器,这些类加载器都出Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader.

从发开人员的角度来看,类加载器有下面3种.

  • 启动类加载器 (BootstrapClassLoader):存放在<JAVA_HOM/lib>目录中的,或者被-Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中(一般是java.lang下的类,和java.io下的类).启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,直接使用null 代替
  • 扩展类加载器(ExtensionClassloader): 由sun.misc.Launcher$ExtClassLoader实现,负责加载<JAVA_HOME>/lib/ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加器
  • 应用程序类加载器(ApplicationClassLoader): 这个类加载器由sun.misc.Launcher$AppClassLoader来实现. 由于这个类加载器是ClassLoader 中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器.负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况这个就是程序中默认的类加载器.

2.2 双亲委派模型

类加载的器双亲委派模型 类加载的器双亲委派模型

黑客自定义一个java.lang.String类,该String类具有系统的String类一样的功能,只是在某个函数稍作修改.比如equals函数,这个函数经常使用,如果在这这个函数中,黑客加入一些"病毒代码".并且通过自定义类加载器加入到JVM中.此时,如果没有双亲委派模型,那么JVM就可能误以为黑客自定义的java.lang.String类是系统的String类,导致"病毒代码"被执行.

JVM在加载类时默认采用的是双亲委派机制.通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归 (本质上就是loadClass函数的递归调用).因此,所有的加载请求最终都应该传送到顶层的启动类加载器中.如果父类加载器可以完成这个类加载请求,就成功返回;只有当父类加载器无法完成此加载请求时,子加载器才会尝试自己去加载.事实上,大多数情况下,越基础的类由越上层的加载器进行加载,因为这些基础类之所以称为"基础",是因为它们总是作为被用户代码调用的API(当然,也存在基础类回调用户用户代码的情形)

类加载器之间的层次关系,称为类加载器的双亲委派模型(Parents Delegation Model).除了顶层的启动类加载器外,其余的类加载器都有父类加载器.

工作过程是: 如果一个类加载器收到了类加载的请求,先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父加器无法加载这个类时,子载加载器才自己去加载.

2.3 破坏双亲委派模型

双亲委派模型主要出现过3次较大规模的"被破坏"情况:

  • 第一次"被破坏"发生在双亲委派模型出现之前.亲委派模型在JDK 1.2 之后才被引入,类加载器和抽象类Java. lang. ClassLoader 则在JDK 1 .0 时代就已经存在,对已经存在的用户自定义类加载器的实现代码, Java 设计者们引入双亲委派模型时做出了一些妥协.
  • 第二次"被破坏"是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的类加载器进行加载),基础类之所以被称为"基础",是因为它们总是作为被用户代码词用的API.但是基础类有时又要调用用户代码,如JNDI.此时就引入了线程上下文类加器.
  • 第三次"被破坏"是由于用户对程序动态性的追求而导致的.代码热替换(HotSwap)、模块热部署(Hot Deployment)等.

OSGi类加载器 OSGi框架实现了一个优雅、完整和动态的组件模型.应用程序(称为bundle)无需重新引导可以被远程安装、启动、升级和卸载(其中Java包/类的管理被详细定义).API中还定义了运行远程下载管理政策的生命周期管理.服务注册允许bundles去检测新服务和取消的服务,然后相应配合.

OSGi 中的每个模块(称为Bundle)与普通的Java 类库区别并不太大,两者一般都以JAR 格式进行封装,并且内部存储的都是Java Package 和Class .但是一个Bundle 可以声明它所依赖的Java Package (通过Import-Package描述),也可以声明它允许导出发布的Java Package(通过Export-Package描述) .在OSGi 里面,Bundle之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖,而且类库的可见性能得到了非常精确的控制,一个模块里只有被Export 过的Package 才可能被外界访问, 其他的Package 和Class 将会被隐藏起来.

OSGi 诱人的特点,要归功于它灵活的类加载器架构.OSGi的Bundle类加载器之间只有规则,没有固定的委派关系.假设存在BundleA 、BundleB 和BundleC三个模块,并且这三个Bundle定义的依赖关系为

  • Bundle A:声明发布了packageA ,依赖了java.*的包;
  • BundleB: 声明依赖了packageA 和packageC ,同时也依赖了java.*的包;
  • BundleC: 声明发布了packageC ,依赖了packageA .

它们的类加载器及父类加载器之间的关系: OSGi类加载器架构

OSGi类载器的加载规则:

  • 以java.*开头的类,委派给父类加载器加载
  • 否则,委派列表名单内的类,委派给父类加载器加载
  • 否则,Import列表中的类,委派给Export这个类的Bundle的类加载器加载
  • 否则,查找当前Bundle 的Classpath,使用自己的类加载器加载
  • 否则,查找是否在自己的Fragment Bundle中,如果是则委派给Fragment Bundle的类加载器加载
  • 否则,查找Dynamic Import 列表的Bundle,委派给对应Bundle的类加载器加载
  • 否则,类查找失败

缺点:OSGi 在提供强大功能的同时, 也引入了额外的复杂度,带来了线程死锁和内存泄漏的风险.

3.虚拟机字节码执行引擎

Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种版本虚机执行引擎的统一外观(Facade).在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎.

3.1 运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素.栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息.每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程.

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code 属性之中, 因此一个栈帧需要分配多少内存, 不会受到程序运行期变量数据的影响, 而仅仅取决于具体的虚拟机实现.

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame), 与这个栈帧相关联的方法称为当前方法(Current Method) .执行引擎运行的所有字节码指令都只针对当前栈帧进行操作.

栈帧的概念模型 栈帧的概念模型

3.1.1 局部变量表

局部变量表(Local Variable Table) 是一组变量值存储空间.用于存放方法参数和方法内部定义的局部变量.在Java程序编译为Class文件时,就在方法的Code属性的max_localso数据项中确定了该方法所需要分配的局部变量表的最大容量.

局部变量表的容量以变盘槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小.

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量.64位的数据会使用两个槽.

如果执行的是实例方法(非static 的方法),局部变量表中第0位索引的Slot 默认是用于传递方法所属对象实例的引用, 在方法中可以通过关键字"this"来访问到这个隐含的参散.其余参数则按照参数表顺序排列,占用从l 开始的局部变量Slot,参数表分配完毕后, 再根据方法体内部定义的变量顺序和作用域分配其余的Slot.

类变量有两次赋初始值的过程, 一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值.

3.1.2 操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈.同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中.操作数栈的每一个元素可以是任意的Java 数据类型,包括long 和double.32位数据类型所占的栈容量为1.64位数据类型所占的栈容量为2.在方法执行的任意时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值.

概念模型中,两个栈帧作为虚拟机栈的元素是完全相互独立的.但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠.这样在进行方法调用的时候可以共用一部分数据无须进行额外的参数复制传递. 两个栈帧之间数据共享

3.1.3 动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用, 持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking).

Class 文件中存放了大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数.这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析.另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接.

3.1.4 方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:

  • 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion).
  • 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java 虚拟机内部产生的异常,还是代码中使用athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion).一个方法使用异常完成出口的方式退出, 是不会给它的上层调用者产生任何返问值的.

一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧很可能会保存这个计数器值.而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息.

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值(如果有的话)压入调用者栈帧的操作数栈中
  • 调整PC计数器的值以指向方法调用指令后面的一条指令等

3.1.4 附加信息

虚拟机规范并没有规定具体虚拟机实现包含什么附加信息,这部分的内容完全取决于具体实现.在实际开发中,一般会把动态连接,方法返回地址和附加信息全部归为一类,称为栈帧信息.

3.2 方法调用

方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程.一切方法调用在class文件里面存储的都只是符号引用,而不是方法在实际运行时内存中的入口地址.

3.2.1解析

所有方法调用中的目标方法在class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的.换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来.这类方法的调用称为解析(Resolution).

在Java 语言中,符合"编译期可知,运行期不可变"这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问, 这两种方法都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析.

Java虚拟机提供的5条方法调用字节指令:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器方法、私有方法和父类方法
  • invokevirtual:调用所有的实例方法
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
  • Invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4 条调用指令,分派逻辑是固化在Java 虚拟机内部的,而invokedynamic 指令的分派逻辑是向用户所设定的引导方法决定的.在Java 语言规范中明确说明了final方法是一种非虚方法

静态方法、私有方法、实例构造器、父类方法.这些方法称为非虚方法,它们在类加载的时候就会把符号引用解析为该方法的直接引用.与之相反,其他方法称为虚方法(除去final方法).

3.2.2 分派

解析调用一定是个静态的过程,在编译期就完全确定,在类加载的解析阶段就将涉及的符号引用全部转变为可以确定的直接引用,不会延迟到运行期再去完成.而分派(Dispatch)调用则可能是静态的也可能是动态的.分派是多态性的体现,Java虚拟机底层提供了我们开发中"重载"(Overload)"和重写"(Override)的底层实现.其中重载属于静态分派,而重写则是动态分派的过程.

静态分派 静态分派只会涉及重载(Oveload),而重载是在编译期间确定的,那么静态分派自然是一个静态的过程(因为还没有涉及到Java虚拟机).静态分派的最直接的解释是在重载的时候是通过参数的静态类型而不是实际类型作为判断依据的.因此在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本.

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派.静态分派是还没有涉及到虚拟机,由编译期执行.虽然编译器能够在编译阶段确定方法的版本,但是很多情况下重载的版本不是唯一的,在这种模糊的情况下,编译器会选择一个更合适的版本.

动态分派 动态分派的一个最直接的例子是重写(Override).

Java虚拟机的invokevirtual指令的具体解析过程如下

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

3.2.3 虚拟机动态分派的实现

由于动态分派是非常频繁的操作,实际实现中不可能真正如此实现.Java虚拟机是通过"稳定优化"的手段——在方法区中建立一个虚方法表(Virtual Method Table),通过使用方法表的索引来代替元数据查找以提高性能.虚方法表中存放着各个方法的实际入口地址(由于Java虚拟机自己建立并维护的方法表,所以没有必要使用符号引用,那不是跟自己过不去嘛),如果子类没有覆盖父类的方法,那么子类的虚方法表里面的地址入口与父类是一致的;如果重写父类的方法,那么子类的方法表的地址将会替换为子类实现版本的地址.

方法表是在类加载的连接阶段(验证、准备、解析)进行初始化,准备了子类的初始化值后,虚拟机会把该类的虚方法表也进行初始化.

虚方法表结构实例

3.3 基于栈的指令集与基于寄存器的指令集

Java 编译器输出的指令流,是一种基于栈的指令集架构(Instruction SetArchitecture, ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作.

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束.

使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些.栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译端实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等.

栈架构指令集的主要缺点是执行速度相对慢一些.栈架构指令集的代码非常紧凑,但是完成相同功能所需要的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量.

results matching ""

    No results matching ""