深入理解JVM——7虚拟机类加载机制
7.1概述
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。
在java语言里面,类型的加载和连接过程都是在程序运行期间完成的,这样会在类加载时稍微增加一些性能开销,但是却能为java应用程序提供高度的灵活性。
java中天生可以动态扩展
的语言特性就是依赖运行期动态加载和动态连接
这个特点实现的。
7.2类加载的时机
类加载机制:jvm把class文件加载到内存,并对数据进行校验、分配、解析和初始化,最终形成jvm可以直接使用的java类型的过程。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括了以上七个阶段,其中验证、准备和解析三个部分统称为连接。
加载、验证、准备、初始化和卸载
这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始。
而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持java语言的运行时绑定。
按部就班地“开始”,而不是按部就班地“进行”或“完成”,因为这些阶段通常都是互相交叉地混合式进行的,通常会在一个阶段执行的过程中调用或激活另外一个阶段。
什么情况下需要开始类加载过程的第一个阶段:加载。虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。
但是对于初始化阶段,虚拟机规范则是严格规定了有些只有四种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
对于这四种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语“有且只有”,这四种场景中的行为称为对一个类进行主动引用
。
除此之外所有引用类的方式,都不会触发初始化,称为被动引用。
- 通过子类引用父类的静态字段,不会导致子类初始化;
- 通过数组定义来引用类,不会触发此类的初始化;
- 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
7.3类加载的过程
7.3.1加载
将class文件字节码内容加载到内存中,并将这些静态数据转换成方法区中的运行时数据结构,在堆中生成一个代表这个类的java.lang.Class对象,作为方法区类数据的访问入口。
“加载”阶段是“类加载”过程的一个阶段,这两个名词看起来很相似。
在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流;
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
- 在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。
相对于类加载过程的其他阶段,加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发期可控性最强的阶段,因为加载阶段既可以使用系统提供的类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员们可以通过定义自己的类加载器去控制字节流的获取方式。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。
然后在java堆中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。
加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持这固定的先后顺序。
7.3.2验证
确保加载的类信息符合jvm规范,没有安全方面的问题。
验证是连接阶段的第一步,这一阶段的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
Java语言本身是相对安全的语言(依然是相对于C/C++来说),使用纯粹的java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器将拒绝编译。
但前面已经说过,Class文件并不一定要求用java源码编译而来,可以使用任何途径,包括用十六进制编辑器直接编写来产生Class文件。
在字节码的语言层面上,上述java代码无法做到的事情都是可以实现的,至少语义上是可以表达出来的。
虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。
尽管验证阶段是非常重要的,并且验证阶段的工作量在虚拟机的类加载子系统中占了很大一部分,但虚拟机规范对这个阶段的限制和指导显得非常笼统,仅仅说了一句如果验证到输入的字节流不符合Class文件的存储格式,就抛出一个java.lang.VerifyError异常或其子类异常,具体应当检查哪些方面,如果检查,何时检查,都没有强制要求或明确说明,所以不同的虚拟机对类验证的实现可能会有所不同,但大致上都会完成下面四个阶段的检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。
文件格式验证
第一阶段是要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
验证点包括:是否以魔数0xCAFEBABE开头;主、次版本号是否在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型(检查常量tag标志);指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量;CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据;Class文件中各个部分及文件本身是否有被删除的或附加的其他信息;实际上第一阶段的验证点远不止这些,上面这些只是从HotSpot虚拟机源码中摘抄的一小部分,该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个java类型信息的要求。
这阶段的验证是基于字节流进行的,进行了这个阶段的验证之后,字节流才会进入内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构进行的。元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求。
这个阶段可能包括的验证点如下:这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类);这个类的父类是否继承了不允许被继承的类(被final修饰的类);如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法;类中的字段、方法是否与父类产生了矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重写);
第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合java语言规范的元数据信息。字节码验证
第三阶段是整个验证过程中最复杂的一个阶段,主要工作是进行数据流和控制流分析。在第二阶段对元数据信息中的数据类型做完校验后,这阶段将对类的方法体进行校验分析。
这阶段的任务是保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。
例如:保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况——在操作栈中放置了一个int类型的数据,使用时却按long类型来加载如本地变量表中;保证跳转指令不会跳转到方法体以外的字节码指令上;保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型则是危险和不合法的;如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说名其一定就是安全的。
即使字节码验证之中进行了大量的检查,也不能保证这一点。这里涉及了离散数学中一个很著名的问题“Halting Problem”。通俗一点的说法就是,通过程序去校验程序逻辑是无法做到绝对准确的——不能通过程序准确地检查出程序是否能在有限的时间之内结束运行。
符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三个阶段——解析阶段中发生。
符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性的校验,通常需要校验以下内容:符号引用中通过字符串描述的全限定名是否能找到对应的类;在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段;符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问;符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
7.3.3准备
正式为类变量(static)分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。
两个容易混淆的概念:这时候进行内存分配的仅包括类变量(static修饰的变量),而不包括实例变量,实例变量会在对象实例化时随着对象一起分配在java堆中;这里所说的初始值“通常情况”下是数据类型的零值。
假设一个类变量的定义为:public static int value = 123; 那么变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器
“通常情况”下初始值是零值,相对的会有一些“特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,public static final int value = 123; 编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
7.3.4解析
虚拟机常量池内的符号引用替换为直接引用的过程。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?
符号引用(Symbolic Reference)
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。直接引用(Direct Reference)
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
解析动作主要针对类或接口、字段、类方法、接口访问四类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四种常量类型。
- 类或接口的解析
- 字段解析
- 类方法解析
- 接口方法解析
7.3.5初始化
初始化阶段是执行类构造器
类构造器
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
虚拟机会保证一个类的
当访问一个java类的静态域时,只有真正声明这个域的类才会被初始化。
- 类的主动引用(一定会发生类的初始化)
- new一个类的对象;
- 调用类的静态成员(除了final常量)和静态方法;
- 使用java.lang.reflect包的方法对类进行反射调用;
- 当虚拟机启动java Hello,则一定会初始化Hello类,说白了就是先启动mian方法所在的类;
- 当初始化一个类,如果其父类没有被初始化,则先会初始化它的父类。
- 类的被动引用(不会发生类的初始化)
- 当访问一个静态域时,只有真正声明这个域的类才会被初始化(通过子类引用父类的静态变量,不会导致子类初始化);
- 通过数组定义类引用,不会触发此类的初始化;
- 引用常量不会触发此类的初始化(常量在编译阶段就存入调用类的常量池中了)。
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。
到了初始化阶段,才真正开始执行类中定义的java程序代码(或者说是字节码)。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器
7.4类加载器
类加载器的作用:将class文件字节码内容加载到内存中,并将这些静态数据转换成方法区中的运行时数据结构,在堆中生成一个代表这个类的java.lang.Class对象,作为方法区类数据的访问入口。
类缓存:标准的javaSE类加载器可以按要求查找类,但一旦某个类被加载到类加载器中,它将维持加载(缓存)一段时间,不过jvm垃圾收集器可以回收这些Class对象。
- 启动类加载器(Bootstrap Classloader)
用来加载java的核心库(JAVA_HOME/jre/lib/rt.jar,或sun.boot.class.path路径下的内容),是用原生代码来实现的,并不继承自java.lang.ClassLoader。
加载扩展类和应用程序类加载器,并指定他们的父类加载器。 - 扩展类加载器(Extension Classloader)
用来加载java的扩展库(JAVA_HOME/jre/ext/.jer,或java.ext.dirs路径下的内容)。
java虚拟机的实现会提供一个扩展库目录,该类加载器在此目录里面查找并加载java类。
由sun.misc.Launcher$ExtClassLoader实现。 - 应用程序类加载器(Application Classloader)
它根据java应用的类路径(classpath,java.class.path路径)来加载类,一般来说,java应用的类都是由它来完成加载的。
由sun.misc.Launcher$AppClassLoader实现。 - 自定义类加载器
开发人员可以通过继承java.lang.ClassLoader类的方式实现自己的类加载器,以满足一些特殊的需求。
java.class.ClassLoader类的基本职责就是根据一个指定的类的名称,找到或者生成其对应的字节代码,然后从这些字节代码中定义出一个java类,即java.lang.Class类的一个实例。
除此之外,ClassLoader还负责加载java应用所需的资源,如图像文件和配置文件等。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。
类加载器的双亲委派模型在JDK1.2期间被引入并被广泛应用于之后几乎所有的java程序中,但它并不是一个强制性的约束模型,而是java设计者们推荐给开发者们的一种类加载器实现方法。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
双亲委托机制是为了保证java核心库的类型安全,这种机制保证不会出现用户自己能定义java.lang.Object类的情况。
类加载器的代理模式:交给其他加载器来加载指定的类。
双亲委派机制是代理模式的一种,并不是所有的类加载器都采用双亲委派机制;tomcat服务器类加载器也使用代理模式,所不同的是它是首先尝试去加载某个类,如果找不到再代理给父类加载器,这与一般类加载器的顺序是相反的。
自定义类加载器的流程:继承java.lang.ClassLoader;首先检查请求的类型是否已经被这个类加载器加载到命名空间中了,如果已经加载,直接返回;委派类加载请求给父类加载器,如果父类加载器能够完成,则返回父类加载器加载的Class实例;调用本类加载类的findClass(……)方法,试图获取对应的字节码,如果获取的到,则调用defineClass(……)导入类型到方法区;如果获取不到对应的字节码或者其他原因失败,返回异常给loadClass(……),loadClass(……)转抛异常,终止加载过程。
实现双亲委派模型的代码都集中来自java.lang.ClassLoader的loadClass()方法:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,则再抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
注意,被两个类加载器加载的同一个类,jvm不认为是相同的类。
思考:
假如我们自己写了一个java.lang.String的类,我们是否可以替换调JDK本身的类?
答案是否定的。我们不能实现。
为什么呢?
我看很多网上解释是说双亲委托机制解决这个问题,其实不是非常的准确。因为双亲委托机制是可以打破的,你完全可以自己写一个classLoader来加载自己写的java.lang.String类,但是你会发现也不会加载成功,具体就是因为针对java.※开头的类,jvm的实现中已经保证了必须由bootstrp来加载。
因为String已经在启动时被加载,所以用户自定义类ClassLoader是无法加载一个自定义的String。既然JVM已经提供了默认的类加载器,为什么还要定义自已的类加载器呢?
因为Java中提供的默认ClassLoader,只加载指定目录下的jar和class。
如果我们想加载其它位置的类或jar时,比如:我要加载网络上的一个class文件,通过动态加载到内存之后,要调用这个类中的方法实现我的业务逻辑。在这样的情况下,默认的ClassLoader就不能满足我们的需求了,所以需要定义自己的ClassLoader。定义自已的类加载器分为两步:
- 继承java.lang.ClassLoader
- 重写父类的findClass方法