深入理解Java虚拟机读书笔记 七
类加载过程
类对象和普通对象是不同的,类对象是在类加载的时候完成的,是jvm创建的并且是单例的,作为这个类和外界交互的入口, 而普通的对象一般是在调用new
之后创建。在Java语言里面, 类型的加载、 连接和初始化过程都是在程序运行期间完成的.
类型从被加载到虚拟机内存直到卸载出,会经历加载,验证,准备,解析,初始化,使用和卸载七个阶段.
加载
加载阶段需要完成三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象, 作为方法区这个类的各种数据的访问入口。
第二个步骤包含了class文件常量池进入运行时常量池的过程,这里需要强调一下不同的类共用一个运行时常量池,同时在进入运行时常量池(方法区的一部分)的过程中,多个class文件中常量池中相同的字符串只会存在一份在运行时常量池,这也是一种优化.
非数组类型的加载,可以通过使用虚拟机内置的引导类加载器完成,也可以由用户自定义的类加载器完成(通过重写findClass()
或者loadClass()
方法).而数组类型的加载不通过类加载器创建,由虚拟机直接在内存中动态构造出来.但是最终还是需要依靠类加载器来完成,取决于数组的类型.
验证
确保class文件的字节流包含的信息符合<<Java虚拟机规范>>的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全.
准备
为类中定义的变量(静态变量,被static
修饰的变量)分配内存并且设置初始值(数据类型的零值).而对于被final static
修饰的变量,编译时会生成Constant Value
属性,在准备阶段会为其进行赋值操作:
// 准备阶段过后value的值是0
public static int value = 123;
// 准备阶段过后value的值是123
public static final int value = 123;
解析
解析阶段是Java虚拟机将class文件中的常量池(主要存放字面量和符号引用)内的符号引用(可以理解为字符串,与虚拟机实现的内存布局无关)替换为直接引用(可以理解为指针,与虚拟机实现的内存布局直接相关)的过程,替换出来的直接引用也是存储在运行时常量池中.
符号引用与直接引用: JVM里的符号引用如何存储? - RednaxelaFX的回答 - 知乎
常量池中存储的是什么: 彻底弄懂java中的常量池
除了invokedynamic
指令(lambda
表达式和接口的默认方法会用到该指令)外,虚拟机实现可以对第一次解析的结果进行缓存, 譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。
解析主要针对类或接口、 字段、 类方法、 接口方法、 方法类型、 方法句柄和调用点限定符这7类符号引用进行.
初始化
进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源.
初始化阶段就是执行类构造器<clinit>()
方法(与类的构造函数不同)的过程。<clinit>()
并不是程序员在Java代码中直接编写的方法,它是Javac
编译器的自动生成物.它是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}
块) 中的语句合并产生的.
Java虚拟机会保证在子类的<clinit>()
方法执行前,父类的<clinit>()
方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()
方法的类型肯定是java.lang.Object
。也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作.
Java虚拟机必须保证一个类的<clinit>()
方法在多线程环境中被正确地加锁同步, 如果多个线程同时去初始化一个类, 那么只会有其中一个线程去执行这个类的<clinit>()
方法, 其他线程都需要阻塞等待, 直到活动线程执行完毕<clinit>()
方法。如果在一个类的<clinit>()
方法中有耗时很长的操作, 那就可能造成多个进程阻塞.
如果执行
<clinit>()
方法的那条线程退出<clinit>()
方法后,其他线程唤醒后则不会再次进入<clinit>()
方法。 同一个类加载器下, 一个类型只会被初始化一次。
关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,但是规定了有且只有下列六种情况必须对类进行初始化(主动引用):
- 遇到
new
、getstatic
、putstatic
或invokestatic
这四条字节码指令时, 如果类型没有进行过初始化, 则需要先触发其初始化阶段:
- 使用
new
关键字实例化对象的时候; - 读取类型的静态字段(除了
final static
修饰的,该字段已经在编译期把结果放入常量池) - 调用类的静态方法时.
由第3点也可以解释,在单例模式中,使用饿汉方法,本质上是利用调用静态方法
JVM
会且只会创建一个对象.
- 使用
java.lang.reflect
包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化 - 初始化类的时候,如果父类没有初始化,则需要先触发父类的初始化
接口也有初始化过程(虽然不能使用静态语句块,但是仍然有变量初始化的赋值操作,也会生成
<clinit>()
方法),但是在初始化时,并不要求父接口全部都完成了初始化,只有在真正使用到父接口的时候(引用父接口中的常量,接口中的变量只能是static final
类型的,即只能是常量),才会初始化.
- 虚拟机启动时,需要指定一个执行的主类(包含
main
方法),虚拟机会先初始化这个主类 - 当使用
JDK 7
新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic
、REF_putStatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句柄, 并且这个方法句柄对应的类没有进行过初始化, 则需要先触发其初始化(TODO:待学习) - 接口中实现了
JDK 8
中的默认方法(接口中的default
方法,不需要强制实现类实现)时,如果这个接口的实现类进行了初始化,那么该接口要在其之前被初始化
类加载器
比较两个类是否“相等”(包括代表类的Class
对象的equals()
方法、isAssignableFrom()
方法、isInstance()
方法的返回结果,也包括了使用instanceof
关键字做对象所属关系判定等各种情况),只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个class文件, 被同一个Java虚拟机加载, 只要加载它们的类加载器不同, 那这两个类就必定不相等.
自JDK 1.2
以来, Java一直保持着三层类加载器、 双亲委派的类加载架构.通常介绍的是JDK 8
及以前的三层类加载器和双亲委派模型(JDK 9
引入了模块化,产生了一些变动):
三层类加载器:
- 启动类加载器(
Bootstrap ClassLoader
):负责加载<JAVA_HOME>/lib
目录下,或者被-Xbootclasspath
指定的路径中存放的类库,比如rt.jar
,tools.jar
.它无法被Java程序直接引用,如果需要把请求委派给它处理,直接使用null
代替; - 扩展类加载器(
Extension ClassLoader
):负责加载<JAVA_HOME>/lib/ext
目录下,或者被java.ext.dirs
系统变量所指定的类库.开发者可以直接使用. - 应用程序类加载器(
Application ClassLoader
):负责加载用户类路径ClassPath
上所有类库,开发者也可以直接使用.如果应用程序中没有定义过自己的类加载器,一般情况下这个是程序中的默认类加载器.
双亲委派:
经典的双亲委派图如下:
双亲委派模型的工作过程是: 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此, 因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类) 时,子加载器才会尝试自己去完成加载。显而易见的好处是:
Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系.例如类
java.lang.Object
,它存放在rt.jar
之中, 无论哪一个类加载器要加载这个类, 最终都是委派给处于模型最顶端的启动类加载器进行加载, 因此Object类
在程序的各种类加载器环境中都能够保证是同一个类。
参考资料:
JVM里的符号引用如何存储? - RednaxelaFX的回答 - 知乎
彻底弄懂java中的常量池
深入理解Java虚拟机