JVM类加载机制
类加载机制:虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始 化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
本文参考《深入理解JAVA虚拟机》一书进行归纳和总结,有兴趣的建议阅读该书。
1.类加载的生命周期
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using) 和 卸载(Unloading) 7个阶段。其中验证、准备、解析3个 部分统称为连接(Linking)。
**注意:**加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程 必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。这些阶段通常都是互相交叉地混合式进行的,通常会在一个阶段执行的过程中调用、激活另外一个阶段。
什么情况下要开始类加载的第一个阶段,即加载阶段呢?
虚拟机规范中并没有强制约束,但是当发生初始化阶段的时候,那么加载、验证和准备阶段必须在此之前完成。因此,必须立即对类进行初始化有下面5种情况。
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初 始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字 实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常 量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化, 则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父 类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()的那个类),虚拟机会先初始化这个主类。
- 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄 所对应的类没有进行过初始化,则需要先触发其初始化。
注意:接口与类真正有所区别的是前面讲述的5种“有 且仅有”需要开始初始化场景中的第3种:当一个类在初始化时,要求其父类全部都已经初始 化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使 用到父接口的时候(如引用接口中定义的常量)才会初始化。
1.1 加载
在加载阶段,虚拟机需要完成以下3件事情:
- 1)通过一个类的全限定名来获取定义此类的二进制字节流。(从哪里获取、怎样获取?)
a: 从zip,jar等归档文件中加载.class文件。
b: 通过网络下载.class文件,这种场景最典型的应用就是Applet。
c: 将Java源文件动态编译为.class文件。即运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy 中,就是用了ProxyGenerator.generateProxyClass来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。
d: 由其他文件生成.class文件,典型场景是JSP应用,即由JSP文件生成对应的Class类。
e: 从专有数据库中提取.class文件。
……
- 2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据 的访问入口。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。
注意:加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的, 加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属 于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
1.2 验证
验证是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃。
验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、 元数据验证、字节码验证、符号引用验证。
- 1)文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。验证阶段是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。
(验证点很多,这里不做说明,感兴趣的可以查看HotSpot虚拟机源码。)
- 2)元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。
例如验证点:
这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
…
- 3)字节码验证:主要目的是通过数据流和控制流分析, 确定程序语义是合法的、符合逻辑的。在对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。即使字节码验证之中进行了大量的检查,也不能保证这一点。
由于数据流验证的高复杂性,虚拟机设计团队为了避免过多的时间消耗在字节码验证阶 段,在JDK 1.6之后的Javac编译器和Java虚拟机中进行了一项优化,给方法体的Code属性的 属性表中增加了一项名为“StackMapTable”的属性,这项属性描述了方法体中所有的基本块 (Basic Block,按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节 码验证期间,就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中 的记录是否合法即可。这样将字节码验证的类型推导转变为类型检查从而节省一些时间。 但是理论上StackMapTable属性也存在错误或被篡改的可能。
- 4)符号引用验证:该验证发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证确保解析动作能正确执行。
例如验证点:
符号引用中通过字符串描述的全限定名是否能找到对应的类。
在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被 当前类访问。
……
总结:符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
1.3 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
注意:
(1)这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
(2)这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value=123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()之中,所以把value赋值为123的动作将在初始化阶段才会执行。
下面列出了Java中所有基本数据类型的零值。
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | ‘\u0000’ |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
特殊情况:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为 ConstantValue属性所指定的值,假设上面类变量value的定义变为:
public static final int value=123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
1.4 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
- 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可 以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
- 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点 限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info、 CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、 CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7种常量类型。
1.5 初始化
(1)类初始化阶段
该阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。
在准备阶段,变量已经赋过一次系统要求的初始值,而初始化阶段是执行类构造器<clinit>()的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,因此Java中对类变量进行初始值设定有两种方式:
- ①声明类变量是指定初始值
- ②使用静态代码块为类变量指定初始值
(2)JVM初始化步骤
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
(3)类初始化时机
只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
- 创建类的实例,也就是new的方式;
- 访问某个类或接口的静态变量,或者对该静态变量赋值;
- 调用类的静态方法;
- 反射(如Class.forName(“com.shengsiyuan.Test”));
- 初始化某个类的子类,则其父类也会被初始化;
- Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类。
(4)Java虚拟机将结束生命周期
- 执行了System.exit()方法;
- 程序正常执行结束;
- 程序在执行过程中遇到了异常或错误而异常终止;
- 由于操作系统出现错误而导致Java虚拟机进程终止。
2.类加载器
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的 类。实现这个动作的代码模块称为“类加载器”。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚 拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通 俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的“相等”,包括代表类的Class对象的equals()、isAssignableFrom()、isInstance()的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。
类加载器可以说是Java语言的一项创新,也是Java语言流行的重要原因之一,它最初是为了满足Java Applet的需求而开发出来的。虽然目前Java Applet技术基本上已经“死掉”(特指浏览器上的Java Applets),但类加载器却在类层次划分、OSGi、热部署、代码加密等领域大放异彩,成为了Java技术体系中一块重要的基石。
2.1 类加载器的分类
(1)从Java虚拟机的角度来讲
一种是启动类加载器 (Bootstrap ClassLoader),这个类加载器使用C++语言实现(仅对于HotSpot虚拟机来说,像MRP、Maxine等虚拟机,整个虚拟机本身都是由Java编写的,自然Bootstrap ClassLoader也是由Java语言而不是C++实现的),是虚拟机自身的一部分。
另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
(2)从Java开发人员的角度来看
类加载器还可以划分得更细致一些,绝大部分Java程序都会使用到以下3种系统提供的类加载器。
- 启动类加载器(Bootstrap ClassLoader)
这个类将器负责将存放在< JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机 识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。 - 扩展类加载器(Extension ClassLoader)
这个加载器由sun.misc.Launcher $ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。 - 应用程序类加载器(Application ClassLoader)
这个类加载器由sun.misc.Launcher $App- ClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一 般情况下这个就是程序中默认的类加载器。
我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。
2.2 类加载的方式
(1)命令行启动应用时候由JVM初始化加载
(2)通过Class.forName()方法动态加载
(3)通过ClassLoader.loadClass()方法动态加载
Class.forName()和ClassLoader.loadClass()区别
Class.forName():将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;
ClassLoader.loadClass():只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
注意:Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。
2.3 类加载器的双亲委派模型(Parents Delegation Model)
类加载器的双亲委派模型在JDK 1.2期间被引入并被广泛应用于之后几乎所有的Java程序 中,但它并不是一个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载器实现 方式。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当 有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。
(1)双亲委派模型的工作过程
如果一个类加载器收到了类加载的请求,它首先不会自己 去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是 如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
双亲委派机制
a: 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
b: 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
c: 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
d: 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
(2)使用双亲委派模型来组织类加载器之间的关系的好处
Java类随着它的类加载器一起具备了一种带有优先级的层次关系。
例如类java.lang.Object,它存放在 rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加 载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有 使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object 类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
因此,双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中。
因此双亲委派模型意义
- 系统类防止内存中出现多份同样的字节码
- 保证Java程序安全稳定运行
//双亲委派模型的实现源码
protected synchronized Class<?>loadClass(String name,boolean resolve)throws ClassNotFoundException {
//首先,检查请求的类是否已经被加载过了
Class c=findLoadedClass(name);
if(c==null){
try{
if(parent!=null){ //若没有加载则调用父加载器的loadClass()方 法
c=parent.loadClass(name,false);
}else{ //若父加载器为空,则默认使用启动类加载器作为父加载器
c=findBootstrapClassOrNull(name);
}
}catch(ClassNotFoundException e){
//如果父类加载器抛出ClassNotFoundException
//说明父类加载器无法完成加载请求
}if(c==null){
//在父类加载器无法加载的时候
//再调用自己的findClass方法来进行类加载
c=findClass(name);
}
}
if(resolve){
resolveClass(c);
}
return c;
}
2.4 破坏双亲委派模型
虽然双亲委派模型并不是一个强制性约束模型,但是在java中大部分的类加载器都遵循这个模型,但是也有例外,目前为止,双亲委派模型主要出现过3次较大规模的“被破坏”情况。
(1)在双亲委派模型出现之前
- 在JDK1.2发布之前还没有出现双亲委派模型,而类加载器和抽象类java.lang.ClassLoader在JDK 1.0时代就已经存在,用户去继承该抽象类的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。
- 在JDK1.2之后双亲委派的具体逻辑就实现在loadClass()这个方法之中,在loadClass()方法的逻辑里,如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。findClass()是java.lang.ClassLoader中新添加的一个protected方法,该方法不提倡用户再去覆盖loadClass()方法,而是把自己的类加载逻辑写到该方法中。
(2)模型自身缺陷所导致
- 双亲委派模型的主要特点是解决各个类加载器的基础类的统一问题,越基础的类由越上层的加载器加载,即基础类一般情况下都是用户调用的API,但是当基础类要调用用户的代码时,这种逆向的方式已经违背了双亲委派模型的一般性原则。
- Java中所有涉及接口提供者SPI(Service Provider Interface)的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。
(3)用户对程序动态性的追求导致
- “动态性”指的是当前一些非常“热门”的名词:代码热替换(HotSwap)、模块热部署(Hot Deployment)等,说白了就是希望应用程序能像我们的计算机外设那样,接上鼠标、U盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用停机也不用重启。对于 个人计算机来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一 次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是企业级软件开发者 具有很大的吸引力。
- 例如OSGi中的类加载器并不符合传统的双亲委派的类加载器。
2.5 Tomcat破坏双亲委派
首先,我们思考一个问题,tomcat为什么要打破双亲机制?
大家都知道,tomcat是一个web容器,它需要解决下面这几个问题:
(1)版本隔离:一个web容器可能需要部署多个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
(2)版本共享:部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有20个应用程 序,那么要有20份相同的类库加载进虚拟机。
(3)类库隔离:web容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
(4) jsp修改:web容器要支持jsp的修改,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp常见的事情, web容器需要支持 jsp 修改后不用重启。
版本隔离,如果tomcat使用默认的双亲委派加载机制,肯定是无法加载多个相同类库的不同版本的,默认的加载器只认权限定类名,并且只有一份。
版本共享,使用默认的双亲委派加载机制是可以实现的。
类库隔离,同样的也需要打破双亲委派机制。
jsp修改,其实就是实现jsp的热加载,实现机制就是每个jsp文件对应一个唯一的类加载器,当jsp文件修改了,我们就将其对应的类加载器给卸载了,然后再重新为其创建一个类加载器,重新加载jsp。
Tomcat几个主要的类加载器
- CommonLoader类加载器:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容 器本身以及各个Webapp访问。
- CatalinaLoader类加载器:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不 可见。
- sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有 Webapp可见,但是对于Tomcat容器不可见。
- WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前 Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同的spring版本, 这样实现就能加载各自的spring版本。
图中可以看出:
- CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用, 从而实现了公有类库的共用。
- CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。
- WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader 实例之间相互隔离。
- JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例, 并通过再建立一个新的Jsp类加载器来实现JSP文件的热加载功能。
很显然,tomcat这种加载机制违背了双亲委派机制模型,它为了实现隔离性,每个 webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器,打破了双亲委派机制。