Java魔法堂:类加载机制入了个门

一、前言                              

  当在CMD/SHELL中输入 $ java Main<CR><LF> 后,Main程序就开始运行了,但在运行之前总得先把Main.class及其所依赖的类加载到JVM中吧!本篇将记录这些日子对类加载机制的学习心得,以便日后查阅。若有纰漏请大家指正,谢谢!

  以下内容均基于JDK7和HotSpot VM

 

二、执行java的那刻                         

  大家都知道通过java命令来启动JVM和运行应用程序,但实际的流程又是如何的呢?

  1. 首先根据java后的运行模式配置项或<JAVA_HOME>/jre/lib/i386/jvm.cfg来决定是以client还是server模式运行JVM,然后加载<JAVA_HOME>/jre/bin/client或server/jvm.dll,并开始启动JVM;

  2. 在启动JVM的同时将加载Bootstrap ClassLoader(启动类加载器,使用C/C++编写,属于JVM的一部分);

  3. 通过Bootstrap ClassLoader加载sun.misc.Launcher类(ExtClassLoader和AppClassLoader是它的内部类);

  4. sun.misc.Launcher类在执行初始化阶段时,会创建一个自己的实例,在创建过程中会创建一个ExtClassLoader(扩展类加载器)实例、一个AppClassLoader(系统类加载器)实例,并将AppClassLoader实例设置为主线程的ThreadContextClassLoader(线程上下文类加载器)。

  5. 然后AppClassLoader实例就开始加载Main.class及其所依赖的类库了。

 

二、类加载的流程                          

  1. 加载(Loading)

  2. 链接(Linking),细分为:验证(Verification)、准备(Preparation)和解析(Resolution)

  3. 初始化(Initialization)

  4. 使用(Using)

  5. 卸载(Unloading)

  注意:加载、链接、初始化三个阶段是交叉混合进行的,并不是加载完成后才执行链接,也不是链接完成后才执行初始化的。

  通过 -XX:+TraceClassLoading 可查看类加载的信息。

 

三、加载阶段                            

  在整个类加载机制中,仅加载阶段可被程序员控制,其余阶段均由JVM完全掌控。

  共分为3个步骤:

  1. 通过类加载器根据一个类的二进制名称(Binary Name)获取定义此类的二进制字节流,在读取类的二进制字节流时链接阶段的验证操作的文件格式验证已经开始,只有通过了文件格式验证后才能存储到方法区,若验证失败则抛出 java.lang.VerifyError 或其子异常类。(文件格式验证用于保证读取的数据能够正确解析并存储在JVM堆栈中的方法区。Class文件格式由JVM规范规定,而方法区的数据结构则有各JVM自行决定)

      二进制字节流的来源低是多样的,下面列举一部分:

      a. 将二进制名称(如com.fsjohnhuang.test.Main)转换为平台相关的文件系统路径(linux下为com/fsjohnhuang/test/Main.class),然后相对与类加载器查找对应的类文件;

      b. 按a的做法将二进制名称转换为文件系统路径,然后类加载器管辖范围下的JAR、EAR和WAR等归档文件中查找类文件;

      c. 通过网络获取二进制字节流。

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

  3. 在内存中生成一个代表类或接口的 java.lang.Class 实例,作为操作该类或接口元数据的入口(Reflection就是利用Class实例的)。

  注意:

    1. 对于short boolean char int float long double基本数据类型是无需执行类加载的;

    2. 对于数据类型的加载,实质上加载的是数组的组件类型(String[]数组的组件类型为String),然后由JVM内部生成一个[Ljava.lang.String的数组类型(在字节码中标识为[Ljava/lang/String;)。因此Java中操作数组时不会像C/C++那样出现数组越界的问题。

 

四、链接阶段                            

   链接阶段又细分为 验证(Verification)、准备(Preparation)和解析(Resolution) 3个子阶段

   解析(Resolution)不一定在类加载时执行,有可能在运行时才执行。

 验证(Verification)

   验证文件格式验证、 元数据验证、 字节码验证 和 符号引用验证 4个操作。

   1. 文件格式验证

       首先对于被反复使用和验证过的类,验证过程是非必要的。可以通过 -Xverify:none 来关闭验证,可缩短虚拟机加载的时间。

       操作对象:二进制字节流
       目的:验证是否符合Class文件格式的规范。

   2. 元数据验证

       操作对象:方法区中的类或接口的信息
  目的:对字节码描述的类的元数据信息进行语义分析,保证符合Java语言规范。
  类的元数据信息包括:
     a. 父类信息(全限定名、修饰符等);
     b. 父类字段、方法信息;
     c. 类的信息(全限定名、修饰符等);
     d. 类的字段、方法信息;
     等等。注意:不含方法体信息!

   3. 字节码验证

  操作对象:方法区中的类信息的Code属性
    目的:对方法体语句进行语义分析,保证方法运行时不会出现危害JVM安全的事件
       由于这种语义分析需要执行类似于下列等检查,因此需要进行类型推导这一十分耗时的操作。
    1. 检查操作数栈的数据类型与指令的操作数类型是否兼容;
    2. 检查跳转指令不会跳转到方法体外的字节码指令上;
    3. 检查类型转换是安全的。

      JDK1.6在Code属性中添加了一个StackMapTable的属性,用于描述方法中所有基本块(Basic Block,按控制流拆分的代码块)开始时本地变量表和操作数栈引用的状态。然后字节码验证时则进行类型检查而不是类型推导,从而提高验证的性能。可通过 -XX:-UseSplitVerifier 来关闭类型检查回归到类型推导,或通过 -XX:+FailOverToOldVerifier 来设置当类型检查失败就采用类型推导。

      JDK1.7则只能采用类型检查了。

      但StackMapTable的数据依然可以被篡改,而这就是JVM开发团队需要考虑的了。

      注意:字节码验证时会触发父类或所实现的接口的符号引用的解析(也就是会触发类加载过程)。

   4. 符号引用验证

     操作对象:方法区中的类或接口信息
     目的:对类的符号引用和类的实际信息(类、字段、方法)进行验证,保证符号引用可成功解析为直接引用,并当前类可以成功访问直接引用
     在执行链接阶段的解析子阶段时,会对符号引用进行符号引用验证,验证包括以下等内容:
    a. 通过符号引用中字符串描述的全限定名是否可以在方法区中找到对应的类。
    b. 通过符号引用中对字段、方法的简单名和描述符是否可以在方法区找到对应的字段和方法。
    c. 当前实例是否有权限访问符号引用的类、字段和方法。
    若验证失败则会抛出 java.lang.IncompatibleClassChangeError 的子类 java.lang.IllegalAccessError 、 java.lang.NoSuchFieldError 和 java.lang.NoSuchMethodError 等。

 

 准备(Preparation)

    在方法区为类变量分配内存空间,并初始化为0。示例如下:

// 经过准备阶段后,value类变量将存储在方法区中,值为0。123的赋值操作将在初始化阶段进行。
public static int value = 123;

// 对于类常量(类静态常量),则直接初始化为ConstantValue属性的值。
// 经过准备阶段后,value类变量将存储在方法区中,值为123。
public static final int value = 123; 

   各类型的零值

int 0
long 0L
short (short)0
char '\u0000'
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null

 

 解析(Resolution)

   再次强调不一定要在类加载时执行,可以在运行时调用时才执行准备阶段。

   准备阶段实质就是将常量池内的符号引用替换为直接引用。

   符号引用(Symbolic References):以一组符号来描述所引用的目标(类、接口、方法、字段等)。只要能无歧义地定位到目标即可,并且与JVM的实际内部布局无关,而引用的目标也不一定已经加载到内存中。符号引用的形式已经由JVM规范规定了。

   直接引用(Direct References):    直接引用可以是直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。如果有了直接引用则目标必定已经在内存中存在了。

   在执行newarray,checkcast,getfield,getstatic,instanceof,invokedynamic,invokeinterface,invokespecial,invokestatic,invokevirtual,ldc,ldc_w,multianewarray,new,putfiled和putstatic这16个字节码指令执行前先对它们使用的符号引用进行解析。

   除了invokedynamic指令外,其他指令触发符号引用解析为直接引用后,将会对直接引用作缓存避免重复解析。(或者不作缓存,但JVM会保证第一解析成功则后续也会解析成功,失败则后续解析一样会收到相同的异常)。而invokedynamic则每次解析均不同。

   解析主要针对类或接口(CONSTANT_Class_info)、字段(CONSTANT_Fieldref_info)、类方法(CONSTANT_Methodref_info)、接口方法(CONSTANT_InterfaceMethodref_info)、方法类型(CONSTANT_MethodType_info)、方法句柄(CONSTANT_MethodHandle_info)和调用点限定符(CONSTANT_InvokeDynamic_info)7种符号引用进行。(后三种是JDK1.7新增的动态语言支持信息相关)

  1. 类或接口的解析

  将类D中的符号引用N解析为直接引用C,首先将N的全限定名传递给D的类加载器去加载类C,然后进过加载、验证、准备阶段,并因为字节码验证而加载父类或实现的接口。一旦任何一个类或接口的加载失败则符号引用N解析为直接应用C的操作就会被宣告失败
  成功解析后则进行符号引用验证,检查D是否具备访问C的权限。若不具备则抛出`java.lang.IllegalAccessError`。

  2. 字段的解析

  首先对`CONSTANT_Fieldref_info`的`class_index`项所指向的符号引用进行类或接口解析。若解析成功后得到类或接口的直接引用C,则在C中查找简单名称和字段描述符与`CONSTANT_Fieldref_info`的`name_index`项所指向的内容相匹配的直接引用,若失败则从下往上递归搜索C所实现的接口中是否有匹配的,若失败则从下往上递归搜索C所实现的父类中是否有匹配的,若失败则抛出`java.lang.NoSuchFieldError`。
  若成功解析直接引用,则进行符号引用验证,失败则抛出`java.lang.IllegalAccessError`。

  3. 类方法的解析

  首先对`CONSTANT_Methodref_info`的`class_index`项所指向的符号引用进行类或接口解析。若解析成功后得到类或接口的直接引用C,则在C中查找简单名称和字段描述符与`CONSTANT_Methodref_info`的`name_index`项所指向的内容相匹配的直接引用,若失败则从下往上递归搜索C所实现的父类中是否有匹配的,若失败则从下往上递归搜索C所实现的接口中是否有匹配的(若成功说明C是一个抽象类并抛出`java.lang.AbstractMethodError`),若失败则抛出`java.lang.NoSuchMethodError`。
  若成功解析直接引用,则进行符号引用验证,失败则抛出`java.lang.IllegalAccessError`。

  4. 接口方法的解析

  首先对`CONSTANT_InterfaceMethodref_info`的`class_index`项所指向的符号引用进行接口解析。若解析成功后得到类或接口的直接引用C(若C不是接口则抛出`java.lang.IncompatibleClassChangeError`),则在C中查找简单名称和字段描述符与`CONSTANT_InterfaceMethodref_info`的`name_index`项所指向的内容相匹配的直接引用,若失败则从下往上递归搜索C的父接口中是否有匹配的,若失败则抛出`java.lang.NoSuchMethodError`。

 

五、初始化阶段                       

  类和接口均有初始化过程,实质上就是执行字节码中的`<clinit>`构造函数。

  类中静态字段和静态代码块均被代码重排到`<clinit>`函数中进行赋值等操作。并且父类必须已经初始化后再初始化子类。

  接口的静态字段也被代码重排到`<clinit>`函数中进行赋值操作。但不要初始化该接口前必须其父接口完成了初始化,而是在真正使用到父接口(静态常量字段)时才触发初始化。

  JVM会自动处理多线程环境下`<clinit>`函数的同步互斥执行。因此若在`<clinit>`执行耗时的操作则会阻塞其他线程的执行。

  主动引用

    JVM规范规定以下5种情况,则必须执行初始化(加载、链接自然会在之前进入执行状态)

       1. 遇到new, getstatic, putstatic或invokestatic这4条字节码指令时,若类没有进行过初始化,则需要先触发初始化。对应的Java代码为通过关键字new一个实例,读或写一个类变量,调用类方法。
  2. 使用`java.lang.reflect`包中的方法操作类时,若类没有进行过初始化,则需要先触发初始化。
  3. 当初始化一个类时,若其父类还没初始化则会先初始化父类。
  4. 当虚拟机启动时,虚拟机会初始化入口函数所在的类。
  5. JDK1.7增加动态语言的支持。如果一个`java.lang.invoke.MethodHandle`实例最后的解析结果是REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,而这个句柄所在的类没有进行初始化,则需要先触发初始化。

 

  除了上述5种情况外,其他引用类的方式是不会触发初始化的,并称为被动引用。下列示例则为被动引用
  1. 通过子类访问父类静态字段不会导致子类初始化,仅仅会导致父类初始化。
  2. Java代码中创建数组对象,不会导致数组的组件类(如SuperClass[]的组件类为SuperClass)初始化。因为创建数组类的字节码指令是newarray。
  3. 类A访问类B的静态常量不会导致类B的初始化。因为在编译阶段会将类使用到的常量直接存储到自身常量池的引用中,因此实际上运行时类A访问的是自身的常量与类B无关系。

 

六、总结                          

  若有纰漏请大家指正,谢谢!

  尊重原创,转载请注明来自:http://www.cnblogs.com/fsjohnhuang/p/4283511.html  ^_^肥仔John

七、参考                          

 《深入理解Java虚拟机 JVM高级特性与最佳实践》 

 

posted @ 2015-02-10 17:43  ^_^肥仔John  阅读(2929)  评论(0编辑  收藏  举报