深入理解jvm——类的加载机制
经典面试题
第一种情况
class SingleTon {
public static int count1;
public static int count2 = 0;
private static SingleTon singleTon=new SingleTon();
private SingleTon(){
count1++;
count2++;
}
public static SingleTon getInstance(){
return singleTon;
}
}
public class Test {
public static void main(String[] args) {
SingleTon.getInstance();
System.out.println(SingleTon.count1);//1
System.out.println(SingleTon.count2);//1
}
}
第二种情况
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.getInstance();
System.out.println(SingleTon.count1);//1
System.out.println(SingleTon.count2);//0
}
}
学习类加载之后再来分析一下结果
什么是类的加载机制
java内存模型.png类的生命周期.png
- 类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(loading),验证(verification),准备(preparation), 解析(resolution),初始化(initialization),使用(using),卸载(unloading)七个阶段.其中,验证,准备,解析三个部分统称为连接(linking).
- 注意:加载、验证、准备、初始化、卸载这五个顺序是固定的,但是解析阶段则是不一定,它在某些情况下可以再初始化之后再开始
类的初始化时机
java虚拟机规范严格规定有且只有5种情况立即对类进行初始化(加载、验证、准备自然需要在之前开始)
- 1、遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先将其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 2、使用java.lang.reflect包的方法对类进行反射调用的时候
- 3、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
- 4、当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 5、当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例解析时,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化
举例分析
举例一
public class SuperClass {
static {
System.out.println("com.peakmain.jvm.SuperClass");
}
public static int value = 234;
}
public class SubClass extends SuperClass {
static {
System.out.println("com.peakmain.jvm.SubClass");
}
}
上述代码结果:
image.png
结论:通过子类引用父类的静态字段,不会导致子类初始化
举例二
修改superClass
public class SuperClass {
static {
System.out.println("com.peakmain.jvm.SuperClass");
}
public static final int value = 234;
}
public class Test {
public static void main(String[] args) {
System.out.println(SuperClass.value);
}
}
运行结果:
image.png
结论:java源码中引用的常量value 在编译阶段通过常量传播优化,已经将常量的值234存储到Test类的常量池,以后Test对常量SuperClass.value的引用实际都转化为Test类对自身常量池的引用
举例三
public class SuperClass {
static {
System.out.println("com.peakmain.jvm.SuperClass");
}
}
public class SubClass extends SuperClass {
static {
System.out.println("com.peakmain.jvm.SubClass");
}
public static int value = 234;
}
public class Test {
static {
System.out.println("Test");
}
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
结果:
image.png
结论:
1、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
2、当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
类初始化字节码分析
上面我们分析了java类的初始化时机,那么我们来看下一个最简单的类的初始化字节码分析
public class Test {
public static void main(String[] args) {
}
}
- 根据上面类初始化时机的第四条,我们知道虚拟机会初始化带main函数的Test类
- javap -v Test.class查看字节码(简单分析类的初始化,具体字节码,后面单独写一篇文章)
Classfile /D:/javaProject/leetcode/out/production/leetcode/com/peakmain/jvm/Test.class
Last modified 2021-6-3; size 389 bytes
MD5 checksum 6200800a2a37017e4a779eaa366b30ec
Compiled from "Test.java"
public class com.peakmain.jvm.Test //类名
minor version: 0 //副版本号
major version: 52 //主版本号
flags: ACC_PUBLIC, ACC_SUPER //访问权限
Constant pool: //常量池
#1 = Methodref #3.#17 // java/lang/Object."<init>":()V
#2 = Class #18 // com/peakmain/jvm/Test
#3 = Class #19 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 Lcom/peakmain/jvm/Test;
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 args
#14 = Utf8 [Ljava/lang/String;
#15 = Utf8 SourceFile
#16 = Utf8 Test.java
#17 = NameAndType #4:#5 // "<init>":()V
#18 = Utf8 com/peakmain/jvm/Test
#19 = Utf8 java/lang/Object
{
public com.peakmain.jvm.Test();//Test的构造函数
descriptor: ()V//返回void
flags: ACC_PUBLIC //访问权限pubilc
Code:
stack=1, locals=1, args_size=1
0: aload_0 //把this装载到操作数栈中
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable://局部变量表
Start Length Slot Name Signature
0 5 0 this Lcom/peakmain/jvm/Test;
public static void main(java.lang.String[]);//main方法
descriptor: ([Ljava/lang/String;)V//返回值void
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 7: 0
LocalVariableTable://局部变量表
Start Length Slot Name Signature
0 1 0 args [Ljava/lang/String;
}
SourceFile: "Test.java"
我们会发现Test和main函数都在栈中
类的加载过程
加载
- 添加jvm参数查看类是否被加载
-XX:+TraceClassLoading
在加载过程,虚拟机需要完成以下三件事情
- 1、通过一个类的全限定名来获取定义此类的二进制字节流。
- 2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
public class Test {
public static void main(String[] args) {
SuperClass[] superClass=new SuperClass[10];
}
}
我们会发现SuperClass类并没有进行初始化,我们直接看字节码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: bipush 10 //常数10推送到操作数栈
2: anewarray #2 // 创建class com/peakmain/jvm/SuperClass的一维数组并添加到栈顶(创建的new对象在堆中)
5: astore_1 //将操作数栈中栈顶元素(这里就是superclass)放到局部变量表下表1的位置
6: return
LineNumberTable:
line 6: 0
line 7: 6
LocalVariableTable://局部变量表
Start Length Slot Name Signature
0 7 0 args [Ljava/lang/String;
6 1 1 superClass [Lcom/peakmain/jvm/SuperClass;
我们发现它只是加载SuperClass,并没有进行初始化
image.png
验证
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
准备
- 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
- 这个阶段中有两个容易产生混淆的概念需要强调一下
- 这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
解析
- 解析阶段是虚拟机将常量池的符号引用替换为直接引用的过程
- 符号引用:符号引用与虚拟机的内存布局无关,引用的目标不一定加载到内存中。在java中,一个java类将会编译成一个class文件,在编译时,java类并不知道所引用类的实际地址,因此只能用符号代替
- 直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引 用,那引用的目标必定已经被加载入内存中了。
初始化
初始化阶段实际就是类构造器<cinit>()方法的过程
- <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
- 类构造器<clinit>()方法与类的构造器<init>()方法不同,虚拟机会保证在子类的<clinit>()方法之前执行,因此,在虚拟机中第一 个被执行的<clinit>()方法的类肯定是 Object.
- 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
- 类构造器<clinit>()方法对于类或者接口来说并不是必需的,如果一个类中没有静态语句块,编译器就不会为这个类生成 <clinit>()方法.
- 接口中也可以定义 static 变量,生成的<clinit>()方法不需要先执行父接口中的<clinit>()方法,同理,接口的实现类在初始化的时 候也一样不会执行接口中的<clinit>()方法.
- 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁,同步,如果多线程同时去初始化一个类,只会有一个线程去 初始化,其他线程都阻塞.
面试题分析
第一种情况
class SingleTon {
public static int count1;
public static int count2 = 0;
private static SingleTon singleTon=new SingleTon();
private SingleTon(){
count1++;
count2++;
}
public static SingleTon getInstance(){
return singleTon;
}
}
public class Test {
public static void main(String[] args) {
SingleTon.getInstance();
System.out.println(SingleTon.count1);//1
System.out.println(SingleTon.count2);//1
}
}
首先连接状态,静态变量初始化:count1=0,count2=0,singleTon=null
初始化阶段:从上到下执行赋值和静态代码块:count1=0,count2=0,创建对象后count1=1,count2=1
第二种情况
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.getInstance();
System.out.println(SingleTon.count1);//1
System.out.println(SingleTon.count2);//0
}
}
首先连接状态,静态变量初始化:singleTon=null,count1=0,count2=0
初始化阶段:从上到下执行赋值和静态代码块:先初始化对象,count1=1,count2=1,之后再赋值,count1没有赋值所以还是1,count2重新赋值变成0
参考文献
- 深入理解java虚拟机:JVM高级特性与最佳实践