一、前言
这是JVM
系列博客的第四篇,这篇博客来讲一讲Java
中的一个类是如何被加载进的,在加载的过程中需要经历哪些步骤。这应该是JVM
中比较重要的一个部分,对于我们理解Java
语言的一些特性有很大的帮助。而在看这篇博客之前,需要先了解JVM
的内存模型,不了解的可以看一看这篇博客:浅析Java的内存模型 。
二、正文
2.1 什么是类加载
我们都知道,编写的Java
代码被存放在后缀名为.java
的源文件中,而当我们需要执行这个文件中的代码时,会先将源文件编译为.class
结尾的字节码文件。JVM
并不认识.java
中的内容文件,真正被JVM
解释器执行的是这个class
文件中的字节码。所谓类加载,就是当我们要执行某一段代码之前,先将这段代码对应的类的字节码读取到内存中(注意,字节码并不一定是存储在class
文件中,也可以来源于网络,或者动态生成,只要符合字节码的格式即可),并进行一些相应的操作。只有当字节码被加载进了内存,才能够被运行。
类从开始加载起,它的生命周期被分为七个阶段:
- 加载;
- 验证;
- 准备;
- 解析;
- 初始化;
- 使用;
- 卸载;
其中的前五个阶段,就是类加载的过程。在类加载期间,这五个过程按部就班的进行,但并不一定是一个一个依次进行,有的步骤之间会有一些交错,比如加载和验证。下面我就来详细介绍一下类加载的五个过程分别有什么作用。
2.2 加载
下面来说说第一个阶段——加载(注意,加载不等同于类加载,只是其中的一个阶段)。这个阶段的工作就是:将字节码读取进内存,并转换成方法区的数据结构,然后存入方法区中,同时生成这个类的一个java.lang.Class对象,作为访问这个类信息的接口。
对象一般都是存在堆内存中,但是虚拟机规范中并未明确规定Class
对象也需要存在堆内存中,所以具体存在哪里需要看具体实现,比如HotSpot
虚拟机的Class
对象就是存在方法区中的。
2.3 验证
第二个过程阶段叫做验证,其目的是验证字节码的合法性和正确性。验证主要分为四个部分:
(1)文件格式验证
JVM
规范对于字节码的每一个部分都有规定,每一个字节代表什么内容,可以是哪些值都是有所限制的。这也就是说,只有完全符合规则的字节码,才能够被JVM
虚拟机所认同。而这一步的工作就是检查字节码的格式是否符合要求。比如字节码的前四个字节是固定的,只能是十六进制的0xCAFEBABE,这是字节码的标识。除此之外,可能还需要检查字节码中的版本号是否和当前JVM
的版本对应;字节码中的各个部分是否与规范相匹配等。
只有通过验证的字节码,才能被存入方法区中。这就表示这一步验证与上面所说的加载过程是交错进行的。在我们当前所说的四种验证中,只有这一种是发生在方法区之外,而剩下的三个都是在方法区中进行。
(2)元数据校验
这一步主要是对类的结构进行分析,判断是否存在不符合语义的部分,例如:
- 分析当前正在加载的类是否有父类,对于
Java
中的类来说,只有Object
类没有父类,其他的都有父类(Object
是公共父类); - 分析当前的类是否继承了一个被
final
修饰的类,被final
修饰的类是不允许被继承的; - 若当前类是非抽象的,而它实现了一个接口或者继承了一个抽象类,则检查当前类是否实现了接口或抽象类中的抽象方法;
……
(3)字节码校验
这一步骤就是对方法体中的代码进行校验,检查这些代码是否符合语义,是否存在不合法的地方,例如:
- 保证数据的类型转换是有效的,不会出现将一个
String
类型的数使用int
进行接收等情况; - 保证代码中的指令不会跳转到方法体之外的字节码指令上;
……
(4)符号引用验证
这一过程将在类加载的第四步——解析过程中发生,而其目的就是为了验证字节码中的符号引用。对于符号引用,在讲到解析的时候再具体描述,当前只需要知道它相当于一个引用类型的变量,也就是用字符串来替代地址作为引用。而对于符号引用的验证包括:
- 符号引用中通过字符串描述的全限定名,是否能找当相应的类;
- 是否存在符号引用所描述的字段和方法;
……
以上就是验证阶段的主要工作。这里可能有一个疑问,我们的代码不都是用合法的Java
语言编写出来,然后被编译为字节码的吗,为什么还需要这么复杂的校验呢?那是因为字节码并不等于Java
语言。Java
语言不能做到的事情,不代表字节码指令做不到。我们完全可以用其他语言编写一些Java
做不到的程序,然后将其编译为Java
的字节码交给JVM
执行,甚至可以自己手动编写字节码(虽然非常难)。字节码解释器所能做到的指令,并不局限于Java
的语法,所以要对字节码进行验证。
2.4 准备
准备阶段的作用的简单了。这一步的作用就是为类中的静态成员变量赋初值(注意是静态变量而不是实例变量,实例变量的值将在创建对象时被赋予)。在这一过程中,JVM
为所有的静态变量赋予其数据类型对应的零值。比如在类中有如下代码:
1 | private static int value = 123; |
在这一阶段,valu
e会被赋予初始值0
,而不是123
(123
将在初始化阶段被赋予)。而每一种不同类型的数据,都有自己对应的零值,如下表格所示:
当然,这里还有一种特殊情况,对于类中的常量(被final
修饰的静态变量),如果在变量声名的地方直接就被赋值,则在这一阶段就会被赋上最终值。如下面这行代码,在准备阶段,就会为变量value
赋值123
,而不是0
,因为对于常量来说,它的值并不允许被改变,所有可以直接赋初值。
1 | private static final int value = 123; |
2.5 解析
这一步的目的是:将字节码中的符号引用,转换为直接引用。我们先来说一说符号引用和直接引用的区别:
- 符号引用:假设在一个类中引用了其他类,或者调用了其他类的方法(当然不仅仅只有这两种情况),则在程序被编译成字节码的后,这些引用会被编译成一串字符,通过一个字符串来表示这些引用。为什么这么做?因为在类没有被加载到内存中时,是没有地址的,也不知道其他数据的地址,所以此时不能使用直接引用,而是用符号来替代,这就是符号引用。一个类被编译后,一般会包含许多的符号引用;
- 直接引用:直接引用就比较好理解了,它可以是直接指向目标的指针,也可以是一个地址偏移量……直接引用和虚拟机的内存结构有关,根据内存的结构,直接引用也可以有不同的形式;
当类被加载到方法区后,就能够找到引用目标的地址了,而符号引用毕竟只是一个虚假的引用,所以这一步的工作就是将这些虚假的引用替换成可以真正寻找到目标的直接引用。而刚刚也提过,在验证阶段,有一个符号引用验证,就是对这个过程进行验证。
2.6 初始化
到了这一步,就是正式执行程序员编写的代码了。在我们编写的类中,可能会为静态变量赋初值,可能会编写静态代码块(static { }),这些都是属于初始化的内容(当然,不包括对实例变量赋初值或者构造方法,这些是在创建对象时执行的)。在编译阶段,编译器会自动收集这些内容,并封装成一个<cinit>()
方法,在这一阶段执行。需要注意的是,这些初始化的内容,是按照我们编写的顺序从上到下收集并封装进<cinit>()
的,也就意味着在前面的代码,无法使用后面声明的变量,比如如下代码:
1 | public class Main { |
上面的代码编写了两个静态代码块,而在它们中间声明了一个静态变量。我们尝试在第一个静态代码块中为这个变量赋值,发现没有问题,但是当我们想要使用这个变量时,发生了编译错误,提示非法向前引用,而在第二个静态代码块中使用则没有问题,这验证了我们之前的说法。但是有点疑惑,为什么在第一个静态代码块中,为a
赋值却没有问题?我认为这个<cinit>()
方法可能采用了C语言
的形式,所有的变量声明都放在了最上方,所以这里可以访问到a
(个人猜测)。
对于初始化阶段,有许多需要注意的问题:
- 在子类被初始化之前,若其父类还没有初始化,则会先初始化父类,也就是父类的
<cinit>()
方法会先执行(和创建对象类似,创建一个子类对象时,一定会先创建父类对象)。正因为如此,Object
类是所有类中最先被初始化的; - 若一个类实现了一个接口,则这个类被初始化时,这个接口并不会初始化。和类不同,接口只有在初次访问成员时才会初始化。除此之外,一个接口实现了一个接口,这个子接口被初始化时,父接口也不会初始化;
- 多线程环境下,虚拟机会同步
<cinit>()
方法的运行。也就是说,只有一个线程会执行这个方法,若多个线程都想要执行这个方法,则只有最先的那个线程能够执行,其他的只能阻塞等待,直到这个方法被执行完。但是其他线程检查到这个方法被执行完,就不会再执行它了。
三、总结
以上对类加载的五个步骤(加载、验证、准备、解析、初始化)的操作和功能做了一个大致的描述,看完之后应该会对类的加载有一个更加深入的了解。这里不得不再次提醒,虽然这五个步骤是按部就班地执行,但并不一定是一个个先后执行地,其中有些步骤的某一些过程是交互进行的。最后希望这篇博客对想要了解这部分内容的人有所帮助。
四、参考
- 《深入理解Java虚拟机》