类加载的时机
首先, 我们来看一下类的生命周期, 如下图所示。其中验证、准备、解析3个阶段统称为连接。加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的, 而解析阶段则不一定, 它在某些情况下可在初始化阶段之后运行, 这是为了支持Java语言的运行时绑定(也叫后期绑定或动态绑定)。
对于初始化阶段, 虚拟机规范严格规定了有且只有以下5种情况, 必须立即对类进行初始化:
- 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时, 如果类没有进行过初始化, 则需要先触发其初始化。生成这4条指令的常见场景是: 使用new关键字实例化对象时, 读取或设置一个类的静态字段(被final修饰、已在编译期将结果放入常量池的静态字段除外)时, 调用一个类的静态方法时。
- 使用java.lang.reflect包的方法对类进行反射调用时, 如果类没有进行过初始化, 则需要先触发其初始化。
- 当初始化一个类时, 如果发现其父类还没有进行过初始化, 则需要先触发其父类的初始化。
- 当虚拟机启动时, 用户需要指定一个要执行的主类, 虚拟机会先初始化这个主类。
- 当使用 JDK 1.7 的动态语言支持时, 如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄, 并且该方法句柄所对应的类没有进行过初始化, 则需要先触发其初始化。
上述5种会触发类进行初始化的场景行为, 称为对一个类进行主动引用。除此之外, 所有引用类的方式都不会触发初始化, 称为被动引用。所谓的被动引用, 例如通过子类引用父类的静态字段, 不会导致子类初始化; 通过数组定义来引用类, 不会触发此类的初始化; 读取一个类的被final修饰的静态字段, 不会触发此类的初始化。下面我们通过代码来演示下第三种情况。
/** * 被动使用类字段演示: * 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。 */ class ConstClass { static { System.out.println("ConstClass init!"); } public static final String HELLO_WORLD = "hello world"; } /** * 非主动使用类字段演示 */ public class Demo { public static void main(String[] args) { System.out.println(ConstClass.HELLO_WORLD); } }
上述代码运行结果如下:
很明显, 读取ConstClass类中的常量时并没有触发该类的初始化, 这是因为常量在编译阶段会通过常量传播优化, 存入到调用类的常量池中, 以后调用类对常量的引用实际都被转化为对自身常量池的引用了。而且我们也能从编译后的字节码文件中看出, Demo类在编译成Class后并没有看到对ConstClass类的符号引用, 下面是Demo类的字节码的反编译结果:
上述的类中, 都是用静态代码块来输出初始化信息的, 而接口则不能, 编译器会为接口生成"<clinit>()"类构造器, 用于初始化接口中定义的成员变量。另外接口与类在初始化阶段也有一个很大的区别, 当初始化一个类时, 要求其父类全部都已经初始化过了; 但是当初始化一个接口时, 并不要求其父接口全部都完成了初始化, 只有在真正使用到父接口时(如引用接口中的常量)才会初始化。
类加载的过程
加载
加载阶段的过程如下:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象(对于 HotSpot 虚拟机, Class对象比较特殊, 它虽然是对象, 但是存放在方法区中), 作为方法区这个类的各种数据的访问入口。
对于非数组类, 加载阶段既可以用系统提供的引导类加载器来完成, 也可以由用户自定义的类加载器去完成, 即继承ClassLoader类 覆写findClass()方法。
对于数组类, 它不通过类加载器来创建, 而由Java虚拟机直接创建。但数组类的元素类型是要靠类加载器去创建的, 一个数组类的创建过程遵循以下规则:
- 如果数组的组件类型是引用类型, 那就采用上述加载阶段的3个过程去加载这个组件类型, 数组类将在加载该组件类型的类加载器的类名称空间上被标识。
- 如果数组的组件类型是非引用类型, Java虚拟机将会把数组类标记为与引导类加载器关联。
- 数组类的可见性与它的组件类型的可见性一致, 如组件类型不是引用类型, 则默认数组类的可见性为public。
加载阶段与连接阶段的部分内容是交叉进行的, 加载阶段尚未完成, 连接阶段可能已经开始, 但这两个阶段的开始时间仍保持着固定的先后顺序。
验证
在<<Java虚拟机规范 (Java SE 7版)>>中, 验证阶段又被分为了4个小阶段: 文件格式验证、元数据验证、字节码验证、符号引用验证。
文件格式验证的主要目的是保证输入的字节流能被正确的解析并存储于方法区中, 并在格式上符合描述一个Java类型信息的要求。通过这个小阶段的验证后, 字节流就会存储到方法区中, 所以后面的3个小阶段验证都是基于方法区的存储结构进行的。
元数据验证的主要目的是对类的元数据信息进行语义校验, 保证不存在不符合Java语言规范的元数据信息。例如, 这个类是否有父类(除了Object类), 这个类的父类是否继承了不允许被继承的类等等。
字节码验证的主要目的是通过数据流和控制流分析, 确定程序语义是合法的、符合逻辑的。这个小阶段会对类的方法体进行校验分析, 确保方法在运行时不会做出危害虚拟机安全的事件。
符号引用验证在虚拟机将符号引用转化为直接引用时(即解析阶段)发生, 它将对类自身以外(常量池中的各种符号引用)信息进行匹配性校验, 以确保解析动作能正确执行。例如, 符号引用中通过字符串描述的全限定名是否能找到对应的类, 符号引用中的类、字段、方法的访问性是否可被当前类访问等等。
准备
准备阶段将正式在方法区中为类变量分配内存, 并设置类变量初始值。这里的初始值又分为以下两种情况。
假设一个类变量的定义为:
public static int value = 123;
则变量value在准备阶段后的初始值为0, 而把value赋值为123的动作将在初始化阶段才会执行。
假设一个类变量的定义为:
public static final int value = 123;
则变量value在准备阶段后的初始值为123。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程, 它主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符等7种符号引用进行解析。
受篇幅所限, 这里我们就简单的拿字段符号引用来举个栗子, 看下它的解析过程:
在解析字段符号引用之前, 先要将字段所属的类或接口的符号引用进行解析, 如果解析类或接口的符号引用成功了, 才会对该类或接口(这里假定用C表示)进行后续的字段搜索:
- 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段, 则返回这个字段的直接引用, 查找结束。
- 否则, 如果在C中实现了接口, 将会按照继承关系从下往上递归搜索各个接口和它的父接口, 如果接口中包含了简单名称和字段描述符都与目标相匹配的字段, 则返回该字段的直接引用, 查找结束。
- 否则, 如果C不是java.lang.Object, 将会按照继承关系从下往上递归搜索其父类, 如果父类中包含了简单名称和字段描述符都与目标相匹配的字段, 则返回这个字段的直接引用, 查找结束。
- 否则, 查找失败, 抛出java.lang.NoSuchFieldError异常。
如果查找过程成功返回引用, 还需要对这个字段进行权限验证, 如不具备对该字段的访问权限, 会抛出java.lang.IllegalAccessError异常。读者如对其他几种引用的解析过程想深入了解, 可自行参阅<<深入理解Java虚拟机>>。
初始化
初始化阶段是执行类构造器<clinit>()方法的过程, 关于<clinit>()方法执行过程中的一些特点如下:
- <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的, 编译器收集的顺序由语句在源文件中出现的顺序所决定。
- <clinit>()方法与实例构造器<init>()方法不同, 它不需要显式的调用父类构造器, 虚拟机会保证在子类的<clinit>()方法执行前, 父类的<clinit>()方法已经执行完毕。
- 因为父类的<clinit>()方法先执行, 所以父类中定义的静态代码块要优先于子类的变量赋值操作。
- 如果一个类中没有静态代码块, 且没有对变量的赋值操作, 那么编译器可以不为这个类生成<clinit>()方法。
- 执行接口或实现类的<clinit>()方法, 不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量被使用时, 父接口才会初始化。
- 虚拟机会保证一个类的<clinit>()方法在多线程环境下被正确的加锁和同步, 这样就保证了只会有一个线程去执行这个类的<clinit>()方法来进行初始化。
类加载器
类与类加载器
对于任意一个类, 都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性, 每一个类加载器, 都拥有一个独立的类名称空间。通俗点说, 就是两个类来源于同一个Class文件, 被同一个虚拟机加载, 但只要加载它们的类加载器不同, 则这两个类就是不相等的。
全盘负责委托机制
虚拟机在运行时会产生3种ClassLoader: 启动类加载器、扩展类加载器、应用程序类加载器, 其中启动类加载器由C++语言实现, 负责装载JRE的核心类库, 如%JAVA_HOME%\jre\lib目录下的rt.jar。扩展类加载器和应用程序类加载器都是ClassLoader的子类, 扩展类加载器负责装载%JAVA_HOME%\jre\lib\ext目录下的jar包, 应用程序类加载器负责装载classpath路径下的类库。应用程序一般都是由这3个类加载器进行装载的, 另外, 我们也可以自定义类加载器。这些类加载器之间又存在父子层级关系, 即类加载器的双亲委派模型, 它要求除了顶层的启动类加载器外, 其余的类加载器都应当有自己的父类加载器, 这种父子关系不是用继承而是用组合的关系来实现的。
双亲委派模型对于保证Java程序的安全运作很重要, 它的源代码实现主要都集中在java.lang.ClassLoader的loadClass()方法中, 该方法的源代码如下:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
JVM使用全盘负责委托机制来装载类, 全盘负责是指当一个ClassLoader装载一个类时, 除非显式的指定另外一个ClassLoader, 否则该类所依赖及引用的类也将由这个ClassLoader来装载; 委托机制是指子类加载器先委托父类加载器寻找目标类, 只有在父类加载器找不到的情况下, 子类加载器才会尝试自己去寻找并装载目标类。
小结
类加载的过程分为加载、验证、准备、解析、初始化5个阶段。虚拟机使用全盘负责委托机制来装载类, 它在运行时会产生多种类加载器, 这些类加载器之间有一种称为双亲委派模型的父子层级关系。自定义类加载器时, 需要覆写ClassLoader类的findClass()方法。