前言


上一篇文章简单学习了Class文件的结构,这次看看虚拟机如何加载Class文件,并且在加载过后做什么额外的处理

 

Class文件的装载流程


  Class文件的装载流程可以分为加载,连接和初始化 3 步,其中连接又分为验证,准备和解析 3 步,整体流程如下图

java两个互相依赖怎么解决_初始化

 

1. 类装载的条件

  Class 文件只有在必须使用的时候才装载,Java虚拟机规定,一个类或接口在初次使用前,必须进行初始化。这里的使用是指主动使用,只有下列的情况才会主动使用

  • 创建一个类的实例时,比如使用new关键字或反射、克隆、反序列化
  • 调用类的静态方法时,即使用了字节码的invokestatic指令
  • 使用类或接口的静态字段时(final常量除外),比如使用getstatic或putstatic指令
  • 使用java.lang.reflect包中的方法反射类的方法时
  • 初始化子类时,要求先初始化父类
  • 作为启动虚拟机,含有main方法的那个类

除了以上的情况属于主动使用,其余都是被动使用,被动使用不会引起类的初始化

主动使用容易理解,下面举一个被动使用的例子方便记忆

public class Parent {
    static{
        System.out.println("Parent init");
    }
    public static int v = 100;
}

public class Child extends Parent{
    static {
        System.out.println("Child init");
    }
}

public class Test1 {
    public static void main(String[] args) {
        System.out.println(Child.v);
    }
}

运行以上代码的输出如下

Parent init
100

虽然在Test1中直接访问了子类对象,但是Child并没有被初始化,只有Parent被初始化,由此可见,在使用一个字段时,只有直接定义了该字段的类才会被初始化(虽然此处Child没有被初始化,但是它已经被加载,只是没有进入初始化阶段)

使用-XX:+TraceClassLoading运行这段代码,可以得到下列日志(仅截取小部分)

[Loaded com.xxxx.Parent from file:/D:/ideaWorkSpace/xxxx/target/classes/]
[Loaded com.xxxx.Child from file:/D:/ideaWorkSpace/xxxx/target/classes/]
Parent init
100

可以看到两个类都已经被加载到系统

 

2. 加载类

  加载类处于类装载的第一个阶段。加载类时,虚拟机会完成以下工作:

  • 通过类的全名获取类的二进制数据流
  • 解析类的二进制数据流为方法区内的数据结构
  • 创建java.lang.Class类的实例,表示该类型

  类的二进制数据流可以从文件系统中的Class文件,也可以是zip等文件中提取类文件,也可以从网络加载,甚至在运行时生成一段Class的二进制信息。获取二进制信息后,Java虚拟机会处理这些数据并最终转为一个java.lang.Class的实例,Class实例是访问类型元数据的接口,也是实现反射的关键数据。

 

3. 验证类

  类加载到系统后就开始连接操作,验证是连接的第一步,目的是保证加载的字节码是合法、合理且符合规范的,验证的步骤比较复杂,大体上需要做的检查如下图:

java两个互相依赖怎么解决_初始化_02

 

 

4. 准备

  类验证通过时,虚拟机就会进入准备阶段。这个阶段,虚拟机会为这个类分配相应的内存空间,并设置初始值。各类型变量的初始值如下表

各类型变量默认初始值

类型

默认初始值

int

0

long

0L

short

(short)0

char

\u0000

boolean

false

reference

null

float

0f

double

0f

Java并不支持boolean类型,对于boolean实际上是int,由于int默认为0,故boolean默认为false

如果类存在常量字段,那么常量字段也会在准备阶段附上正确的值。

 

5. 解析类

  连接的第三步是解析阶段,解析阶段的工作就是将类、接口、字段和方法的符号引用转为直接引用。符号引用就是一些字面量的引用,与虚拟机内部数据结构与内存布局无关。在Class类文件中,通过常量池进行了大量的符号引用。

invokevirtual #24 <java/io/PrintStream.priintln>

  这个是System.out.println()的字节码,可以看到它使用了常量池的第24项,查看并分析常量池,可以看到如下的结构

java两个互相依赖怎么解决_java两个互相依赖怎么解决_03

 

 

 常量池第24项被invokevirtual引用,顺着CONSTANT_Methodref #24的引用关系查找,最终发现所有对于Class及NameAndType类型的引用都是基于字符串的。因此,可以认为invokevirtual函数调用通过字面量的引用描述已经表达清楚。这就是符号引用。

  程序实际运行时只有符号引用是不够的,当方法被调用时,系统需要明确知道方法的位置。以方法为例,Java虚拟机为每个方法都准备了一张方法表,所有的方法都列在其中,当需要调用一个类的方法时,只要知道这个方法在方法表中的偏移量就可以直接调用。通过解析操作,符号引用可以转变为目标方法在类的方法表中的位置。

  综上所述,解析就是将符号引用转换为直接引用,得到类、字段或方法在内存中的指针或偏移量。

 

6. 初始化

  初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,表示类可以顺利装载到系统中。此时,类才会开始执行Java字节码。初始化阶段的重要工作就是执行类的初始化方法<clinit>。方法<clinit>是由编译器自动生成的,它是由类静态成员的赋值语句及static语句块共同产生的。

public class SimpleDemo {
    public static int id = 1;
    public static int number;
    static {
        number = 4;
    }
}

比如上面这个类,编译后的字节码如下

public class SimpleDemo {
     <ClassVersion=52>
     <SourceFile=SimpleDemo.java>

     public static int id;
     public static int number;

     public SimpleDemo() { // <init> //()V
         <localVar:index=0 , name=this , desc=LSimpleDemo;, sig=null, start=L1, end=L2>

         L1 {
             aload0 // reference to self
             invokespecial java/lang/Object.<init>()V
             return
         }
         L2 {
         }
     }

     static  { // <clinit> //()V
         L1 {
             iconst_1
             putstatic SimpleDemo.id:int
         }
         L2 {
             iconst_4
             putstatic SimpleDemo.number:int
         }
         L3 {
             return
         }
     }
}

可以看到,生成的<clinit>方法中,整合了这个类的static赋值语句和static语句块,先后对id和number进行赋值。

前面提到过加载一个类之前,虚拟机总会尝试加载该类的父类,因此父类的<clinit>方法总在子类的<clinit>方法之前被调用,也就是说,子类的static块是在父类之后执行。

并非所有的类都会产生<clinit>方法,如果一个类没有赋值语句,也没有static语句块,那么编译器就不会为该类生成<clinit>方法。

还有一个重要的点是,虚拟机会确保<clinit>方法执行的线程安全,当多个线程试图初始化同一个类时,只有一个线程可以进入<clinit>方法,如果第一个线程成功执行,则后面的线程就不会再执行该方法了。

也是由于<clinit>是带锁线程安全的,所以在多线程环境下进行类初始化时,可能引发死锁,并且这种死锁很难发现。下面有一个死锁的例子

class StaticA{
    static{
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e){

        }
        try {
            Class.forName("com.blog.test.StaticB");
        } catch (ClassNotFoundException e){

        }
        System.out.println("com.blog.test.StaticA init success");
    }
}

class StaticB {
    static{
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e){

        }
        try {
            Class.forName("com.blog.test.StaticA");
        } catch (ClassNotFoundException e){

        }
        System.out.println("com.blog.test.StaticB init success");
    }
}

public class StaticDeadLockMain extends Thread {
    private char flag;
    public StaticDeadLockMain(char flag){
        this.flag = flag;
        this.setName("Thread"+flag);
    }

    @Override
    public void run() {
        try {
            Class.forName("com.blog.test.Static" + flag);
        } catch (ClassNotFoundException e){
            e.printStackTrace();
        }
        System.out.println(getName() + "over");
    }

    public static void main(String[] args) {
        StaticDeadLockMain loadA = new StaticDeadLockMain('A');
        loadA.start();
        StaticDeadLockMain loadB = new StaticDeadLockMain('B');
        loadB.start();
    }
}

上面的代码简单来说就是一个类在静态代码块中初始化另一个类,形成相互初始化抢占资源。上述代码中执行main方法,main方法一直不停止,也没有输出,通过jstack查看堆栈信息如下

java两个互相依赖怎么解决_初始化_04

 堆栈信息中也没有足够信息可以判断发生死锁,但是死锁确实存在,所以我们在初始化类时,要格外小心这种情况的死锁。