JVM之类加载过程

当我们在Java代码中写下new String()的时候,我们理所当然认为java会返回给我们一个String对象,但是在JVM背后做了很多事情,包括类的加载、对象内存的分配等等工作,这些工作对我们来说都是透明的,了解JVM背后做的这些事情能让我们更好的理解java的运行过程。今天我们就一起来看看jvm如何把一个类加载到内存中的。

类的生命周期

一个类在jvm中的生命周期如下图:

其中链接又分为三个步骤,验证、准备和解析。

加载

这里的加载指的是类生命周期中的一个过程,不是我们广义上讲的加载,加载是指把class二进制字节流读入到内存中,并且按照一定的数据结构存储在jvm的方法区,创建一个java.lang.Class对象。所以读取二进制字节流的位置没有做限制,有下面几种位置可以读取:

  1. 从ZIP包读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
  2. 从网络中获取,这种场景最典型的应用是Applet。
  3. 运行时计算生成,这种场景使用得最多得就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass的代理类的二进制字节流。
  4. 由其他文件生成,典型场景是JSP应用,即由JSP文件生成对应的Class类。
  5. 从数据库读取,这种场景相对少见,例如有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

所以加载完成后在内存中就创建了一个java.lang.Class对象存储在方法区中。

验证

验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。主要包括四个验证过程:

  1. 文件格式验证
    文件格式验证主要是验证输入的字节流是否符合Class文件格式的规范,以及能否被当前版本的虚拟机处理,主要包括以下几点:
    · 是否以魔数0xCAFEBABE开头。
    · 主次版本号是否在当前虚拟机的处理范围之内
    · 常量池的常量中是否有不被支持的常量类型(tag标志)。
    · 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
    · Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。 ……
  2. 元数据验证
    元数据验证是对字节码描述的信息(即类的元数据信息)进行语义分析,以保证其描述的信息符合Java语言规范的要求。该阶段的主要目的是对类的元数据信息进行语义检验,保证不存在不符合Java语言规范的元数据信息。包括下面这些验证点:
    · 该类是否有父类(除了java.lang.Object之外,所有的类都应有父类)
    · 该类的父类是否继承了不允许被继承的类(final修饰的类)
    · 若此类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法 ……
  3. 字节码验证
    第三阶段的主要目的是进行数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验之后,这个阶段将对类的方法体进行校验分析,以保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。例如:
    · 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作。
    · 保证跳转指令不会跳转到方法体以外的字节码指令上。
    · 保证方法体中类型转换是有效的,例如子类对象可以赋值给父类数据类型,但父类对象赋值给子类数据 类型是危险和不合法的。 ……
  4. 符号引用验证
    最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验下列内容:
    · 符号引用中通过字符串描述的全限定名是否能找到对应的类。
    · 指定的类中是否存在符合描述符与简单名称描述的方法与字段。
    · 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。 ……
    符号引用和直接引用可以参考http://www.javajs.cn/JVM%E4%B9%8B%E7%AC%A6%E5%8F%B7%E5%BC%95%E7%94%A8%E5%92%8C%E7%9B%B4%E6%8E%A5%E5%BC%95%E7%94%A8/#more

准备

准备阶段包括下面两个过程:

  1. 为类变量分配内存
  2. 设置类变量初始值

所以可以看出类变量的内存分配比实例变量的内存分配早。设置类变量的初始值举个例子来看:

1
public static int num = 10;

上面num的值在准备阶段完成之后就是0,准备阶段就是设置类变量的零值,而设置为10的这个过程是在初始化阶段完成的,不过这都是在一般情况下,如果代码改为以下:

1
public static final int num = 10;

在准备阶段完成之后num的值就是10,因为被final修饰的变量只能进行一次赋值,所以在准备阶段就完成了赋值操作。

初始化

初始化是类加载的最后一个过程,这个阶段才是按照程序员的想法来执行Java代码的。初始化阶段是执行\<clinit>类构造器的阶段,通过按代码中的顺序收集类中的静态代码,把收集的代码放入\<clinit>方法中,然后执行\<clinit>方法。前面的代码

1
public static int num = 10;

在准备阶段num的值是0,初始化阶段完成之后num的值为10。
\<clinit>()方法细节:

  1. 是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定,特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。
  2. <clinit>()方法与类的构造函数(或者说实例构造器<init>() 方法)不同,不需要显式的调用父类的()方法。虚拟机会自动保证在子类的<clinit>()方法运行之前,父类的\<clinit>()方法已经执行结束。因此虚拟机中第一个执行<clinit>()方法的类肯定为java.lang.Object。
  3. <clinit>()方法对于类或接口不是必须的,如果一个类中不包含静态语句块,也没有对类变量的赋值操作,编译器可以不为该类生成<clinit>()方法。
  4. 接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  5. 虚拟机会保证一个类的<clinit>()方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的<clinit>()方法,其它线程都会阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时的操作,就可能造成多个进程阻塞,在实际过程中此种阻塞很隐蔽。

何时触发初始化

  1. 为一个类型创建一个新的对象实例时(比如new、反射、序列化)
  2. 调用一个类型的静态方法时(即在字节码中执行invokestatic指令)
  3. 调用一个类型或接口的静态字段,或者对这些静态字段执行赋值操作时(即在字节码中,执行getstatic或者putstatic指令),不过用final修饰的静态字段除外,它被初始化为一个编译时常量表达式
    调用JavaAPI中的反射方法时(比如调用java.lang.Class中的方法,或者java.lang.reflect包中其他类的方法)
  4. 初始化一个类的派生类时(Java虚拟机规范明确要求初始化一个类时,它的超类必须提前完成初始化操作,接口例外)
  5. JVM启动包含main方法的启动类时。

常见面试题

实验一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package classload;
class SingleTon {
private static SingleTon singleTon = new SingleTon();
public static int count1;
public static int count2;

private SingleTon() {
count1++;
count2++;
}

public static SingleTon getInstance() {
return singleTon;
}
}
public class Test {
public static void main(String[] args) {
SingleTon singleTon = SingleTon.getInstance();
System.out.println("count1=" + singleTon.count1);
System.out.println("count2=" + singleTon.count2);
}
}

运行结果:

1
2
count1=1
count2=1

实验二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SingleTon {
private static SingleTon singleTon = new SingleTon();
public static int count1;
public static int count2 = 0;

private SingleTon() {
count1++;
count2++;
}

public static SingleTon getInstance() {
return singleTon;
}
}

public class Test {
public static void main(String[] args) {
SingleTon singleTon = SingleTon.getInstance();
System.out.println("count1=" + singleTon.count1);
System.out.println("count2=" + singleTon.count2);
}

}

运行结果:

1
2
count1=1
count2=0

分析结果

接下来我们分析为什么实验一和实验二的结果不一样。
实验一:

  1. SingleTon准备阶段,count1和count2赋值为0
  2. 进入初始化阶段,\<clinit>方法执行顺序:然后执行SingleTon类的构造函数,进行count1++、count2++
    实验二:
  3. SingleTon准备阶段,count1和count2赋值为0
  4. 进入初始化阶段,\<clinit>方法执行顺序:然后执行SingleTon类的构造函数,进行count1++、count2++
  5. 执行count2=0语句。

所以实验二的count2=0,是因为在初始化阶段先执行了实例的构造函数,然后才执行了count2=0的赋值,那就大家就会产生疑问,难道类还没初始化完成就可以进行实例初始化吗???答案是肯定的,也就是实例初始化方法被包在了类初始化方法里面,实例初始化就发生在类初始化之前了。

实验三:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package classload;

/**
* @author feidao Created on 2019/2/28.
*/
class Foo {
int i = 1;

Foo() {
System.out.println(i);
int x = getValue();
System.out.println(x);
}

{
i = 2;
}

protected int getValue() {
return i;
}
}

//子类
class Bar extends Foo {
int j = 1;

Bar() {
j = 2;
}

{
j = 3;
}

@Override
protected int getValue() {
return j;
}
}

public class ConstructorExample {
public static void main(String... args) {
Bar bar = new Bar();
System.out.println(bar.getValue());
}
}

运行结果:

1
2
3
2
0
2

接下来我们分析一下这个实验的运行结果,首先遇到new Bar()指令,这时候Bar类还没有加载进来,所以开始加载Bar类,发现Bar类有父类Foo,开始加载Foo方法。执行顺序:

  1. Foo进行类初始化。
  2. Bar进行类初始化
  3. 进行Foo类的实例初始化,成员变量赋值和实例代码块运行(按照代码顺序进行),执int i = 1;i = 2;
  4. 执行Foo构造函数,输出i为2,然后执行bar.getValue方法,输出0。
  5. 执行Bar的实例初始化,成员变量赋值和实例代码块运行,int j = 1;j=3
  6. 执行Bar的构造函数j=2,输出结果2。

在这个实验中也可以看到,创建一个类的实例的时候,还没有执行构造函数就一个实例在jvm中就创建完成了,接可以调用实例的方法了,这个例子中,bar的getValue方法的执行就发生在构造函数之前。

总结

在我们的代码中要考虑类初始化和实例初始化的顺序对我们代码的影响。类初始化过程实际就是执行在编译期收集的静态代码块和静态变量的赋值操作,按代码中的顺序执行,而实例的初始化过程就是收集成员变量的赋值和普通代码块,按代码中的顺序执行,总结执行顺序就是:
父类类初始化->子类类初始化->父类实例初始化->父类构造函数->子类实例初始化->子类构造函数。

<参考> https://blog.csdn.net/justloveyou_/article/details/72466105
<参考> https://www.jianshu.com/p/ebaa1a03c594
<参考> https://crowhawk.github.io/2017/08/21/jvm_5/
<参考> https://www.cnblogs.com/aspirant/p/7200523.html
<参考> https://blog.csdn.net/haibo_bear/article/details/53841000