你真的了解一段Java程序的生命史吗

作为一名程序猿 ,我们每天都在写Code,但你真的了解它的生命周期么?今天就来简单聊下它的生命历程,说起一段Java Code,从出生到game over大体分这么几步:编译、类加载、运行、GC。

编译

Java语言的编译期其实是一段“不确定 ”的过程,因为可能是一个前端编译器把.java文件转变为.class文件的过程;也可能是指JVM的后端运行期编译器(JIT编译器)把字节码转变为机器码的过程;还可能是指使用静态提前编译器(AOT编译器)直接把.java文件编译成本地机器码的过程。但是在这里我们说的是第一类。也是符合我们大众对编译认知的。编译在这个时间段经历了哪些过程呢?

词法、语法分析

词法分析是将源代码的字符流转变为Token集合,而语法分析则是根据Token序列抽象构造语法树(ATS)的过程,ATS是一种用来描述程序代码语法结构的树形表示形式,语法树的每个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至代码注释都可以是一个语法结构。

填充符号表

完成了语法和词法分析之后,下一步就是填充符号表的过程,符号表中所登记的信息在编译的不同阶段都要用到。在这里延伸一下符号表的概念。符号表是什么呢?它是由一组符号地址和符号信息构成的表格,最简单的可以理解为哈希表的K-V值对的形式。为什么会用到符号表呢?符号表最早期的应用之一就是组织程序代码的信息。最初,计算机程序只是一串简单的数字,但程序猿们很快发现使用符号来表示操作和内存地址(变量名)要方便得多。将名称和数字关联起来就需要一张符号表。随着程序的增长,符号表操作的性能逐渐变成了程序开发效率的瓶颈,为此从而诞生了许多提升序号表效率的数据结构和算法。至于所谓的数据结构和算法有哪些呢?大体说下:无序链表中的顺序查找、有序数组中的二分查找、二叉查找树、平衡查找树(在这我们主要接触到的是红黑树)、散列表(基于拉链法的散列表,基于线性探测法的散列表)。像Java中的java.util.TreeMap和java.util.HashMap分别是基于红黑树和拉链法的散列表的符号表实现的。这里提到的符号表的概念不再细说,感兴趣的可以查找相关资料。

语义分析

经过上两步之后,我们获得了程序代码的抽象语法树表示,语法树能表示一个正确的源代码抽象,但无法保证源程序是符合逻辑的,这时候语义分析登场了,它的主要任务就是对结构上正确的源程序进行上下文有关性质的审查。标注检查、数据及控制流分析、解语法糖是语义分析阶段的几个步骤,在这具体说下语法糖的概念。语法糖是指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但更方便程序猿使用。Java中最常用的语法糖主要是泛型、变长参数、自从装箱/拆箱、遍历循环,JVM在运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程也就是解语法糖。举个泛型擦除的例子,List和List在编译之后会进行泛型擦除,变成一样的原生类型List

字节码生成

字节码生成是Javac编译过程的最后一个阶段,在这个阶段会把前面各步骤生成的信息转化成字节码写到磁盘中,还会进行了少量代码添加和转换的工作。实例构造器()方法和类构造器()方法(这里的实例构造器并不是指默认构造函数,如果用户代码没有提供任何构造函数,那编译器将会添加一个没有参数的、访问性与当前类一致的默认构造函数,这个工作在填充符号表阶段已经完成,而类构造器()方法指的是编译器自动收集类中的所有类变量赋值动作和静态语句块中的语句合并产生的)就是在这个阶段添加到语法树中的。到此为止整个编译过程结束。

类加载

编译将程序编译成字节码之后,下一步就是类加载到内存的过程。

类加载的过程是在虚拟机内存的方法区进行,这地方涉及到虚拟机内存,所以在这首先简单介绍下程序在内存区域分布的概念。虚拟机内存区域划分为:程序计数器、栈、本地方法栈、堆、方法区(部分区域为运行时常量池)、直接内存。

程序计数器

程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在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将常量池内的符号引用替换为直接引用(指向目标的指针、相对偏移量或句柄)的过程,前面我们谈到的编译填充符号表的价值在这地方体现出来了。解析过程无非就是对类或接口、字段、接口方法进行解析。

初始化

类初始化阶段是类加载过程的最后一步,在准备阶段,变量已经赋过一次初始值,而在这一步,则会根据程序猿定制的要求进行初始化类变量和其他资源。在这个阶段就是执行前面编译字节码生成流程提到的()方法的过程。虚拟机也保证在多线程环境下这个方法被同时调用时被正确的加锁、同步,保证只有一个线程去执行这个方法而其他线程阻塞等待,笔者以前写的一篇文章《从一个简单的Java单例示例谈谈并发》中,基于类初始化的单例线程安全的写法涉及到的就是这块,有兴趣的可以结合起来一起看看。这地方还涉及到另一个我们比较关心的知识点,Java何时触发对类的初始化操作呢?

  • 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有初始化,则需要触发其初始化,前面各种叉叉指令什么鬼,简单理解就是new一个对象的时候,读取或者设置一个类的静态字段的时候,调用一个类的静态方法的时候。
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有初始化,则需要触发其初始化。
  • 当初始化一个类,发现其父类还没进行初始化,则先触发其父类的初始化操作。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(main方法所在类),虚拟机会先初始化这个主类。
  • 当使用JDK1.7以上的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REFgetStatic、REFputStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应类没有进行初始化,则触发初始化操作。

运行

经过了上面两个阶段,程序开始正常跑起来了,我们都知道程序执行过程涉及到了各种指令的计算操作, 程序如何执行的呢?

这地方就会使用到文章开头谈到的后端编译器(JIT即时编译器)+解释器这种搭配使用的混合模式(HotSpot虚拟机默认采用了解释器与一个编译器),字节码执行引擎则负责着这类各种程序计算操作的任务,它在执行Java代码的时候有可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备。

栈帧是用于支持虚拟机进行方法调用和执行的数据结构,具体的压栈弹栈各种指令计算的思路涉及到了一个经典的算法——Dijkstra算法,至于如何执行有兴趣的自己查资料吧这地方不会过多深入。

运行期的优化问题在这个阶段同样重要,而JVM设计团队则把对性能的优化集中到了这个阶段,这样可以让那些不是由Javac产生的Class文件同样享受到编译器优化带来的好处,至于具体的优化技术有哪些呢?有很多,这里简单提几个具有代表性的优化技术:公共子表达式消除、数组边界检查消除、方法内联、逃逸分析等等。

GC

终于说到程序要进入死亡阶段了。JVM是如何判断程序药丸的呢?

这地方其实采用了可达性分析算法,这个算法的基本思路是通过一系列的称为“GC Roots”的对象作为起始点,从这个节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时(用图论话说,就是从GC Roots到这个对象不可达),则证明此对象不可用,这时候就被判定为可回收的对象。

当我们已经知道要回收的对象何时触发垃圾收集呢?安全点,安全点就是一些让程序暂定执行从而进行GC的位置,由此我们很容易知道GC停顿的时间是垃圾收集的核心。所有的垃圾收集算法以及衍生出来的垃圾收集器无不围绕着尽量减少GC停顿时间产生的,现在最新的G1垃圾收集器可以建立可预测的停顿时间模型,有计划的避免在整个Java堆中进行全区域的垃圾收集。

前文介绍内存区域分布的概念的时候,我们谈到了新生代、老年代,而不同的垃圾收集器有可能作用于新生代,也有可能作用于老年代,甚至没有分代的概念(比如G1收集器),说到这,下面就具体介绍下垃圾收集算法及对应的垃圾收集器

标记-清除算法

最基础的收集算法,算法分为标记和清除两个阶段:首先标记处所有要回收的对象,在标记完成之后统一回收所有被标记的对象。它最大的不足是效率不高,还会产生大量不连续的内存碎片,这样导致的问题当程序运行过程分配较大对象时,即使堆中还有足够的内存,但是无法找到足够的连续内存只能不得不触发一次GC操作。这地方对应的垃圾收集器是CMS收集器。

复制算法

复制算法是为了解决效率问题而生的,它可以将可用内存容量划分为大小相等的两块,,每次只使用其中一块,当这一块内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样每次会对整个半区进行GC,并且不会产生内存碎片等问题。

现在的商业虚拟机大多采用这种算法来回收新生代,另外划分内存比例也不是1:1,像HotSpot默认Eden(一块Eden区)和Survivor(两块Survivor区)的大小比例为8:1,每次使用Eden和其中一块Surviovr区,也就是新生代中可用内存空间是整个新生代的90%,当回收时,将Eden和其中一块Survivor中还存活的对象一次性复制到另一块Survivor中,最后清理掉Eden和刚才用到的Survivor空间,细心的读者在这地方也许会有发现,如果复制过程那块没使用的Survivor不够用怎么办呢?

这时候需要依赖老年代进行分配担保,担保成功就会将Eden和其中一块Survivor中还存活的对象移动到老年代中,担保失败就不得不在老年代触发一次垃圾回收。这地方延伸一下,新生代垃圾回收称为Minor GC,因为Java对象大多朝生夕死的特性,所以Minor GC很频繁,一般回收速度也快,而老年代垃圾回收称为Major GC/Full GC,Major GC的速度一般会比Minor GC的速度慢很多,从前面的分析过程我们可以轻易的推断,出现了Major GC,经常会伴随着一次Minor GC,但非绝对,因此我们GC的目的其实也是通过调优尽量控制减少Major GC的频率。这地方对应的垃圾收集器是Serial收集器、ParNew收集器(Serial收集器多线程版本,可与后面谈到的老年代收集器CMS进行配合工作)、Parallel Scavenge收集器。

标记-整理算法

这个算法是应用在老年代垃圾回收的算法,因为老年代不像复制算法那样回收频率高,另外它还会浪费空间。标记-整理过程与标记-清除差不多,无非后续步骤不是直接对可回收对象进行清除,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。这地方对应的垃圾收集器是Serial Old收集器、Parallel Old收集器。

分代收集算法

当前商业虚拟机都采用这种算法,它的思想就是我们前面提到的对堆内存区域进行分代,新生代和老年代,不同的区域采用不同垃圾收集算法。新生代用复制算法,老年代用标记-整理或标记-清除算法。

回顾

前面扯了这么多,也许大家对一段Java Code的生命史有点概念了,或者说没怎么看懂呀,在这地方我们举个例子回顾下整个流程,当我们new一个对象的时候,会经历什么呢?

结合前面所说的,JVM遇到一个new指令时,首先去检查整个指令参数能否在方法区的常量池定位到一个类的符号引用,并且检查整个符号引用代表的类是否已被加载、解析和初始化过,如果没有,则必须先执行对应类加载过程。类加载检查通过后,接下来JVM将会为新生对象分配内存,这个过程是在堆中进行的,分配大小在类加载完成后就可以确定,如果堆内存是规整的,则采用指针移动对象大小相等距离即可,这种分配方式叫“指针碰撞”,如果是零散的,则JVM维护一个列表记录哪些内存可用,分配并更新列表记录,这种方式叫“空闲列表”,至于采用哪种方式,取决于我们前面提到的堆采用了哪种垃圾收集器决定的。

划分完对象内存之后,虚拟机会进行必要的初始化操作,接下来需要对对象进行必要的设置,这些信息设置在对象头(类元数据信息、对象的哈希码、对象的GC分代年龄等等)里面,这些工作完成之后,一个新对象产生了,这地方其实还没结束,再下一步就是调用()方法进行程序猿计划的对对象字段进行的赋值操作,最后设置栈中的引用指向这个堆中对象所在的内存地址(直接引用),这时候一个真正可用的对象已经产生了,至于后续对对象进行的各种操作及最后的死亡就是前面提到的字节码执行引擎啊GC啊相信大家已不再陌生。


参考

本文内容参考《深入Java虚拟机(第2版)》、《算法(第4版)》,感兴趣的可自行查阅。