上一篇我们已经看过了Class文件的格式,认识了Class文件中描述的各类信息,但Class文件最终都需要被加载虚拟机中之后才能被运行和使用,下面就来看一下JVM虚拟机是如何加载这些Class文件的
首先来谈谈虚拟机的类加载机制
虚拟机的类加载机制是指虚拟机将描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这一整个过程就称为类加载机制
在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,与编译是完全分开的,这样的设计让类加载过程产生了额外的消耗、增加了一些性能开销,并且让Java语言进行提前编译会面临额外的困难;但这样设计却给Java应用提供了极高的扩展性和灵活性,比如Java的动态扩展特性就是依赖这个设计而实现的,依赖于运行期动态加载和动态连接这个特点来实现的;比如去编写一个面向接口的程序,可以等多程序运行时再指定接口对应的实现类(多态)
一个类从被加载到虚拟机内存中开始,到卸载出内存为止,整个过程(不单单只是类加载过程),也可以说是整个生命周期将会经历七个阶段
-
加载(Loading)
-
验证(Verification)
-
准备(Preparation)
-
解析(Resolution)
-
初始化(Intialization)
-
使用(Using)
-
卸载(Unloading)
其中验证、准备、解析三步部分统称为连接(Linking)
其中对前3部分,顺序是一定的,也就是一定是先执行加载、然后验证、然后准备,但到了解析过程,就不一定了,在某些情况下,解析过程会和初始化过程替换,也就是先初始化了,然后再解析,之所以这样做,是因为要支持Java语言的运行时绑定特性(也就是所谓的动态绑定,后面再详细介绍)
但这里说的按顺序执行,仅仅只是开始的顺序,并不是完成了加载、然后再进行验证,这样按顺序完成,因为有可能这些阶段会互相交叉地混合进行的,会在前面一个阶段执行的过程中调用、激活了另一个阶段,比如加载开始了,但在加载的过程中激活了验证过程
而类加载过程指的是前面5个阶段,如果归纳为连接阶段,则只有加载、连接、初始化这三个阶段
下面来说明一下类加载的时机
对于第一个阶段加载,虚拟机规范并没有进行强制约束,这点是交由虚拟机的具体实现来自由掌握的
但对于初始化阶段,虚拟机规范则是严格地规定了有且仅有六种情况必须对类进行初始化(而加载、验证、准备这三个过程就肯定是在初始化之前发生)
如下六种
- 遇到了new、getStatic、putStatic或者invokeStatic这4条字节码指令时,如果对应的类型没有进行过初始化,则会先触发其初始化阶段,这4条指令分别在下面的Java场景中生成
-
使用new关键字去实例化对象,产生new指令
-
读取或设置一个静态类型的字段,产生getStatic命令(但并不是所有的静态类型字段,如果被final修饰,已在编译器就把结果放入常量池的静态字段,是不是生成getStatic命令的)
-
使用一个类型的静态方法的时候,产生invokeStatic命令
-
使用反射特性对类型进行反射调用的时候,如果类型还没进行过初始化,则需要先触发该类型初始化
-
当初始化类的时候,发现其父类还没有进行过初始化,也需要先触发其父类的初始化
-
当虚拟机启动的时候,用户需要指定一个主函数,即main方法,虚拟机启动的时候会对主函数所在的类进行初始化
-
当使用JDK7新加入的动态语言支持时,如果一个MethodHandler实例最后的解析结果为REF_getStatic,REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的句柄时,并且这个方法句柄对应的类还没有进行过初始化,需要先触发该类初始化
-
当一个接口中定义了JDK8的新加入的默认方法(default关键字修饰的接口方法),如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化(难道不用default方法,接口就不会提前实现类先初始化了?????)
上面已经分析过类在什么时候进行加载了,下面就看一下类加载的过程
加载
加载阶段仅仅只是类加载过程中的一个阶段,在加载阶段,JVM虚拟机主要完成以下三件事情
-
通过一个类的全限定名来获取定义该类的二进制字节流
-
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构(所以类的信息都存放在方法区中)
-
在内存中生成一个代表这个类的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中对于不同数据类型的零值定义
| 数据类型 | 零值 |
| — | — |
| int | 0 |
| long | 0L |
| short | 0 |
| char | ‘/u0000’ |
| byte | 0 |
| boolean | false |
| float | 0.0f |
| double | 0.0d |
| reference(引用类型) | null |
上面提到的是通常情况下会初始化为零值,但也存在特殊情况,假如在Class文件的字段属性表中存在这ConstantValue属性,那么在准备阶段的变量值就会被初始化为ConstantValue属性所指定的初始值,最常见的就是使用final关键字来进行修饰
可以看到,加上了final关键字的时候,就会出现ConstantValue属性了,那么此时在准备阶段对于该属性的初始化就不是零值了,而是ConstantValue
解析
前面提到过,符号引用验证是发生在解析阶段的,所以解析阶段的功能就能大致推断出跟符号引用相关
解析阶段是Java虚拟机将常量池的符号引用替换为直接引用的过程,所以解析的对象就是符号引用,就是对符号引用进行解析,(这里的常量池就是Class文件里面的Constant Pool【运行时常量池】,没个Class文件都维护自己的一个运行时常量池,所以对应每一个Class对象都有自己的一个运行时常量池)
符号引用之前已经在解析Class文件结构的时候说明过很多次了,下面就来详细说明一下符号引用与直接引用的区别
-
符号引用:符号引用使用一组符号来描述引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用与虚拟机实现的内存布局无关,引用的目标也不一定是已经加载到虚拟机内存当中的内容
-
直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局直接相关的,只要使用了直接引用,那么引用的目标一定是已经加载虚拟机内存当中的内容
Java虚拟机规范并没有规定解析阶段发生的具体时间,只要求了在执行操作符号引用的字节码指令之前,要先对他们所使用的符号引用进行解析
同时在解析阶段中也会去对方法或者字段的可访问性进行检查(符号引用验证)
对同一个符号引用进行多次解析请求是很常见的事,所以虚拟机对此也做了一层缓存处理,除了invokedynamic指令之外,虚拟机会对符号引用的第一次解析结果进行缓存、比如对符号引用解析后,会将其修改成直接引用,并且把常量标识为已解析的状态,从而避免了解析动作的重复执行,但这里有个问题要解决,因为使用了缓存技术,所以如果第一次解析结果成功,那么后面的都要成功,而且对应的是同一个对象;相反,如果第一次解析结果失败,那么后面的都要失败,哪怕这个请求的符号引用在后来成功加载进了虚拟机内存中,这是一个一致性的问题,所以无论是否真正执行了多次解析动作,Java虚拟机都要保证的每次解析对应的都在同一个实体类中
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)
最后
这份清华大牛整理的进大厂必备的redis视频、面试题和技术文档
祝大家早日进入大厂,拿到满意的薪资和职级~~~加油!!
感谢大家的支持!!
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!**
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)
最后
这份清华大牛整理的进大厂必备的redis视频、面试题和技术文档
祝大家早日进入大厂,拿到满意的薪资和职级~~~加油!!
感谢大家的支持!!
[外链图片转存中…(img-lvnDjBnb-1712642498534)]
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!