编译:
Java语言的编译期其实是一段“不确定 ”的过程,因为可能是一个前端编译器把.java文件转变为.class文件的过程;
也可能是指JVM的后端运行期编译器(JIT编译器)把字节码转变为机器码的过程;还可能是指使用静态提前编译器(AOT
编译器)直接把.java文件编译成本地机器码的过程。但是在这里我们说的是第一类。也是符合我们大众对编译认知的。
编译在这个时间段经历了哪些过程呢?
词法,语法分析:
词法分析是将源代码的字符流转变为Token集合,而语法分析则是根据Token序列抽象构造语法树(AST)的过程,
AST是一种用来描述程序代码语法结构的树形表示形式,语法树的每个节点都代表着程序代码中的一个语法结构,例如包、
类型、修饰符、运算符、接口、返回值甚至代码注释都可以是一个语法结构。
填充符号表:
完成了语法和词法分析之后,下一步就是填充符号表的过程,符号表中所登记的信息在编译的不同阶段都要用到。
在这里延伸一下符号表的概念。符号表是什么呢?它是由一组符号地址和符号信息构成的表格,最简单的可以理解为
哈希表的K-V值对的形式。为什么会用到符号表呢?符号表最早期的应用之一就是组织程序代码的信息。最初,计算机程序
只是一串简单的数字,但程序猿们很快发现使用符号来表示操作和内存地址(变量名)要方便得多。将名称和数字关联起来
就需要一张符号表。随着程序的增长,符号表操作的性能逐渐变成了程序开发效率的瓶颈,为此从而诞生了许多提升序号表
效率的数据结构和算法。至于所谓的数据结构和算法有哪些呢?大体说下:无序链表中的顺序查找、有序数组中的二分查找、
二叉查找树、平衡查找树(在这我们主要接触到的是红黑树)、散列表(基于拉链法的散列表,基于线性探测法的散列表)。
像Java中的java.util.TreeMap和java.util.HashMap分别是基于红黑树和拉链法的散列表的符号表实现的。这里提到的符号表
的概念不再细说,感兴趣的可以查找相关资料。
字节码生成:
字节码生成是Javac编译过程的最后一个阶段,在这个阶段会把前面各步骤生成的信息转化成字节码写到磁盘中,还会进行
了少量代码添加和转换的工作。实例构造器<init>()方法和类构造器<clinit>()方法(这里的实例构造器并不是指默认构造函数,
如果用户代码没有提供任何构造函数,那编译器将会添加一个没有参数的、访问性与当前类一致的默认构造函数,这个工作在填充
符号表阶段已经完成,而类构造器<clinit>()方法指的是编译器自动收集类中的所有类变量赋值动作和静态语句块中的语句合并产生
的)就是在这个阶段添加到语法树中的。到此为止整个编译过程结束。
类加载:
编译将程序编译成字节码之后,下一步就是类加载到内存的过程。
类加载的过程是在虚拟机内存的方法区进行,这地方涉及到虚拟机内存,所以在这首先简单介绍下程序在内存区域分布的概念。
虚拟机内存区域划分为:程序计数器、栈、本地方法栈、堆、方法区(部分区域为运行时常量池)、直接内存。
程序计数器:
程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在JVM概念模型中,
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
栈:
栈用于存储局部变量表、操作数栈、动态链接、方法出口等信息。其中局部变量表存放了编译期克制的各种基本数据类型、
对象引用。它与程序计数器一样都是线程私有的。
本地方法栈:
本地方法栈与上面介绍的虚拟机栈作用相似,它们的区别不过是虚拟机栈为虚拟机执行Java方法(字节码)服务,
而本地方法栈则为虚拟机使用的Native方法服务,甚至有的虚拟机会把这两块合二为一。
堆:
堆是JVM管理内存最大的一块。它是被所有线程共享的一块区域,它的唯一目的是存放对象实例,几乎所有的对象
实例都在这里分配内存(像特殊的类对象会在方法区分配内存)。这地方也是垃圾收集管理的主要区域,从内存回收角度看,
现在垃圾收集器都采用分代收集算法(后面会详细介绍),所以Java堆还可以进一步细分:新生代和老年代,而新生代进一步细分:
Eden空间、From Survivor空间、To Survivor空间。为了效率考虑,堆还可能划分为多个线程私有的分配缓冲区(TLAB)。无论如何
划分,都与存放内容无关,无论哪个区域,存放的依然是对象实例,它们存在的目的只是为了更好的回收和分配内存而已。
方法区:
方法区与堆一样,都是线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
而运行时常量池是方法区的一部分,它主要用于存放编译期声明各种字面量和符号引用。
直接内存:
直接内存并不是虚拟机运行时数据区的一部分,也是不Java规范中定义的内存区域,你可以简单理解为堆外内存,内存分配不受Java堆大小
的限制但受整个内存大小的限制。说完了虚拟机内存区域的概念,我们回到正题,类加载的流程到底是什么呢?加载、验证、准备、解析、初始化五步。
其中加载、验证、准备、初始化是顺序执行的,而解析则不一定,它有可能会在初始化之后执行。
加载:
在加载阶段,JVM需要完成三个步骤:首先通过类的全限定名来获取定义此类的二进制字节流,然后将这个字节流所代表的静态存储结构
转化为方法区的运行时数据结构,最后在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据入口。在第一步获取
二进制字节流中并没有明确指出从一个*.class文件中获取,规定的灵活性导致我们可以从ZIP(为JAR、EAR/WAR格式提供基础)包中获取,从
网络获取(Applet),运行时计算生成(动态代理),其他文件产生(JSP文件生成的Class类),从数据库获取。
验证:
验证,顾名思义,其实就是为了确保Class文件字节流中包含信息符合JVM的要求,因为Class文件的来源途径不一定中规中矩的从编译器产生,
也有可能用十六进制编辑器直接编写Class文件。校验流程为文件格式校验、元数据验证、字节码验证,这地方的具体安全校验方式不再细说。
准备:
准备阶段正式为类变量分配内存并设置初始值的阶段,这些变量所使用的内存都在方法区进行分配。
解析:
解析阶段是JVM将常量池内的符号引用替换为直接引用(指向目标的指针、相对偏移量或句柄)的过程,
前面我们谈到的编译填充符号表的价值在这地方体现出来了。解析过程无非就是对类或接口、字段、接口方法进行解析。
初始化:
类初始化阶段是类加载过程的最后一步,在准备阶段,变量已经赋过一次初始值,而在这一步,则会根据程序猿定制的要求进行初始化
类变量和其他资源。在这个阶段就是执行前面编译字节码生成流程提到的<clinit>()方法的过程。虚拟机也保证在多线程环境下这个方法被同时
调用时被正确的加锁、同步,保证只有一个线程去执行这个方法而其他线程阻塞等待,笔者以前写的一篇文章《从一个简单的Java单例示例谈谈并发》
中,基于类初始化的单例线程安全的写法涉及到的就是这块,有兴趣的可以结合起来一起看看。
运行:
经过了上面两个阶段,程序开始正常跑起来了,我们都知道程序执行过程涉及到了各种指令的计算操作, 程序如何执行的呢?
这地方就会使用到文章开头谈到的后端编译器(JIT即时编译器)+解释器这种搭配使用的混合模式(HotSpot虚拟机默认采用了解释器
与一个编译器),字节码执行引擎则负责着这类各种程序计算操作的任务,它在执行Java代码的时候有可能会有解释执行(通过解释器执行)
和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备。栈帧是用于支持虚拟机进行方法调用和执行的数据结构,具体
的压栈弹栈各种指令计算的思路涉及到了一个经典的算法——Dijkstra算法,至于如何执行有兴趣的自己查资料吧这地方不会过多深入。运行期
的优化问题在这个阶段同样重要,而JVM设计团队则把对性能的优化集中到了这个阶段,这样可以让那些不是由Javac产生的Class文件同样享受
到编译器优化带来的好处,至于具体的优化技术有哪些呢?有很多,这里简单提几个具有代表性的优化技术:公共子表达式消除、数组边界检查消除、
方法内联、逃逸分析等等。
GC:
终于说到程序要进入死亡阶段了。JVM是如何判断程序药丸的呢?这地方其实采用了可达性分析算法,这个算法的基本思路是通过一系列
的称为“GC Roots”的对象作为起始点,从这个节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相
连时(用图论话说,就是从GC Roots到这个对象不可达),则证明此对象不可用,这时候就被判定为可回收的对象。当我们已经知道要回收的对
象何时触发垃圾收集呢?安全点,安全点就是一些让程序暂定执行从而进行GC的位置,由此我们很容易知道GC停顿的时间是垃圾收集的核心。所
有的垃圾收集算法以及衍生出来的垃圾收集器无不围绕着尽量减少GC停顿时间产生的,现在最新的G1垃圾收集器可以建立可预测的停顿时间模型,
有计划的避免在整个Java堆中进行全区域的垃圾收集。前文介绍内存区域分布的概念的时候,我们谈到了新生代、老年代,而不同的垃圾收集器有
可能作用于新生代,也有可能作用于老年代,甚至没有分代的概念(比如G1收集器),说到这,下面就具体介绍下垃圾收集算法及对应的垃圾收
集器