JVM(六):虚拟机类加载机制

回顾

上一篇我们已经看过了Class文件的格式,认识了Class文件中描述的各类信息,但Class文件最终都需要被加载虚拟机中之后才能被运行和使用,下面就来看一下JVM虚拟机是如何加载这些Class文件的

概述

首先来谈谈虚拟机的类加载机制

虚拟机的类加载机制是指虚拟机将描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这一整个过程就称为类加载机制

在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,与编译是完全分开的,这样的设计让类加载过程产生了额外的消耗、增加了一些性能开销,并且让Java语言进行提前编译会面临额外的困难;但这样设计却给Java应用提供了极高的扩展性和灵活性,比如Java的动态扩展特性就是依赖这个设计而实现的,依赖于运行期动态加载和动态连接这个特点来实现的;比如去编写一个面向接口的程序,可以等多程序运行时再指定接口对应的实现类(多态)

类加载的时机

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,整个过程(不单单只是类加载过程),也可以说是整个生命周期将会经历七个阶段

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Intialization)
  • 使用(Using)
  • 卸载(Unloading)

其中验证、准备、解析三步部分统称为连接(Linking)
在这里插入图片描述
其中对前3部分,顺序是一定的,也就是一定是先执行加载、然后验证、然后准备,但到了解析过程,就不一定了,在某些情况下,解析过程会和初始化过程替换,也就是先初始化了,然后再解析,之所以这样做,是因为要支持Java语言的运行时绑定特性(也就是所谓的动态绑定,后面再详细介绍)

但这里说的按顺序执行,仅仅只是开始的顺序,并不是完成了加载、然后再进行验证,这样按顺序完成,因为有可能这些阶段会互相交叉地混合进行的,会在前面一个阶段执行的过程中调用、激活了另一个阶段,比如加载开始了,但在加载的过程中激活了验证过程

而类加载过程指的是前面5个阶段,如果归纳为连接阶段,则只有加载、连接、初始化这三个阶段

下面来说明一下类加载的时机

对于第一个阶段加载,虚拟机规范并没有进行强制约束,这点是交由虚拟机的具体实现来自由掌握的

但对于初始化阶段,虚拟机规范则是严格地规定了有且仅有六种情况必须对类进行初始化(而加载、验证、准备这三个过程就肯定是在初始化之前发生)

如下六种

  1. 遇到了new、getStatic、putStatic或者invokeStatic这4条字节码指令时,如果对应的类型没有进行过初始化,则会先触发其初始化阶段,这4条指令分别在下面的Java场景中生成
    • 使用new关键字去实例化对象,产生new指令
    • 读取或设置一个静态类型的字段,产生getStatic命令(但并不是所有的静态类型字段,如果被final修饰,已在编译器就把结果放入常量池的静态字段,是不是生成getStatic命令的)
    • 使用一个类型的静态方法的时候,产生invokeStatic命令
  2. 使用反射特性对类型进行反射调用的时候,如果类型还没进行过初始化,则需要先触发该类型初始化
  3. 当初始化类的时候,发现其父类还没有进行过初始化,也需要先触发其父类的初始化
  4. 当虚拟机启动的时候,用户需要指定一个主函数,即main方法,虚拟机启动的时候会对主函数所在的类进行初始化
  5. 当使用JDK7新加入的动态语言支持时,如果一个MethodHandler实例最后的解析结果为REF_getStatic,REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的句柄时,并且这个方法句柄对应的类还没有进行过初始化,需要先触发该类初始化
  6. 当一个接口中定义了JDK8的新加入的默认方法(default关键字修饰的接口方法),如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化(难道不用default方法,接口就不会提前实现类先初始化了?????)

类加载的过程

上面已经分析过类在什么时候进行加载了,下面就看一下类加载的过程

加载

加载阶段仅仅只是类加载过程中的一个阶段,在加载阶段,JVM虚拟机主要完成以下三件事情

  1. 通过一个类的全限定名来获取定义该类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构(所以类的信息都存放在方法区中)
  3. 在内存中生成一个代表这个类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入口

对于第一步,虚拟机规范里面并没有限定死二进制字节流一定从Class文件中取出,确切地说压根没有指明要从那里获取、如何获取

同时加载过程也有区分,主要分为

  • 非数组类型的加载阶段
  • 数组类型的加载阶段

这两个的主要不同在于第一步,如何通过一个类的全限定名来获取该类的字节流

对于非数组类型的加载阶段,获取该类的字节流是通过虚拟机内置的类加载器来完成的,而且类加载器也可以分为引导类加载器和自定义的类加载器(自定义类加载器只需要重写类加载的findClass或者loadClass方法即可)

但对于数组类型的加载阶段,数组类本身是没有类加载器创建的,而是由Java虚拟机直接在内存中动态构建出来的,但是数组类也需要使用到类加载器,这是因为数组里面存储的元素类型,是通过类加载器来完成的(这里的元素是指去除了所有维度的,比如一个二维数组,需要降两维,也就是一维数组里面的元素)

一个数组类的创建过程需要遵循以下规则

  • 如果数组的组件类型(即去掉一个维度的类型)是引用类型,则采用递归处理,继续去使用对应的加载阶段去加载这个组件(数组也是引用类型,比如二维数组,去掉一个维度变成了一维数组,任然是一个引用类型,同时还是一个数组,根据规则,仍然需要去降维度),并且数组将会被标识在加载该组件类型的类加载器的类名称空间上,这样做的目的是保证了一个类型必须与类加载器一起确定唯一性(后面介绍这个特性)
  • 如果数组的组件类型不是引用类型,而是基本的数据类型,比如一个int[]数组,那么虚拟机将会把数组标记为与引导类加载器关联(基本数据类型使用引导类加载器)
  • 数组类也是一个类,自然也有访问性,数组类的可访问性与它的组件类型的访问性是一致的,如果组件类型不是引用类型而是基本数据类型,那么数组类的可访问性将默认为Public,可被所有的类和接口访问到

拓展:估计会有一些人只知道对于成员属性和方法有权限修饰符,但对于类只知道那个public class,下面就来拓展一下

对于不是嵌套类,既不是内部类,那么就会有两种权限修饰符

  • public:公有的,对于所有包都能被访问到
  • default:这是缺省值,仅仅对于本包内可见

举个栗子

在这里插入图片描述
在这里插入图片描述
上面两张图片是我自己定义的两个类,一个有public修饰,一个缺省,接下来我会在另一个包中实例化这两个类
在这里插入图片描述
可以看到,idea爆红了,并且执行程序的时候也报错说明该类不是公共的,并且无法从外部程序包中对其访问

对于Class文件里面的信息,两者存在的区别也很明显

我们可以看到,对于使用public修饰的类,其flags会有ACC_PUBLIC(ACC_SUPER代表拥有父类,除了Object之外,其他的都有ACC_SUPER标志位)

在这里插入图片描述
下面我们来看一下没有Public修饰的,很明显可以看到flags处并没有ACC_PUBLIC标志位

在这里插入图片描述
加载的动作是交由类加载器完成的,当完成了加载阶段后,此时外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,至于方法区使用什么数据结构去存储外部的二进制字节流,虚拟机规范并没有明确指出,当方法区使用二进制字节流生成对应的具体数据结构,元数据妥善保管在方法区之后,那么就会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口,外部通过这个对象去取方法区的对应类型数据

这里要注意,加载阶段与连接阶段(验证、准备、解析)的部分动作是交叉进行的,即加载阶段尚未完成,连接阶段可能已经开始了,夹在加载阶段之中进行,但这两个的开始时间依然是保持顺序的,即先开始加载,后面再开始连接,但加载还没结束,连接就开始了

验证

完成了加载动作之后接下来的动作就是验证了,验证是属于连接操作中的第一步

验证的目的是确保Class文件的字节流中包含的信息是符合规范的全部约束要求的,保证这些信息被当作代码运行后不会危害虚拟机自身的安全

Java是一个相对安全的语言(对于C/C++来说)使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、或者将一个对象转化成它并未实现的类型、又或者跳转到不存在的代码行之类的事,如果尝试那么做了,编译器会直接抛出异常、并且拒绝编译的,这是在编译的时候的安全保证,但前面我们提到过,Class文件的来源并不是统一确定的,甚至可以用虚拟机去执行外部进来的Class文件,那这时候是没有经过编译步骤的,那么Java是怎么保证这些Class文件的安全性呢????

此时如果Java虚拟机不检查输入的字节流,对外来的Class文件完全信任的话,很可能会因为载入了错误的数据或有恶意企图的字节流而导致整个系统受到攻击而崩溃,这也是验证阶段在类加载机制中的必要性体现

验证阶段是非常重要的,验证阶段的严谨程度直接决定了虚拟机能承受多大程度的恶意代码的攻击,因为这个阶段很重要,所以验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重,验证阶段大致上会完成下面4个阶段的检验动作

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证
文件格式验证

首先要进行验证,肯定是先验证字节流是否符合Class文件格式的规范,验证能不能被当前版本的虚拟机进行处理

这一阶段可能会包括下面这些验证点

  • Class文件是否以魔数进行开头(魔数是0xCAFEBABY)

  • 主、次版本号是否在当前Java虚拟机接受的范围之内,如果不在范围之内,代表当前的虚拟机是无法处理这个Class文件的

  • 常量池的常量中是否有不被支持的常量类型,即检查常量的tag标志

  • 指向常量池中的常量的各种索引值是否有指向不存在的常量或不符合类型的常量

  • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据

  • Class文件中各个部分及文件本身是否有删除的或者添加的其他信息

  • 其他。。。。。。

上面仅仅是一些验证点,实际上远远不止那么少,文件格式的验证阶段的主要目的是保证输入的字节流可以正确地被虚拟机进行解析并且存储在方法区中,而且文件格式的验证阶段是基于二进制字节流进行的,只有通过了这个阶段的二进制字节流才能被允许进入Java虚拟机内存的方法区中进行存储,经过了文件格式验证阶段后,方法区里就生成了对应的数据结构了,后面的验证都是基于方法去上的存储结构进行的,不再是直接读取和操作字节流了。。。。。。。

元数据验证

第二个阶段就是对类的元数据进行验证,该阶段的作用是保证字节码描述的信息(字节码其实就是对类进行描述)符合规范,该阶段可能包括的验证点如下

  • 这个类是否含有父类(除了Object类之外,所有的类都应当会有父类)
  • 这个类的父类是否不能被继承(即final关键字修饰的类是不可以被继承的)
  • 类中的字段、方法是否与父类产生矛盾(比如出现不符合规则的重载、覆盖了父类的final字段)
  • 其他。。。。。

元数据验证阶段主要目的是对类的元数据信息进行语义校验,保证不存在与规范定义相斥的元数据信息

字节码验证

第三个阶段就是对字节码进行验证,这部分的验证与元数据验证不一样,这个阶段的验证主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的,这阶段是主要对类的方法体进行校验的,说白了就是Class文件里面的Code属性,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为(而元数据验证是保证类描述是正确的,没有对具体方法进行验证)

可能有以下的验证点

  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上

  • 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类变量,这是安全的;但如果将一个父类对象赋值给子类变量,就有可能出现承诺过多的行为(之前Java基础讲过),此时是不合法的

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能正常配合工作,比如在操作栈里面放置了一个int类型的数据,使用时也会根据int类型来加载入本地变量表中

  • 其他。。。。。。

没有通过字节码验证的方法体,该方法体一定是有问题的,但通过了字节码验证的方法体,并不一定没有问题,并不一定是安全的,即使字节码验证阶段进行了再大量再严密的检查,也仍然不能保证,因为这涉及到了一个悖论,也就是著名的停机问题,该问题说明了不能通过程序准确地检查出程序是否能够在有限的时间之内结束运行

有兴趣可以去了解一下,还有另外一个著名的悖论是理发师

由于对于字节码验证需要使用数据流分析和控制流分析,这两个分析都是高度复杂性的,而为了避免过多的执行时间消耗在字节码验证阶段中,在JDK6之后的Javac编译器与Java虚拟机里进行了一项联合优化

优化的内容是尽可能多的把校验辅助措施挪到Javac编译器里进行,也就是说,减少了类加载过程的校验辅助措施

具体优化的做法是给Code属性的属性表中新增加了一项名为StackMapTable的新属性,这项属性描述了方法体所有的基本块(基本块是按照控制流拆分出来的代码块)在开始时本地变量表和操作栈应有的状态(也就是说,这个属性记录了方法的本地变量表和操作栈的状态),那么在字节码验证期间,虚拟机就不再需要去计算推导Code属性的本地变量表和操作栈的状态了,而是直接校验StackMapTable里面的信息,说白了就是给字节码验证过程减少了计算、推导状态的过程,只需要直接进行类型检查校验即可

但这种优化不仅带来了性能提升,而且又提高了安全性,比如说如果有人篡改了Class文件的Code属性,是不会被发现的,现在不仅要篡改Class文件的Code属性,同时也要去篡改StackMapTable属性才能绕过检查,不过这样仅仅提高了一点点安全性而已,因为StakMapTable可以根据Code属性生成的,把篡改之后的Code属性再拿去生成同样也能避过检查

符号引用验证

符号引用校验是最后一个阶段了

符号引用验证是发生在虚拟机将符号引用转化为直接引用的时候,而符号引用一般在解析阶段才会发生转化,所以这最后一个验证是跟解析阶段交互进行的(之前我们也分析过符号引用与直接引用的大概区别,直接引用是直接指向了目标地址的指针,而符号引用则是使用了一组符号来描述所引用的目标,只要这一组符号描述不会产生歧义就行)

符号引用验证可以看作是对类自身以外,即常量池中各种符号引用所引用的各类信息进行匹配性校验,说白了就是,判断该引用类是否存在或者能否进行访问、哪些内容可以访问、哪些内容不能访问

本阶段通常需要校验下列内容

  • 符号引用中通过字符串描述的全限定名是否可以找到类,即判断引用的类是否存在
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段,即判断要引用的方法和字段在引用类中是否存在
  • 符号引用中的类、字段、方法的可访问性,是否允许被当前类所访问,即判断符号引用中的类、字段、方法的访问权限(public、private、protected、default)

符号引用验证的目的是为了确保解析之后的行为,即Code属性里面的代码可以正常执行,如果无法通过符号引用验证,是会抛出IncompatibleClassChangeError的子类异常的,比如IlleaglAccessException(非法进入)、NoSuchFieldError(没有字段)、NoSuchMethodError(没有方法)

至此整个验证阶段就基本上结束了

准备

完成了验证阶段,现在class文件读取了、并且也验证过没有问题了,接下来就要准备去给类分配内存和初始化静态变量了(这里的初始化是指给一个初始值)

准备阶段是正式为类中定义的变量(静态变量)分配内存并设置类变量的初始值的阶段,从语义上、概念上讲,这些静态变量所使用的内存空间应该都是方法区上的(类的信息存储在方法区,实例对象存储在Java堆,静态变量应该属于类的信息,所以应该在方法区上分配内存空间)

但实际上并不是如此,在JDK7及之前,HotSpot使用永久代来实现方法区的时候,实现是符合上述逻辑概念的,但在JDK8之后,类变量会随着Class对象一起存放在Java堆之中(Class文件经过加载阶段之后,在方法区有对应的数据结构存储,同时在Java堆也生成了对应的Class对象),这时候,对于类变量在方法区完全只是一种逻辑概念的表述了

关于准备阶段,这里强调一点

  • 准备阶段是进行内存分配的,而内存分配仅仅只是针对静态变量的
  • 对于成员变量,分配内存则是在实例化对象的时候才随着对象一起分配进Java堆中
  • 对静态变量的初始值是通常情况下指数据类型的零值,而不是在类中给静态变量赋的值

下面来看看Java中对于不同数据类型的零值定义

数据类型零值
int0
long0L
short0
char‘/u0000’
byte0
booleanfalse
float0.0f
double0.0d
reference(引用类型)null

上面提到的是通常情况下会初始化为零值,但也存在特殊情况,假如在Class文件的字段属性表中存在这ConstantValue属性,那么在准备阶段的变量值就会被初始化为ConstantValue属性所指定的初始值,最常见的就是使用final关键字来进行修饰
在这里插入图片描述
可以看到,加上了final关键字的时候,就会出现ConstantValue属性了,那么此时在准备阶段对于该属性的初始化就不是零值了,而是ConstantValue

解析

前面提到过,符号引用验证是发生在解析阶段的,所以解析阶段的功能就能大致推断出跟符号引用相关

解析阶段是Java虚拟机将常量池的符号引用替换为直接引用的过程,所以解析的对象就是符号引用,就是对符号引用进行解析,(这里的常量池就是Class文件里面的Constant Pool【运行时常量池】,没个Class文件都维护自己的一个运行时常量池,所以对应每一个Class对象都有自己的一个运行时常量池)

符号引用之前已经在解析Class文件结构的时候说明过很多次了,下面就来详细说明一下符号引用与直接引用的区别

  • 符号引用:符号引用使用一组符号来描述引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用与虚拟机实现的内存布局无关,引用的目标也不一定是已经加载到虚拟机内存当中的内容
  • 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局直接相关的,只要使用了直接引用,那么引用的目标一定是已经加载虚拟机内存当中的内容

Java虚拟机规范并没有规定解析阶段发生的具体时间,只要求了在执行操作符号引用的字节码指令之前,要先对他们所使用的符号引用进行解析

同时在解析阶段中也会去对方法或者字段的可访问性进行检查(符号引用验证)

对同一个符号引用进行多次解析请求是很常见的事,所以虚拟机对此也做了一层缓存处理,除了invokedynamic指令之外,虚拟机会对符号引用的第一次解析结果进行缓存、比如对符号引用解析后,会将其修改成直接引用,并且把常量标识为已解析的状态,从而避免了解析动作的重复执行,但这里有个问题要解决,因为使用了缓存技术,所以如果第一次解析结果成功,那么后面的都要成功,而且对应的是同一个对象;相反,如果第一次解析结果失败,那么后面的都要失败,哪怕这个请求的符号引用在后来成功加载进了虚拟机内存中,这是一个一致性的问题,所以无论是否真正执行了多次解析动作,Java虚拟机都要保证的每次解析对应的都在同一个实体类中

不过对于invokedynamic指令,上面的规则就不成立了,因为invokedynamic这条指令是用来支持动态语言的,动态的含义是指必须等到程序实际运行到这条指令时,解析动作才能开始,而其余可触发解析的指令都是静态的,静态的就代表可以在刚刚完成加载阶段,但还没有在开始执行指令时就提前进行解析,如果在执行invokedynamic指令前面已经有invokeddynamic指令执行过了,并不意味着此时执行的invokedynamic指令的结果会跟前面的保持一致

invokedyncmic最常见出现的地方就是Lambda表达式和接口的默认方法,底层调用时都会执行invokedynamic指令

解析动作主要针对的对象就是符号引用,而符号引用又分为以下七种

  • 类或接口:Constant_Class_info
  • 字段:Constant_Fieldref_info
  • 类方法:Constant_Methodref_info
  • 接口方法:Constant_InterfaceMethodref_info
  • 方法类型:Constant_MethodType_info
  • 方法句柄:Constant_MethodHandler_info
  • 调用点限定符:Constant_Dynamic_info和Constant_InvokeDynamic_info

下面对常用的前4种的符号引用进行分析、分析其解析的过程

类或接口的解析

类或接口的解析是对CONSTANT_Class_info进行解析,里面的结构只是一个tag(标志位,代表是一个Constant_Class_info类型而已)和name_index

当前的类为D,需要把一个未解析过的符号引用N解析为一个类或接口C的直接使用,那么完成整个解析过程需要包括以下3个步骤

  1. 判断C是不是一个数组类型,如果C不是一个数组类型,那么虚拟机就会把符号解析N的全限定名传递给D的类加载器去加载C类,加载C类的过程同样也要经过7个阶段,而且C类在解析阶段同样可能也会需要对类或接口进行解析
  2. 如果C是一个数组类型,那就不能使用类加载器去完成加载了,并且此时N的符号引用不再是一个全限定名了,而是之前Class文件指定的规则,此时就会进行降维操作,直至不再是数组类型(前面已经提过),最后使用本类的类加载器去加载元素的引用类型,接着由虚拟机会生成一个代表该数组维度和元素的数组对象(可以看到是由虚拟机完成,而不是类加载器)
  3. 如果上面两步没有出现错误,那么此时C已经被加载进来了,此时还需要确认D是否具备对C的访问权限,如果发现了不具备权限,就会抛出IllegalExcepton异常
    • 对于类型,在JDK9之后还引入了模块化的概念,也就是说,对于Public类型,已经不是程序的任何位置都可以进行对该类进行访问了,还需要判断是否在同一个模块
    • 对于可以访问的类权限,肯定是满足下面3条规则之一
      • C是public类型,并且与D属于同个模块
      • C是public类型,但与D属于不同模块,但C的模块允许D的模块进行访问
      • C不是Public类型,而是一个缺省类型,并且与D属于同一个包

对于类或接口的解析,要注意的一点是符号引用指向的类是哪种类型的,如果是非数组类型,那么会使用当前类的类加载器来完成;如果是数组类型的,进行降维操作,直到为非数组类型的,同样交由当前类的类加载器来加载降维得到的非数组类型,但最后是给虚拟机去生成一个代表该数组维度和元素的数组对象

字段解析

字段解析是针对常量池里面的CONSTANT_Fieldref_info,里面的结构会比较复杂,里面是主要有两个index(回顾之前的Class文件结构)

  • class_index:指向声明字段的类
  • nameAndtype_index:指向字段的描述信息

要解析一个未被解析过的字段符号引用,首先会对字段表内的class_index指向的CONSTANT_Class_info符号进行解析(注意这个是标识字段属于哪个类的,而不是标识字段的类型),解析的过程跟类或接口的解析是一样的,如果解析成功了,那么此时得到该字段所属的类记为C,Java虚拟机规范要求按照下面的步骤对C进行后续字段的搜索(C代表的就是解析出来的字段类型)

  1. 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段(根据字段引用的nameAndType_index来判断),则会返回这个字段的直接引用,查找结束
  2. 如果C本身并不能匹配上字段,但在C实现了接口,那么会按照关系从下往上递归搜索各个接口和它的父接口,判断每个接口里面是否包含了简单名称和字段描述符都与目标相匹配的字段,如果找到,则返回这个字段的直接引用
  3. 如果接口中没有发现,那就i可能出现在父类身上了,与接口类似,按照继承关系从下往上递归寻找父类,看看是否出现匹配的字段,如果找到,直接返回这个字段的直接引用
  4. 如果本身没有、实现的接口没有、继承的父类也没有,那就代表该字段不存在了,抛出NoSuchFieldsError异常
  5. 如果查找成功并且返回了引用,下面还会进行权限验证,判断对该字段有没有访问权限
  6. 这样,就能够确保虚拟机获得字段的唯一解析结果

可能看到这就感觉有点突兀了,为什么类要搜索自己的字段???

首先,这里的字段解析是针对符号引用来解析的,而符号引用变成直接引用肯定是需要进行搜索的,不搜索你怎么知道地址在哪呢????是位于父类上呢?还是位于接口上呢?,所以对于自身的静态字段也是要进行搜索的!!!

那假如父类(接口)与子类都出现了相同的字段呢???

按照规则,可以看到会优先找到子类的字段,这也是为什么对于变量重名的情况,会优先选用子类的,因为对于子类,这个符号引用已经优先选用了子类了

举个栗子

定义一个子类、父类,并且都拥有name

在这里插入图片描述
在这里插入图片描述
可以看到结果直接为son

在这里插入图片描述
但有时候Javac编译器会拒绝编译这种父类或者接口出现了重复变量的情况,举个栗子

在这里插入图片描述

可以看到,我这里创建了两个接口,并且两个接口都有相同类型的静态变量,让一个类都去实现了这两个接口,那么这个类应当也会拥有接口的静态变量,但IDEA很明显的已经提示报错了,提示说这个静态变量出现了重复了,下面我给实现类本身自己加一个相同类型的变量

在这里插入图片描述
可以看到,又不会进行报错了,所以这里可以确定的一件事,子类自身的优先级会比其接口高,对于父类的优先级却是跟接口是一样的

在这里插入图片描述
所以,对于ORACLE的JVM虚拟机在字段解析的过程中,不允许接口、父类等出现重复的相同类型的静态变量,接口与父类的优先级是一样的,而子类却大于这两者,只要子类出现了,就优先选中子类的,如果按照实际的情况,先去找接口,然后再找父类,对于上述情况是可以确定唯一的变量的,但实际情况却不是如此

方法解析

方法解析是针对CONSTANT_Methodref_info符号引用来进行解析的,而CONSTANT_Methodref_info的结构与CONSTANT_Fieldref_info是类似的,同样有两个index,一个指向的方法所属的类(Constant_Class_info),另外一个则是类型和名称(NameAndType_index)

方法解析的第一步跟字段解析是一样的,同样也是需要先解析出方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C来代表这个方法所属的接口和类,接下来也要进行方法搜索,搜索的步骤如下

  1. 首先,CONSTANT_Methodref_info主要是针对类方法的,而接口方法则是另外一个,称为CONSTANT_InterfaceMethod-ref_info

  2. 解析出方法中的class_index项,如果发现其是一个接口,直接进行抛错,因为接口是由另外一个来实现的,如果找到了类,记为C

  3. 在类C中查找是否有简单名称与描述符都与目标相匹配的方法,如果有,则返回这个方法的直接引用,查找结束

  4. 如果没有,则会从类C的父类从下往上去查找是否有简单名称和描述符都与目标相匹配的方法,如果有,也是返回这个方法的直接引用,然后查找结束

  5. 如果父类没有,则从类C的接口从下往上去查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,那就证明C是一个抽象类(因为没有实现方法????,但是接口有默认的方法不需要去实现呀。。。。。),抛出AbstractMethodError

  6. 如果没找到,宣告方法查找失败,抛出NoSuchMethodError

  7. 如果找到了,也会去判断权限,如果没有权限进行访问,抛出IllegalAccessError

对于第五点存在疑问。。。。 不过个人感觉只要不是从本类或者父类中寻找得出,都会进行抛错

意思就是说,如果对于静态方法,父类中有,并且父类中实现了接口,方法是来自接口的,那么就会出错???

接口方法解析

接口方法解析真的是CONSTANT_InterfaceMethod-ref_info,结构跟CONSTANT_Methodref_info是一样的,只不过class_index指向的不是类了,而是一个接口,搜索的步骤如下

  • 同样去根据class_index去找到接口,并且判断类型,如果类型不是接口,抛错,抛出IncompatibleClassChangeError,如果类型正确,该接口记为C

  • 如果类型正确,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有,则返回这个方法的直接引用,查找结束

  • 如果没有,则需要递归去接口C中的父接口去进行查询,直到遇到Object类(接口的方法也是会搜索Object的),看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回方法的直接引用,结束搜索

  • 但这里要解决的一个问题是,Java是允许多实现的,并且接口也是允许多继承的,所以对于接口方法的解析的复杂性比方法解析的复杂性会高很多,虚拟机规范说明:如果存在多个父接口有相同的匹配方法,则会从多个方法返回其中一个来结束搜索,但却没有知名返回哪个,而在实际情况中,与前面的解析方法类似,不同发行商实现的Javac编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性

  • 如果父类接口都没有,那就证明不存在了,抛出NoSuchMethodError

  • 同时最后也要进行权限的控制,因为在JDK9之前,接口中的静态方法一定为Public类型的,不存在其他类型的,而且又没有JDK9的模块化,所以不存在权限的控制,但自从JDK9之后,接口中的静态方法支持private类型了,并且还有模块化,所以要进行权限的控制,接口方法的访问也可能会由于权限问题而抛出IllegalAccessError异常

初始化

下面就是类加载的最后一个阶段了,前面虚拟机主要操控的对象都是CLASS文件,直到初始化,才正式执行类中编写的Java代码

其实到这里,可能有的人会觉得经过上面4个阶段,不是已经把一个类该解析的都解析了嘛?初始化是做什么的?

回顾在准备阶段的时候,也有提到过给静态变量进行初始化,但那时候的初始化,只是初始化为零值(final修饰的除外),但还没有将Java代码中的值初始化进去,所以,初始化阶段就是去执行静态代码的,包括静态变量的赋值

进行准备阶段时,变量已经赋值过一次系统要求的初始零值了,而在初始化阶段,则会根据程序员通过程序编码指定的主管计划去初始化变量和其他资源,从更另外一种直观的角度来说,初始化阶段其实就是执行类构造器方法的过程(在之前看Class文件的时候,出现过clinit方法)

方法并不是我们写的(我们只写过构造方法,既init方法,可没有写过什么clinit),它是Javac编译器的自动生成物,下面就来看看这个方法是如何生成的,并且会影响到什么细节

clinit方法其实是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static语句块)中的语句合并产生的,而合并的顺序取决于源代码里面,既static语句块的顺序和类变量的赋值动作顺序

这里有一个细节点,静态语句块只能访问到定义在之前的静态变量,而不能访问到定义在之后的静态变量,举个栗子

在这里插入图片描述
可以看到,对于后面定义的numberTwo,在static代码块里面是无法访问的,IDEA提示Illegal forwad reference(无法向前引用),

clinit方法与init方法(构造方法)不一样,clinit不需要显示地调用构造器,即使是存在继承关系,也不需要显示地调用父类的构造方法,虚拟机会保证在子类的clinit方法执行前,父类的clinit方法已经执行完毕,因此在Java虚拟机中,第一个执行clinit方法的一定是Object类,同时也由于父类的clinit方法肯定优先于子类执行,这也就意味着父类中定义的静态语句块肯定会优先于子类的变量赋值操作的

clinit方法对于类或接口来说并不是必需的,如果类或接口没有静态语句块,也没有静态变量的赋值,那么不会出现clinit方法

在这里插入图片描述
可以看到,如果没有静态代码块和静态变量赋值,的确没有clinit方法

接口中是不支持使用静态代码块的,但可以给静态变量赋初始值(并且该变量一定是final修饰的),因此接口与类一样,都可以生成clinit方法,但接口与类不一样,执行接口的clinit方法不需要先去执行父接口的clinit方法,也就是说,不会优先将父接口的静态变量给初始化,而是只有当使用到父类接口的变量时,才会进行初始化(这也是很容易理解的,因为接口没有静态代码块,所以不可能对静态变量进行修改,所以可以只有用到父类接口的变量时,再进行初始化是不会受影响的,而且还能降低点不必要的初始化消耗)

Java虚拟机必须保证一个类的方法在多线程的环境中被正确加锁同步(因为一个类只能被初始化一次),如果多个线程去初始化一个类,那么只会有其中一个线程去执行这个类的方法,其他线程此时都要进行等待,如果该类的clinit方法需要耗时很长,那么其他线程就会进入阻塞的状态,等执行完clinit方法后,其他线程就会恢复,但并不会去执行clinit方法了,因为对于同一个类加载器,一个类型仅仅只会被初始化一次

至此,整个类的加载过程就完成了。。。。。。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值