Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。与那些在编译时需要进⾏连接的语⾔不同,在Java语⾔⾥⾯,类型的加载、连接和
初始化过程都是在程序运⾏期间完成的,这种策略让Java语⾔进⾏提前编译会⾯临额外
的困难,也会让类加载时稍微增加⼀些性能开销, 但是却为Java应⽤提供了极⾼的扩展
性和灵活性,Java天⽣可以动态扩展的语⾔特性就是依赖运⾏期动态加载和动态连接这
个特点实现的。例如,编写⼀个⾯向接⼝的应⽤程序,可以等到运⾏时再指定其实际的实
现类,⽤户可以通过Java预置的或⾃定义类加载器,让某个本地的应⽤程序在运⾏时从
⽹络或其他地⽅上加载⼀个⼆进制流作为其程序代码的⼀部分。
1 类加载的时机
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。
图7-1中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
请注意,这里笔者写的是按部就班地“开始”,而不是按部就班地“进行”或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
《Java虚拟机规范》
则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
1)遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
-使用new关键字实例化对象的时候。
-读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
-调用一个类型的静态方法的时候。
2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先
初始化这个主类。
5)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
6)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
接口初始化:
接⼝的加载过程与类加载过程稍有不同,针对接⼝需要做⼀些特殊说明:接⼝也有初始化
过程,这点与类是⼀致的,上⾯的代码都是⽤静态语句块‘static{}’输出信息,而不能在接口使用‘static{}’语句块,但编译器仍然会为接⼝⽣成“<clinit>()”类构造器,⽤于初始化接⼝中所定义的成员变量。接⼝与类真正有所区别的是前⾯讲述的六种“有且有”需要触发初始化场景中的第三种: 当⼀个类在初始化时,要求其⽗类全部都已经初始化过了,但是⼀个接⼝在初始化时,并不要求其⽗接⼝全部都完成了初始化,只有在真正使⽤到⽗接⼝的时候(如引⽤接⼝中定义的常量)才会初始化。
2类加载的过程
7.1加载:
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的⼀个阶段。
在加载阶段,Java虚拟机需要完成以下三件事情:
1)通过⼀个类的全限定名来获取定义此类的⼆进制字节流。
2)将这个字节流所代表的静态存储结构转化为⽅法区的运⾏时数据结构。
3)在内存中⽣成⼀个代表这个类的java.lang.Class对象,作为⽅法区这个类的各种数据的访问⼊⼝。
相对于类加载过程的其他阶段,非数组类型的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的阶段。加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()或loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。
对于数组类⽽⾔,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类
的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完
成加载,⼀个数组类(下⾯简称 为C)创建过程遵循以下规则:
如果数组的组件类型(Component Type,指的是数组去掉⼀个维度的类型,注意和前⾯的元素类型区分开来)是引⽤类型,那就递归采⽤本节中定义的加载过程去加载这个组件类型,数组C将被标识在加载该组件类型的类加载器的类名称空间上
如果数组的组件类型不是引⽤类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组C标记为与引导类加载器关联。数组类的可访问性与它的组件类型的可访问性⼀致,如果组件类型不是引⽤类型,它的数组类的可访问性将默认为public,可被所有的类和接⼝访问到。
7.2验证:
这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。包括:
-
文件格式验证:验证字节流是否符合Class文件格式的规范。如是--否以魔数0xCAFEBABE开头。—是否以魔数0xCAFEBABE开头。
2)元数据验证:字节码描述的信息进行分析。如·这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类);这个类的父类是否继承了不允许被继承的类(被final修饰的类);如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
3)字节码验证:是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。如保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况。
4)符号引用验证:
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:
·符号引用中通过字符串描述的全限定名是否能找到对应的类。
·在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
·符号引用中的类、字段、方法的可访问性(private、protected、public、<package>)是否可被当前类访问。
7.3 准备
准备阶段是正式为类变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。
7.4解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引⽤(Symbolic References): 符号引⽤以⼀组符号来描述所引⽤的⽬标,符号可以是任何形式的字⾯量,只要使⽤时能⽆歧义地定位到⽬标即可。符号引⽤与虚拟机实现的内存布局⽆关,引⽤的⽬标并不⼀定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引⽤必须都是⼀致的,因为符号引⽤的字⾯量形式明确定义在《Java虚拟机规范》的Class⽂件格式中。
直接引⽤(Direct References): 直接引⽤是可以直接指向⽬标的指针、相对偏移量或者是⼀个能间接定位到⽬标的句柄。直接引⽤是和虚拟机实现的内存布局直接相关的,同⼀个符号引⽤在不同虚拟机实例上翻译出来的直接引⽤⼀般不会相同。如果有了直接引⽤,那引⽤的⽬标必定已经在虚拟机的内存中存在。
7.5 初始化
类加载的最后一个步骤,经过这个步骤后,类信息完全进入了jvm内存,直到它被垃圾回收器回收。
1、前面几个阶段都是虚拟机来搞定的。我们也干涉不了,从代码上只能遵从它的语法要求。而这个阶段,是初始化赋值,java虚拟机才真正开始执行类中编写的java程序代码,将主导权移交给应用程序。
2、在准备阶段,静态变量已经赋过一次系统要求的初始值了,而在初始化阶段要执行初始化函数。初始化阶段就是执⾏类构造器<clinit>()⽅法的过程。<clinit>()并不是程序员在Java代码中直接编写的⽅法,它是Javac编译器的⾃动⽣成物
3、<clinit>()⽅法是由编译器自动收集类中的所有静态变量的赋值动作和静态语句块( static 代码块)中的语句合并产生的。
4、<clinit>()⽅法与类的构造函数(即在虚拟机视⻆中的实例构造器<init>()⽅法)不同,它不需要显式地调⽤⽗类构造器,虚拟机能保障父类的函数优先于子类函数的执行。。因此在Java虚拟机中第⼀个被执⾏的<clinit>()⽅法的类型肯定是java.lang.Object
5、·<clinit>()⽅法对于类或接⼝来说并不是必需的,如果⼀个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类⽣成<clinit>()⽅法。
3 类加载器
java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。
3.1类与类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果。
3.2 双亲委派模型
站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现[1],是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。自JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构。
启动类加载器(Bootstrap Class Loader):
它在Java虚拟机启动后初始化,它主要负责加载以下路径的文件:
%JAVA_HOME%/jre/lib/*.jar
%JAVA_HOME%/jre/classes/*
-Xbootclasspath 参数指定的路径。
这一步会加载一个关键的类: sun.misc.Launcher ,这个类包含了两个静态内部类: ExtClassLoader , AppClassLoader 。
由于启动类加载器是由C++实现的,所以在Java代码里面是访问不到启动类加载器的,如果尝试通过 String.class.getClassLoader() 获取启动类加载器的引用,会返回 null。
ExtClassLoader(标准扩展类加载器):
ExtClassLoader主要Java 写的,具体来说就是sun.misc.Launcher$ExtClassLoader ExtClassLoader 主要加载:
%JAVA_HOME%/jre/lib/ext/*;
ext 下的所有 classes 目录;
java.ext.dirs 系统变量指定的路径中类库;
AppClassLoader(系统类加载器):
AppClassLoader 也是用Java写成的,它的实现类是
sun.misc.Launcher$AppClassLoader ,另外我们知道 ClassLoader 中有个
getSystemClassLoader 方法,此方法返回的就是它。
负责加载 -classpath 所指定的位置的类或者是jar文档
也是Java程序默认的类加载器。
各种类加载器之间的层次关系被称为类加载器的“双亲委派模型。父类和子类不是继承关系而是协作包含关系。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
这样设计的好处:
1.避免重复加载
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有
优先级的层次关系,通过这种层级关可以避免类的重复加载,当父加载器已经
加载了该类时,就没有必要子加载器再加载一次。
2.避免核心类篡改
考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为 java.lang.Integer 的类,通过双亲委派模型传递到启动类加载器,而启动类加载器发现这个名字的类,发现该类已被加载,就不会重新加载网络传递过来的 java.lang.Integer ,而直接返回已加载过的Integer.class ,这样便可以防止核心API库被随意篡改。
4 Java模块化系统(jdk9以上)
在JDK 9中引入的Java模块化系统(Java Platform Module System,JPMS)是对Java技术的一次重要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。JDK 9的模块不仅仅像之前的JAR包那样只是简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:
·依赖其他模块的列表。
·导出的包列表,即其他模块可以使用的列表。
·开放的包列表,即其他模块可反射访问模块的列表。
·使用的服务列表。
·提供服务的实现列表。
4.1模块的兼容性:
为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK 9提出了与“类路
径”(ClassPath)相对应的“模块路径”(ModulePath)的概念。简单来说,就是某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上。只要是放在类路径上的JAR
⽂件,⽆论其中是否包含模块化信息(是否包含了module-info.class⽂件),它都会被当
作传统的JAR包来对待; 相应地,只要放在模块路径上的JAR⽂件,即使没有使⽤JMOD
后缀,甚⾄说其中并不包含module-info.class⽂件,它也仍然会被当作⼀个模块来对待。
JAR⽂件在类路径的访问规则:
所有类路径下的JAR⽂件及其他资源⽂件,都被视为⾃动打包在⼀个匿名模块(Unnamed Module)⾥,这个匿名模块⼏乎是没有任何隔离的,它可以看到和使⽤类路径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块中导出的包。
模块在模块路径的访问规则: 模块路径下的具名模块(Named Module)只能访问到它依赖定义中列明依赖的模块和包,匿名模块⾥所有的内容对具名模块来说都是不可⻅的,即具名模块看不⻅传统JAR包的内容。
JAR⽂件在模块路径的访问规则:
如果把⼀个传统的、不包含模块定义的JAR⽂件放置到模块路径中,它就会变成⼀个⾃动模块(Automatic Module)。尽管不包含module info.class,但⾃动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,⾃动模块也默认导出⾃⼰所有的包。
4.2模块化下的类加载器:
扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取
代。 这其实是⼀个很顺理成章的变动,既然整个JDK都基于模块化进⾏构建(原来的rt.jar
和tools.jar被拆分成数⼗个JMOD⽂件),其中的Java类库就已天然地满⾜了可扩展的需
求,那⾃然⽆须再保留 <JAVA_HOME>\lib\ext⽬录,取消可扩展类加载器。
JDK 9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发⽣了
变动。当平台及应⽤程序类加载器收到类加载请求,在委派给⽗加载器加载前,要先判断
该类是否能够归属到某⼀个系统模块中,如果可以找到这样的归属关系,就要优先委派给
负责那个模块的加载器完成加载,也许这可以算是对双亲委派的第四次破坏。