2

java类加载机制

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,确实编程语言发展的一大步

虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。

1 类的生命周期

一个类从被加载到内存到卸载出内存,整个生命周期包括:

  • 加载loading
  • 验证verification
  • 准备preparation
  • 解析resolution
  • 初始化initialization
  • 使用using
  • 卸载unloading

其中验证、准备和解析,这三步合起来又被称为连接(liking)。

加载、验证、准备、初始化和卸载,这五个阶段的顺序是确定的,而解析不一定。某些情况下,解析可能在初始化之后再开始,这就是java动态绑定。

java虚拟机规范中严格规定了有且只有5种情况必须对类立即进行初始化:

  • 遇到new、getstatic、putstatic或invokestatic这四个指令时,必须进行初始化。

    生成这几个指令的场景有:

    • 使用new实例化一个对象时;
    • 读取或者设置一个类的静态字段时;
    • 调用一个类的静态方法时。
  • 使用reflect包的方法对类进行反射时,也触发初始化。
  • 初始化一个类的时候,若父类还未初始化,则首先进行父类的初始化。
  • 包含main方法的那个类,虚拟机启动时会首先初始化这个主类。
  • 当使用jdk1.7的动态语言支持时,

接口的加载和类加载的过程稍有些不同:

  • 接口和类一样都有初始化过程,虽然接口里面不能有static{}语句块,但是编译器仍然会为接口生成<clinit>()类构造器,用于初始化接口中所定义的成员变量。

    java接口中的变量必须得是final静态的,但接口里最好不要有变量。

  • 当一个类初始化时,必须要求父类全部都已经初始化,但是接口在初始化时并不要求其父接口也全部初始化,只有在使用到父接口时才会初始化。

2 类加载的过程

2.1 加载

加载是类加载的一个阶段。在加载阶段,虚拟机要完成3件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

加载阶段完成之后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中。

2.2 验证

验证阶段是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

  • 文件格式验证

    验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。

  • 元数据验证

    对字节码描述的信息进行语义分析,以保证其描述信息符合java语言规范。

  • 字节码验证

    最复杂的一个阶段,通过对数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

  • 符号引用验证

    对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。目的是为了确保解析动作能正常执行。

验证阶段是非常重要的,但不是一定必要的阶段。如果所运行的代码都已经被反复使用和验证过,就可以通过jvm参数来关闭大部分类验证措施。

2.3 准备

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

此时进行内存分配的变量仅包括类变量,而不包含实例变量,实例变量将在对象实例化时随着对象一起分配在java堆中。

这里所说的初始值是指数据类型的零值,比如:

public static int v = 123;

那v的值在准备阶段是0,而不是123。

数据类型 零值 数据类型 零值
int 0 boolean false
long 0L float 0.0f
short (short)0 double 0.0d
char 'u0000' reference null
byte (byte)0

如果一个变量是常量,或者final类型的,那么在准备阶段就被初始化为常量值,如:

public static final int v = 123;

此时v的值在准备阶段是123。

2.4 解析

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

  • 符号引用:是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定加载到内存中。各种虚拟机的内存布局可以各不相同,但是能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在java虚拟机规范的Class文件格式中。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存相关的,如果有了直接引用,那么引用的目标必定已经在内存中了。

解析主要是针对类或者接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行的。

2.5 初始化

类初始化时类加载过程的最后一步。前面的阶段中,除了加载的时候,可以由用户指定自定义类加载器之外,别的都是由虚拟机主导控制。初始化阶段才真正执行类中定义的java代码。

在准备阶段变量已经被赋过零值,而初始化阶段是根据程序里面的来初始化类变量和其他资源,可以理解为执行类构造器的<clinit>()方法的过程。

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

    public class Test{
      static{
        i=0;                          //这句话是给变量赋值,可以编译通过
        System.out.println(i);        //这句话是要访问i,编译器会提示“非法向前引用”编译不过。   
      }
      static int i = 1;
    }
  • <clinit>()方法和类的构造函数不同,它不需要显示调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此虚拟机第一个被执行<clinit>()方法的类肯定是java.lang.Object。
  • 由上一条可以得出结论,父类中定义的静态语句块要早于子类的变量赋值操作。
  • <clinit>()方法对类或者接口不是必须的,如果一个类中没有静态语句块,也没有对变量进行赋值操作,那么编译器就不会为类生成<clinit>()方法。
  • 前面加载的时候有说到,接口中不能有静态语句块,但是可以有变量的初始化赋值操作。接口和类都会生成<clinit>()方法,到那时接口执行<clinit>()方法时不需要先执行父接口的<clinit>()方法,只有当父接口定义的变量被使用的时候,父接口才被初始化。另外,接口的实现类在初始化的时候,也不用执行接口的<clinit>()方法。
  • 虚拟机会保证一个类的<clinit>()方法在多线程的环境中被正确的加锁、同步。

3 类加载器

类加载阶段的加载阶段,即“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到jvm外部实现,使得应用程序自己可以决定如何获取所需要的类。实现这个动作的代码模块称为“类加载器”。

对于任意一个类来说,需要加载它的类加载器和其类本身来保证唯一性。如果同一个Class文件,被不同的类加载器加载了,那么产生的两个类是不相同的。

3.1 类加载器的分类

对于java虚拟机来说,只有两种不同的类加载器:

  • 启动类加载器 Bootstrap ClassLoader:C++实现的,虚拟机的一部分。
  • 其他类加载器:java语言实现,独立于jvm外部。全部继承抽象类java.lang.ClassLoader。

从java程序员的角度来看,有三种系统提供的类加载器:

  • 启动类加载器 Bootstrap ClassLoader
    负责将放在JAVA_HOEM/lib目录里的,或者是被-Xbootclasspath参数指定的路径中的,并且可以被虚拟机识别的类库加载到虚拟机内存中。
    启动类加载器无法被java程序直接引用。如果是用户在编写自定义类加载器的时候,需要把加载请求委派给启动类加载器,返回null就行了。
  • 扩展类加载器 Extension ClassLoader
    负责加载JAVA_HOEM/lib/ext目录中的,或者被java.ext.dirs系统变量指定的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器 Application ClassLoader
    这个类加载器是ClassLoader中的getSystemClassLoader()方法中的返回值,所以也称为系统类加载器,负责加载用户类路径上指定的类库。
    开发者可以直接使用此类加载器,如果应用程序没有自定义自己的类加载器,一般情况下这个就是程序的默认类加载器。

开发者可以自己编写一些自定义类加载器,用来进行特定类的加载。他们的关系是:

双亲委派模型

3.1 双亲委派模型

双亲委派模型要求除了最顶层的启动类加载器外,其余的加载器都得有自己的父类加载器。这里的类加载器的父子关系不是通过继承来实现,而是使用组合关系来复用复加载器的代码。

双亲委派模型的工作过程是:

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是这样。因此,所有的类加载请求最终都会传送到最顶层的启动类加载器,只有当父加载器反馈自己无法加载这个加载请求的时候,子加载器才会尝试自己去加载。

使用这个模型的好处就是java类随着它的加载器一起具备了一种带有优先级的层次关系。比如java.lang.Object,无论哪个类加载器要加载这个类的时候,最终都是委派给最顶端的启动类加载器进行加载,因此Object类在程序的各个类加载器环境中都是同一个类。如果不使用这个模型的话,由各个类加载器自己加载,就会出现多个Object类。

双亲委派模型的逻辑实现代码很简单:

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
  //首先检查请求的类是否已经被加载过
    Class c = findLoadedClass(name);
    if(c==null){
        if(parent!=null){
            c=parent.loadClass(name, false);
        }else{
            c=findBootstrapClassOrNull(name);
        }
        //如果父类加载器无法加载的时候,就调用本身的方法去加载
        if(c==null){
            c=findClass(name);
        }
    }
    if(resolve){
        resolveClass(c);
    }
    return c;
}

3.2 破坏双亲委派模型

双亲委派模型并不是一个强制性的约束模型,在java世界中,大部分加载器都遵循这个模型,在java历史上有三种比较大的被破坏情况。

  • 第一次是jdk1.2发布的时候。由于双亲委派模型是在1.2才引入的,java.lang.ClassLoader是在1.0的时候就存在了,面对在此之前的用户自定义类加载器的代码,java设计者添加了一个findClass方法来作为妥协。
  • 第二次是JNDI服务。双亲委派模型很好地解决了各个类加载器的基础类统一问题,但是当基础类又回来调用用户的代码就没办法了。所以引入了线程上下文 类加载器(Thread Context ClassLaoder)。
  • 第三次就是热更新热部署的时候。代表就是OSGi,每一个程序模块都有一个自己的类加载器,当需要更换一个模块的时候,就把模块连同其加载器一起换掉。此时的类加载器的结构成了网状结构了。

4 写在最后

把书从后面往前面看还是挺有意思的。


zhenfeng_zhu
19 声望1 粉丝

我要赚好多好多money~~