赞
踩
目录
一个Java类会被编译成一个Class字节码文件,这个文件包含这个类的所有信息,交给虚拟机去执行。
一方面,虚拟机需要正确加载Class文件。另一方面,静态的Class文件中有一些不确定信息,比如多态,需要在运行时补充。
什么是虚拟机的类加载机制
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为“虚拟机的类加载机制”。
Java语言的特点
在Java中,类型的加载、链接和初始化过程是在程序运行期间完成的
。
这种策略使得Java语言进行提前编译会面临额外的困难,类加载时的开销也较大,但使得Java程序具有极高的扩展性和灵活性。
比如一个面向接口的程序,在运行阶段才会得知使用的具体实现类。
或者自定义一个类加载器,让本地的程序在运行时从网络上或其他地方加载一个Class文件,作为代码的一部分。
当程序主动使用某个类时,如果该类还未被加载到内存中,则JVM
会通过加载
、链接
、初始化
3个步骤来对该类进行初始化
。
其中链接部分包括验证、准备和解析三个步骤
。
其中,加载、验证、准备、初始化和卸载这五个阶段,顺序是固定的,按部就班地开始。
而解析阶段则不确定,某些情况下可以在初始化阶段后才进行,这是为了支持Java语言的运行时绑定特性。
注意,每个阶段并不一定是按顺序进行或完成,只是按顺序开始。因为这些阶段通常是互相交叉地混合进行的,会在一个阶段中激活另一个阶段。
注意,类加载过程只负责class文件的加载
,至于它是否可以运行,则由Execution Engine(执行引擎)决定。
加载阶段,JVM需要做三件事情
将这个字节流代表的静态存储结构,转化成方法区的运行时数据结构
。在内存中生成一个代表这个类的Class对象
,作为方法区这个类的各种数据的访问入口。定义类的二进制字节流可以有不同来源
这个阶段其实相当灵活,因为只规定要获取一个类的二进制字节流,而没有规定具体从哪里获取。
比如:
在整个类加载过程中,非数组类型的加载阶段(获取二进制流的动作)是开发人员可控性最强的。
可以使用JVM内置的引导类加载器完成,也可以自定义类加载器。
加载数组类型
数组类本身不通过类加载器创建,而是由JVM直接在内存中动态构造出来的。
但数组类的元素类型,还是要靠类加载器来完成加载的。
加载的目的
加载阶段结束之后,JVM外部的二进制字节流就按照虚拟机设定的格式,存储在方法区之中了
。方法区中的数据存储格式完全由虚拟机实现自行设计。
类型数据放在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,作为程序访问方法区中的类型数据的外部接口。
加载阶段和链接阶段的部分动作,比如一些字节码文件格式验证动作,是交叉进行的。加载阶段尚未完成,可能链接阶段已经开始了。
但这些夹在加载阶段之中进行的链接阶段动作,依然属于链接阶段的一部分。这两个阶段的开始时间依然保持着固定的先后顺序。
链接阶段的目的
当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入链接阶段,链接阶段负责把类的二进制数据合并到JRE。
验证的目的
验证的目的在于确保这个Class文件符合JVM的全部规范,且运行后不会危害虚拟机的自身安全
。
Java是相对安全的语言,做了一些不合理的事情就无法通过编译。但Class文件不一定是Java代码编译得来的,所以必须对其进行验证。
而且验证非常严谨,验证阶段的工作量在整个类加载过程中占了相当大的比重
。
主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。
文件格式验证
目的:只有通过这个阶段的验证,这段字节流才被允许进入JVM内存的方法区中进行存储。
后面的三个验证阶段都是直接从方法区操作这段数据。
元数据验证
对字节码描述的信息进行语法的分析,分析是否符合java的语言语法的规范
。
字节码验证
最复杂、最重要的验证环节。通过数据流分析和控制流分析,确定语义是合法的,符合逻辑的
。
主要是针对方法体的验证
。保证类方法在运行时不会有危害出现。
为了避免字节码验证阶段消耗过多时间,JVM的设计团队尽可能多的把校验辅助措施放在了javac编译器中进行。
具体做法是给方法体Code属性的属性表中新增了一项名为“StackMapTable”的属性,描述了方法体中所有的基本快开始时本地变量表和操作站应有的状态。这样,字节码验证阶段只需要检查这些记录是否合法即可,将类型推导变成了类型检查,大大提高了效率。
符号引用验证
这个阶段的校验行为,发生在符号引用转换为直接引用的时候
,这个转换动作将在链接的解析阶段发生
。
主要目的是,检验该类是否缺少或被禁止访问它依赖的外部类、方法、字段等资源,保证引用一定会被访问到,不会出现类等无法访问的问题
。
这个阶段并不是一定要进行。如果测试过没问题,就可以在生产环境使用如下参数,关闭大部分这个阶段的验证,加快虚拟机的类加载过程。
-Xverify:none
准备阶段负责为类的静态变量分配内存,并设置初始值
。
这里只包括静态变量,实例变量将会在对象实例化时随着对象一起被分配在Java堆中。
如果是普通的静态变量,在准备阶段会被赋它相应类型的初始值。
但如果是final的静态常量,在编译阶段该常量的值就会被确定。那么在准备阶段,虚拟机会直接将常量值赋给它,而不是赋默认值。
解析阶段,是虚拟机常量池内的符号引用(变量名)替换成直接引用(地址)的过程
。
如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载
(但未必触发这个类的链接以及初始化)。
并未规定解析阶段发生的具体时机,可以根据虚拟机实现,选择是在类加载器加载时就解析常量池的符号引用,还是等到将要使用一个符号引用时再去解析它。
解析动作主要针对类或接口、字段、类方法、接口方法等。在解析完成之前,还需要进行访问权限验证。
直到初始化阶段,JVM才开始真正执行类中的Java代码,将主导权交给程序。
初始化阶段就是执行类构造器<clinit>()方法的过程
。这个<clinit>()方法并不存在于Java代码中
,它是javac编译器自动生成的。
<clinit>()
<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的
,顺序就是代码书写顺序。
(定义在静态代码块中的内容,可以给后面书写的静态变量赋值,但不能引用它)
<clinit>()方法与类的构造函数(即虚拟机中的<init>()方法)不同,它不需要显示调用父类构造器
由于父类的<clinit>()先执行,所以父类的静态代码块会优先于子类的变量赋值操作。
<clinit>()不是必须的。
接口中不能定义静态代码块,但可以有变量初始化的赋值操作,所以接口也会被生成<clinit>()方法。
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁和同步
,即类的构造方法是线程安全的。
示例
准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的,如果类中有语句:
private static int a = 10
它的执行过程是这样的:
程序中定义了一个A类,有个静态整形变量m,有静态代码块,无参构造器
目的:验证类构造器<clinit>是否会将所有类变量的赋值动作和静态代码块中的语句合并
- public class Demo{
- public static void main(String[] args){
- A a = new A();
- System.out.println(a.m);
- }
- }
-
- class A{
- static{
- System.out.println("A类静态代码块初始化");
- m = 300;
- }
-
- static int m = 100;
-
- public A() {
- System.out.println("A类的无参构造初始化");
- }
- }
输出
A类静态代码块初始化 A类的无参构造初始化 100
这个程序的内存分析:
m的赋值历程:
JVM的类加载分为三个阶段:分别是加载、链接、初始化。其中链接阶段有三个操作:验证、准备、解析
加载:把Class文件读进内存,创建出它的Class对象
验证:
准备:在方法区中为类的静态变量分配内存空间,并设置类中静态变量的初始值。如果是final修饰的,会直接赋值。
解析:将常量池中的符号引用替换为直接引用。
初始化:执行类的构造方法
什么时候进行类加载过程的第一个阶段“加载”,没有明确规定,由虚拟机自由实现。
主动引用会触发类加载初始化
但对于“初始化”阶段,做了严格规定,有且只有这六种情况必须对类进行初始化:
new
关键字实例化对象静态字段
(final的除外)静态方法
反射调用
。如果类型没有进行过初始化,则要先进行初始化。初始化main方法所在的类
先初始化其父类
。
接口中定义了默认方法
,如果有这个接口的实现类发生了初始化,则接口必须在其之前被初始化
这几种行为,都属于对一个类的主动引用行为
。
触发一个类的加载过程,未必也触发它的链接和初始化过程。
特殊情况:
如果该变量的值在编译时就可以确定下来,那么这个变量相当于“宏变量”。Java编译器会在编译时直接把这个变量出现的地方替换成它的值,因此即使程序使用该静态变量,也不会导致该类的初始化。
如果final类型的静态变量的值不能在编译时确定下来,则必须等到运行时才可以确定该变量的值,如果通过该类来访问它的静态变量,则会导致该类被初始化。
被动引用不会触发初始化
当访问一个静态域时,只有真正声明这个域的类才会被初始化。
通过数组定义类引用,不会触发此类的初始化
引用常量不会触发此类的初始化(常量在链接阶段就存入调用类的常量池中了)
JVM的设计团队有意把类加载阶段的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作,放在了虚拟机外部去实现。
实现这个动作的代码叫做“类加载器”,可以由开发人员决定如何获取所需的类。
类加载器只用于类加载过程的“加载”阶段,但作用很大。
对于任意一个类,必须由它的类加载器和这个类本身一起共同确定其在JVM中的唯一性
。每个类加载器都有一个独立的类命名空间。
(即使两个类来源于同一个Class文件,在同一个JVM中,如果加载它们的类加载器不同,这两个类就不相等)。
这里的“相等”,包括equals()、isInstance()、isAssignableFrom()方法的返回结果。
比如自定义了一个类加载器,加载了一个类(com.zcy.User)并实例化了一个对象,比如叫user。
- user.getClass(); //class com.zcy.User
- user instanceof com.zcy.User //false
可以看到,它肯定是com.zcy.User类型,但与这个类做类型比较,会返回false,这是因为JVM中同时存在两个User类:
从JVM的角度来看,它自身是C/C++实现的,对它来说只有两种不同的类加载器:
(JDK 9之后,HotSpot有一个无法获取实例的,代表Bootstrap ClassLoader的Java类出现)。
但开发人员的角度,类加载器应当划分地细致一点。注意,下列三个类加载器是有层级关系的
。
启动(Bootstrap)类加载器
扩展(Extension)类加载器
应用程序类加载器(Application)/ 系统(System)类加载器
获取示例
- public static void main(String[] args) throws ClassNotFoundException {
- //获取系统类加载器
- ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
- System.out.println(systemClassLoader); //sun.misc.Launcher$AppClassLoader@18b4aac2
-
- //获取系统类加载器的父类加载器:扩展类加载器
- ClassLoader extensionClassLoader = systemClassLoader.getParent();
- System.out.println(extensionClassLoader); //sun.misc.Launcher$ExtClassLoader@1b6d3586
-
- //获取扩展类加载器的父类加载器:根类加载器
- ClassLoader BootstrapClassLoader = extensionClassLoader.getParent(); //无法直接获取,会返回null
- System.out.println(BootstrapClassLoader);
-
- //测试当前类(用户类)是哪个加载器加载的
- //获取当前类的Class对象
- Class name = Class.forName("com.zcy.Test.Demo");
- //调用Class的getClassLoader()方法,获取这个类的构造器对象
- ClassLoader classLoader = name.getClassLoader();
- System.out.println(classLoader); //sun.misc.Launcher$AppClassLoader@18b4aac2
-
- //测试JDK内置的类是哪个加载器加载的
- Class name2 = Class.forName("java.lang.Integer");
- ClassLoader classLoader1 = name2.getClassLoader();
- System.out.println(classLoader1); //null
- }
类加载器之间的关系(双亲委派模型):
双亲委派模型要求,除了顶层的启动类加载器之外,每个类加载器都要有自己的父类加载器。
不过父子关系不是用继承实现的,而是一种逻辑上的优先级关系,每个类加载器都有一个名义上的“father”。
1、工作过程
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载
,这就是双亲委派模式2、源码中的实现
3、工作逻辑
先检查请求加载的类型是否已经加载过,如果没有,就调用父类加载器的loadClass()方法。如果父类加载器为null,使用启动类加载器加载。
如果父类加载器加载失败,就调用此加载器自身的loadClass()方法尝试加载。
4、意义
避免类的重复加载
。当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。核心API中定义类型不会被随意替换
,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。5、破坏双亲委派模型
双亲委派模型并不是一个具有强制约束力的模型,只是Java设计者推荐的最佳实践。
Java世界中的大多数类加载器都遵循这个模型,但也有例外。直到Java模块化出现为止,双亲委派模型出现过3次较大规模的“被破坏”情况。
线程上下文类加载器
这是双亲委派模型的第二次被破坏,第一次是为了兼容用户自定义的老旧类加载器。
双亲委派模型自身也有缺陷,它解决了各个类加载器协作时,基础类型的一致性问题。但如果有基础类型需要调用高层的用户代码,如何实现?
比如JNDI服务,它由启动类加载器加载,但它需要调用一些用户代码,启动类加载器无法直接加载它们。(启动类加载器没有双亲,它自己又无法加载,因为用户代码只能由应用程序类加载器加载。)
为了解决这个问题,Java团队只能引入一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。
线程上下文类加载器可以通过Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,就从父线程继承一个。如果全局范围内都没有设置,那就是用应用程序类加载器。
有了线程上下文类加载器,程序就可以做一些“舞弊”的事情,即可以通过父类加载器去请求子类加载器,违背了双亲委派模型的一般性原则
。
重写方式
重写一个类继承ClassLoader,并重写loadClass方法,也可以破坏双亲委派机制。
正常情况下自定义类加载器,如果不想破坏双亲委派模型,只需要重写findClass()方法。
JVM中除了双亲委派,还有两种辅助性的加载策略:
缓存机制:
全盘负责:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。