开往虚拟机的车已经出发,关注上车

Java 类的一生

首先要知道,我说的这个类,也就是我们上篇内容说的那个 Class 文件 通常指一串 “二进制字节流”。

从我们码出 public class Cafe 的时候,可以说。这个种子 bean 就已经埋下了。然后我们将它通过前端编译器 javac 编译成了 Class 文件 。

紧接着它被 JVM 加载,后被应用到程序中,最后被 JVM 卸载。短短的几句话,概括了 Java 类 的一生。但这其中每一步,都有着可以用来喝一宿的故事。今天我还是长话短说,少喝点,讲一讲 Java 类的一生。

Javac 的孕育

当你敲完最后一个 ;) 时,你高兴的执行了你的代码。而此时, 的一生,算是开始了。

首先经历的就是被 Javac 这个前端编译器进行编译。因为这样,它才能被 JVM 所使用,这一步 Javac 都对它做了什么呢?

第一步:词法、语法分析

Javac.java 文件中的每个 标记 通过词法和语法分析构建出一颗抽象语法树。我们在编码的时候,都是敲下 abcde 的单个字符为最小单元,但在 Javac 这里,最小的单元就是 public int a = 11,这些,每一项都会作为一个 标记。把每个单元转成 标记 集合的过程称为词法分析,根据 标记 构建出来抽象语法树这个过程叫语法分析。有了这颗 就方便后面的操作了。

第二步:填充符号表

对刚刚的语法树进行遍历,将出现的符号定义和符号信息保存到符号表中;

第三步:注解处理器

这一步是干啥呢,这么跟你说吧, lombok 用过吗?就干那个了。没错,代码的修改。这些还要得益于 插入式注解处理器 ,它可以让你很轻松的来操作第一步生成出来的那颗抽象语法树,来达到对代码进行额外的操作。哦对了,如果这一步有对抽象语法树进行过操作,那么需要重新执行填充符号表的动作。

第四步:语义分析

对程序进行语义逻辑分析。也是我们经常说的编译报错的地方,就在这了。比如你写个 int i = 0 boolean j = false int k = i + j 你这样写肯定没问题,不过在编译的时候,就会报错了。不要说你在 IDE 中写的时候就会爆红。细品。还有一个例子,就是你定义的 final 的变量。你在编写的时候,是必须要初始化,而且不允许再被修改的,这个值有没有被改过,也是在这个阶段来检查的。你不要说在 IDE 编写的时候改就会爆红。

第五步:解语法糖

关于 Java 的语法糖,我们几乎只要在编码,就会多多少少的使用到,比如 泛型自动拆装箱增强 for 循环。这一步就是将其还原成他们本来的样子。

第六步:生成字节码文件

经历了上面几步之后,最终会得到一个 .class 的字节码文件。其使用的就是第二步填充的那个符号表的信息。这一步比较关键的内容就是生成 方法和 方法。类构造器和实例构造器。这个实例构造器和我们代码中的构造函数不同。 和 的作用主要是代码收敛,比如 可以确定父类的 static 代码块一定先于子类执行。

关于符号表的这里,我相信大家很多人都看过一些相关的博文或资料,不过几乎无人谈及这个符号表里到底是个什么东西,所以,我也就不说了。那必不可能,其实这个符号表是编译原理层面的内容,还是需要了解一下编译原理的这块内容才行,不过我之前也说过,关于编译原理这块自己真的是一概不知。所以这没办法在这里写清楚,不过抱着技术分享的认真态度,还是去了解了一些,这块还是相对较基础的。所谓的符号表,在编译原理中,它是讲,将程序中出现的有关标识的符号的属性信息保存下来。主要是用于语法分析和内存分配阶段。保存的形式也不单一,可以用数组、散列表、栈、树等数据结构来进行登记。内容的话,比如 Java 中的方法为例 public void fun(param1,param2) 那么在符号表中就要保存 fun 与之对应的值,param1 与之对应的值,以此类推。这里的举例为个人理解所写,真正编译原理所表达的符号与之类似。如感兴趣可进行编译原理内容学习。

类的生命周期

当编译器 Javac 孕育出 .class 字节码之后,接下来类的生命周期就围绕以下 加载 - 验证 - 准备 - 解析 - 初始化 - 使用 - 卸载 7步 展开了。

1. 加载

将字节码二进制流加载到内存,当我们的代码经历过前端编译器,便成为了可以被虚拟机加载的字节码文件。当然,这个类,可能是我们自己写的(编码)也有可能是通过运行时生成的字节码内容,所以前面说加载的时候说的是 将字节码二进制流加载到内存,而不是 class 文件加载到内存,因为字节码包含了各种形式的内容,比如 class 文件,或者本地或网络传输的二进制流等。

加载的过程中,主要是将这个字节码二进制流转换成虚拟机所能使用的信息,基本内容包含

  1. 通过全部限定名来获取一个二进制的字节流;
  2. 把字节流中定义的内容转到方法区中的数据结构;
  3. 生成一个可以表示这个二进制流的 Class 对象,作为访问方法区内容的入口;

以上的操作,是由一个叫做 类加载器 的家伙完成的,关于这哥们,待会我们再好好看看它。

2. 验证

验证加载的字节码内容是否合法,这一步主要是防止虚拟机遭到破坏

  • 验证文件格式
  • 元数据验证
  • 字节码验证
  • 符号引用验证

3. 准备

准备阶段为类中定义的静态变量(类变量)分配内存并赋默认值(非程序中的默认值)

  • public static int id = 123 默认值赋值 0 而非 123
  • 123 会在对应程序的类构造器(注意与实例构造器区别)中的<clinit>() 方法中完成,
  • 如果是 public static final int id = 123 会在此阶段完成赋值

继续深入:

123 会在对应程序的类构造器(注意与实例构造器区别)中的<clinit>() 方法中完成,这个 <clinit>() 方法是由 javac 编译器编译出来的方法

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
  • <clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object
  • 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
  • 非必须,如果类或接口中没有静态变量或方法,编译器会省略生成<clinit>()方法这步操作
  • <clinit>() 方法在多线程环境会被正确同步

4. 解析

解析阶段将类的符号引用替换为直接引用

  • 符号引用(Symbolic References):还记得之前文章中提到的 Class 文件中存的内容吗,还记得那些表吗?这个符号引用就是那时的索引。
  • 直接引用(Direct References):可以直接或间接定位到目标的真实的内存地址的引用。可以是直接指针、偏移量。

5. 初始化

对于类加载,虚拟机规范没有严格要求,具体可由虚拟机的实现自行决定。

但对类,虚拟机规范明确了类必须初始化的 6 个场景(有且仅有)

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,能够触发这四条指令的场景
    • new关键字
    • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候
    • 调动类的静态方法
  2. 对类型使用反射调用。
  3. 子类初始化时会优先初始化父类。
  4. 虚拟启动时用户指定的那个类。(包含 main() 方法的类)会被初始化。
  5. 当使用JDK 7新加入的动态语言支持时,执行句柄为静态字段或方法,或构造函数时需要对目标进行初始化(此处需了解 JDK 7 新增的动态语言支持,后续有机会会对此出内容单独整理文章说明,欢迎关注)。
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

上面说的是 6 种 主动 方式触发的初始化,还有 3 种 被动 引用不会引发类的初始化。

  1. 通过子类访问父类静态变量,只会初始化父类,不会初始化子类。
  2. 使用new关键字来创建类型数组不会触发初始化。
  3. 访问常量字段,即 static final 共同修饰的字段。这个原因上面有说明过,在这里再墨迹一下,会在编译器 javac 阶段将其放入常量池。

6. 使用

就是使用咯。

7. 卸载

还记得之前我们写的一篇文章 JVM 是怎么把“送”出去的内存又“要”回来的 其中有写到 类卸载 的相关内容,没读过的朋友可以点击链接阅读全文,效果更好,也可以阅读下面截取的片段。

类的卸载还是比较严格的,而且这个条件也比较苛刻,判断一个类型信息是否可以被回收(卸载)需要 同时 满足以下三个条件:

  1. 该类信息对应的所有实例被回收
  2. 加载该类的加载器被回收(关键
  3. 对应的 java.lang.Class 对象没有在任何地方被引用

具体内容如下:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,否则通常是很难达成的。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

多了解一点:

其中第三点与我们经常见到的操作诸如 spring 这种大量使用反射的框架、JDK 的动态代理、以及 CG lib 这种操作字节码的框架基本上都需要 JVM 拥有类卸载的功能,否则会导致一些自定义加载器加载的临时类信息占据着方法区的内存,带来不必要的压力。

当类被卸载后,类的一个生命周期就结束了,还有,上面的顺序并非一成不变,不过 加载 - 使用 - 卸载,这个大的框架顺序还是必须这样的,简单说明下这是因为 Java 动态语言的支持导致的。以上,就是我这次要分享的主要内容 Java 类的一生,谢谢你的阅读。


下面我们简单扩展一下类加载这哥儿们。

类加载器

关于这哥们,它的工作很简单,只需要负责把二进制流加载到内存中,哦对了,加载完了会打个标。完事。

打标就是通过类的 完全限定名 来为每个 Class文件的二进制流 命名,这样做是可以方便应用程序加载自己需要的类,还有一个原因我们下面会看到。

类与类加载器的区别

类加载器只实现类的加载动作。

类之间的比较,前提条件是同一个类加载器。如果由不同类加载器加载的相同完全限定名的类,那他们也是完全不同的(打标的原因其二)。也不能这样去做比较。

两类类加载器

  • 由 C++ 实现的启动类加载器(BootStrap ClassLoader)
  • 由 Java 实现的 (java.lang.ClassLoader)的子类

启动类加载器 (C++ 实现)

负责加载 JAVA_HOME/lib 目录下的 jar,注意:识别方式为文件名识别,即使放入不符合规范的文件也不能被加载。 该加载器不能被 Java 程序引用,访问时会返回 null

扩展类加载器(Java 实现)

负责加载 JAVA_HOME/lib/ext 目录下的类,可被程序调用。也可加载 java.ext.dirs 系统变量所指定的路径中所有的类库。

应用/系统类加载器(Java 实现)

这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”

它负责加载用户类路径(ClassPath)上所有的类库

可以直接在代码中使用这个类加载器。

如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型

Java 类的一生 - 图1

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

破坏双亲委派模型

不遵守这个模型规则就可以破坏双亲委派模型

  1. 重写 loadClass 方法
  2. JVM 自己被自己的 SPI 机制打破。后 JDK6 通过 ServiceLoader + 责任链模式解决

关于 SPI 的话,我们后面有机会在深入聊聊,因为其不属于虚拟机范畴,暂时先不深入讨论了。这部分内容和 JNDI 有关(Java Naming and Directory Interface,Java命名和目录接口)是 Java 的一个关键技术点。不知道可不太好,关注我,一起学习。

(正文完)


如果觉得写的还不错,可以关注点赞在看支持我。(##)

推荐阅读