文档章节

Java类加载机制

IronWong
 IronWong
发布于 2015/09/17 16:31
字数 6837
阅读 72
收藏 1

一、类加载的时机

        类的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证、准备和解析三个部分统称为连接(下划线的部分)。每个阶段的开始的时机基本都是按顺序“开始”的,但并不一定按顺序“进行”或者“完成”,因为这些阶段通常互相交叉,会在一个阶段执行的过程中调用或激活另一个阶段。解析阶段会有例外,它有可能会在初始化阶段以后才开始,这是为了支持Java的运行时绑定(也称动态绑定或者晚期绑定)。

        第一阶段——加载,虚拟机并没有强制规定一定要在什么时候进行,但有且仅有四种情况,必须要对类进行初始化,因此,加载,验证,准备必须已经在此之前开始。四种情况如下:

        (1) 遇到new、getstatic、putstatic或invokestatic这四条字节码指令的时候,如果类没有进行过初始化,则需要先触发其初始化。常见的生成这4条指令的Java代码场景是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外),以及调用一个类的静态方法。

        (2) 使用反射对类进行反射调用的时候,如果累没有进行过初始化,则需要先触发其初始化。

        (3) 当初始化一个类的时候,如果其父类还没进行过初始化,则要先触发其父类的初始化。

        (4) 当虚拟机启动时,用户指定的要指定的主类,即包含main()方法的那个类,需要先初始化。

        这四种是“有且只有”会触发类的初始化的情况,成为对一个类进行主动引用。除此之前所有引用类的方式,都不会触发初始化,成为被动引用。被动引用有三种情况,如下所示:

        (1) 通过子类引用父类的静态字段,不会导致子类初始化,但是父类会初始化。

/**
 *    SuperClass.java
 */
 public class SuperClass {
     static {
         System.out.println("SuperClass init!");
     }
     
     public static int value = 110;
 }
 
 /**
  *    SubClass.java
  */
  public class SubClass extends SuperClass {
      static {
          System.out.println("SubClass init!");
      }
  }
  
  /**
   *    NotInitialization.java
   *    非主动使用类字段演示
   */
   
   public class NotInitialization {
       public static void main(String[] args) {
           System.out.println(SubClass.value);
       }
   }

        只会输出“SuperClass init!”和数字,不会输出“SubClass init!”。

        (2) 通过数组定义来引用类,不会导致类的初始化。

SuperClass superClasses = new SuperClass[10];

        (3) 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。所谓的常量,即static final修饰的量。

        接口的加载过程与类加载过程稍有一些不同。主要是初始化场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正用到父接口的时候(如引用接口中定义的常量)才会初始化。

二、类的加载过程

1. 加载

        “加载”是“类加载”过程的一个阶段。在加载阶段,虚拟机需要完成以下三件事:

        (1) 通过一个类的全限定名来获取定义此类的二进制字节流。不一定需要从class文件中获取,还可以从压缩包中读取(ZIP,JAR,EAR,WAR),从网络中获取(Applet),运行时计算生成(动态代理技术),由其他文件生成(JSP),甚至是数据库中。

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

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

2. 验证

        验证是连接阶段的第一步。目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。class文件并不一定是Java源码编译而来的,还有可能用十六进制编辑器直接编写产生class文件。所以可能存在访问数组边界意外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的情况。如果不检查输入的字节流,很可能会因为载入了有害的字节流而导致系统崩溃。验证一般分为下面四步:

        (1) 文件格式验证,主要验证字节流是否符合class文件格式的规范,并且能否被当前版本的虚拟机处理。主要验证下面几点:

    • 是否以魔数0xCAFEBABE开头。

    • 主、次版本号是否在当前虚拟机处理范围之内。

    • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。

    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。

    • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。

    • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。

        需要保证输入的字节流能正确地解析并存储于方法区之类,格式上符合描述一个Java类型信息的要求。这个阶段的验证是基于字节流进行的,经过了这个阶段的验证之后,字节流才会进入内场的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构进行的。

        (2) 元数据验证,对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。这个阶段可能包括的验证点如下:

    • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。

    • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。

    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。

    • 类中的字段、方法是否与父类产生了矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同,等。)

        第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。

        (3) 字节码验证,主要是进行数据流和控制流分析,对类的方法体进行校验分析。这个阶段的任务是保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如:

    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现这样的情况:在操作栈中放置了一个int类型的数据,使用时却按long类型来加载到本地变量表中。

    • 保证跳转指令不会跳转到方法体意外的字节码指令上。

    • 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型, 则是危险和不合法的。

        不能保证通过字节码验证的方法体一定是安全的。Halting Problem:不能通过程序准确地检查出程序是否能在有限的时间之内结束运行。

        (4) 符号引用验证。可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性的校验。为了验证解析阶段中虚拟机将符号引用转化为直接引用的时候的安全性。通常需要校验以下内容:

    • 符号引用中通过字符串描述的全限定名是否能找到对应的类。

    • 指定的类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。

    • 符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。

3. 准备

        准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。

        有两个需要注意的地方:

    • 这个时候进行内存分配的仅包括类变量(static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

    • 赋的初始值“通常情况”下是数据类型的零值,初始化阶段才会把变量赋值为等号后的值。“特殊情况”,假如有final属性,则会直接赋值成等号右边的值。

4. 解析

        解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的。同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

        解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用。下面是这四种引用的解析过程。

        (1) 类或接口的解析。

        假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整改解析的过程需要包括以下三个步骤:

    • 如果C不是一个数组类型,那虚拟机会把代表N的全限定名传递给D的类加载器去加载这个类C。 加载过程可能又会触发其他相关类的加载动作。

    • 如果C是一个数组类型,并且数组的元素类型为对象,那会按照第1点的规则加载数组元素类型。然后虚拟机会生成一个代表此数组维度和元素的数组对象。

    • 如果上述步骤没有问题,C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认C是否具备对D的访问权限。

        (2) 字段解析

        要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果解析成功完成,那将这个字段所属的类或接口用C表示,然后按照以下步骤对C进行后续字段的搜索:

    • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

    • 否则,如果在C中实现了接口,将会按照继承关系从上往下递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

    • 否则,如果C不是java.lang.Object的话,将会按照继承关系从上往下递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

    • 否则,查找失败,抛出NoSuchFieldError异常。

        实际应用中,虚拟机的编译器实现可能会比上述规范要求得更加严格一些,如果有一个同名字段同时出现在接口和父类中,那编译器将可能拒绝编译。

        (3) 类方法解析

        类方法解析的第一个步骤与字段解析一样,也是需要先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用。如果解析成功,用C表示这个类,接下来虚拟机会进行下述的步骤:

    • 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法中发现class_index中索引的C是个接口,那就直接抛出IncompatibleClassChangeError异常。

    • 如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法。如果有则返回这个方法的直接引用,查找结束。

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

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

    • 否则,宣告方法查找失败,抛出NoSuchMethodError。

        最后,如果查找过程成功返回了直接引用,将会对这个方法进行全线验证;如果发现不具备对此方法的全线访问,将抛出IllegalAccessError异常。

        (4) 接口方法解析

        接口方法也是需要先解析出接口方法表的class_index项中索引的方法所属的类或者接口的符号引用。如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

    • 与类方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出IncompatibleClassChangeError异常。

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

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

    • 否则,查找失败,抛出NoSuchMethodError异常。

        由于接口中的所有方法都默认是public的,所以不存在访问权限的问题。

5. 初始化

        前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码(或者说是字节码)。

        准备阶段,变量已经赋过一次系统要求的初始值(一般情况是零值),而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源。从另外一个角度说:初始阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法在执行过程中可能会影响程序运行行为,下面是这些行为的一些特点和细节:

    • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但不能访问。

    • <clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。

    • 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

    • <clinit>()方法对于类或者接口来说并不是必须的。如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生产<clinit>()方法。

    • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作。因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

    • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞。

三、类加载器

1. 定义

        虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块被称为“类加载器”。

        对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。换句话说,比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类是来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等情况。

2. 双亲委派模型

        站在Java虚拟机的角度讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现(只限于Hot Spot),是虚拟机自身的一部分;另外一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

        从开发人员的角度来看,类加载器就还可以划分得更细致一些,绝大部分Java程序都会使用到以下三种系统提供的类加载器:

    • 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)加载到虚拟机内存中。启动类加载器无法被Java程序直接引用。

    • 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它扶着加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

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

类加载器双亲委派模型

        上图中所展示的类加载器之间的层次关系,就成为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合的关系来复用父类加载器的代码。

        双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

        使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。

3. 违反双亲委派模型的情况

        上面提到过的双亲委派模型并不是一个强制性的约束模型,而是Java设计者门推荐给开发者门的类加载器实现方式。《深入理解Java虚拟机》里介绍了三种不满足双亲委派模型的情况,第一种是在双亲委派模型出现之前,对这个没有兴趣了解,下面只介绍后面两种。

        (1) 模型自身缺陷导致违反双亲委派模型。

        对于JNDI服务来说,它的代码由启动类加载器去加载,但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能“认识”这些代码。为了解决这个问题,Java设计团队引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。

        有了线程上下文加载器,就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

        (2) 用户对程序动态性的追求导致违反双亲委派模型。

        这里所说的“动态性”是指当前一些非常“热”门的名词:代码热替换(HotSwap)、模块热部署(Hot Deployment)等,说白了就是希望应用程序像我们的电脑外设那样,插上鼠标或U盘,不用重启机器就能立即使用。

        OSGi(Open Service Gateway Initiative)技术是面向Java的动态模型系统。OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现。每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。

        在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构,当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:

    • 将以java.*开头的类,委派给父类加载器加载。

    • 否则,将委派列表名单内的类,委派给父类加载器加载。

    • 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。

    • 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。

    • 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。

    • 否则,查找Dynameic Import列表的Bundle,委派给对应Bundle的类加载器加载。

    • 否则,类查找失败。

        据说,弄懂了OSGi的实现,就能明白类加载器的精粹

© 著作权归作者所有

共有 人打赏支持
上一篇: 研三的学习计划
下一篇: 实习的迷茫 V3.3
IronWong
粉丝 0
博文 18
码字总数 20236
作品 0
杭州
程序员
私信 提问

暂无文章

Java网络编程

基本概念 网络IO会涉及到同步,异步,阻塞,非阻塞等几个概念。 一个网络IO读取过程是数据从 网卡 到 内核缓冲区 到 用户内存 的过程。同步和异步区别在于数据从内核到用户内存的过程是否需要...

春哥大魔王的博客
23分钟前
0
0
Spring "reg:zookeeper" 的前缀 "reg" 未绑定等类似问题解决方案。

今天同事遇到一个Spring启动加载配置文件时,不识别reg:zookeeper标签的问题。 我查看配置,发现是Spring配置文件的头部没有引入reg标签的命名空间,具体如下图: 所以,以后遇到类似的标签未...

花漾年华
52分钟前
1
0
阿里云领衔云市场

近期,2018年Q4及全年的全球云基础设施服务市场数据新鲜出炉,发布方是美国市场研究机构Synergy Research Group。这个机构是专做电信网络市场情报的公司,成立于1999年,每年都会公布各大公有...

linuxCool
今天
2
0
C++友元函数和友元类(C++ friend)详解

私有成员只能在类的成员函数内部访问,如果想在别处访问对象的私有成员,只能通过类提供的接口(成员函数)间接地进行。这固然能够带来数据隐藏的好处,利于将来程序的扩充,但也会增加程序书...

shzwork
今天
3
0
JAVA对map进行分组

public static Map<String, List<Map<String, Object>>> transition(List<Map<String, Object>> list){ Map<String, List<Map<String, Object>>> map = new HashMap<>(); //......

火龙战士
今天
2
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部