Java程序的生命史

说起一段Java Code,从出生到game over大体分这么几步:编译、类加载、运行、GC.

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

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

 2.类加载

加载、验证、准备、解析、初始化五步。其中加载、验证、准备、初始化是顺序执行的,而解析则不一定,它有可能会在初始化之后执行。


     a.加载

在加载阶段,JVM需要完成三个步骤:首先通过类的全限定名来获取定义此类的二进制字节流,然后将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,最后在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据入口。在第一步获取二进制字节流中并没有明确指出从一个*.class文件中获取,规定的灵活性导致我们可以从ZIP(为JAR、EAR/WAR格式提供基础)包中获取,从网络获取(Applet),运行时计算生成(动态代理),其他文件产生(JSP文件生成的Class类),从数据库获取。


      b. 验证

  验证,顾名思义,其实就是为了确保Class文件字节流中包含信息符合JVM的要求,因为Class文件的来源途径不一定中规中矩的从编译器产生,也有可能用十六进制编辑器直接编写Class文件。校验流程为文件格式校验、元数据验证、字节码验证,这地方的具体安全校验方式不再细说。


  c.准备
  准备阶段正式为类变量分配内存并设置初始值的阶段,这些变量所使用的内存都在方法区进行分配。


 
 d.解析
  解析阶段是JVM将常量池内的符号引用替换为直接引用(指向目标的指针、相对偏移量或句柄)的过程,前面我们谈到的编译填充符号表的价值在这地方体现出来了。解析过程无非就是对类或接口、字段、接口方法进行解析。

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

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

3.运行

  经过了上面两个阶段,程序开始正常跑起来了,我们都知道程序执行过程涉及到了各种指令的计算操作, 程序如何执行的呢?这地方就会使用到文章开头谈到的后端编译器(JIT即时编译器)+解释器这种搭配使用的混合模式(HotSpot虚拟机默认采用了解释器与一个编译器),字节码执行引擎则负责着这类各种程序计算操作的任务,它在执行Java代码的时候有可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备。栈帧是用于支持虚拟机进行方法调用和执行的数据结构,具体的压栈弹栈各种指令计算的思路涉及到了一个经典的算法——Dijkstra算法,至于如何执行有兴趣的自己查资料吧这地方不会过多深入。运行期的优化问题在这个阶段同样重要,而JVM设计团队则把对性能的优化集中到了这个阶段,这样可以让那些不是由Javac产生的Class文件同样享受到编译器优化带来的好处,至于具体的优化技术有哪些呢?有很多,这里简单提几个具有代表性的优化技术:公共子表达式消除、数组边界检查消除、方法内联、逃逸分析等等。

      4.GC

  终于说到程序要进入死亡阶段了。JVM是如何判断程序药丸的呢?这地方其实采用了可达性分析算法,这个算法的基本思路是通过一系列的称为“GC Roots”的对象作为起始点,从这个节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时(用图论话说,就是从GC Roots到这个对象不可达),则证明此对象不可用,这时候就被判定为可回收的对象。当我们已经知道要回收的对象何时触发垃圾收集呢?安全点,安全点就是一些让程序暂定执行从而进行GC的位置,由此我们很容易知道GC停顿的时间是垃圾收集的核心。所有的垃圾收集算法以及衍生出来的垃圾收集器无不围绕着尽量减少GC停顿时间产生的,现在最新的G1垃圾收集器可以建立可预测的停顿时间模型,有计划的避免在整个Java堆中进行全区域的垃圾收集。前文介绍内存区域分布的概念的时候,我们谈到了新生代、老年代,而不同的垃圾收集器有可能作用于新生代,也有可能作用于老年代,甚至没有分代的概念(比如G1收集器),说到这,下面就具体介绍下垃圾收集算法及对应的垃圾收集器。

a.复制算法

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

b.分代收集算法

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

猜你喜欢

转载自blog.csdn.net/qq_35221138/article/details/53819842