JVM学习笔记(四):类加载机制
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
一、类加载的时机
1. 类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载7个阶段。
其中加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,而解析阶段在某些情况下可以在初始化之后再开始。
什么情况下需要开始类加载过程的第一个阶段:加载?
Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。
但对于初始化阶段,虚拟机规范严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
(1). 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。这四条指令都是字节码指令,可以理解为new,获取或设置一个类的静态属性(除了被final修饰的静态字段),调用一个类的静态方法。
(2). 使用 java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。
(3). 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
(4). 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
(5). 当使用jdk1.7 的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
二、类加载的过程
1. 加载(查找并加载类的二进制数据)
在加载阶段,虚拟机需要完成以下三件事情:
(1). 通过一个类的全限定名来获取其定义的二进制字节流。
(2). 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
(3). 在内存中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,然后在内存中实例化一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。
针对实例化一个java.lang.Class类的对象,并没有规定的在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面。
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
2. 验证(确保被加载的类的正确性)
验证是连接阶段的第一步,验证的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:
(1). 文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
(2). 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类(除了java.lang.Object之外,所有类都应有父类)、这个父类是否继承了不允许被继承的类(被final修饰的类)等。
(3). 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
(4). 符号引用验证:可以看做是对类自身以外的信息进行匹配性校验,确保解析动作能正确执行。
对于虚拟机的类加载机制来说,验证阶段是一个非常重要,但不是一定必要的阶段。
3. 准备(为类的静态变量分配内存,并将其初始化为默认值)
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。这个阶段中有两个容易产生混淆的概念需要强调一下:
(1). 这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一块分配在Java堆中。
(2). 这里所设置的初始值通常情况下是数据类型的零值,而不是被在Java代码中被显式地赋予的值。
假设一个类变量的定义为:
public static int value = 123;
那么变量value在准备阶段过后的初始值为0,而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是在程序编译后,存放于类构造器<clinit>()方法之中的,所以把value赋值为123的动作将在初始化阶段才会执行。
下表为Java中所有基本数据类型的零值:
上面提到,在“通常情况”下初始值是零值,相对就会有一些“特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。
假设上面的类变量value被定义为:
public static final int value = 123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
4. 解析(把类中的符号引用转换为直接引用)
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量。
直接引用:直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
5. 初始化
初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码(或者说字节码)。
在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。
这里简单说明下<clinit>()方法的执行规则:
(1). <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
(2). <clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
(3). 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优于子类的变量赋值操作。
(4). <clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
(5). 接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口鱼类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
(6). 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
以下面的代码为例说明类变量的赋值操作。
static class Parent { public static int A = 1; static { A = 2; } } static class Sub extends Parent { public static int B = A; } public static void main(String[] args) { System.out.println(Sub.B); }
执行上面的代码,字段B的值将会是2,而不是1。
执行步骤:首先在准备阶段为类变量分配内存并设置类变量初始值,这样A和B均被赋值为默认值0,而后再在调用<clinit>()方法时给他们赋予程序中指定的值。
当我们调用Sub.B时,触发Sub的<clinit>()方法,
根据规则2,在此之前,要先执行完其父类Parent的<clinit>()方法,
又根据规则1,在执行<clinit>()方法时,需要按static语句或static变量赋值操作等在代码中出现的顺序来执行相关的static语句,因此当触发执行Parent的<clinit>()方法时,会先将A赋值为1,再执行static语句块中语句,将A赋值为2,
而后再执行Sub类的<clinit>()方法,这样便会将B的赋值为2.
如果我们颠倒一下Parent类中“public static int A = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。
因为根据规则1,执行Parent的<clinit>()方法时,根据顺序先执行了static语句块中的内容,后执行了“public static int A = 1;”语句。
另外,在颠倒二者的顺序之后,如果在static语句块中对A进行访问(比如将A赋给某个变量),在编译时将会报错,因为根据规则1,它只能对A进行赋值,而不能访问。
6. 从一道题目看类加载
1 public class SingleTon { 2 public static SingleTon singleTon = new SingleTon(); 3 public static int a; 4 public static int b = 0; 5 //public static SingleTon singleTon = new SingleTon(); 6 7 private SingleTon() 8 { 9 a++; 10 b++; 11 } 12 public static SingleTon getInstance() 13 { 14 return singleTon; 15 } 16 17 public static void main(String[] args) 18 { 19 SingleTon s = SingleTon.getInstance(); 20 System.out.println("a = " + s.a); 21 System.out.println("b = " + s.b); 22 } 23 }
运行结果:
执行步骤:
SingleTon在JVM找到main方法入口的时候,便会进行类的初始化动作。
类的初始化包括下面几个步骤:
(1). 类的加载,由classloader将二进制文件加载到内存。
(2). 连接阶段,其中该阶段又分为三个过程
a. 验证,验证加载进来的字节码的合法性。
b. 准备,为类的静态变量分配内存并初始化为默认值(int类型默认值为0,并不是指代码中“=”后面的值,注意此时类的实例还没有生成,因此不涉及实例变量)。
此时,singleTon会被赋值为null,a和b会被赋值为0。(对应代码中2-4行)
c. 解析,将符号引用解析为直接引用。
(3). 初始化,将类的静态变量初始化为程序中的值(即代码中“=”后面的值)。
在初始化singleTon的时候,(即代码第2行:new singleTon()),会执行构造函数,此时a变为1,b变为1,(直接在准备阶段的默认初始值0上加1)
然后再去初始化a,由于没有赋值动作,故a仍然为1,但是在初始化b的时候,b会被重新赋值为0(即代码第3-4行)。
因此在打印的时候b输出的为0。
因为对于static的初始化是按照定义的顺序进行的,因此如果将public static Singleton instance = new Singleton();放到最后初始化(即放到第5行),则打印的a和b都为1。
三、类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。
站在Java虚拟机的角度来讲,只存在两种不同的类加载器:
(1). 启动类加载器:它使用C++实现,是虚拟机自身的一部分。(这里仅限于Hotspot,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的)
(2). 所有其他的类加载器:这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
站在Java开发人员的角度来看,类加载器可以大致划分为以下三类:
Java中默认提供三个ClassLoader类加载器,这三个类加载器分别为:Bootstrap ClassLoader、Extension ClassLoader、App ClassLoader,其中AppClassLoader在很多地方被叫做System ClassLoader
(1). 启动类加载器(Bootstrap ClassLoader):它负责将存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库加载到虚拟机内存中。
(2). 扩展类加载器(Extension ClassLoader):它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库。开发者可以直接使用扩展类加载器。
(3). 应用程序类加载器(App ClassLoader):它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
这几种类加载器的层次关系如下图所示:
这种层次关系称为类加载器的双亲委派模型。我们把每一层上面的类加载器叫做当前层类加载器的父加载器。
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
使用双亲委派模型来组织类加载器之间的关系,有一个很明显的好处,就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,这对于保证Java程序的稳定运作很重要。例如,类java.lang.Object类存放在JDK\jre\lib下的rt.jar之中,因此无论是哪个类加载器要加载此类,最终都会委派给启动类加载器进行加载,这边保证了Object类在程序中的各种类加载器中都是同一个类。
参考:
【深入Java虚拟机】之四:类加载机制
从一道题目看类加载
《深入理解Java虚拟机:JVM高级特性与最佳实践》