写点什么

还不了解 static ?年轻人,劝你耗子尾汁...

发布于: 5 小时前
还不了解 static ?年轻人,劝你耗子尾汁...

1. 引言

Java 的 static 关键字大家应该都不陌生,网上也有很多介绍 static 的文章,笔者认为很多文章并没有从源头知识上对其进行介绍,反而让读者阅读完以后变的更加迷惑。 本文带大家从虚拟机类加载机制角度详细认识一下 static。在正式介绍 static 之前,我们先看看两个小例子。

1.1. 案例一

思考下面的代码输出什么:

结果如下:

看完文章后点开 展开源码

1.2. 案例二

思考下面的代码又输出什么:

结果如下:

看完文章后点开 展开源码

看完上面两个案例,你是否能准确理清案例的输出结果呢?尤其是案例二,如果不了解类加载机制、字节码等相关知识,很可能会陷入思绪混乱的状态。

这两个案例是非常经典的 Java static 笔试题,网上也有很多关于这方面的介绍。但是,笔者认为网上的资料讲解的不够全面。本文将带大家一起从源头上剖析上面的例子,彻底搞明白这个知识点。

2. Java 虚拟机类加载机制

此部分的内容来源于周志明老师的《深入理解 Java 虚拟机》[1] 这本书。

2.1. 加载时机

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期如图 2-1 所示的七个阶段:

图 2-1 一个类的生命周期

对于初始化阶段,《Java 虚拟机规范》[2] 严格规定有且只有遇到下面六种情况必须对类立即初始化(而加载、验证、准备自然需要在此之前开始):

  1. 遇到 newgetstaticputstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令最常见的 Java 代码场景是:

  • 使用 new 关键字实例化对象;

  • 读取或者设置一个类的静态字段(被 final 修饰、已在编译器把结果放入常量池的静态字段除外);

  • 调用一个类的静态方法。

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

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

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

  4. 当使用 JDK7 新加入的动态语言(invokedynamic)时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_newInvokeSpecial、REF_invokeStatic 这四种类型的方法句柄,并且方法句柄对应类没有进行过初始化,则需要先触发其初始化;

  5. 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那么接口要在其之前被初始化。

6.当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那么接口要在其之前被初始化。

2.2. 加载过程

上一节中我们提到了一个类加载到内存以及卸载出内存的整个生命周期,并且介绍了类的六种加载时机,类的加载时机对我们理解 static 是很关键的。接下来我们再简单介绍一下类加载的几个主要过程,其中类加载过程中的准备和初始化阶段都跟 static 有关,需要我们关注一下。

2.2.1. 加载(Loading)

首先要说明的是 “加载”(Loading)只是 “类加载”(Class Loading)过程的一个阶段,不要混淆了这两个概念。在加载阶段,虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流;

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;

  3. 在 java 堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

这里有几个注意点:

  • 《Java 虚拟机规范》[2] 中对这三点要求其实并不是特别具体。例如:对于第一条而言,开发人员可以通过定义自己的类加载器来完成(通过重写一个类加载器的 loadClass() 方法),可以实现从 jar、zip、war 等压缩包中读取,也可以从网络中获取;

  • 方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量等,因此加载阶段会将类的结构信息存储在这里。

2.2.2. 验证(Verification)

这一阶段是验证 Class 文件的字节流中包含的信息是否符合《Java 虚拟机规范》[2] 的全部约束要求,从而保证这些信息不会危害虚拟机自身的安全。大致会完成下面四个校验动作:

  1. 文件格式校验 这一阶段主要是验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。例如:是否以魔数 0xCAFEBABE 开头、主次版本号是否在虚拟机介绍范围等。

  2. 元数据验证 这一阶段主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。例如:这个类是否有父类、这个类的父类是不是 final 类型的、如果这个类不是抽象类,那么是否实现了父类或接口中要求实现的方法等,总的来说就是验证语法是否正确。

  3. 字节码验证 这一阶段是整个验证阶段最复杂的,主要工作是进行数据流和控制流分析。在第二阶段对元数据信息中的数据类型做完校验后,这阶段将对类的方法体进行校验分析。这阶段的任务是保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。

  4. 符号引用验证 主要是在虚拟机将符号引用转化为直接引用时进行校验,这个转化动作是发生在解析阶段。符号引用可以看作是对类自身以外(常量池的各种符号引用)的信息进行匹配性的校验。

验证阶段对于虚拟机的类加载机制来说,是一个非常重要但不一定是必要的阶段。如果所运行的全部代码都已经被反复使用和验证过,在实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,从而缩短虚拟机类加载的时间。

2.2.3. 准备(Preparation)

准备阶段是正式为类中定义的变量(即 static 变量)分配内存并设置类变量的初始值阶段。例如下面定义的类中的变量:

在初始化阶段结束后其值为 0,而不是 1,因为这个时候还未执行任何 Java 方法。那么有没有办法在准备阶段为 value 赋值呢?答案是有的,可以将其定义为常量类型。因为定义成常量后它的信息会被存储在字段属性表中的 ConstantValue 属性中,这也是为什么你使用 ASM 创建类时初始化成员变量,有的可以直接赋值,有的不能赋值。修改后如下:

ASM 创建成员变量实例:

那么在类中定义的成员变量是在什么时候进行赋值的呢?我们后面会介绍。

2.2.4. 解析(Resolution)

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

2.2.5. 初始化(Initialization)

前面我们介绍了类初始化的六个时机,下面再进一步介绍下初始化阶段做了哪些事情。

在准备阶段,类变量已经赋值过一次系统要求的初始零值。而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。简单来说,就是初始化阶段执行类构造器 <clinit>() 方法的过程。那么什么是类构造方法呢?与其相似的还有一个实例构造方法 <init>(),下面一起介绍:

  • 类构造器 <clinit>() 方法:是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。在初始化阶段会调用一次,因此只加载一次;

  • 实例构造 <init>() 方法: 是实例创建出来的时候调用,包括调用 new 操作符、调用 Class 或 java.lang.reflect.Constructor 对象的 newInstance() 方法、调用任何现有对象的 clone() 方法以及通过 java.io.ObjectInputStream 类的 getObject() 方法反序列化。

注意:类构造器方法和实例构造方法不同,它不需要调用父类构造方法,Java 虚拟机会保证子类的 <clint>() 方法执行前,父类的 <clint>() 方法已经执行完毕

至此,我们了解了类加载的时机、类加载的过程,可以得出如下结论:

  1. 在类的初始化时机中,当初始化一个类时,如果发现其父类还没有进行过初始化,则需要触发父类的初始化;

  2. 在准备阶段,会给类变量赋零值;

  3. 在初始化阶段 JVM 会调用类构造器方法,并且 Java 虚拟机会保证子类的类构造方法执行前,父类的类构造方法已经执行完毕,这一点也是我们理解 static 的关键;

  4. 编译期会收集静态代码块,其顺序是按照在源码中定义的顺序决定的,并最终生成一个类构造器方法。

这些都是跟 static 相关的知识点,接下来我们再看一下 Java 成员变量在字节码中是如何初始化的。

3. 字节码剖析

下面举一个例子来说明,先看看下面这个例子:

对于上面的这些成员变量在字节码中是如何体现的呢,来看下对应的字节码:

观察字节码,我们可以得出如下信息:

  1. 首先我们可以看到 41 行的构造方法中在开始的位置(即 47 行代码)调用了父类中的无参构造方法,这个是编译器自动加上的,就算没有在 Test 的构造方法中调用 super();

  2. 在调用了父类的无参构造方法后,可以看到非静态成员变量 la(50 行)、date(55 行)以及在源码尾部定义的 level(58 行)依次进行了初始化;

  3. 除了常量 age 外,其他 static 变量以及静态代码块按照在源码中定义的顺序会合并到了类构造器方法中(95 行),类构造器方法会在类初始化阶段调用;

  4. 类中定义的非 static 变量的初始化操作是在实例构造函数中进行的,跟定义顺序有关系,不过一定是在构造方法中的源码执行之前;

  5. 实例构造方法会隐式调用父类的构造方法。

现在我们得到了如上的结论,再来分析一下文章开始的两个例子。直接看第二个例子,根据上面的结论,按照字节码中的代码执行顺序修改代码:

现在我们来分析上面代码执行的流程:

  • 首先我们运行 Student 类中的 main 方法。根据前面介绍的类加载时机,我们知道这个类有一个 main 方法,是一个入口类,会立即初始化 Student 类。在类初始化阶段会先调用类构造器方法 <clinit>(),JVM 会保证子类的 <clinit>() 方法执行之前先执行完父类的 <clint>() 方法,会先调用父类 Person 中的静态代码块。因此,Person 直接执行静态代码块,输出:person static;

  • 上一步输出父类的 static 代码块后,会调用自己的静态代码块输出:student static。至此,JVM 初始化了 Student 和 Person 这两个类;

  • 然后执行 Student 的 main 方法。在此方法中出现了 new,按照我们前面介绍的类加载时机,遇到 new 会立刻初始化该类。又因为 Student 类已经初始化过了,所以不需要再初始化了,然后执行 Student 的构造方法;

  • 在执行 Student 构造方法时,会隐式调用父类的构造方法。调用父类构造方法结束后会给非 static 成员变量进行初始化操作,按照这个顺序继续往下,我们先看调用父类构造方法;

  • 在 Student 的构造方法中调用父类 Person 的构造方法,Person 的构造方法中也会隐式调用父类的构造方法。因为它的父类是 Object,所以没有内容输出。接着会给 Person 中的 food 变量进行初始化,当遇到 new 的时候,JVM 马上去初始化 Food 这个类。因此,会先调用 Food 的静态代码块,输出:food static;

  • 在 Food 类初始化完毕后,继续调用 Food 的构造方法,这个时候输出:person's food。再接着调用 Person 构造方法中的其他代码,输出:person constructor;

  • 至此,Person 的构造函数执行完毕后,又回到 Student 的构造方法继续执行。此时又遇到了 new Food,因为 Food 类已经初始化过了,所以这里直接调用它的构造方法,输出:Student's food;

  • 最后,执行 Student 构造方法中其余代码,输出:student constructor。

经过上述分析,就得到了例子中的最终结果。

4. 总结

本文从 static 这个例子着手,给大家分析其背后的知识。在了解了这些知识后,再遇到此类问题就可以保持一个清晰的思路去分析问题。同时,这也是了解类加载机制的一个机会。

最后,欢迎加入开源社区一起讨论,期待得到大家的指点。

5. 参考文献

[1] 周志明.深入理解 Java 虚拟机[M]. 北京:机械工业出版社,2020.

[2] [美]蒂姆·林霍尔姆(Tim,Lindholm),弗兰克·耶林(Frank Yellin),吉拉德·布拉查(Glad Bracha),亚历史斯·巴克利(Alex Buckley)著. Java 虚拟机规范[M]. 爱飞翔,周志明等译. 北京:机械工业出版社,2019.


文章来源:神策技术社区


发布于: 5 小时前阅读数: 5
用户头像

公众号:神策技术社区 2021.03.09 加入

公众号:神策技术社区

评论

发布
暂无评论
还不了解 static ?年轻人,劝你耗子尾汁...