一文了解JVM类加载机制
简介
今天给大家聊聊类加载机制,类被虚拟机加载、验证、解析、初始化后才会根据对象大小进行内存分配。那虚拟机是如何进行类加载、验证、解析、初始化的呢?类加载过程中虚拟机进行了哪些工作呢?什么是类加载器?什么是双亲委派?为什么又有打破双亲委派?接下来小刀为你一一揭晓。
类被虚拟机加载到内存,再到被卸载出内存整个生命周期一共需要经历过以下几个阶段:加载、验证、准备、解析、初始化、使用和卸载。
其中如上图所示,验证、准备、解析这三个阶段可以合并称链接。我们要注意一点的是,这几个阶段是按照顺序开始的,但是不一定按照顺序进行或者完成的。
加载
加载是整个过程的第一个阶段,虚拟机在这个阶段完成三件事:
通过一个类全限定名,找到这个类,获取这个类的二进制流
将这个字节流所代表的静态存储结构转化成方法区的数据结构
在内存中生成一个代表这个类的
Class
对象,作为方法区各个数据的访问入口,这个对象有时候也被叫做“元对象”
注意,加载过程中不一定要从一个 Class
文件中获取,也可以从 jar 包或者 war 包中读取,或者是通过动态代理生成,或者由其他文件生成(由 jsp文件生成对应的Class 类)
类加载器
完成类加载这个过程的那部分代码模块我们叫做类加载器。虚拟机只是实现了“启动类加载器”,还有其他类加载器独立于虚拟机之外,让应用程序自己去决定如何获取所需要的类实现类加载这个动作。
所以这样子来看的话,对于虚拟机来说只有两种类加载器,启动类加载器和其他类加载器。如下:
启动类加载器:它使用
C++
实现(这里仅限于Hotspot
,也就是JDK1.5
之后默认的虚拟机,有很多其他的虚拟机是用Java
语言实现的),是虚拟机自身的一部分。其他的类加载器:这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类 java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
但是对于 Java 开发人员角度来说,类加载器是可以分为启动类加载器、扩展类加载器、应用程序类加载器这三大类:
启动类加载器:它负责加载存放在
JDK\jre\lib
下,或被-Xbootclasspath
参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar
,所有的java.*
开头的类均被Bootstrap ClassLoader
加载)。启动类加载器是无法被 Java 程序直接引用的。扩展类加载器:该加载器由
sun.misc.Launcher$ExtClassLoader
实现,它负责加载JDK\jre\lib\ext
目录中,或者由java.ext.dirs
系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。应用程序类加载器:该类加载器由
sun.misc.Launcher$AppClassLoader
来实现,它负责加载用户类路径(ClassPath
)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
在我们的应用程序中的类都是由这三个加载器配合加载,如果有必要,我们还可以加入自定义的类加载器。
所以整个类加载器的层级关系就如同上图所示,我们把每一层的上一层加载器叫做父类加载器,注意,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。
双亲委派
接下来我们来聊聊双亲委派,首先我们来看看什么是双亲委派。双亲委派就是某一个类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,则成功返回;如果父类加载器无法完成加载任务,将抛出ClassNotFoundException
异常后,再调用自己的findClass()
方法进行加载,依次类推
那为什么要通过双亲委派这个机制去加载类呢?首先虚拟机判定两个类是否为同一个类是通过类全限定名是否相同,加载这个类的加载器是否相同来判定这两个类是否为同一个类。其次,有一个好处是 java 随着他的类加载器一起具备了一种带有优先级层次关系。
那么这样的话,如果没有双亲委派模型,让所有类加载器自行加载的话,假如用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath中,系统就会出现多个不同的Object类, Java类型体系中基础行为就无法保证。同时双亲委派也保证了内存中不会出现多份同样的字节码。
打破双亲委派
刚刚我们知道双亲委派如果可以被委托的父类加载器加载,他就不能被当前的类加载器加载。那么这种情况下你就知道 String
类一定是被BootstrapClasserLoader
加载的/lib
下的那个 rt.jar
的那个java/lang/String.class
但是我们要清楚,虚拟机不是强制要求类加载机制一定要按照双亲委派的模型。这个模型也会有一些问题,比如我们经常会通过数据库提供商的驱动来链接操作数据库。但是 jdk 只是提供了一个规范接口,没有提供实现。数据库提供商提供了具体的驱动实现。但是我们总不可能把数据库驱动放在 jdk 的目录里面吧。
这里我们通过加载 mysql 驱动来举一个例子,首先看下面的代码:
Class clz = Class.forName("java.sql.Driver");
Driver driver = (Driver)clz.newInstance();
应为java.sql.Driver
是在 rt.jar
下的类文件,所以按照我们上面的描述,这个应该会被启动类加载器加载,问题是java.sql.Driver
是个接口,无法真的实例化,就报错了。
那么我们要正确加载驱动需要在 classpath
里加一个 mysql-connector-java.jar
,然后再编写如下代码:
Class clz = Class.forName("com.mysql.jdbc.Driver");
Driver driver = (Driver)clz.newInstance();
mysql-connector-java.jar
中的
com.mysql.jdbc.Driver
JDBC4.0
以后,开始支持使用
spi
的方式来注册这个
Driver
,具体做法就是在
mysql
的
jar
包中的
META-INF/services/java.sql.Driver
文件中指明当前使用的
Driver
是哪个,然后使用的时候就直接这样就可以了,具体代码可以写成下面这样子:
Connection conn= DriverManager.getConnection(
"jdbc:mysql://localhost:3306/mydb?characterEncoding=GBK",
"root", "");
DriverManager
就根据"jdbc:mysql"
这个提示去找具体实现,从META-INF/services/java.sql.Driver
文件中获取具体的实现类“com.mysql.cj.jdbc.Driver”
。加载这个类用class.forName(“com.mysql.jdbc.Driver”)
来加载,我们可以用代码编译器清楚的看到这个文件
这里就有个问题,Class.forName()
加载用的是调用者的Classloader
,这个调用者DriverManager
是在rt.jar
中的,ClassLoader
是启动类加载器,而com.mysql.jdbc.Driver
肯定不在<JAVA_HOME>/lib
下,所以肯定是无法加载mysql
中的这个类的。这就是双亲委派模型的局限性了,父级加载器无法加载子级类加载器路径中的类。
这个问题如何解决呢?由于这个mysql
的drvier
只有应用类加载器能加载,那么我们只要在启动类加载器中有方法获取应用程序类加载器,然后通过它去加载就可以了。这就是所谓的线程上下文加载器。
线程上下文类加载器让父级类加载器能通过调用子级类加载器来加载类,这打破了双亲委派模型的原则
通过以上的例子我们知道了在某些情况下是需要违反双亲委派这种模型来加载类的,到目前为止双亲委派模型主要出现过三次大规模的“破坏”的情况:
第一次:在双亲委派模型发布之前,即JDK1.2
之前。为了兼容之前JDK
版本中自定义类加载器的实现。(即没有按照双亲委派模型来设计)
解决办法:把自己的类加载器逻辑写到findClass()
方法中,在loadClass()
方法的逻辑里如果父类加载失败,则会调用自己写的findClass()
方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。
第二次:自身的缺陷所致。JNDI
服务需要调用独立厂商实现并部署在应用程序的ClassPath
下的JNDI
接口提供者(SPI)的代码,但是启动类加载器不认识这些代码。
解决办法:引入上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的 setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围都没有设置过的话,那这个类加载器默认就是应用程序的类加载了。类似的服务还有:JDBC、JCE、JAXB、JBI 等等。
第三次:用户对动态性的追求而导致的,例如:代码热替换、热部署
解决办法:OSGI实现模块化热部署的关键是它自定义的类加载机制实现的。OSGi每个模块都有自己独立的classpath。
验证阶段
聊完了加载阶段我们再来聊一聊验证阶段,验证的主要目的确保 Class
文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
我们知道如果我们的业务代码中有诸如:访问越界、对象转型给一个它未实现的类型等在编译运行的时候会报错。所以想要通过写出错误的 Java
代码来生成错误的字节码语言是无法做到的。那么这样是不是说不用验证字节码是否正确了呢?
其实并不是,比如你很简单粗暴直接编写 Class
文件,然后丢给虚拟机运行,这个时候如果没有对字节流进行验证就很容易因为导入有害的字节流导致了系统崩溃。
验证阶段导致有 4 个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证。
文件验证:文件格式验证简单的来说就是我们的.class
文件是要符合JVM
的规范,并且是否能够被当前的 JVM
处理。
元数据验证:元数据验证主要是对类的元数据信息进行语义分析
字节码验证:字节码验证主要通过数据流和控制流分析对类中的方法体进行校验和分析,保证被校验的方法不会有危害虚拟机的代码。
符号引用验证:最后一个阶段是在将符号引用转换成直接引用(符号引用和直接引用会在下文解析阶段详细讲解)时候对类自身外,常量池中的各种符号引用进行匹配型校验,该校验是为了确保在解析阶段的时候能够确保正常执行。
准备阶段
准备阶段就是正式为类变量(即静态变量、被 static
修饰的变量)分配内存并且设置初始值。static
变量在 JDK 7
之前存储于 instanceKlass
末尾,从 JDK 7
开始,存储于 _java_mirror
末尾(1.7之前存储于方法区,1.7之后存储于堆内存中)。
关于准备阶段,还有两个容易产生混淆的概念需要着重强调,首先是这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value = 123;
那变量value
在准备阶段过后的初始值为 0 而不是 123 ,因为这时尚未开始执行任何Java方法,而把value
赋值为 123 的 putstatic
指令是程序被编译后,存放于类构造器<clinit>()
方法之中,所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。
还有一种特殊的情况就是在字段被final
修饰的属性,如果是被final
修饰,那么它在准备阶段变量的value
的值就是被指定的值,比如:
public static final int value = 123;
这个时候在准备阶段JVM
就会将value
赋值为 123。
具体我们来看看测试代码:
public class A {
static String s = "sssss";
static final String c = "cccccc";
public A() {
}
public static void main(String[] args) {
}
}
上面代码中在A
类里面定义了两个类属性,一个被 final
修饰,另一个没有被final
修饰。然后我们来看看编译后具体的字节码:
从字节码看,c 属性
在准备阶段就赋值了,而 s
在初始化阶段才赋值。
解析阶段
上面我们分别介绍了验证和准备两个阶段,接下来我们看看解析,解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程。 符号引用和直接引用刚刚在准备阶段我们有略微的提到过,现在来讲讲这两者的关联。
我们先通过代码来初步感受符号引用和直接引用,然后再通过文字定义加深理解。
首先我们来看代码:
public class Test {
public void create() {
add();
}
public void add() { }
}
上面有个Test类定义了两个方法create()
和add()
其中 在create
方法中调用了add
方法。我们把这个类编译成字节码文件 Test.class
然后查看一下这个文件:
我们可以看到字节码文件的 12 行Constant pool
这部分就是常量池,里面存储的就是大部分常量。
然后我们再看看create
这个函数的第一行字节码在第 47 行如下:
1: invokevirtual #2 // Method add:()V
invokevirtual
是一个操作码, 后面的 #2
是操作数,这里举一个简单的例子假设有指令如下:
// 以下指令是用于讲解,并非真实汇编指令
set a 1
set b 2
add a b c
上面的汇编指令可以翻译成下面的高级语言:
a = 1
b = 2
c = a + b
我们把每条汇编指令的第一个操作符的 16 十六进制编码叫做操作码,所以invokevirtual #2
通过查看指令信息表代表用于指定这个要调用的函数,#2
是字节码文件里面常量池里面的下标。我们按图索骥找到第 14 行
#3.#14 // Test.add:()V 2 = Methodref
这里又引用了另外两个常量池项分别是:
#15 // Test 3 = Class
#10:#6 // add:()V 14 = NameAndType
我们一直按图索骥寻找下去就会得到如下结构:
我们可以看到最后都在常量池里面找到了标记为Utf8的常量池项,这些常量池项描述了此方法所属的“类,方法名,描述符”等信息,这就是常量池引用。
找到符号引用后在通过这些信息去对应的类的方法寻找方法表的偏移指针,这个偏移指针就是直接引用。
我们在上文有讲解验证阶段的时候提到验证的第四阶段是虚拟机将符号引用转化成直接引用,就是我们例子中按图索骥的过程。
虚拟机把第一次把符号引用转化成直接引用后,将指令修改为invokevirtual_quick
,并把操作数修改成指向方法表的偏移量(指针)再调用该方法的时候就直接用了直接引用了。
我们通过上述例子感受了直接引用和符号引用后,接下来看看对它们的文字定义描述:
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info CONSTANT_Fieldref_info CONSTANT_Methodref_info等类型的常量出现。
直接引用:直接引用可以是直接指向目标的指针(比如,指向“类型”【Class
对象】、类变量、类方法的直接引用可能是指向方法区的指针)。相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)。一个能间接定位到目标的句柄
直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。
解析动作主要对类、接口、字段、类方法、接口方法、方法类型、方法句柄、和调用点限制符 7 类符号引用分别对常量池的 7 种常量类型,具体解析过程这里就不详细给出了。
初始化
类初始化是加载过程的最后一个步骤,我们知道在准备阶段变量已经被赋值过一次零值了,而在初始化阶段,会根据程序员制定的值去初始化变量和其他资源。
准确的说初始化阶段程序的<clinit>()
方法会被执行,这里我们说所的<clinit>()
和类的构造器函数<init>()
是不同的,它不需要显式的调用父类构造器,虚拟机会保证子类<clinit>()
调用前父类的<clinit>()
已经调用完毕。
<clinit>()
方法是 static{}
代码块修饰的语句,虚拟机会收集 static{}
代码块中的语句统一处理,其中要注意static{}
代码块中只能访问定义前的变量,定义在它之后的变量只能赋值不能访问。如下所示:
需要注意的是虚拟机会保证<clinit>()
在多线程环境中正确的加锁同步,如果多个线程同时初始化一个类的话,那么只有一个线程进入执行<clinit>()
方法,其余的线程会在等待执行。所以如果在静态代码块中有耗时较长的任务的情况下,可能会存在线程阻塞,大家要格外注意哟!
public class A {
static {
if (true ) {
while (true) { // 应该避免这种耗时的代码在静态代码块里面
System.out.println("true");
}
}
}
public static void main(String[] args) {
Runnable runnable = () -> {
System.out.println(Thread.currentThread() + " start");
A a = new A();
System.out.println(Thread.currentThread() + " end");
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
}
}
以上就是虚拟机类加载机制的整个过程,既然到这里了就点个关注来个👍 吧!