Java虚拟机的类加载机制

Java虚拟机的类加载机制

Java虚拟机在程序执行过程中会动态加载类,所谓类的加载指的是将一个Class文件描述的Class对象加载到JVM中,形成一个Class对象的过程。这里”Class对象”更通用的指的是一个二进制字节流,并不一定以一个文件的形式存在,而Class对象可以表示类,也可以表示一个接口。

Java类的加载主要有“加载、验证、准备、解析、初始化”这几个过程,其中“验证、准备、解析”又被统称为链接过程。

类加载器

在开始描述类加载过程之前,我们先讲一下类加载器,类的加载过程中“通过一个类的全限定名来获取描述此类的二进制流”的过程是由类加载器实现的。

类与类加载器

对于任意一个类,都需要加载它的类加载器和这个类本身一同确立其在JVM中的唯一性,对于每一个类加载器都有一个独立的类名称空间。

这里说的唯一性包括了Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法返回的结果不同,也包括了instanceof运算符的判定结果。

类加载器类型

从JVM的角度来说,只存在两种不同的类加载器:
- Bootstrap ClassLoader 启动类加载器 这个类加载器是JVM自身一部分,在Java程序中是不可获得的。
- 其它类加载器 这些类加载器独立于JVM,位于JVM外部,并且全部继承java.lang.ClassLoader这个基类。

从用户的角度看,可以有四种类加载器:
- Bootstrap ClassLoader 启动类加载器 即上文提到的Bootstrap ClassLoader,这个类加载器负责加载Java语言的基本组件。进一步而言,它加载的类位于“%JAVA_HOME\lib”或者-Xbootclasspath指定的路径下。同时,它只将自己识别的(按文件名识别,如rt.jar)类库加载到JVM中。对于其它的类库,即使位于lib下也不会被加载。
- Extension ClassLoader 扩展类加载器 它加载位于“%JAVA_HOME\lib\ext”或java.ext.dirs系统变量指定路径下的所有类库。它对于用户来说是可获得的,用户可以直接使用这个类加载器。
- Application ClassLoader 应用程序类加载器 由于它是ClassLoader的getSystemClassLoader()方法的返回值,所以又被称为系统类加载器。它负责加载classpath上的类库已经类文件。这个类加载器是正常情况下程序默认使用的类加载器。它对于用户来说是可获得的,用户可以直接使用这个类加载器。
- Custom ClassLoader 自定义类加载器 用户可以继承java.lang.ClassLoader类来自定义自己的类加载器,选择从自定义的路径下去加载类。

双亲委托模型

双亲委托模型是JVM类加载器自行遵守的一种模式,它不是强制的,但是Extension ClassLoader和Application ClassLoader都遵守这个模式,用户自定义ClassLoader时也应该遵守这个模式。

所谓双亲委托模式指的是“当一个类加载器要加载一个类时,它首先会将加载请求交给它的父加载器,如果父加载器加载不成功,子加载器才会继续进行加载”。这样做的好处是,可以避免不同的类加载器加载同一个类形成多个代表同一个类的Class对象,从而避免不必要的麻烦。如Integer类只能会被Bootstrap ClassLoader加载,而不会被Extension ClassLoader或者Application ClassLoader加载。
这里写图片描述

加载

“加载”阶段,虚拟机主要完成:
- 1、通过一个类的权限定名来获取一个定义此类的二进制字节流。
- 2、将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。
- 3、在内存中生成一个代表这个类的java.lang.Class对象,作为这个类的各种数据结构的访问入口。

这里需要加以说明的是数组类的加载,数组类的不是由类加载器创建的,而是由虚拟机自主创建。在创建一个数组类时,虚拟机会先加载这个数组类的元数据类型,如”[java.lang.Integer”一维数组、”[[java.lang.Integer”二维数组的元数据的类型都是”java.lang.Integer”,高维相似。加载完元数据后,虚拟机会生成数组类的Class对象,同时这个数组类将会和加载其元数据的类加载器的类名称空间相关联。同时数组类的访问修饰符与其元数据相同。

验证

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

准备

准备阶段是正式为类变量分配内存并且设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这里说的类变量是指类中被static修饰的变量。

另外这里说的”设置初始值“指的是“为变量的内存空间设零值”。程序逻辑意义上的初始化会在初始化阶段进行。如:

    public static int value = 123;

在准备阶段后,value的值为0,初始化阶段后value的值才会被设置为123。

但是有一种情况例外,即对拥有ConstantValue属性的类字段的初始化。这类变量,它的值在准备阶段就会被设置成用户初始化的值。

ConstantValue

类中static变量的初始化赋值分为两种:正常情况下是初始化阶段在类初始化方法<clinit>()中完成,另外一种是在准备阶段通过字段的ConstantValue赋值。
在实际程序中,只有static final的字段才会有ConstantValue属性,并且只限定于基本类型字符串常量。这里可以理解为这些常量值在编译器就会放置到Class文件的常量池中,可以直接赋值。

解析

解析主要负责将虚拟机常量池中的符号引用解析成直接引用。

初始化

初始化阶段是指根据用户的初始化逻辑将类变量设置成一定的值,初始化将做重点阐述。

初始化的时机

JVM虚拟机规范里详细定义了“当且仅当”如下5种开始初始化的情况:
- 遇到new、getstatic、putstatic、invokestatic字节码时,如果类没有初始化则进行初始化则进行初始化。它们分别对应使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()法的那个类),虚拟机会先初始化这个主类。
- 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

这5种情况被称为对一个类的主动引用。除此之外,所有引用类的方式都称为被动引用,不会触发初始化。以下是几种被动引用:
- 通过子类来引用父类的静态变量 这种情况下只会导致父类的初始化,而子类不会进行初始化。
- 引用数组类 创建数组对象时,只会导致数组类的初始化,而不会触发数组元类型的初始化。如:

    new Integer[10];

上述代码只会触发”[com.comple.ArrayBase”数组类的初始化(而它的初始化往往是不被注意到的),但并不会触发“com.comple.ArrayBase”的初始化。
- 引用一个类的static final静态常量(限于基本类型和String类型常量) 这种情况下不会导致被引用类的初始化。因为是静态常量,所以在编译期就会通过常量传播优化将静态常量放置到引用类的常量表中,对这个静态常量的引用就变成了对自身常量池的静态变量的引用了。举例子说:

    public class A{
        public static final String HELLO_WORLD = "Hello World!";
    }

    public class B{
        public static void main(String[] args){
            System.out.println(A.HELLO_WORLD);
        }
    }

上面这段代码,在编译期结束后就可以认为A和B之间就没有关系了,B引用的A的静态常量值被存在了B的常量池中,B对这个常量的引用这变为了对自身常量池中变量值的引用。所以B中对HELLO_WORLD的引用并不会导致A的初始化。

接口的初始化

接口中的变量都是static final的,所以如前面ConstantValue小节所述,接口内字段的初始化分为两种情况:
- 字段为基本类型和String类型常量。这种情况下,字段会带有ConstantValue属性,在准备阶段就会被设置成ConstantValue属性的值。同时,这种情况也对应着上面提到的第三种被动引用的情况,如果本字段是当前描述的这种情况,其它类来引用本字段不会触发当前类的初始化阶段。
- 字段为非基本类型或者String类型常量。这种情况如下面这段代码所示:

    public interface A{
        Object aObject = new Object();
    }

    public interface B extends A{
        Object bObject = A.aObject;
    }

上述这段代码中,接口B的初始化会导致接口A的初始化,因为虽然B中引用的是A的static final变量,但是A中的aObject字段并不带有ConstantValue属性,所以它需要在初始化阶段进行赋值,接口B的初始化也就触发了A的初始化。

接下来讨论一下父子接口的初始化顺序。父子接口的初始化和父子类的初始化不同,子接口的初始化并不会父接口的初始化。但这也不是绝对的,可以把上面这段代码改成下面这段代码:

    public interface A{
        Object aObject = new Object();
    }

    public interface B extends A{
        Object bObject = A.aObject;
    }

这段代码与上面一段的不同只在于B继承了A。这种情况下,B的初始化是会导致A的初始化的,但是实际上这与他们之间的继承关系并与关系,关键在于B引用了A中的不带ConstantValue属性的字段
再看两段代码:

    public interface A{
        Object aObject = new Object();
    }

    public interface B extends A{
        Object bObject = A.aObject;
    }

这里父子接口的初始化就完全没有联系了,子接口的初始化不会导致父接口的初始化。

    public interface A{
        String aString = "Hello World!";
    }

    public interface B extends A{
        String bString  = A.aString;
    }

上述这种情况就属于前文所述的被动引用的的第三种情况。

总结起来说,父子接口之间的初始化并无必然的联系,单纯的子接口的初始化不会触发父接口的初始化,具体会不会触发在于子接口是否引用了父接口中的不带ConstantValue属性的字段,这种关系又与不带继承关系的两个接口的初始化没有区别。

初始化的顺序

类或接口的初始化都由<clinit>()方法完成。JVM会搜集static块和变量初始化操作生成<clinit>()方法(接口只有变量初始化操作,没有static块)。<clinit>()中初始化的顺序和定义static块与变量初始化操作的顺序一致。
有关初始化顺序需要注意以下几点:
- static块可以为在其后定义的static变量赋值,但是不能访问它:

    public class Test{
        static{
            i = 0;
            System.out.println(i);
        }
        static int i = 1;
    }

上述这段代码中,static块为i赋值为0是可以的,但是System.out.println(i);语句在编译时则会出错,会提示“非法向前引用”。
- 子类的初始化会触发父类的初始化,并且父类的<clinit>()方法会在子类的<clinit>()方法执行前执行完毕。
- 子接口的初始化不会触发父接口的初始化,除非直接引用了父接口的static变量才会导致初始化。同样的,接口的实现类初始化也不会触发父接口的初始化,除非直接引用了父接口的static变量。
- <clinit>()在多线程环境中被正确地加锁、同步。如果多个线程同时初始化一个类,那么只有一个线程能调用<clinit>()方法,其它线程阻塞,直到活动线程执行<clinit>()方法执行完毕。阻塞线程唤醒之后将不再执行<clinit>()方法。


本文参照《深入理解Java虚拟机(第2版)》第7章。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值