13、jvm类加载器

JVM主要包含三大核心部分:运行时数据区,类加载器和执行引擎。

jvm把字节码文件加载到内存,通过验证、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型。

类加载过程:

1、加载loading

通过一个类的全名(包.类路径)来获取此类的二进制字节流。

将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。

jvm规范对new,getstatic,putstatic,invokestatic和反射,若类没有进行过初始化,先进行初始化。

可以从压缩文件(war、jar、zip)中获取,也可从网络、jsp页面、数据库中获取,还可以使用动态代理生成的$Proxy类文件;

加载阶段与连接阶段的部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。

站在Java虚拟机的角度讲,只存在两种不同的类加载:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现(只限于Hot Spot,而MPR、Maxine等虚拟机  是由Java语言实现的),是虚拟机自身的一部分;另外一种用户自定义类装载器。前者是Java虚拟机实现的一部分,后者是Java程序的一部分。由不同的类装载器装载的类将被放在虚拟机内部的不同命名空间中。 

 

系统提供的类装载器主要由下面三个:

  • 启动类加载器(bootstrap classloader):它用来加载 Java 的核心库,是用原生代码(本地代码,与平台有关)来实现的,并不继承自java.lang.ClassLoader。这个类加载器负责将存放在jre/lib目录中的jar文件,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识加的类库(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)加载到虚拟机内存中。启动类加载器无法被Java程序直接引用。如果需要将加载请求委派给启动类加载器,直接使用null代替:ClassLoader cl = null;

  • 扩展类加载器(extensions classloader):扩展类加载器是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader) 实现的。它负责将jre/lib/ext 或者由系统变量java.ext.dir 指定位置中的类库加载到内存中;

  • 应用程序类加载器(application classloader):系统类加载器是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的,由于这个类加载器是ClassLoader中getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序默认的类加载器。

用户自定义的类装载器是普通的Java对象,它的类必须继承java.lang.ClassLoader类。ClassLoader中定义的方法为程序为程序提供了访问类装载器机制的接口。此外,对于每一个被装载的类型,Java虚拟机都会为它创建一个java.lang.Class类的实例来代表该类型。和所有其它对象一样,用户自定义的类装载器以有Class类的实例都放在内存中的堆区,而装载的类型信息则都放在方法区。(详见java块的类加载器)

 

2验证verification

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并却不会危害虚拟机自身的安全,一些在编译层面上可以控制的事情(比如超边界访问数组,跨类型进行类型对象转换存在时,编译器是拒绝工作的)可以通过直接修改class文件的方式进行破解,这就是验证阶段存在的原因。

按照虚拟机规范,如果验证到输入的字节流不符合Class文件的存储格式,就抛出一个java.lang.VerifyError异常或其子类异常。

验证大致分成4个阶段的验证过程:文件格式验证、元数据验证、字节码验证和符号引用验证

文件格式验证:

比如是否以魔数开头,主次版本号是否在虚拟机可处理范围之内,常量池是否有不支持类型等。

class文件并不一定用java编译器生成,直接用16进制编写器也可以编写一个class文件;

经过这个阶段的验证之后,字节流才会进入内存的方法区进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构进行的。

 

元数据验证:

这一阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合JAVA语言规范的要求,这个阶段可能包括的验证点有:

这个类是否有父类;

父类是否继承了final类;

如果这个类不是抽象类,是否实现了父类或接口中需要实现的所有方法;

类中的字段和父类是否有矛盾(实现的方法而返回类型不同)

 

字节码验证:

字节码验证是验证中最复杂的一个阶段,主要工作是对   数据流和控制流   分析,对类的方法体校验分析,保证该方法在运行时不会做出危害JVM安全的行为;

通过了字节码验证并不能保证一定安全,程序效验无法通过程序去检查程序是否能在有限的时间内结束:"halt problem" 停机问题;

1.6加入StackMapTable功能对这个阶段做了优化,提高速度,但这个StackMapTable也可能被篡改,可以通过参数-XX:-UseSplitVerifier来关闭这个选项。或者-XX:+FailOverVerfier在类型效验失败时退回到旧的类型推导方式进行效验;

在jdk1.7之后对应主版本号大于50的class文件,不再允许回退;

 

符号引用验证:

对引用的类是否存在

对引用的类、属性、方法的访问权限验证

 

对于jvm的类加载机制来说,验证是一个非常重要、但是不是必须的阶段,若(包括自己写的代码)代码经过反复使用和验证,可以考虑使用-Xverify:none来关闭验证;以缩短类加载时间

 

3准备preparation

准备阶段是为类(static)变量在方法区内分配内存 。这个时候内存分配的仅包括类变量(static变量),不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中;静态方法和方法也是在准备阶段分配的,分配到方法区;

注:static int a = 32;

在准备阶段a的值为零值,是没有进行赋值的,在初始化赋值或在其他实例方法中赋值。


如果static final int a = b;

在准备阶段就会进行赋值,在javac编译时就将a生成ConstantValue属性,见上章。

 

4解析resolution(转换解析)

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用在CLASS文件中它以CONSTANT_CLASS_INFO, CONSTANT_FIELDREF_INTO, CONSTANT_METHODREF_INFO等类型的常量出现

符号引用:(Symbolic References)符号引用以一组符号来描述所引用的目标,可以是任何形式的字面量,引用的目标并不一定已经加载到内存中,与虚拟机内存布局无关。

直接引用:(Direct References)直接引用可以是直接指向目标的指针,相对偏移量,或是一个能间接定位到目标的句柄。与虚拟机内存布局相关。

 

虚拟机规范并未规定解析阶段发生的具体时间,只要求了在执行anewarray,checkcast,getfield,getstatic,instanceof,invokeinterface,invokedynamic

invokespecial,invokestatic,invokevirtual,ldc,ldc_w,multianewarray,new,putfield,putstatic这16个操作符号引用的字节码指令之前,先对它们使用的符号引用进行解析。所以jvm需要判断是在类加载器link时解析还是使用时解析。

 

对同一个符号引用进行多次解析请求是很常见的事情。出invokedynamic指令以外,把已经解析过的class标记为已解析状态,避免多次解析;invokedynamic指令用于动态语言支持,动态语言,必须等到程序执行invokedynamic指令时,执行解析,不管有没有标记为已解析状态。

解析动作主要针对  类和接口,字段,类方法,接口方法、方法类型、方法句柄、调用点限定符7类符号引用进行。分别对应于常量池的CONSTANT_CLASS_INFO,CONSTANT_FIELDREF_INFO,CONSTANT_METHODREF_INFO,CONSTANT_INTERFACEMETHODREF_INFO、CONSTANT_MethodType_info、CONSTANT_MethodHanle_info和CONSTANT_InvokeDynamic_info7种类型。

 

对  类和接口的解析:假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用(未标记为已解析状态的类或接口),虚拟机完成解析的过程需要以下三个步骤:

a、如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。

b、如果C是数组类型,并且数组的元素类型是对象,则按照1的情况处理。如果元素类型不是对象,则由虚拟机生成一个代表此数组维度和元素的数组对象。

c、如果上述步骤没有异常,C在虚拟机中已经生成一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具有对C的访问权限,如果没有则抛出java.lang.IllegalAccessError异常。

 

对  字段的解析:先找到字段所属的类或借口,将所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索:

1.如果C本身包含这个字段,就返回这个字段的直接引用,查找结束

2.否则,如果C中实现了接口,将会按  照继承关系从下往上递归搜索   各个接口和它的父接口,如果匹配,则返回这个字段的直接引用,查找结束

3.否则,如果C中继承了父类,将会按  照继承关系从上往下递归搜索父类,如果匹配,返回字段的直接引用,查找结束

4.查找失败,抛出java.lang.NoSuchFieldError异常。

5.如果查找过程成功返回应用,则还需要对该字段进行权限验证,不通过,则抛出java.lang.IllegalAccessError异常

 

 

对  类方法的解析:第一个步骤与字段解析一样,同样是需要先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用:

1.对方法表中的class_index项中索引的CONSTANT_Class_info符号引用进行解析。用C表示这个方法所属的类或接口。

2.类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,则抛出java.lang.IncompatibleClassChangeError。

interface A{
	static void fun();
Demo1.java:3: 错误: 此处不允许使用修饰符static  ,在ide中编写时报错,javac编译时报错,如果用其他工具生成的class文件会在运行时报错

3.在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用。

4.否则,在C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用。

5.否则,在C实现的接口列表及它们的父接口中递归的查找是否有简单名称和描述符都与目标相匹配的方法,如果有说明C是个抽象类,查找结束,抛出java.lang.AbstractMethodError异常。

否则,查找失败,抛出java.lang.NoSuchMethodError异常。

如果查找返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对这个方法的访问权限,则抛出java.lang.IllegalAccessError异常。

 

对 接口方法的解析 :

1.对方法表中的class_index项中索引的CONSTANT_Class_info符号引用进行解析。用C表示这个方法所属的接口。

2.如果在接口方法表中发现class_index中索引的C是个类,则抛出java.lang.IncompatibleClassChangeError。

3.否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用。

4.否则,在接口C的父接口中递归查找,直到java.lang.Object类(包括在内),看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用。

5.否则,查找失败,抛出java.lang.NoSuchMethodError。

 

5初始化

是类加载过程的最后一步,初始化阶段才真正开始执行类中定义的JAVA程序代

初始化是类加载过程的最后一步,初始化阶段才真正开始执行类文件中定义的JAVA程序代码,准备阶段中,类变量已经得到内存分配,而在初始化阶段,主要执行静态块里面的内容和完成对类变量赋值;

从另一角度:初始化阶段是执行类初始化<clinit>()方法的过程(个人理解<clinit>()为初始化方法,<init>()为实例化方法);

1、<clinit>()方法是由编译器执行 类变量的赋值和静态语句块。编译器收集的顺序和语句在源文件中出现的顺序一致,静态语句块中只能访问到定义在它之前的类变量,定义在它之后的变量,只能赋值,不能访问  

2、 <clinit>()方法与类的构造函数<init>()不同,不需要显式的调用父类构造器,虚拟机会保证父类的<clinit>()在执行子类的<clinit>()方法之前完毕。因此,虚拟机执行的第一个<clinit>()方法肯定是java.lang.Object.

3、 由于父类<clinit>()方法先执行,也就意味着父类中定义的静态语句要优先于子类的类变量赋值操作(父类的初始化方法先于子类初始化方法,初始化方法包括对类变量的赋值)。 

4、<clinit>()方法不需要执行父接口的<clinit>()方法。只有当父几口中定义的变量被使用时,父接口才初始化另外,接口的实现类在初始化时一样不会执行接口的<clinit>()方法。

5、 虚拟机会保证一个类的<clinit>()方法在多线程环境中正确的加锁同步如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都会阻塞,直到该方法执行完,如果在一个类的<clinit>()方法中有耗时很长的操作,可能会造成多个进程阻塞,在实际应用中,这种阻塞往往很隐蔽(这也是和实例化有明显区别的地方)。 

 

&类加载器

通过一个类的全限定名来获取描述此类的二进制流,执行这个动作的代码模块成为“类加载器”。  

类加载在类层次划分、OSGi、热部署、代码加密、代理 方面应用广泛;

虽然类加载器只用于实现类的加载工作,但他的作用不限于加载阶段,任意一个类和他的类加载器都是在编译时确定,每一个类加载器都拥有一个独立的类名称空间;比较两个类是否相等:使用同一个类加载器进行加载,即使两个类源自己相同的Class文件,只要加载它们的加载器不同,那这两个类也是不相等的。

 

即使初始化出来的包.类名称完全相同, 但他们是由两个不同的加载器加载而来,他们就是两个相同名的类!

这里的相等,包括equals,isAssignableFrom(),isInstance() instanceof等情况。

 

双亲委派模型

从jvm的角度来看,只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader),使用C++实现,是虚拟机自身的一部分。另一种是JAVA类继承抽象类java.lang.ClassLoader的类加载器,独立于JVM; 

双亲是指一个父类和一个子类,没有其他继承或实现的关系。

双亲委派的工作过程(java模块类加载器 有讲 这里复习下):当一个类加载器受到类加载请求,它首先不会自己尝试去加载这个类,而是把这个请求委派给自己的父类(层层向上委派,直到启动类加载器),当父加载器无法完成这个加载请求时,子加载器才会尝试自己去加载(自下向上请求,自上向下加载)。父类能加载就有父类加载,父类不能加载就由子类尝试加载(可能该子类还是不能完成加载,就由他的子类继续尝试加载);

可以看出类加载器是有层次的,Bootstrap 是最先开始也是必须启动的,而java.lang.Object也是最先初始化的,所以可以把Objec放在Bootstrap启动的rt.jar中;查看这个rt.jar文件

同时sun.misc.Launcher$ExtClassLoader 和sun.misc.Launcher$AppClassLoader都在这个rt.jar包中;这样,每个类加载时都会首先加载jre/lib里面的jar文件包括rt.jar。

从上图可以看出扩张类加载器和系统类加载器都是继承java.lang.ClassLoader这个类:系统类加载器与扩张类加载器以及启动类加载器之间的父子关系不以继承来实现,使用组合关系来复用父加载器的代码。

查看loadClass方法,调用了findLoadedClass方法,这个findLoadedClass会调用JNI方法;

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException{
        synchronized (getClassLoadingLock(name)) {
           // 如果加载过则能通过类名在堆内存中找到java.lang.Class对应的实例;若没有被类加载器加载过则返回空;
            Class c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                     //判断该类加载器不为启动类加载器,这个parent是指类加载器之间的父子关系,而不是这里的继承关系
                    if (parent != null) {
                      //表示没有被加载的类有父类加载器去启动加载,该方法会一直向上委派到启动类加载器;
                        c = parent.loadClass(name, false);
                    } else {
                       //没有被加载的类由启动类加载器去执行加载,可能返回为空  
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    //父加载类不能实现加载,抛出异常
                    //c的值还是为null
                }
              if (c == null) {
                  //父类不能实现加载,由自己尝试加载
                    long t1 = System.nanoTime();
                    c = findClass(name);  //这个方法在URLClassLoader中定义
双亲委派模型保证java程序稳定运行;保证某个范围的类一定是被某个类加载器所加载的,从安全层面上,杜绝通过使用JRE相同的类名冒充现有JRE的类  替换攻击。  

破坏双亲委派模型  

双亲委派模型并不是一个强制性的约束模型,在java程序中类加载器一般都遵循这个双亲模型,但也有例外

1、由于双亲委派模型是在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()方法,不调用父类加载,就会导致双亲委派模型失效). 

 

2、这个模型本身是有缺陷的,类加载器总是向上委派而不能调用自己的代码,比如JNDI服务,它本身由启动类加载器加载,但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者代码,但启动类加载器并不会认识这些代码, 为了解决这个问题,引入了线程上下文类加载器(Thread Context ClassLoader)。这个类可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建 线程时未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。这个设置,实际上就是让父类可以通过指定子加载器来帮助自己加载类。

        这种行为就是打破了双亲委派模型的层次结构逆向使用类加载器,java中所用涉及spi的加载动作基本上都是采用这种方式,如:jndi/jdbc/jce/jaxb/jbi等

 

3、由于用户对程序动态性的追求而导致的,比如HotSwap(代码热替代), HotDeployment(模块热部署)   ,OSGi成为java模块化标准,OSGi实现模块化热部署的关键是它自定义的类加载器机制;每一个模块都有自己的类加载器,当需要替换一个bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。

      在OSGi环境下,类加载器不再是双亲委派模型结构而是网状结构,这里不详解。

 

猜你喜欢

转载自nickfover.iteye.com/blog/2145764