Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

深入理解JVM类加载机制 #3

Open
bitfishxyz opened this issue Jul 1, 2019 · 1 comment
Open

深入理解JVM类加载机制 #3

bitfishxyz opened this issue Jul 1, 2019 · 1 comment
Labels
JVM Java虚拟机相关知识点

Comments

@bitfishxyz
Copy link
Member

bitfishxyz commented Jul 1, 2019

深入理解JVM类加载机制

首先我们来看一看这样一个面试题:

public class Main {

    public static void main(String[] args) {
        System.out.println("father's age is: " + Son.factor);
    }
}

class Grandpa
{
    static {
        System.out.println("Grandpa's static code block");
    }
}
class Father extends Grandpa
{
    static {
        System.out.println("Father's static code block");
    }

    public static int factor = 25;

    public Father() {
        System.out.println("I am father~");
    }
}
class Son extends Father
{
    static {
        System.out.println("Son's static code block");
    }

    public Son()
    {
        System.out.println("I am son~");
    }
}

请问: 运行上述程序后,控制台会输入哪些内容?

其实,这一题考察的是Java的类加载机制。Java虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

上述题目的结果就是:

Grandpa's static code block
Father's static code block
father's age is: 25

你回答对了吗?如果你对Java加载机制不理解,那么你是无法解答这道题目的。

所以这篇文章,我先带大家学习Java类加载的基础知识,然后再实战分析几道题目让大家掌握思路。

Java类加载的七个阶段

当我们的Java代码编译完成后,会生成对应的 class 文件。接着我们执行程序的时候,我们其实是启动了JVM 虚拟机执行 class 字节码文件的内容。而 JVM 虚拟机执行 class 字节码的过程可以分为七个阶段:加载Loading、验证Verification、准备Preparation、解析Resolution、初始化Initialization、使用Using、卸载Unloading。

这些步骤总体上是按照顺序进行的,但是Java语言本身支持运行时绑定,所以解析阶段也可以是在初始化之后进行的。以上顺序都只是说开始的顺序,实际过程中是交叉进行的,加载过程中可能就已经开始验证了。

但是JVM会在什么时机加载一个class文件呢?
答案是在需要对类进行初始化的时候,有五种情况会触发类的初始化(后面会说)。当然在进行初始化之前,需要先加载Loading、验证Verification、准备Preparation、解析Resolution,所以我们从加载开始,按顺序讲解。

加载

一般来说加载分为以下几步:

  • 通过一个类的全限定名获取此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

其实加载阶段用一句话来说就是:把代码数据加载到内存中。

验证

验证用于确保类或接口的二进制表示结构上是正确的,从而确保字节流包含的信息对虚拟机来说是安全的。Java虚拟机规范中关于验证阶段的规则也是在不断增加的,但大体上会完成下面的验证动作。

1 . 文件格式验证:主要验证字节流是否符合Class文件格式规范,并且能被当前版本的虚拟机处理。主要验证点:

  • 是否以魔数0xCAFEBABE开头
  • 主次版本号是否在当前虚拟机处理范围之内
  • 常量池的常量是否有不被支持的类型 (检查常量tag标志)
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据
  • Class文件中各个部分及文件本身是否有被删除的或者附加的其他信息

2 . 元数据验证:主要对字节码描述的信息进行语义分析,以保证其提供的信息符合Java语言规范的要求。主要验证点:

  • 该类是否有父类(只有Object对象没有父类,其余都有)
  • 该类是否继承了不允许被继承的类(被final修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,出现不符合规则的方法重载,例如方法参数都一致,但是返回值类型却不同)

3 . 字节码验证:主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,字节码验证将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。主要有:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似的情况:操作数栈里的一个int数据,但是使用时却当做long类型加载到本地变量中
  • 保证跳转不会跳到方法体以外的字节码指令上
  • 保证方法体内的类型转换是合法的。例如子类赋值给父类是合法的,但是父类赋值给子类或者其它毫无继承关系的类型,则是不合法的。
  1. 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段解析阶段发生。符号引用是对类自身以外(常量池中的各种符号引用)的信息进行匹配校验。通常有:
  • 符号引用中通过字符串描述的全限定名是否找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  • 符号引用中的类、方法、字段的访问性(private,public,protected、default)是否可被当前类访问符号引用验证的目的是确保解析动作能够正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

准备

当完成字节码文件的校验之后,JVM 便会开始为类变量分配内存并初始化。这里需要注意两个关键点,即内存分配的对象以及初始化的类型。

Java 中的变量有「类变量」和「类成员变量」两种类型,「类变量」指的是被 static 修饰的变量,而其他所有类型的变量都属于「类成员变量」。在准备阶段,JVM 只会为「类变量」分配内存,而不会为「类成员变量」分配内存。「类成员变量」的内存分配需要等到初始化阶段才开始。
例如下面的代码在准备阶段,只会为 factor 属性分配内存,而不会为 website 属性分配内存。

public static int factor = 3;
public String website = "www.github.com/codeman-cs";

在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值。
例如下面的代码在准备阶段之后,sector 的值将是 0,而不是 3。

public static int sector = 3;

但如果一个变量是常量(被 final 修饰)的话,那么在准备阶段,属性便会被赋予用户希望的值。例如下面的代码在准备阶段之后,number 的值将是 3,而不是 0。

public static final int number = 3;

之所以 static final 会直接被复制,而 static 变量会被赋予零值。其实我们稍微思考一下就能想明白了。

两个语句的区别是一个有 final 关键字修饰,另外一个没有。而 final 关键字在 Java 中代表不可改变的意思,意思就是说 number 的值一旦赋值就不会在改变了。既然一旦赋值就不会再改变,那么就必须一开始就给其赋予用户想要的值,因此被 final 修饰的类变量在准备阶段就会被赋予想要的值。而没有被 final 修饰的类变量,其可能在初始化阶段或者运行阶段发生变化,所以就没有必要在准备阶段对它赋予用户想要的值。

解析

当通过准备阶段之后,JVM 针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类引用进行解析。这个阶段的主要任务是将其在常量池中的符号引用替换成直接其在内存中的直接引用。

其实这个阶段对于我们来说也是几乎透明的,了解一下就好。

初始化(重点)

到了初始化阶段,用户定义的 Java 程序代码才真正开始执行。在这个阶段,JVM 会根据语句执行顺序对类对象进行初始化,一般来说当 JVM 遇到下面 5 种情况的时候会触发初始化:

  • 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。

    生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

  • 当使用 JDK1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle实例最后的解析结果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

好了,那么在初始化阶段究竟会执行哪些代码呢?

这里要说两个概念:类初始化方法和对象初始化方法。

  • 类初始化方法

    编译器会按照其出现顺序,收集类变量的赋值语句和静态代码块,最终组成类初始化方法。

  • 对象初始化方法

    编译器会按照其出现顺序,收集成员变量的赋值语句、普通代码块,最后收集构造函数的代码,最终组成对象初始化方法。

在当前这个阶段,只会执行类初始化方法,不会执行对象初始化方法。对象初始化方法会在实例化一个对象的时候调用。

使用

当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码。

卸载

当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存。

示例

看完了Java的类加载机制之后,是不是有点懵呢。不怕,我们先通过一个小例子来醒醒神。

public class Monkey {
    int age = 100;

    Monkey() {
        System.out.println("monkey's construct");
        System.out.println("age=" + age +", amount=" + amount);
    }

    {
        System.out.println("Monkey's normal code block");
    }

    static {
        System.out.println("Monkey's static code block");
    }

    static int amount = 112;

    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}

思考一下上面这段代码输出什么?

怎么样,想好了吗,公布答案了。

Monkey's static code block
Hello World

下面我们来简单分析一下,首先根据上面说到的触发初始化的5种情况的第4种(当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类),我们会进行类的初始化。

那么类的初始化顺序到底是怎么样的呢?

在我们代码中,我们只知道有一个构造方法,但实际上Java代码编译成字节码之后,是没有构造方法的概念的,只有类初始化方法对象初始化方法

上面的类的类初始化方法就是下面的内容:

    static {
        System.out.println("Monkey's static code block");
    }

    static int amount = 112;

下面就是上面的Monkey类的类对象初始化方法:

    int age = 100;

    {
        System.out.println("Monkey's normal code block");
    }

    System.out.println("monkey's construct");
    System.out.println("age=" + age +", amount=" + amount);

上面的代码中,先执行Monkey的类初始化方法,打印出了Monkey's static code block,然后执行我们的main函数。而上面的这个例子其实没有执行对象初始化方法。因为我们确实没有进行 Monkey 类对象的实例化。如果你在 main 方法中增加 new Monkey() 语句,你会发现对象的初始化方法执行了!

感兴趣的朋友可以自己动手试一下,我这里就不执行了。

回到我们的面试题

现在我们再来看看我们最开始的面试题。

对于上面的这个例子,我们可以从入口开始分析一路分析下去:

首先程序到 main 方法这里,使用标准化输出 Son 类中的 factor 类成员变量,但是 Son 类中并没有定义这个类成员变量。于是往父类去找,我们在 Father 类中找到了对应的类成员变量,于是触发了 Father 的初始化。

但根据我们上面说到的初始化的 5 种情况中的第 3 种: 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。我们需要先初始化 Father 类的父类,也就是先初始化 Grandpa 类再初始化 Father 类。

于是我们先初始化 Grandpa 类输出:「Grandpa's static code block」,再初始化 Father 类输出:「Father's static code block」。

最后,所有父类都初始化完成之后,Son 类才能调用父类的静态变量,从而输出:「father's age is: 25」。

也许会有人问为什么没有输出「Son's static code block」这个字符串?

因为factor这个字段其实是父类的,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

不过注意了,虽然Son没有被初始化,但是它还是内加载了。

总结

有五种情况会导致JVM尝试初始化一个类,而在初始化一个类之前有加载、验证、准备和解析的过程。

初始化过程中有两种初始化方法:类初始化方法和对象初始化方法。

静态成员变量的赋值和静态代码块属于类初始化方法。

成员变量的赋值、普通代码块和构造函数的代码属于对象初始化方法。

参考链接

https://www.cnblogs.com/chanshuyi/p/the_java_class_load_mechamism.html

@bitfishxyz bitfishxyz added the JVM Java虚拟机相关知识点 label Jul 1, 2019
@bitfishxyz
Copy link
Member Author

bitfishxyz commented Jul 1, 2019

引用一个静态常量

再来一个示例

public class FinalMain {
    public static void main(String[] args) {
        System.out.println(A.str);
        System.out.println(B.str);
    }
}

class A{
    public static String str = "A";

    static {
        System.out.println("Class A is initialized");
    }
}

class B{
    public static final String str = "B";

    static {
        System.out.println("Class B is initialized");
    }
}

运行main函数后,打印的结果是?

Class A is initialized
A
B

应为main函数使用到了类A的静态变量,所以我们需要先初始化类A。

而因为B的字符串是一个常量,编译阶段会被放到调用这个常量的方法所在的类的常量池中,这里就是FinalMain。调用类并没有直接引用到B类,所以没有触发B的初始化。
也就是编译完成后,FinalMain和B以及没有任何关系了。这是编译阶段的优化,知道就行了。

其实这里反编译一下类FinalMain看一下就明白了。

public class FinalMain {
    public FinalMain() {
    }

    public static void main(String[] args) {
        System.out.println(A.str);
        System.out.println("B");
    }
}

可以看到,编译完成后,FinalMain并没有引用类B。

数组

public class ThisMain {
    public static void main(String[] args) {
        A[] arr = new A[10];
    }
}

class A{
    public static String str = "A";

    static {
        System.out.println("Class A is initialized");
    }
}

执行上面的代码后,输出是什么?

这里我们new的是一个数组类型,并没有触发类A的初始化,所以什么都没有打印。

接口

接口初始化的时候并不要求父类初始化,只有在真正用到的时候才需要初始化。这是和一般的Java类不一样的地方。

赋值顺序

public class ThisMain {
    public static void main(String[] args) {
        A.getInstance();
        System.out.println("value1: " + A.value1);
        System.out.println("value2: " + A.value2);
    }
}

class A{
    public static int value1;

    private A(){
        value1++;
        value2++;
    }
    public static A a = new A();
    public static int value2 = 0;

    public static A getInstance(){
        return a;
    }
}

上面的代码的结果是?

类初始化有两个阶段:

  • 给类变量赋零值
  • 执行类初始化方法

上面的实际顺序是

// 零值阶段
value1 = 0;
value2 = 0;

// 然后new A()
value1++;
value2++;

// 然后第二个赋值语句
value2 = 0;

所以结果为

value1: 1
value2: 0

@bitfishxyz bitfishxyz reopened this Jul 3, 2019
This was referenced Jul 9, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
JVM Java虚拟机相关知识点
Projects
None yet
Development

No branches or pull requests

1 participant