类加载机制

来源:互联网 发布:gephi 布局算法 编辑:程序博客网 时间:2024/06/06 15:03

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。它们开始的顺序如下图所示:
这里写图片描述

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

这里简要说明下Java中的绑定:绑定指的是把一个方法的调用与方法所在的类(方法主体)关联起来,对java来说,绑定分为静态绑定和动态绑定:

  • 静态绑定:即前期绑定。在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。针对java,简单的可以理解为程序编译期的绑定。java当中的方法只有final,static,private和构造方法是前期绑定的。
  • 动态绑定:即晚期绑定,也叫运行时绑定。在运行时根据具体对象的类型进行绑定。在java中,几乎所有的方法都是后期绑定的。

一、加载

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

注意,这里第1条中的二进制字节流并不只是单纯地从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在内存中实例化一个java.lang.Class类的对象(并没有明确在堆中,对于HotSpot而言,Class对象比较特殊,它放在方法区里面),这样便可以通过该对象访问方法区中的这些数据。


二、验证

验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。

  • 文件格式的验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的,不会再直接操作字节流。例如:是否以魔术0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  • 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。例如:这个类是否有父类(除了java.lang.Object之外)、这个类是否继承了final类等。
  • 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为,主要针对就是的指令操作的安全性。
  • 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。 例如:符号引用中通过全限定名是否能找到对应的类、在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段、符号引用中的类、字段、方法的访问性是否可被当前类访问等。

三、准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。

2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

假设一个类变量的定义为:
public static int value = 3;

那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的putstatic指令是在程序编译后,存放于类构造器< clinit>()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行

3、如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。

假设上面的类变量value被定义为:
public static final int value = 3;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中。

下表列出了Java中所有基本数据类型以及reference类型的默认零值:
这里写图片描述

这里还需要注意如下几点:

  • 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
  • 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
  • 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
  • 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。

四、解析

解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。


五、初始化

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器< clinit>()方法的过程。

这里简单说明下< clinit>()方法的执行规则:
1、< clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
2、< clinit>()方法与实例构造器< init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的< clinit>()方法执行之前,父类的< clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的< clinit>()方法的类肯定是java.lang.Object。
3、< clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成< clinit>()方法。
4、(忽略!!)接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作(实践发现不会有< clinit>方法),因此接口与类一样会生成< clinit>()方法。但是接口与类不同的是:执行接口的< clinit>()方法不需要先执行父接口的< clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的< clinit>()方法。
5、虚拟机会保证一个类的< clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的< clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行< clinit>()方法完毕。如果在一个类的< clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

class Father{      public static int a = 1;      static{          a = 2;      }  }  class Child extends Father{      public static int b = a;  }  public class ClinitTest{      public static void main(String[] args){          System.out.println(Child.b);      }  }

执行上面的代码,会打印出2,也就是说b的值被赋为了2。

我们来看得到该结果的步骤:
首先在准备阶段为类变量分配内存并设置类变量初始值,这样A和B均被赋值为默认值0。
而后再在调用< clinit>()方法时给他们赋予程序中指定的值。当我们调用Child.b时,触发Child的< clinit>()方法,根据规则2,在此之前,要先执行完其父类Father的< clinit>()方法,又根据规则1,在执行< clinit>()方法时,需要按static语句或static变量赋值操作等在代码中出现的顺序来执行相关的static语句,因此当触发执行Father的< clinit>()方法时,会先将a赋值为1,再执行static语句块中语句,将a赋值为2,而后再执行Child类的< clinit>()方法,这样便会将b的赋值为2.

如果我们颠倒一下Father类中“public static int a = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。很明显是根据规则1,执行Father的< clinit>()方法时,根据顺序先执行了static语句块中的内容,后执行了“public static int a = 1;”语句。

另外,在颠倒二者的顺序之后,如果在static语句块中对a进行访问(比如将a赋给某个变量),在编译时将会报错,因为根据规则1,它只能对a进行赋值,而不能访问。


六、类加载的时机

虚拟机规范严格规定了有且只有5中情况(jdk1.7)必须对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new(创建对象并将其引用压入栈顶),getstatic(获取指定类的静态域并压入栈顶),putstatic(为静态域赋值),invokestatic(调用静态方法)这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先触发其初始化。

例1:

/** * 被动使用类字段 * 通过子类引用父类的静态字段,不会导致子类初始化 */public class SSClass{    static    {        System.out.println("SSClass");    }}    public class SuperClass extends SSClass{    static    {        System.out.println("SuperClass init!");    }    public static int value = 123;    public SuperClass()    {        System.out.println("init SuperClass");    }}public class SubClass extends SuperClass{    static    {        System.out.println("SubClass init");    }    static int a;    public SubClass()    {        System.out.println("init SubClass");    }}public class NotInitialization{    public static void main(String[] args)    {        System.out.println(SubClass.value); //非主动使用类字段    }}

运行结果:

SSClassSuperClass init!123

对于静态字段,只要直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。是否加载和验证了子类?这取决于虚拟机的具体实现。对于HotSpot虚拟机而言,java -XX:+TraceClassLoading NotInitialization 来观察到此操作会导致子类的加载。

....[Loaded NotInitialization from file:/Users/mook/][Loaded java.lang.Void from /Library/Java/JavaVirtualMachines/jdk1.7.0_80.jdk/Contents/Home/jre/lib/rt.jar][Loaded SSClass from file:/Users/mook/][Loaded SuperClass from file:/Users/mook/][Loaded SubClass from file:/Users/mook/]SSClassSuperClass init!123[Loaded java.lang.Shutdown from /Library/Java/JavaVirtualMachines/jdk1.7.0_80.jdk/Contents/Home/jre/lib/rt.jar][Loaded java.lang.Shutdown$Lock from /Library/Java/JavaVirtualMachines/jdk1.7.0_80.jdk/Contents/Home/jre/lib/rt.jar]....

例2:
通过数组定义来引用类,不会触发此类的初始化

public class NotInitialization{    public static void main(String[] args)    {        SuperClass[] sca = new SuperClass[10];    }}

例3:
常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

public class ConstClass{    static    {        System.out.println("ConstClass init!");    }    public static  final String HELLOWORLD = "hello world";}public class NotInitialization{    public static void main(String[] args)    {        System.out.println(ConstClass.HELLOWORLD);    }}

运行结果:hello world

最后看一下接口的初始化过程与类初始化过程的不同:

接口也有初始化过程,上面的代码中我们都是用静态语句块来输出初始化信息的,而在接口中不能使用“static{}”语句块,但编译器仍然会为接口生成< clinit>类构造器,用于初始化接口中定义的成员变量(实际上是static final修饰的全局常量)。

二者在初始化时最主要的区别是:当一个类在初始化时,要求其父类全部已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量),才会初始化该父接口。这点也与类初始化的情况很不同,回过头来看第2个例子就知道,调用类中的static final常量时并不会 触发该类的初始化,但是调用接口中的static final常量时便会触发该接口的初始化


七、案例分析

public class Test {    public static void main(String[] args) {        func();    }    static Test st = new Test();    static void func(){}}

根据上面的代码,有以下步骤:

  1. 首先在执行此段代码时,首先由main方法的调用触发静态初始化。
  2. 在初始化Test 类的静态部分时,遇到st这个成员。
  3. 但凑巧这个变量引用的是本类的实例。
  4. 那么问题来了,此时静态初始化过程还没完成就要初始化实例部分了。是这样么?
  5. 从人的角度是的。但从java的角度,一旦开始初始化静态部分,无论是否完成,后续都不会再重新触发静态初始化流程了
  6. 因此在实例化st变量时,实际上是把实例初始化嵌入到了静态初始化流程中,嵌入到了静态初始化的起始位置。这就导致了实例初始化完全至于静态初始化之前。
    看一下编译生成的字节码指令:
    在静态初始化的开头先调用了类的默认构造函数:
  static {};    flags: ACC_STATIC    Code:      stack=2, locals=0, args_size=0         0: new           #3                  // class Test         3: dup                    4: invokespecial #4                  // Method "<init>":()V         7: putstatic     #5                  // Field st:LTest;        10: return              LineNumberTable:        line 5: 0

再看一个案例:

public class StaticTest{    public static void main(String[] args)    {        staticFunction();    }    static StaticTest st = new StaticTest();    static    {        System.out.println("1");    }    {        System.out.println("2");    }    StaticTest()    {        System.out.println("3");        System.out.println("a="+a+",b="+b);    }    public static void staticFunction(){        System.out.println("4");    }    int a=110;    static int b =112;}
23a=110,b=014

实例初始化不一定要在类初始化结束之后才开始初始化。
类的生命周期是:加载->验证->准备->解析->初始化->使用->卸载,只有在准备阶段和初始化阶段才会涉及类变量的初始化和赋值,因此只针对这两个阶段进行分析;

类的准备阶段需要做是为类变量分配内存并设置默认值,因此类变量st为null、b为0;(需要注意的是如果类变量是final,编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将变量设置为指定的值,如果这里这么定义:static final int b=112,那么在准备阶段b的值就是112,而不再是0了。)

类的初始化阶段需要做是执行类构造器(类构造器是编译器收集所有静态语句块和类变量的赋值语句按语句在源码中的顺序合并生成类构造器,对象的构造方法是< init>(),类的构造方法是< clinit>(),可以在堆栈信息中看到),因此先执行第一条静态变量的赋值语句即st = new StaticTest (),此时会进行对象的初始化,对象的初始化是先初始化成员变量再执行构造方法,因此设置a为110->打印2->执行构造方法(打印3,此时a已经赋值为110,但是b只是设置了默认值0,并未完成赋值动作),等对象的初始化完成后继续执行之前的类构造器的语句,接下来就不详细说了,按照语句在源码中的顺序执行即可。
  


总结

整个类加载过程中,除了在加载阶段用户应用程序可以自定义类加载器参与之外,其余所有的动作完全由虚拟机主导和控制。到了初始化才开始执行类中定义的Java程序代码(亦及字节码),但这里的执行代码只是个开端,它仅限于< clinit>()方法。类加载过程中主要是将Class文件(准确地讲,应该是类的二进制字节流)加载到虚拟机内存中,真正执行字节码的操作,在加载完成后才真正开始。