JVM类加载机制
概述
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最 终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。而这里说的Class文件并不是特指磁盘上的文件,还有二进制流,网络传输,数据库读取,动态生成的代码等等
前言
在讲解之前,我先给大家讲一个故事,听完故事,我们再去看下边的内容估计更有感觉
话说有那么一天中午到饭点了,我肚子饿了想吃鱼香肉丝盖饭,然后我就问了一下同事 (应用类加载器) 给我买饭了没有,我同事说没有,然后同事就问了问组长 (拓展类加载器) 给我买饭了没有,组长说没有,然后组长又去问了一下主管 (启动类加载器) 给我买饭了没有,主管也说没有,然后说让我自己去食堂吃吧,然后我就自己一个人屁颠屁颠的去地下食堂吃饭去了 (被加载) ,但是呢,我们食堂有门禁,当然我也有门禁卡,刷完卡以后我就进入食堂了 (验证) ,然后人超级多,我怕没座位,就把我拿的书放到了入餐区排列整齐的N个桌子上的某一个上边 (准备) ,然后我就去窗口买了 “我想吃的饭”(符号引用) => “鱼香肉丝盖饭”(直接引用) (解析) ,最后我拿着饭到座位上把书收起来坐下 (初始化) ,就开吃了 (使用) 。吃饱喝足然后就屁颠屁颠出食堂回去了 (卸载) 。
类加载机制
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最 终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
类加载过程(去食堂吃饭的全过程)
类的加载过程需要经历如下几个步骤:
加载->验证->准备->解析->初始化->使用->卸载
同时验证,准备和解析又统称为连接阶段。当然使用和卸载不是重点,重点是使用之前的部分。
加载(进食堂)
首先,类加载和加载这两个概念要分清楚,加载属于类加载过程中的一步,我们常说的加载就是指的这里,在加载非数组类型的阶段,虚拟机需要完成下边三个步骤:
1。 通过一个类的全限定名来找到定义了这个类信息的二进制流,也就是读取文件并加载数据流
2。 将这个字节流所代表的静态存储结构转化成运行时数据区中方法区的运行时数据结构
3。 在内存中生成一个代表当前类的java.lang.Class对象,来作为方法区中读取这个类数据信息的访问入口
类被加载到方法区中后主要包含 运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等信息。类加载器的引用指的是这个类到类加载器实例的引用,对应class实例的引用指的是类加载器在加载类信息放到方法区中后,会创建一个对应的Class 类型的对象实例放到堆(逻辑上的方法区)中, 作为开发人员访问方法区中类定义的入口和切入点。这里的Class对象是特指java语言中的对象,而每一个Class对象在底层都是属于Klass的某个属性,Klass你可以理解为C++的某个类。而Class就是Klass中的某个字段在内存中的直接指针,这个字段就是引用地址。
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element
Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,一个数组类创建过程遵循以下规则:
- 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的元素类 型区分开来)是引用类型,那就递归采用上边定义的加载过程去加载这个组件类型,数组将被标识在加载该组件类型的类加载器的类名称空间上
- 如果数组的组件类型不是引用类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组标记为与引导类加载器关联。
- 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到。
连接(去吃饭)
连接是类加载过程中很重要的一部分,这部分由三个小步骤组成,分别是验证,准备和解析,加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束时,连接阶段可能就已经开始了,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段在开始时间维度上说,仍然保持着固定的加载在前,连接在后的先后顺序。
验证(刷门禁卡)
验证是连接阶段的第一步,主要完成的工作就是为了验证Class文件格式的正确性,类似于我们写的业务接口需要判断参数的合法性一样,主要是用来确保虚拟机的安全,防止被恶意的破坏,从整体上去分析的话,大致可以分为4个阶段的验证,分别是文件格式的验证,元数据验证,字节码验证和符号引用的验证
文件格式验证(验证是信用卡还是门禁卡)
第一个阶段文件格式验证就是为了验证Class文件是否符合java虚拟机规范,并且能被当前版本的虚拟机进行处理,比如会验证是否以魔数0xCAFEBABE开头的文件,主次版本号是否能被当前虚拟机加载,是否在对应的版本号范围内,如果对魔数版本号这些不清楚,可以看我上一篇博文02-彻底搞懂JVM之类文件结构,主要检查的就是类文件结构中的数据项和标签是否合法,是否存在不能识别的数据项等等信息。
元数据验证(验证卡信息)
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,比如需要验证this_class对应的类是否有super_class,还有就是父类是否继承了非法的父类,比如被final修饰的类,再有就是是否实现了父类或者接口中所有的方法,再或者是否有属性字段冲突,方法一致返回结果不一致等等诸多情况。
字节码验证(核对卡信息和人脸)
第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,这个有点智能的感觉了,毕竟分析语义都是很复杂并且情况多变的,比如在一个操作数栈中装载了一个int类型的变量,但是使用的时候却要按照long类型去加载到本地变量表里,再比如某个方法的跳转指令指向了其他的方法体或者字节码位置上,再比如去破坏向上造型进行逆向操作等等编译型错误。
符号引用验证(词穷了,不知道咋形容)
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的解析阶段中发生。符号引用验证可以看作是对当前类的常量池中的各种符号引用中的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部 类、方法、字段等资源。
比如说在符号引用中找到的全限定名描述符是否能找到对应的类,再有就是符号引用中类,方法和字段的权限修饰符是否能被当前类访问等等。典型的有java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等异常
准备(占座)
准备阶段是正式为类中定义的静态变量(被static修饰的)分配内存并设置默认值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但是方法区只是一个逻辑上的分区,在JDK 7及之前,HotSpot使用永久代来实现方法区时,可以理解为在方法区分配内存;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候类变量在方法区分配内存就不是一个实际存在的概念了,而是一个站在逻辑分区的角度存在的概念了。这里需要注意一个点,那就是常量,类变量和实例变量的概念不要弄混了。常量在准备阶段不是设置的默认值而是直接赋值真实内容,是什么就在开辟的内存空间中存储什么,静态变量存储的是默认值,而实例变量不分配内存,它要等到初始化阶段才会分配。
- 常量: 当前类中被final修饰的变量
- 类变量(静态变量): 当前类中被static修饰的变量
- 实例变量: 常写的那种基本类型或引用类型的变量 比如 private String name;这种就属于实例变量
解析(打饭)
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,这里先说一下符号引用和直接引用的概念
- 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何 形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
- 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
就比如我上边的故事,符号引用代表的就是我想吃的饭,是以形容词的概念存在的,而买到手的鱼香肉丝盖饭就是真实存在的东西,再比如姓名和实实在在的人,这个名字就是这个人的符号引用,这个人就是直接引用。在类文件结构中以CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,而且虚拟机犯规也没有明确要求解析这个动作在什么阶段执行,只规定了在执行ane-warray、 checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、 invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic这17个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需 要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。除invokedynamic指令以外其他的指令都会对解析的内容进行缓存,之后的操作会直接读取缓存的解析内容,而invokedynamic指令是为了给动态编译提供支持的,也就是说必须要等到程序实际运行到这条指令的时候,解析动作才能进行。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7 类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、 CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、 CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和 CONSTANT_InvokeDynamic_info 8种常量类型。还有几个方法调用的字节码指令
- invokestatic 用于调用静态方法。
- invokespecial 用于调用实例构造器()方法、私有方法和父类中的方法。
- invokevirtual 用于调用所有的虚方法。
- invokeinterface 用于调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4 条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。
同时这里会存在几个概念有必要说明一下
- 非虚方法
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本, Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final 修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。 - 虚方法
不在非虚方法中的都算是虚方法,你可以理解为虚方法就是多态的一种体现,比如方法的重写和重载,当出现多个的时候只有在运行时才能确定需要执行哪一个方法。
关于方法的调用还有两个概念,如下:
-
解析
解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号 引用全部转变为明确的直接引用,不必延迟到运行期再去完成。 -
分派
分派调用则要复杂许多,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况,这块内容偏多,不过多解析,有兴趣的可以去多了解一下。简单点的描述就是分派可分为静态分派和动态分派,重载属于静态分派,覆盖属于动态分派。静态分派是指在重载时通过参数的静态类型而非实际类型作为判断依据,在编译阶段,编译器可根据参数的静态类型决定使用哪一个重载版本。动态分派则需要根据实际类型来调用相应的方法。
初始化(回座位)
还有一个概念估计大家也可能糊涂,那就是常说的<clinit>()
和<init>()
两个方法
<clinit>()
Class对象的初始化,也就是加载一个类到初始化阶段要执行的方法,它是javac编译器生成的一个方法,它是收集了类中所有类变量赋值的动作和静态语句块然后合并到一起搞成一个方法区执行的。<init>()
实例对象的初始化,也就是new一个对象后要执行构造器函数前要执行的方法。
在准备阶段,已经对类的静态变量进行了那内存开辟和赋默认值,以及常量的处理,而初始化阶段就是要去初始化类中变量和其他资源,也就是说初始化阶段就是执行类构造器<clinit>()
方法的过程。而且大家都知道,在初始化一个类之前肯定要先初始化父类,也就是说在Java虚拟机中第一个被执行的<clinit>()
方法的类型肯定是java.lang.Object。由于父类的<clinit>()
方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作会先执行。但是接口例外,实现接口的类也不需要先执行接口的clinit方法,当然这个方法也不是必须存在的,只有在类中存在静态语句块的时候才会搞出来一个,如果没有要执行的东西,也就没有这个方法了。Java虚拟机必须保证一个类的clinit()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的clinit()方法,其他线程都需要阻塞等待,直到活动线程执行完clinit()方法。如果在一个类的clinit()方法中有耗时很长的操作,那就可能造成多个进程阻塞。
使用(吃饭)
使用就没啥可说的了,也就是得到我们初始化好的实例去写业务代码了。
卸载(离开食堂)
当代表类的Class对象不再被引用,即不可达时,Class对象就会结束生命周期,类在方法区内的数据也会被卸载,从而结束类的生命周期。由Java虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。Java虚拟机本身会始终引用这些类加载器,而这些类加载器则会始终引用它们所加载的类的Class对象,因此这些Class对象始终是可达的。当然由用户自定义的类加载器加载的类还是可以被卸载的。同时当虚拟机进程终止以后,自然所有的类也就被卸载了。
双亲委派模型
上边了解了一下加载的过程,这里我就说说JVM的启动到类被使用是怎么转起来的,以及类加载器的区别和关系,比如我们有一个Test.class里边有个main方法需要被加载到JVM,假设JVM还没有启动,详细过程大致是这样的,我以windows系统进程举例:
- java.exe调用底层的jvm.dll动态链接库(C++写的)文件去创建一个jvm虚拟机
- 然后库文件创建jvm后创建一个引导类加载器实例,一样都是C++的代码写的
- 然后C++代码会调用java代码去创建jvm启动器的实例sun.misc.Launcher,同时去用引导类加载器去加载Launcher的拓展类和应用程序类加载器实现,将这些加载器加载到jvm里边
- JVM默认使用Launcher的getClassLoader()方法返回的类加载器AppClassLoader的实例加载我们的应用程序。AppClassLoader去调用loadClass方法去加载要运行的Test类
- 加载完成后jvm会去执行Test类的main方法入口,然后由C++去发起调用,最后程序开始运行。
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。简单点说就是比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。 这里所指的相等,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance() 方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。
站在虚拟机的角度去看的话,类加载器分为两类,一类是由C++实现的启动类加载器,它是JVM的一部分,另一类是由java语言实现的类加载器,这部分类加载器全部都是继承自java.lang.ClassLoader这个抽象类。而站在类加载器的角度去看的话,全部的类加载器可以分为:
- 启动类加载器
这个类加载器负责加载存放在 <JAVA_HOME>\lib目录的内容,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的类库才会加载到虚拟机的内存中。(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时, 如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。 - 拓展类加载器
这个类加载器是在类sun.misc.Launcher的内部类ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。由于是java实现的,所以程序中也可以调用。 - 应用程序类加载器
这个类加载器由 sun.misc.Launcher的内部类AppClassLoader来实现的。由于应用程序类加载器是ClassLoader类中的getSystem- ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径 (ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。 - 自定义类加载器
负责加载用户自定义路径下的类包,相当于一个订制货,自定义类加载器只需要继承 java.lang.ClassLoader 类,该类有两个核心方法,一个是 loadClass(String, boolean),实现了双亲委派机制,还有一个方法是findClass,默认实现是空方法,所以我们自定义类加载器主要是重写findClass方法就行了。
各种类加载器之间的层次关系被称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。而他们的执行过程如下图:
看着这个图,然后再回想一下最上边故事中问领导买饭没有那个事,估计你就很清楚了。简单点说的话就是遇到类先问上级,上级搞不定自己再去搞,是不是很爽~
为啥要搞双亲委派模型
到这里也就延伸出来一个问题,为什么要搞一个双亲委派模型去加载类呢?不这么玩不行么?
其实答案也显而易见的,主要是两点:
- 沙箱安全
比如自己写了一个tr.jar中的类,比如Object或者String什么的,那通过这个机制就不会被加载,这样可以保证核心的API不会被轻易的篡改。 - 保证了类不会被重复加载
保证了类只会被加载一次,不管怎么加载,最后只会被某一个类加载器加载进JVM里边,也保证了类被加载的唯一性。
破坏双亲委派模型
那双亲委派模型这么安全,那能被破坏么?答案肯定是可以破坏了,双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的 类加载器实现方式。只不过大部分的类加载都是按照这么模型去设计的罢了,当然也有很多例外的。
由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类 java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的 protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass()中编写代码。双亲委派的具体逻辑就实现在这里面, 按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。破坏模型的还有JNDI服务,OSGi技术(热部署)还有我们一直用的tomcat的类加载实现等等,这些都是破坏它的典型案例。
我们就说说常用的tomcat这个容器,它为什么会打破这个模型呢?
试想一下,双亲委派模型的安全和唯一性的特点,如果我们要在一个tomcat容器中部署多个web应用,而且这些应用都用到了相同的依赖,不同的版本等等情况,如果保证唯一性的话,那怎么保证应用间的隔离呢?是不是感觉唯一性在这行不通了。再想想之前的jsp技术,一个jsp最后也是要编译成class文件去执行的,那涉及到热部署的时候,jsp每次改动都不需要重启就可以实现,又是怎么一回事呢,对不对,这些需求导致我们必须去破坏这个模型才能达成效果。所以说这个模型不是强制性约束,而是大家在类加载领域中普遍的一种共性的认知,大家都会往这个方向去契合,除非它无法实现我们的需求。
简单说一下tomcat的类加载实现,如下图:
当然不是tomcat的东西还是会交给双亲委派去执行的,自己定义的类会采用自己的。