虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的JAVA类型,这就是虚拟机的类加载机制。
类加载的生命周期包括:加载Loading, 验证Verification, 准备Preparation, 解析Resolution, 初始化Initialization, 使用Using和卸载Unloading.
除解析阶段外,其他几个阶段的顺序都是固定的。解析阶段在某些情况下可以在初始化阶段之后再开始,这是为了支持JAVA语言的运行时绑定(动态绑定/晚期绑定)
虚拟机规范严格规定了有且只有四种情况必须对类进行初始化(加载,验证,准备自动在之前开始)
- 遇到new,getstatic,putstatic,invokestatic这4条字节码指令时,如果类没有进行初始化,则先初始化。这4个字节码常见的出现场景是:使用new关键字实例化对象的时候,读取或设置静态字段(被final修饰,已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 反射调用时
- 初始化一个类时,如果其父类还未初始化,则先出发父类初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类
这4种情况称为对类的主动引用,其他情况称为被动引用。
对于访问静态字段,只有直接定义这个字段的类才被初始化,因此通过子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。但是对于HOTSPOT,会触发子类的加载。
通过数组定义引用类,不会触发此类的初始化。
常量在编译阶段会存入调用类的常量池,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
接口的加载和类加载过程稍有不同,接口不能有static代码段,但接口中还是会生成<clinit>()类构造器,用于初始化接口中所定义的成员变量。一个接口在初始化时,并不要求其父类也初始化了。
类加载的过程
加载
在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在JAVA堆中生成一个代表着各类的java.lang.Class对象,作为方法区这些数据的访问入口
事实上,这三条限定都不是很严格,比如第一条,并没有明确指出通过全限定名从哪里得到二进制流,由此就有很多不同的实现:
- 在ZIP包中读取(JAR,EAR,WAR)
- 从网络中获取(APPLET)
- 运行时计算生成,这种场景使用的最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass来为特定接口申城$Proxy的代理类的二进制流
- 由其它文件生成(jsp)
- 从数据库中读取,有些中间件服务器(SAP NETWEAVER)
加载阶段完成后,虚拟机外部的二进制流就按照虚拟机所需的格式存储在方法区中,方法区中的数据存储格式由虚拟机实现自行定义。然后在JAVA堆中实例化一个java.lang.Class类对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。加载阶段与连接阶段的部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。
验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并却不会危害虚拟机自身的安全。
一些在编译层面上可以控制的事情(比如超边界访问数组,跨类型进行类型对象转换存在时,编译器是拒绝工作的)可以通过直接修改class文件的方式进行破解,这就是验证阶段存在的原因。
按照虚拟机规范,如果验证到输入的字节流不符合Class文件的存储格式,就抛出一个java.lang.VerifyError异常或其子类异常。
大致分成4个阶段的验证过程:文件格式验证、元数据验证、字节码验证和符号引用验证
文件格式验证:
比如是否以魔数开头,主次版本号是否在虚拟机可处理范围之内,常量池是否有不支持类型等。
经过这个阶段的验证之后,字节流才会进入内存的方法区进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构进行的。
元数据验证:
对字节码描述的信息进行语义分析,以保证其描述的信息符合JAVA语言规范的要求,这个阶段可能包括的验证点有:
这个类是否有父类,父类是否集成了不允许继承的类,如果不是抽象类是否实现了其父类或接口中要求实现的所有方法,类中的字段和父类是否有矛盾
字节码验证:
最复杂的一个解读那,主要工作是进行数据流和控制流分析。这阶段对类的方法体进行校验分析,保证该方法在运行时不会做出危害JVM安全的行为,例如:
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,保证跳转指令不会跳转到方法体以外的字节码指令上,保证方法体中的类型转换是有效的。。。
这个验证并不能保证一定安全(停机问题,通过程序去校验程序逻辑是无法做到绝对准确的)
1.6加入StackMapTable功能对这个阶段做了优化,提高速度,但这个StackMapTable也可能被篡改,可以通过启动参数来关闭这个选项。
符号引用验证:
这个阶段发生在虚拟机将符号引用转化为直接引用的时候。这个转化动作将在连接的第三个阶段----解析阶段中发生。
可以看作是对类自身以外的信息进行匹配性的校验。
比如:符号引用中通过字符串描述的全限定名是否能找到对应的类,是否存在所描述的方法和字段。。。。
如果无法通过符号验证,将会抛出一个Java.lang.IncompatibleClassChangeError异常的子类,比如Java.lang.IllegalAccessError,java.lang.NoSuchFieldError,java.lang.NoSuchMethodError
可以使用启动参数来关闭大部分类验证措施,缩短虚拟机类加载时间。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个时候内存分配的仅包括类变量(static变量),不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。其次是这里所说的初始值“通常情况下”是数据类型的零值(随后在初始化阶段生成定义的初值)。如果该变量被final修饰,将在编译时生成ConstantValue,这样在准备阶段将直接设置成该初值。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用在CLASS文件中它以CONSTANT_CLASS_INFO, CONSTANT_FIELDREF_INTO, CONSTANT_METHODREF_INFO等类型的常量出现
符号引用:(Symbolic References)符号引用以一组符号来描述所引用的目标,可以是任何形式的字面量,引用的目标并不一定已经加载到内存中,与虚拟机内存布局无关。
直接引用:(Direct References)直接引用可以是直接指向目标的指针,相对偏移量,或是一个能间接定位到目标的句柄。与虚拟机内存布局相关。
虚拟机规范并未规定解析阶段发生的具体时间,只要求了在执行anewarray,checkcast,getfield,getstatic,instanceof,invokeinterface,
invokespecial,invokestatic,invokevirtual,multianewarray,new,putfield,putstatic这13个操作符号引用的字节码指令之前,先对它们使用的符号引用进行解析。
对同一个符号引用进行多次解析请求是很常见的事情。有的实现会进行缓存。
解析动作主要针对类/接口,字段,类方法,接口方法四类符号引用进行。分别对应于常量池的CONSTANT_CLASS_INFO,CONSTANT_FIELDREF_INFO,CONSTANT_METHODREF_INFO,
CONSTANT_INTERFACEMETHODREF_INFO四种类型。
类和接口的解析:假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下三个步骤:
- 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。
- 如果C是数组类型,并且数组的元素类型是对象,则按照1的情况处理。如果元素类型不是对象,则由虚拟机生成一个代表此数组维度和元素的数组对象。
- 如果上述步骤没有异常,C在虚拟机中世纪已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认C是否具有对D的访问权限,如果没有则抛出java.lang.IllegalAccessError异常。
字段解析:要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是这个字段所属的类或者接口,如果解析成功,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索:
- 如果C本身包含这个字段,就返回这个字段的直接引用,查找结束
- 否则,如果C中实现了接口,将会按照集成关系从善根倒下递归搜索各个接口和它的父接口,如果匹配,则返回这个字段的直接引用,查找结束
- 否则,如果C不是java.lang.Object,将会按照继承关系从上往下递归搜索父类,如果匹配,返回值额济引用,查找结束
- 查找失败,抛出java.lang.NoSuchFieldError异常。
本身--接口--父类--失败
如果成功返回了引用,将进行权限验证,不具备权限则抛出java.lang.IllegalAccessError
类方法解析:第一个步骤与字段解析一样,同样是需要先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用,再按照以下步骤搜索:
- 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index索引的C是个接口,那直接抛出java.lang.IncompatibleClassChangeError异常
- 在C中查找是否有匹配,有则结束
- 否则,在C父类中查找匹配
- 在C接口列表和父接口中查找,如果存在匹配,说明C是抽象类,抛出java.lang.AbstractMethodError
- 抛出java.lang.NoSuchMethodError.
返回成功后检查权限验证,不通过则抛出java.lang.IllegalAccessError
接口方法解析:第一步一样,后面步骤:
- 与类方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError
- 在C中查找是否有匹配
- 在C的父接口中查找匹配
- 失败,抛出java.lang.NoSuchMethodError
由于接口方法都是默认public,所以不存在访问权限问题
初始化
是类加载过程的最后一步,初始化阶段才真正开始执行类中定义的JAVA程序代码
准备阶段中,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的计划来赋值
或者说,初始化阶段是执行类构造器<clinit>()方法的过程
- <clinit>()方法是由编译器自动收集类中的所有类变量的复制动作和静态语句块中的语句合并而成。编译器收集的顺序和语句在源文件中出现的顺序一致,静态语句块中只能访问到定义在它之前的变量,定义在它之后的变量,只能赋值,不能访问
- <clinit>()方法与类的构造函数<init>()不同,不需要显式的调用父类构造器,虚拟机会保证父类的<clinit>()在子类的之前完成。因此,虚拟机执行的第一个<clinit>()方法肯定是java.lang.Object.
- 由于父类<clinit>()方法先执行,也就意味着父类中定义的静态语句要优先于子类的变量赋值操作。
- <clinit>()方法并不是必须的,如果一个类没有静态语句块也没有对变量赋值操作,就不会生成
- 接口中不能使用静态语句块,但仍有变量初始化赋值的操作,因此也会生成<clinit>()方法,但与类不同的是,接口的<clinit>()方法不需要执行父接口的<clinit>()方法。只有当父几口中定义的变量被使用时,父接口才初始化,另外,接口的实现类在初始化时一样不会执行接口的<clinit>()方法。
- 虚拟机会保证一个类的<clinit>()方法在多线程环境中正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都会阻塞,直到该方法执行完,如果在一个类的<clinit>()方法中有耗时很长的操作,可能会造成多个进程阻塞,在实际应用中,这种阻塞往往很隐蔽。
类加载器
通过一个类的全限定名来获取描述此类的二进制流,执行这个动作的代码模块成为“类加载器”。
两个类只有在同一个类加载器加载的前提下才有意思,否则即使两个类原子相同的Class文件,只要加载它们的加载器不同,那这两个类也是不相等的。
这里的相等,包括equals,isAssignableFrom(),isInstance() instanceof等情况。
双亲委派模型
只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader),使用C++实现,是虚拟机自身的一部分。另一种是所有其他的类加载器,使用JAVA实现,独立于JVM,并且全部继承自抽象类java.lang.ClassLoader.
绝大部分JAVA程序都会使用到以下三种系统提供的类加载器:
- 启动类加载器(Bootstrap ClassLoader),负责将存放在<JAVA+HOME>\lib目录中的,或者被-Xbootclasspath参数所制定的路径中的,并且是JVM识别的(仅按照文件名识别,如rt.jar,如果名字不符合,即使放在lib目录中也不会被加载),加载到虚拟机内存中,启动类加载器无法被JAVA程序直接引用。
- 扩展类加载器,由sun.misc.Launcher$ExtClassLoader实现,负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
- 应用程序类加载器(Application ClassLoader),由sun.misc.Launcher$AppClassLoader来实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般称它为系统类加载器。负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
这张图表示类加载器的双亲委派模型(Parents Delegation model). 双亲委派模型要求除了顶层的启动加载类外,其余的类加载器都应当有自己的父类加载器。,这里类加载器之间的父子关系一般不会以继承的关系来实现,而是使用组合关系来复用父类加载器的代码。
双亲委派模型并不是一个强制性的模型,仅是推荐
双亲委派模型的工作过程是:当一个类加载器受到类加载请求,它首先不会自己尝试去加载这个类,而是把这个请求委派给自己的父类加载器去完成,只有当父加载器表示自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
这个模型的好处,就是保证某个范围的类一定是被某个类加载器所加载的,这就保证在程序中同一个类不会被不同的类加载器加载。这样做的一个主要的考量,就是从安全层面上,杜绝通过使用和JRE相同的类名冒充现有JRE的类达到替换的攻击方式。
破坏双亲委派模型
到目前为止,有三次大规模的破坏双亲委派模型的情况
由于双亲委派模型是在JDK1.2后引入,而在JDK1.0时就存在java.lang.ClassLoader和类加载器。为了向前兼容,JDK1.2后的java.lang.ClassLoader添加了一个新的protected方法findClass(),再次之前,用户继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),该方法唯一逻辑就是调用自己的loadClass(). JDK1.2之后就不提倡重写loadClass()方法,而应当将自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就保证新写出来的类加载符合双亲委派规则的。(默认loadClass()方法会先调用父类加载,如果失败,再调用findClass()方法,如果在自己的类加载器实现中重写loadClass()方法,不调用父类加载,就会导致双亲委派模型失效).
第二次被破坏是由这个模型自身的缺陷造成的,如果基础类调用回用户的代码,比如JNDI服务,它本身由启动类加载器加载,但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者代码,但启动类加载器并不会认识这些代码,为了解决这个问题,引入了线程上下文类加载器(Thread Context ClassLoader)。这个类可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。这个设置,实际上就是让父类可以通过指定子加载器来帮助自己加载类。
第三次被破坏是由于用户对程序动态性的追求而导致的,比如HotSwap, HotDeployment
事实上,当前业界事实上的JAVA模块坏标准OSGi也破坏了双亲委派模型,每一个程序模块(bundle)都有一个自己的类加载器,当需要替换一个bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。