类加载过程
一、类的生命周期
类的生命周期指的是:类从加载到虚拟机内存中开始,到卸载出内存为止。可以同一张图概括:
注意:加载、验证、准备、初始化和卸载必须按顺序开始,而解析阶段不一定,在某种情况下可以在初始化阶段之后再开始。
二、类加载过程
Class文件需要加载到虚拟机之后才能运行和使用,系统加载Class类型的文件的步骤如下,其中连接($link$)又可以分为:验证->准备->解析。
图、类加载的全过程
注意:类加载和加载不一样哈,加载只是类加载其中的一步。
2.1 加载
加载是类加载的第一步,主要完成一下三件事:
- 通过一个类的全限定名(包名+类名,eg.JVM.test2)来获取定义此类的二进制字节流(Class文件是一组以8位字节为基础单位的二进制流)。
- 将这个字节流所代表的静态存储结构转化为方法区(存的是类信息..)的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
虚拟机规范上面这3点并不具体,因此是非常灵活的。比如:"通过全类名获取定义此类的二进制字节流" 并没有指明具体从哪里获取、怎样获取。除了从编译好的 .class 文件中读取,还有以下几种方式:
-
从 zip 包中读取,如 jar、war 等
-
从网络中获取,如Applet
-
通过动态代理生成代理类的二进制字节流
-
从数据库中读取(少见)
一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以使用系统提供的引导类加载器,也可以用户自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass()
方法)。但是对于数组类,他们不通过类加载器创建,它由 Java 虚拟机直接创建,而数组类的元素类型最终是要靠类加载器去创建。(这里先标注一下,引用类型和非引用类型的类加载器不一样)
加载阶段与连接阶段的部分内容交叉进行,加载阶段尚未完成,连接阶段可能已经开始,但这两个阶段的开始时间仍然保持先后顺序。
2.2 验证
验证的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。(毕竟Class文件并不一定要求用Java源码编译而来,可以通过任何途径产生,甚至可以用十六进制编辑器直接编写来产生Class文件,所以Java代码无法做到的事情(eg、数组越界)至少在语义上是可以表达出来的,所以验证这一步很重要)。以下是验证要做的事情:
2.3 准备
准备阶段是正式为类变量(被$static$修饰符修饰的变量)分配内存并设置类变量初始值的阶段,类变量所使用的内存将在方法区中进行分配。注意一下两点:
- 准备阶段仅是为类变量分配内存,不包括实例变量(实例变量会在对象实例化时随着对象一块分配在Java堆中)。
- 准备阶段为类变量设置初始值,通常情况下指的是数据类型的默认零值。
public static int value = 111;//准备阶段为value类变量设置的初始值为0,初始化阶段才会赋值111
特殊情况:
public static final int value = 111;//准备阶段 value 的值就被赋值为 111
基本数据类型的零值如下:
2.4 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段(类定义的成员变量)、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。下面来具体看一下二者关系:
- 符号引用:就是一组符号来描述目标,可以是任何字面量。符号引用的字面量形式明确定义在Java虚拟机规范的Class文件中,比如CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。
- 直接引用:就是直接指向目标的指针、相对偏移量或者一个间接定位到目标的句柄。
- 在程序实际运行时,只有符号引用是不够的,举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方发表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
- 综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
2.5 初始化(重点)
类初始化阶段是类加载过程的最后一步,也是真正执行类中定义的Java程序代码(字节码),初始化阶段是执行初始化方法$<clinit>()$方法的过程。
对于<clinit>()
方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit>()
方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起死锁,并且这种死锁很难被发现。
public class Ten { public static class DeadLoopClass{ static{ if (true) { System.out.println(Thread.currentThread() + " init DeadLoopClass"); while (true) { } } } } public static void main(String[] args) { Runnable script = new Runnable() { @Override public void run() { System.out.println(Thread.currentThread() + " start!"); DeadLoopClass dlc = new DeadLoopClass(); System.out.println(Thread.currentThread() + " run over!"); } }; Thread thread1 = new Thread(script); Thread thread2 = new Thread(script); thread1.start(); thread2.start(); } }
Thread[Thread-0,5,main] start! Thread[Thread-1,5,main] start! Thread[Thread-0,5,main] init DeadLoopClass
Thread[Thread-0,5,main] 线程处于死循环,而Thread[Thread-1,5,main]一直在等Thread[Thread-0,5,main] 释放锁,所以出现了死锁。这是因为虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁,同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,知道活动线程执行完毕。(需要注意的是,其他线程虽然被阻塞,但如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程唤醒之后也不会再进入<clinit>()方法。因为同一个类加载器下,一个类只能初始化一次。)
虚拟机规范则是严格规定了有且只有6种情况必须立即对类进行”初始化“:
- 当遇到new、getstatic、putstatic或invokestatic这4条直接码指令时,比如new实例化对象,读取一个静态字段(未被final修饰,被final修饰的类变量在准备阶段就已经赋值),调用一个类的静态字段时。
- 当JVM执行new指令时会初始化类,即当程序创建一个类的实例对象时。
- 当JVM执行getstatic指令时会初始化类,即程序访问类的静态变量。(不是静态常量,常量会被加载到运行时常量池)
- 当JVM执行putstatic指令时会初始化类,即程序给类的静态变量赋值。
- 当JVM执行invokestatic指令时会初始化类,即程序调用类的静态方法。
- 当使用java.lang.reflect包的方法对类进行反射调用时,如Class.forName() /*Class类中的静态方法forName,直接获取到一个类的Class文件对象*/ 、Class类的newInstance()方法/*使用该类无参的构造函数创建对象*/等。如果该类没有初始化,需要出发其初始化。
- MethodHandle和VarHandle可以看作是轻量级的反射调用机制,而要想使用这2个调用, 就必须先使用findStaticVarHandle来初始化要调用的类。
- 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
- 当虚拟机启动时,用户需要定义一个要执行的主类(包含main方法的那个类)。即JVM会优先初始化包含main方法的那个类。
- 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
注意:这里对比一下<init>()和<clinit>()。
- <init>()是实例对象构造器,即在new一个对象时调用对象的类的constructor方法时才会执行<init>(),是对非静态变量的初始化。通常一个类有多个实例对象的构造器。
- <clinit>()是Class类构造器,在类加载过程中初始化阶段会调用,<clinit>()是对静态变量和静态代码块进行初始化操作。通常一个类只对应一个,不带参数且无返回值。
-
class X { static Log log = LogFactory.getLog(); // <clinit> private int x = 1; // <init> X(){ // <init> } static { // <clinit> } }
- 其他关于<init>()和<clinit>()的详解点这里。
<clinit>()的顺序:
<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作语句和静态块(static {})中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序所决定。静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量(注意访问和定义的区别),在前面的静态语句块中可以赋值,但不能访问。下面看一个例子:
public class Nine { static{ i = 0; //给后面定义的变量i赋值,可以正常编译通过 System.out.println(i); //访问定义在后面的变量i,会报错 } static int i = 1; public static void main(String[] args) { System.out.println("类变量i的值:" + Nine.i); } }
java: 非法前向引用
虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。所以最先执行的<clinit>()肯定是java.lang.Object。由于父类的 <clinit>() 方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值操作。看下面一个例子:
public class Ten { static { A = 2; } public static int A = 1; } public class Nine extends Ten{ public static int B = A; public static void main(String[] args) { System.out.println("类变量B的值:" + Nine.B); } }
类变量B的值:1
关于接口初始化
接口中不能使用静态代码块,但是仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但是接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。 只有当父接口中定义的变量使用时,父接口才会初始化。 另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法,除非是default修饰符修饰的方法。
注:接口中的成员变量都是static final类型的常量,因此在准备阶段就已经初始化。
三、类的卸载
类的生命周期的最后一步是卸载,卸载即该类的Class对象被GC。
卸载类需要满足3个要求:
- 该类所有的实例对象都已被GC。
- 该类没有其他任何地方的引用。
- 该类的类加载器的实例已被GC。
在JVM的生命周期,由JVM自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器的加载的类是可能被卸载的。
因为JDK自带的BootstrapClassLoader,ExtClassLoader,AppClassLoader负责加载jdk提供的类,所以他们(类加载的实例)肯定不会被回收,而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。
作者:Ryanjie
出处:http://www.cnblogs.com/ryanjan/
本文版权归作者和博客园所有,欢迎转载。转载请在留言板处留言给我,且在文章标明原文链接,谢谢!
如果您觉得本篇博文对您有所收获,觉得我还算用心,请点击右下角的 [推荐],谢谢!