本文主要讨论两个问题:何时类加载?如何类加载?
何为类加载
类加载时机
类加载的过程
序:java文件被编译成.class文件放在磁盘中
加载阶段:
根据类的全限定名将字节码加载到内存,加载到内存哪里呢?方法区!
可以认为,.class文件是类的静态结构,而加载阶段就是把这种静态结构编程动态的运行时结构
根据这个字节码生成一个
java.lang.Class
对象我们讲过,万物皆可为对象,所以我们口口声声所讲的类也是对象
这一阶段有许多类加载器的作用:
启动类加载器(Bootstrap ClassLoader): 加载<JAVA_HOME>\lib
中的类库到虚拟机
扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext
中的类库
应用程序类加载器(Application ClassLoader):负责加载用户类库上所指定的类库。
判断两个类相等,不仅要它们的全限定名相同,而且要加载它们的类加载器也相同,
双亲委派模型:
这是一种层次结构,或者说是树状结构,其目的,是让越基础的类被越基础的类加器加载。举个例子,对于java.lang.Object类,它存在于rt.jar中,它是万类之祖,所以无论出现在程序中的哪里,它应该都是同一个Object类,使用双亲外派模型就可以很好的实现这个效果:无论我们使用哪个类加载器加载它,最终都会传递给启动类加载器加载,从而保证了Object类的唯一性。
验证阶段:
主要从安全性的角度考虑,验证class文件中的字节流是否安全,是否合法。
准备阶段:
为类变量分配空间,并设置初始值
这个阶段只给类变量分配,实例变量还在后头。
注意是设置初始值,不是赋值,初始值都是默认为0
类变量放在哪? 方法区!
解析阶段:
将常量池内的符号引用变为直接引用,关于常量池,符号引用,可以参考这篇文章。符号引用主要有:
- 类和接口的全限定名(这个常量在类索引或者父类索引中就会用到)
- 字段的名称和描述符 (这个在字段表集合中会被用到)
- 方法的名称和描述符(这个在方法表集合中会被用到)
所以说,解析阶段就是将上述三种符号引用解析成直接引用。
类或者接口解析:
主要需要判断当前指向类或者接口的引用是不是数组,如果不是数组,那就直接加载对应的类或者接口;
如果是数组,且数组元素也是对象,则需要先加载元素,最后生成数组对象
字段解析:
首先根据字段表中的信息找到其所在类或者接口,然后一直从当前类向上回溯,直到找到对应字段,否则返回NoSuchFieldError
类或者接口方法解析:
首先根据字段表中的信息找到其所在类或者接口,如果当前是类方法,但找到的是接口,出错,如果当前是接口方法,找到的是个类,也出错,IncompatibleClassChangeError,(为什么这样的,因为常量池中对类和方法使用的是一个表结构定义的,所以有时候可能会有歧义,需要这样判断)
上述条件满足后,从当前类或者接口一直回溯向上找,直到找到对应方法
初始化阶段:
类加载的最后一个阶段。这一阶段,JVM才真正开始执行我们写的java代码。换个角度讲,这个阶段,jvm执行类构造器<clinit>()
方法。
<clinit>()
类构造器方法,<init>()
对象构造器方法, 所以,<clinit>()
用来初始化类变量,包括被static修饰的变量和包含在static语句块中的变量,而<init>()
方法修饰非静态变量。
关于<clinit>()
的执行规则
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。<clinit>()
方法与实例构造器<init>()
方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()
方法执行之前,父类的<clinit>()
方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()
方法的类肯定是java.lang.Object
。也就意味着父类中定义的静态语句块/静态变量的初始化要优先于子类的静态语句块/静态变量的初始化执行<clinit>()
方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()
方法。- 接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成
<clinit>()
方法。但是接口与类不同的是:执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。 - 虚拟机会保证一个类的
<clinit>()
方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()
方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()
方法完毕,但如果执行<clinit>()
方法的那条线程推出后,其他线程唤醒之后不会再次进入/执行<clinit>()
,因为在同一个类加载器下,一个类型只会被初始化一次(单例模式种就有利用这种类加载机制去实现的)。如果在一个类的<clinit>()
方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
从上述规则我们可以得到以下结论:
1. 静态变量确实是先于实例变量被创建的(这点很重要!后面讲到的单例模式的实现就会用到这一点),类变量在类加载的时候就被赋值了。而实例变量要等到new的时候。
参考:
《深入理解java虚拟机》