JVM 类加载
JVM类加载
JVM整个流程图
一个java文件被编译为class文件后,剩下的操作都交给jvm来执行,其中第一步就是将class文件加载到jvm,而这一步就是由类加载器来完成的
类加载的流程又分为加载(Loading),验证(Verification),准备(Preparation),解析(Resolution),初始化(Initialization)
而其中验证,准备,解析这三步统称为连接(Linking)
类加载器只负责加载class文件,至于加载的class是否能正常执行则是由执行引擎决定
加载
这里的加载只是类加载器中的一步操作,也叫加载而已,这一步完成三个事情
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 通过这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个所代表这个类的java.long.Class对象,作为方法区这个类的各种数据的访问入口
其中这三步在java虚拟机规范中并没有要求特别具体,java虚拟机实现的灵活度相当大,例如第一步中获取二进制字节流
- 从zip压缩包中读取,最终成为日后的jar,war文件格式的基础
- 从网络中读取,这种场景最经典的应用就是Web Applet
- 运行时计算生成,这种场景最多的就是动态代理技术
- 其他文件生成,例如JSP应用,由JSP生成Class文件
- ...
相对于类加载其他阶段,非数组类型的加载阶段,加载阶段中是开发人员可控性最强的阶段,加载阶段既可以使用java虚拟机内置的引导类加载
器来完成,也可以使用用户自定义加载器来完成
连接
验证
验证是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合java虚拟机规范的全部约束要求,保证这些信息被当做代码后运行不会对java虚拟机产生危害
而验证有包含四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证
文件格式验证
第一阶段要确保字节流符合Class文件格式规范,并能被当前版本虚拟机处理,这一阶段可能包含下面这些验证点
- 是否以魔术 0xCAFEBABE 开头
- 主,次版本号是否在当前java虚拟机接收范围内
- 常连池的常量中是否有不被支持的常量类型(检查常量的tag标志)
- ....
实际上第一阶段的验证点远远不止这些,上面所列出的只是从HotSpot虚拟机中的一小部分,该验证阶段的主要目的就是为了保证输入的字节流能正确的解析并存储在方法区中,格式上符合描述一个java类型信息的要求.
这个阶段验证是基于二进制字节流进行的,只有通过这个阶段的验证过后,这段字节流才会被允许进入java虚拟机内存的方法区中进行存储,后面的三个验证阶段全部都是基于方法区中的存储结构上进行的,不会再进行直接读取,操作字节流了
元数据验证
第二阶段是对字节码描述的信息进行语义分析,以确保其描述的信息符合java语言的规范,这个阶段可能包括验证点如下
- 这个类是否有父类 (除了java.lang.Object之外,所有的类都应该有父类)
- 这个类的父类是否继承了不允许继承的类 (被final修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段,方法是否与父类产生矛盾 (例如覆盖父类final字段,或者出现不符合规则的方法重载,例如方法参数一致,返回类型却不同)
- ...
字节码验证
第三阶段是整个验证过程最复杂的一个阶段,目的是通过数据类分析和控制流分析,确定程序语义是合法的,符合逻辑的,在第二阶段对元数据信息中的数据类型校验完毕后,这个阶段就是要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会出现对虚拟机危害的行为,例如
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于"在操作栈放置一个int类型数据,使用时却按照long类型来加载到本地变量表中"这样的情况
- 确保任何跳转指令都不会跳转到方法体以外的字节码指令上
- 保证方法中类型转换总是有效的,例如把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至赋值给与它没有继承关系的完全不相干的一个数据类型,则是危险和不合法的
- ...
如果一个类型中有方法体的字节码没有通过字节码验证,那么它肯定是有问题的,但是如果一个方法体通过了字节码验证,也并不能保证它一定就是安全的,即使字节码验证阶段进行再大量,再严密的检查,也不能保证这一点
符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段解析阶段中发生,符号引用验证可以看做是类自身以外(常连池中各种符号引用)的类型信息进行匹配性校验,通俗的来说就是检查该类是否缺少或被禁止访问它所依赖的外部某些外部类,方法,字段等资源,本阶段通常校验下列内容
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符及简单名称描述的方法和字段
- 符号引用中的类,字段,方法的可访问性,(private,protected,public,< package >)是否可被当前类访问
- ...
符号引用验证主要目的就是确保解析行为能正常运行,如果无法通过符号引用验证,java虚拟机将抛出一个java.lang.IncompatibleCLassChangeError的子类异常
验证阶段对于虚拟机的类加载机制来说,是非常重要的,但却不是必须执行的阶段,因为验证阶段只有通过或者不通过的区别,只要通过了验证,其后就对程序运行期没有任何影响了,如果程序运行的全部代码都已经被反复使用和验证过,在生成环境的实施阶段可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间
准备
准备阶段是正式的为类中定义的变量(即静态变量,被static修饰的变量),分配内存并设置变量的初始值的阶段
关于准备阶段,首先是这时候进行内存分配仅包括类变量,而不是实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中
在这个阶段中,所有的基本类型都会被赋值为零值
基本数据类型的零值
数据类型 | 零 值 |
---|---|
int | 0 |
long | 0L |
short | (short) 0 |
char | '\u0000' |
byte | (byte) 0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
这里不包含final修饰的static,因为final在编译期间就已经分配值了,准备阶段会显式初始化
解析
解析阶段是java虚拟机将常量池内的符号引用替换为直接引用的过程
- 符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面两,只要使用时能无歧义的定位到目标即可,符号引用于虚拟机实现的内存布局无关,符号引用的目标并不一定已经被加载到虚拟机内存中的内存,符号引用的字面量形式明确定义在java虚拟机规范的Class文件格式中
- 直接引用(Direct Reference) 直接引用是可以直接指向目标的指针,相对偏移量,或者是一个能间接定位到目标的句柄,直接引用是和虚拟机内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有直接引用,那么引用的目标必须已经在虚拟机的内存中存在
解析动作一般针对类或接口,字段,类方法,接口方法,方法类型等,对应常量池中的CONSTANT_Class_info,CONSTANT_Fieldref_info
CONSTANT_MethodHandle_info等
初始化
类的初始化时类加载的最后一个步骤,进行准备阶段时,变量已经赋值过一次系统要求的零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源,初始化阶段其实就是执行类构造器clinit方法的过程,clinit并不是程序员在java代码中编写的方法,它是由javac编译器的自动产生物
clinit方法是由编译器自动收集类中所有类变量赋值(静态的变量)的动作和静态语句块 static{} 块中的合并产生的,收集的顺序是由语句在源文件中出现的顺序决定的
静态语句块中只能访问到定义静态语句块之前的变量,而定义在它之后的变量,可以赋值,不能访问
public class JvmDemo{ static{ //在定义之前可以进行赋值 count=4; //但是不能进行访问,这句代码报错,非法向前引用 System.out.println(count); } private static int count=1; }
clinit方法和类的构造函数(即在虚拟机视角中的实例构造器init方法)不同,它不需要显示调用父类构造器,java虚拟机会保证在调用子类clinit方法前,父类的clinit方法已经执行完毕,因此java虚拟机中第一个执行的clinit方法的类型一定是java.long.Object
clinit方法对于类来说并不是必须的,如果类中没有对类变量赋值的操作,同时也没有静态代码块,那么编译器可以不为这个类生成clinit方法
接口中不能使用静态语句块,但仍然有变量赋值的操作,因此接口与类一样都会生成clinit方法,但与接口不同的是,执行接口clinit方法不需要先执行父接口的clinit方法,因为只有父接口中定义的变量被使用时,父接口才会被实例化,此外,接口的实现类初始化时也一样不会执行接口中的clinit方法
java虚拟机必须保证一个类的clinit方法在多线程情况下能够正确的加锁同步,如果有多个线程同时初始化一个类,那么只会有其中一个线程执行类的clinit方法,其他线程都需要阻塞等待,知道活动线程执行完毕clinit方法
类加载器
从java虚拟机角度来看,只存在两种类加载器器:一种启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++实现,是虚拟机的一部分,另一种就是其他所有的类加载器,这些都由java实现,独立于虚拟机外部,并且全部继承于java.lang.ClassLoader
而从程序角度来划分了类加载器
启动类加载器(引导类加载器,Bootstrap ClassLoader)
使用C++实现,嵌套在JVM内部,这个类加载器用来负责加载存放在<JAVA_HOME>\lib目录,用于加载JVM自身需要的类,并不继承java.lang.ClassLoader,没有父加载器,加载应用类加载器和扩展类加载器,并指定为它们的父类加载器
扩展类加载器(Extension ClassLoader)
这个类加载器在类sun.misc.Launcher$ExtClassLoader中以java代码实现,它负责加载<JAVA_HOME>\lib\ext目录中,或被java.ext.dirs系统变量指定的路径中所有类库
程序类加载器(系统类加载器 Application ClassLoader)
这个类加载器由sun.misc.Launcher$AppClassLoader实现,负责加载用户类路径ClassPath上所有类库,父类加载器为扩展类加载器,该类是程序中的默认加载器,一般情况java应用的类都是由它来完成,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器
继承关系
ClassLoader
常用方法
- Class loadClass(String name) :name参数指定类装载器需要装载类的名字,必须使用全限定类名,如:com.smart.bean.Car。该方法有一个重载方法 loadClass(String name,boolean resolve),resolve参数告诉类装载器时候需要解析该类,在初始化之前,因考虑进行类解析的工作,但并不是所有的类都需要解析。如果JVM只需要知道该类是否存在或找出该类的超类,那么就不需要进行解析。
- Class defineClass(String name,byte[] b,int len):将类文件的字节数组转换成JVM内部的java.lang.Class对象。字节数组可以从本地文件系统、远程网络获取。参数name为字节数组对应的全限定类名。
- Class findSystemClass(String name):从本地文件系统在来Class文件。如果本地系统不存在该Class文件。则抛出ClassNotFoundException异常。该方法是JVM默认使用的装载机制
- Class findLoadedClass(String name):调用该方法来查看ClassLoader是否已载入某个类。如果已载入,那么返回java.lang.Class对象;否则返回null。如果强行装载某个已存在的类,那么则抛出链接错误。
- ClassLoader getParent():获取类装载器的父装载器。除根装载器外,所有的类装载器都有且仅有一个父装载器。ExtClassLoader的父装载器是根装载器,因为根装载器非java语言编写,所以无法获取,将返回null。
双亲委派机制
双亲委派模型的工作过程:当接收到类加载请求时,类加载器首先会委托父类加载器(注意这里说的父类一般不是继承关系,而是通常使用组合关系来复用父类加载器的代码)进行加载,每一层都是如此,因此所有类加载请求最后都会到达启动类加载器(Bootstrap ClassLoader),只有父类加载器无法完成这个加载请求时(在它的搜索范围内没有找到所需的类),子类加载器才会尝试自己去完成加载
使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是java中的类随着它的类加载器一起具备了一种带有优先级的层次关系
例如java.lang.Object,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终委托还是派给模型中处于顶端的类加载器进行加载,因此Object类在程序的各种类加载器环境中都能保证是同一个类,同时双亲委派模型也可以保证java核心API的安全性,例如自己也在项目中创建一个java.lang.String,如果加载了自定义的String类,程序将变得十分混乱
双亲委派模型实现全部集中在loadClass()方法中
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { //首先检查请求的类是否已经被加载过了 // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { //如果抛出ClassNotFound异常说明父类无法加载这个类 //那么将调用当前类的findClass()来加载 // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { long t1 = System.nanoTime(); c = findClass(name); sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
例如下面自定义一个String,全限定名为java.lang.String
package java.lang; /** * @author : Jame * @date : 2021-01-25 13:49 **/ public class String { public static void main(String[] args) { System.out.println("自定义String"); } }
运行出现下面错误
原因就是类加载器向上委托直到引导类加载器,引导类加载器发现可以加载java.lang下的类,于是加载了java自带的String类,之后调用java自带的String类的main方法,发现没有该方法,于是抛出异常
其他
在JVM中判断两个类是否相同有两个条件
- 两个类的全限定名一致
- 加载这两个类的类加载器(ClassLoader实例对象)必须一样
即使两个类的类对象(Class对象)来源于同一个class文件,被同一个的类加载器加载,只要加载它们的ClassLoader对象实例不同,那么这两个类对象也是不相等的
对类加载器的引用
JVM必须知道一个类型是由启动加载器还是用户类加载器加载的,如果一个类型是用户类加载器加载的,那么JVM会将这个类加载器的一个引用类型信息的一部分保存在方法区中,当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器时相同的
类的使用和被动使用
java程序对类的使用方式分为:主动使用和被动使用
主动使用,分为七种情况:
创建类的实例
访问某个类或接口的静态变量,或者对静态变量赋值
调用类的静态方法
反射 (例如:Class.forName("com.jame.Test"))
初始化一个类的子类
Java虚拟机启动时被标明为启动的类
JDK7 开始提供的动态语言支持:
java.lang.invoke.MethodHandle实例的解析结果
REF_getStatic,REF_putStatic,REF_invokeStatic句柄对应类没有初始化,则初始化
除了以上七种情况,其他事宜Java类的方式都被看做为对类的被动使用,都不会导致类的初始化
这里的类的初始化指的是类加载中3大步骤中的最后一步初始化步骤
本文仅个人理解,如果有不对的地方欢迎评论指出或私信,谢谢٩(๑>◡<๑)۶