关于前端:还不了解-static-年轻人劝你耗子尾汁

4次阅读

共计 10576 个字符,预计需要花费 27 分钟才能阅读完成。

1. 引言

Java 的 static 关键字大家应该都不生疏,网上也有很多介绍 static 的文章,笔者认为很多文章并没有从源头常识上对其进行介绍,反而让读者浏览完当前变的更加蛊惑。本文带大家从虚拟机类加载机制角度具体认识一下 static。在正式介绍 static 之前,咱们先看看两个小例子。
1.1. 案例一

思考上面的代码输入什么:

class Base{static{ System.out.println(“base static”); } public Base(){ System.out.println(“base constructor”); }} public class Main extends Base{static{ System.out.println(“main static”); } public Main(){ System.out.println(“main constructor”); } public static void main(String[] args) {new Main(); }}

后果如下:
看完文章后点开 开展源码

1.2. 案例二

思考上面的代码又输入什么:

class Food{static{ System.out.println(“food static”); } public Food(String str) {System.out.println(str +”’s food”); }} public class Person {Food foo = new Food(“person”); static{System.out.println(“person static”); } public Person() { System.out.println(“person constructor”); }} class Student extends Person {Food bar = new Food(“Student”); static{System.out.println(“student static”); } public Student() { System.out.println(“student constructor”); } public static void main(String[] args) {new Student(); }}

后果如下:
看完文章后点开 开展源码
看完下面两个案例,你是否能精确理清案例的输入后果呢?尤其是案例二,如果不理解类加载机制、字节码等相干常识,很可能会陷入思路凌乱的状态。
这两个案例是十分经典的 Java static 口试题,网上也有很多对于这方面的介绍。然而,笔者认为网上的材料解说的不够全面。本文将带大家一起从源头上分析下面的例子,彻底搞明确这个知识点。

2. Java 虚拟机类加载机制

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

2.1. 加载机会

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

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

对于初始化阶段,《Java 虚拟机标准》[2] 严格规定有且只有遇到上面六种状况必须对类立刻初始化(而加载、验证、筹备天然须要在此之前开始):
遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则须要先触发其初始化。生成这 4 条指令最常见的 Java 代码场景是:
应用 new 关键字实例化对象;
读取或者设置一个类的动态字段(被 final 润饰、已在编译器把后果放入常量池的动态字段除外);
调用一个类的静态方法。

应用 java.lang.reflect 包的办法对类进行反射调用的时候,如果类没有进行过初始化,则须要先触发其初始化;
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则须要触发父类的初始化;
当虚拟机启动时,用户须要指定一个执行的主类(蕴含 main() 办法的类),虚构机会先初始化这个类;
当应用 JDK7 新退出的动静语言(invokedynamic)时,如果一个 java.lang.invoke.MethodHandle 实例最初的解析后果为 REF_getStatic、REF_putStatic、REF_newInvokeSpecial、REF_invokeStatic 这四种类型的办法句柄,并且办法句柄对应类没有进行过初始化,则须要先触发其初始化;
当一个接口中定义了 JDK8 新退出的默认办法(被 default 关键字润饰的接口办法)时,如果这个接口的实现类产生了初始化,那么接口要在其之前被初始化。
当一个接口中定义了 JDK8 新退出的默认办法(被 default 关键字润饰的接口办法)时,如果这个接口的实现类产生了初始化,那么接口要在其之前被初始化。

2.2. 加载过程

上一节中咱们提到了一个类加载到内存以及卸载出内存的整个生命周期,并且介绍了类的六种加载机会,类的加载机会对咱们了解 static 是很要害的。接下来咱们再简略介绍一下类加载的几个次要过程,其中类加载过程中的筹备和初始化阶段都跟 static 无关,须要咱们关注一下。

2.2.1. 加载(Loading)

首先要阐明的是“加载”(Loading)只是“类加载”(Class Loading)过程的一个阶段,不要混同了这两个概念。在加载阶段,虚拟机须要实现以下三件事件:
通过一个类的全限定名来获取定义此类的二进制字节流;
将这个字节流所代表的动态存储构造转化为办法区的运行时数据结构;
在 java 堆中生成一个代表这个类的 java.lang.Class 对象,作为办法区这个类的各种数据的拜访入口。
这里有几个留神点:
《Java 虚拟机标准》[2] 中对这三点要求其实并不是特地具体。例如:对于第一条而言,开发人员能够通过定义本人的类加载器来实现(通过重写一个类加载器的 loadClass() 办法),能够实现从 jar、zip、war 等压缩包中读取,也能够从网络中获取;
办法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、动态变量等,因而加载阶段会将类的构造信息存储在这里。

2.2.2. 验证(Verification)

这一阶段是验证 Class 文件的字节流中蕴含的信息是否合乎《Java 虚拟机标准》[2] 的全副束缚要求,从而保障这些信息不会危害虚拟机本身的平安。大抵会实现上面四个校验动作:

文件格式校验

这一阶段次要是验证字节流是否合乎 Class 文件格式的标准,并且能被以后版本的虚拟机解决。例如:是否以魔数 0xCAFEBABE 结尾、主次版本号是否在虚拟机介绍范畴等。

元数据验证

这一阶段次要是对字节码形容的信息进行语义剖析,以保障其形容的信息合乎 Java 语言标准的要求。例如:这个类是否有父类、这个类的父类是不是 final 类型的、如果这个类不是抽象类,那么是否实现了父类或接口中要求实现的办法等,总的来说就是验证语法是否正确。

字节码验证

这一阶段是整个验证阶段最简单的,次要工作是进行数据流和控制流剖析。在第二阶段对元数据信息中的数据类型做完校验后,这阶段将对类的办法体进行校验剖析。这阶段的工作是保障被校验类的办法在运行时不会做出危害虚拟机平安的行为。

符号援用验证

次要是在虚拟机将符号援用转化为间接援用时进行校验,这个转化动作是产生在解析阶段。符号援用能够看作是对类本身以外(常量池的各种符号援用)的信息进行匹配性的校验。
验证阶段对于虚拟机的类加载机制来说,是一个十分重要但不肯定是必要的阶段。如果所运行的全副代码都曾经被重复应用和验证过,在施行阶段就能够思考应用 -Xverify:none 参数来敞开大部分的类验证措施,从而缩短虚拟机类加载的工夫。

2.2.3. 筹备(Preparation)

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

public static int value = 1;

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

public static final int value = 1;

ASM 创立成员变量实例:

// 应用 ASM 创立一个类 public static void testCheckClassAdapterStyle() throws IOException { ClassWriter cw = new ClassWriter(0); cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC , “cn/curious/asm/FooBar”, null, “java/lang/Object”, new String[]{}); cw.visitField(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC + Opcodes.ACC_FINAL, “LESS”, Type.INT_TYPE.getDescriptor(), null, -1).visitEnd();//final 类型设置初始值为 -1 cw.visitField(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, “EQUAL”, Type.INT_TYPE.getDescriptor(), null, 99).visitEnd();// 非 final 类型设置初始值为 99 cw.visitEnd(); byte[] b = cw.toByteArray(); // 输入到文件中 File file = new File(FILE_PATH_PREFIX + File.separator + “FooBar.class”); FileUtils.writeByteArrayToFile(file, b);}// 输入后果 public class FooBar {public static final int LESS = -1; public static int EQUAL;}

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

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>() 办法曾经执行结束。
至此,咱们理解了类加载的机会、类加载的过程,能够得出如下论断:
在类的初始化机会中,当初始化一个类时,如果发现其父类还没有进行过初始化,则须要触发父类的初始化;
在筹备阶段,会给类变量赋零值;
在初始化阶段 JVM 会调用类结构器办法,并且 Java 虚构机会保障子类的类构造方法执行前,父类的类构造方法曾经执行结束,这一点也是咱们了解 static 的要害;
编译期会收集动态代码块,其程序是依照在源码中定义的程序决定的,并最终生成一个类结构器办法。
这些都是跟 static 相干的知识点,接下来咱们再看一下 Java 成员变量在字节码中是如何初始化的。

3. 字节码分析

上面举一个例子来阐明,先看看上面这个例子:

public class Test {private static final int age = 99;// 类变量,常量 private float pi;// 成员变量,未初始化 private long la = 9999;// 成员变量,已初始化 private final Date date = new Date();// 非 static 类型 private static List<String> list = new ArrayList<>();//static 类型 // 动态代码块 static { System.out.println(“static part one”); } // 动态代码块 static {System.out.println(“static part two”); } // 无参构造方法 public Test(){ System.out.println(“test constructor”); } // 构造方法 public Test(String sr){} public static void main(String[] args) {new Test(); } private static int name = 10;// 动态成员变量 private int level = 100;// 非动态成员变量}

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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
// 字节码信息 public class cn.curious.asm.Test minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPERConstant pool: #1 = Methodref #20.#47 // java/lang/Object.”<init>”:()V //… 省略 #68 = Utf8 println{ private static final int age; descriptor: I flags: ACC_PRIVATE, ACC_STATIC, ACC_FINAL ConstantValue: int 99 // 根本类型的常量会保留在 constantValue 属性中 private float pi; descriptor: F flags: ACC_PRIVATE private long la; descriptor: J flags: ACC_PRIVATE private final java.util.Date date; descriptor: Ljava/util/Date; flags: ACC_PRIVATE, ACC_FINAL private static java.util.List<java.lang.String> list; descriptor: Ljava/util/List; flags: ACC_PRIVATE, ACC_STATIC Signature: #34 // Ljava/util/List<Ljava/lang/String;>; private static int name; descriptor: I flags: ACC_PRIVATE, ACC_STATIC private int level; descriptor: I flags: ACC_PRIVATE public cn.curious.asm.Test();// 无参构造方法 descriptor: ()V flags: ACC_PUBLIC Code: stack=3, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object.”<init>”:()V// 隐式调用父类构造方法 4: aload_0 5: ldc2_w #2 // long 9999l 8: putfield #4 // Field la:J 11: aload_0 12: new #5 // class java/util/Date 15: dup 16: invokespecial #6 // Method java/util/Date.”<init>”:()V 19: putfield #7 // Field date:Ljava/util/Date; 22: aload_0 23: bipush 100 25: putfield #8 // Field level:I// 初始化 level 28: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream; 31: ldc #10 // String test constructor 33: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 36: return public cn.curious.asm.Test(java.lang.String); descriptor: (Ljava/lang/String;)V flags: ACC_PUBLIC Code: stack=3, locals=2, args_size=2 0: aload_0 1: invokespecial #1 // Method java/lang/Object.”<init>”:()V 4: aload_0 5: ldc2_w #2 // long 9999l 8: putfield #4 // Field la:J 11: aload_0 12: new #5 // class java/util/Date 15: dup 16: invokespecial #6 // Method java/util/Date.”<init>”:()V 19: putfield #7 // Field date:Ljava/util/Date; /// 初始化 date 22: aload_0 23: bipush 100 25: putfield #8 // Field level:I 28: return public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 0: new #12 // class cn/curious/asm/Test 3: dup 4: invokespecial #13 // Method “<init>”:()V 7: pop 8: return static {};//<clinit> 类结构器 descriptor: ()V flags: ACC_STATIC Code: stack=2, locals=0, args_size=0 0: new #14 // class java/util/ArrayList 3: dup 4: invokespecial #15 // Method java/util/ArrayList.”<init>”:()V 7: putstatic #16 // Field list:Ljava/util/List;// 初始化 list 10: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream; 13: ldc #17 // String static part one // 动态代码块中代码 15: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 18: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream; 21: ldc #18 // String static part two // 动态代码块中代码 23: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 26: bipush 10 28: putstatic #19 // Field name:I // 写在类开端的 name 31: return}SourceFile: “Test.java”

察看字节码,咱们能够得出如下信息:
首先咱们能够看到 41 行的构造方法中在开始的地位(即 47 行代码)调用了父类中的无参构造方法,这个是编译器主动加上的,就算没有在 Test 的构造方法中调用 super();
在调用了父类的无参构造方法后,能够看到非动态成员变量 la(50 行)、date(55 行)以及在源码尾部定义的 level(58 行)顺次进行了初始化;
除了常量 age 外,其余 static 变量以及动态代码块依照在源码中定义的程序会合并到了类结构器办法中(95 行),类结构器办法会在类初始化阶段调用;
类中定义的非 static 变量的初始化操作是在实例构造函数中进行的,跟定义程序有关系,不过肯定是在构造方法中的源码执行之前;
实例构造方法会隐式调用父类的构造方法。
当初咱们失去了如上的论断,再来剖析一下文章开始的两个例子。间接看第二个例子,依据下面的论断,依照字节码中的代码执行程序批改代码:

class Food{static{ System.out.println(“food static”); } public Food(String str) {super(); System.out.println(str +”’s food”); }} public class Person {Food foo; static{ System.out.println(“person static”); } public Person() { super(); foo = new Food(“person”); System.out.println(“person constructor”); }} class Student extends Person {Food bar; static{ System.out.println(“student static”); } public Student() { super(); bar = new Food(“Student”); System.out.println(“student constructor”); } public static void main(String[] args) {new Student(); }}

当初咱们来剖析下面代码执行的流程:
首先咱们运行 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.

文章起源:神策技术社区

正文完
 0