JVM深入理解(2)——类加载子系统(1)
接上篇文章JVM深入理解(1)——概述
写在最前,本篇文章大部分来源于b站尚硅谷JVM全套教程的提炼,并附带自己的理解。主要是为了帮助自己理解,和用于复习。如果同时还能对其他人有所裨益,那就更好不过了。如果有谬误的地方,还请不吝指出。
类加载器子系统作用
- 类加载器子系统负责从文件系统或网络中加载Class文件,class文件在文件开头有特定的文件标识。
- ClassLoader只负责class文件加载,不关心是否能运行。(由执行引擎判断)
- 加载的类信息存放在方法区的内存空间,方法区还会存放运行时常量池信息。
ClassLoader角色
- class文件存放在本地硬盘上,在执行时,要加载到JVM中。
- class文件在JVM中被称为DNA元数据模板,放在方法区。
- ClassLoader是一个运输工具,它使得class文件在JVM中变成元数据模板(也就是Class类对象)。
-
注:
- 一个实例可以通过getClass()得到其类对象,类对象对同一个类是唯一的。
- 一个类对象可以通过getClassLoader()获得其类加载器,类加载器不止一种(后文解释)。
类加载过程
阶段一:加载 Loading
- 通过类的全限定名称获取此类的二进制字节流
- 将字节流代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个类对象,作为方法区这个类的各种数据的访问入口。
注意:class文件并不一定是实际存在于硬盘上的文件,它可以动态生成,也可以从 网络中获取。
阶段二:链接 Linking
-
验证
-
- 确保class文件字节流符合虚拟机要求,并确保安全性。
准备
-
- 为类变量(static变量)分配内存,并设置类变量的默认初始值。
-
- 不包含final修饰的类变量,因为final编译时就已经分配了,准备阶段会显式初始化。
-
- 不会为实例变量分配初始化,类变量分配在方法区中,实例变量随对象分配至java堆中。
解析
-
- 将常量池内的符号引用转为直接引用的过程。
-
- 事实上,解析往往会伴随着JVM执行初始化之后再执行
-
- 符号引用:一组符号描述引用的目标。直接引用:直接指向目标的指针、相对偏移量或间接定位到目标的句柄。
阶段三:初始化*
- 初始化阶段即执行类构造器方法<clinit>()的过程
- 此方法不需定义,javac编译器自动收集类变量的赋值动作和静态代码块中的语句合并而来。(1)
- 指令按语句在源文件出现的顺序执行 (2)
- 此方法不同于类的构造器(虚拟机中的<init>())(3)
- 若该类有父类,JVM保证子类的<clinit>()执行前,父类已经执行完毕。
- 虚拟机必须保证一个类的<clinit>()在多线程下被同步加锁,这是由于一个类只会被加载到内存一次,被缓存起来。也就是说<clinit>()同样也只会被调用一次。(4)
后文一一解释这几个规则。
我们可以通过在IDEA–>setting–>Plugins 搜索查找jclass来查看我们的字节码文件。
(1):
我们编译下面这段代码,并通过view–>Show ByteCode with Jclasslib来查看。
public class initTest {
private static int num=1; //在准备阶段num已经被赋值为0了
static{
num=2;
}
public static void main(String[] args) {
System.out.println(initTest.num);
}
}
我们先注释掉static块
字节码:
0 iconst_1
1 putstatic #3 <Test/JVM/clsLoader/initTest.num : I>
4 return
然后加上static块(记得重新编译):
0 iconst_1
1 putstatic #3 <Test/JVM/clsLoader/initTest.num : I>
4 iconst_2
5 putstatic #3 <Test/JVM/clsLoader/initTest.num : I>
8 return
所以验证了我们前面所说<clinit>()方法由类变量赋值和静态代码块合并而来。
(2):
下面根据以上所学的知识进行一个判断题:
public class initTest {
static{
num=20;
}
private static int num=10;
}
这段代码能够顺利执行么?
将代码编译执行,果然没报错,为什么?
可能的解答是:对于类变量的初始化先开始,然后再执行静态代码块。
如果按这样理解,下面这段代码同样不会出错
public class initTest {
static{
num=20;
System.out.println(num);
}
private static int num=10;
}
事实并非如此,以上代码会爆出非法前向引用(illegal forward reference)的错误。
实际上,第一段代码之所以顺利执行,是因为num变量在类加载过程的准备中已经被初始化为了0。根据上文所述:
指令按语句在源文件出现的顺序执行
实际上是先执行了num=20, 然后再执行num=10
通过字节码来check一下:
0 bipush 20
2 putstatic #2 <Test/JVM/clsLoader/initTest.num : I>
5 bipush 10
7 putstatic #2 <Test/JVM/clsLoader/initTest.num : I>
10 return
是不是非常神奇?
(3):
<clinit>()方法仅会在类中有类变量时,才会出现。
而<\init>()方法对于每个类都会存在,代表着类的构造函数。
(4):
我们来看这样一段代码:
public class initTest2 {
public static void main(String[] args) {
Thread t1=new Thread(()->{
System.out.println(Thread.currentThread().getName()+": 开始");
DeadThread deadThread = new DeadThread();
System.out.println(Thread.currentThread().getName()+": 结束");
});
Thread t2=new Thread(()->{
System.out.println(Thread.currentThread().getName()+": 开始");
DeadThread deadThread = new DeadThread();
System.out.println(Thread.currentThread().getName()+": 结束");
});
t1.start();
t2.start();
}
}
class DeadThread{
static {
if(true){
System.out.println(Thread.currentThread().getName()+" 进入dead thread");
while(true){
}
}
}
}
如果DeadThread的<clinit>()方法不被同步保护,我们看到两个线程均输出*“进入dead thread”*语句。
如果不通过同步保护,而仅通过检查再执行(check-then-act)式来判断是否方法仅被调用了一次,那么当线程A卡在while循环时,线程B可能认为<clinit>()方法并未执行完成也调用此方法。(或者线程A、B同时进入此方法)从而不能保证其仅被调用一次。通过加锁(阻塞同步)可以保证,一次只能有一个线程进入此方法。
Thread-0: 开始
Thread-1: 开始
Thread-0 进入dead thread