【JVM】【一】【Java编译运行流程】


一、Java编译运行流程图

Java从源文件编译到运行主要经历了两大步骤:

  1. 编译器(Compiler)将源文件(Source)编译成字节码(ByteCode),并存入磁盘(Disk);即将 *.java文件转为 *.class文件这个过程,这个过程也被称为编译器的前端(前端编译)。例如:JDK的Javac编译器。
  2. 由Java虚拟机内的解释器(Interpreter)解释运行字节码文件,即将已经编译好的.class字节码文件从磁盘里面加载到内存里面。

如图:

图1-1

二、Java编译过程

Javac编译器的编译过程主要分一个准备三个处理过程:

  1. 初始化插入式注解处理器
  2. 解析与填充符号表
    • 词法分析
    • 语法分析
    • 填充符号表
  3. 插入式注解处理器的注解处理
  4. 分析与字节码生成
    • 语义分析
    • 字节码生成

大致流程如下:

图2-1 详细流程图: 图2-2

2.1、初始化插入式注解处理器

2.2、解析与填充符号表

解析步骤包含了经典程序编译原理中的词法分析和语法分析两个过程

2.2.1、词法分析

词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序写时的最小元素,但标记才是编译时的最小元素。
关键字、变量名、字面量、运算符都可以作为标记,如“inta=b+2”这句代码中就包含了6个标记,分别是int、a、=、b、+、1。
虽然关键字int由3个字符构成,但是它只是一个独立的标记,不可以再拆分。

2.2.2、语法分析

语法分析是根据标记序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构树形表示方式,抽象语法树的每一个节点都代表者程序代码中的一个语法结构
例如包、类型、修饰符、运算符、接口返回值甚至连代码注释等都可以是一种特定的语法结构。

2.2.3、填充符号表

完成词法分析和语法分析之后,下一步就是填充符号表的过程。符号表是由一组符号地址符号信息构成的表格
在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。

2.3、插入式注解处理器的注解处理

注解(Annotation)是在 JDK 1.5 中新增的,注解在设计上原本是与普通代码一样,只在运行期间发挥作用。
但是在JDK1.6中,插入式注解处理器可以提前至编译期对代码中的特点注解进行处理,从而影响到前端编译器的工作过程。
我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。
如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次(Round),这也就对应着图2-1的那个回环过程有了编译器注解处理过程。Lombok就是依赖于插入式注解器实现的。

2.4、分析与字节码生成

2.4.1、语义分析

语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,比如进行类型检查,控制流检查,数据流检查,解语发糖。

2.4.2、字节码生成

字节码生成是 Javac 编译过程的最后一个阶段,字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。如前面提到的 () 方法和()方法 就是在这一阶段添加到语法树中的。在字节码生成阶段,除了生成构造器以外,还有一些其它的代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为 StringBiulder 或 StringBuffer。

2.5、结束

完成了对语法树的遍历和调整之后,就会把填充了所需信息的符号表交给 com.sun.tools.javac.jvm.ClassWriter 类,由这个类的 writeClass() 方法输出字节码,最终生成字节码文件,到此为止整个编译过程就结束了。
最后生成的class文件由以下部分组成:

  1. 结构信息:
  • class文件格式版本号
  • 各部分的数量与大小的信息
  1. 元数据(对应于Java源码中声明与常量的信息):
  • 类/继承的超类/实现的接口的声明信息
  • 类/继承的超类/实现的接口的域
  • 类/继承的超类/实现的接口的方法声明信息
  • 类/继承的超类/实现的接口的常量池
  1. 方法信息(对应Java源码中语句和表达式对应的信息):
  • 字节码
  • 异常处理器表
  • 求值栈与局部变量区大小
  • 求值栈的类型记录、调试符号信息

三、Java运行过程(学的不太清楚,后续优化)

3.1、Java虚拟机运行流程

  • JVM通过类加载器子系统(ClassLoader) 把class文件加载到内存中 运行时数据区(Runtime Data Area),并做一定的处理;
  • 执行引擎(Execution Engine) 将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能。(字节码文件是jvm的一套指令集规范,并不能直接交个底层操作系统去执行)
    如图:

3.2、类加载器子系统

3.3、执行引擎

执行引擎里面主要有解释器(Interpreter)和即时编译器(JIT),他们都是将运行时数据区里的字节码文件转换成机器码,但是各有不同;(有的虚拟机只有解释器,有的虚拟机只有即时编译器,主流的虚拟机Hotspot是两个都有的)

  • 解释器:逐行逐句翻译成机器码,第二次执行依旧如此。
  • 即时编译器:当虚拟机发现某个方法或者代码块的运行特别频繁时,就会把这些代码认定为「热点代码」(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化(将热点代码的机器码放入代码缓存区),完成这个任务的编译器称为即时编译器(JIT)。

如图(这里的字节码文件是存放在运行时数据区的字节码文件):

3.3.1、Java虚拟机运行模式

  • interpreted mode:纯解释模式 java -Xint -version
  • compiled mode:纯编译模式 java -Xcomp -version
  • mixed mode:混合模式 java -Xmixed -version
  • AOT(Ahead-of-Time Compilation) 了解即可

输入java -version就可以看到你的当前使用的虚拟机的执行模式
例如:

其中的mixed mode 就是混合模式

3.3.1.1、纯解释模式

JVM启动时,指定-Xint参数,就是告诉JVM只进行解释执行,不对代码进行编译。这种模式抛弃了JIT可能带来的性能优势。毕竟解释器是逐条读入,逐条解释执行的。

3.3.1.2、纯编译模式

JVM启动时,指定-Xcomp参数,就是告诉JVM关闭解释器,使用编译模式(或者叫最大优化级别),不进行解释执行。这种模式并不表示执行效率最高,它会导致JVM启动变得非常慢,同时有些JIT编译器的优化操作(如分支预测)并不能进行有效的优化。

3.3.1.3、混合模式

混合模式,就是解释和编译混合的一种模式,新版本的JDK(例如JDK8)默认采用的是混合模式(JVM参数为-Xmixed)。通常运行在server模式的JVM,会进行上万次调用以收集足够的信息进行高效的编译,client模式这个限制是1500次。Hotspot JVM内置了两个不同的JIT编译器,C1对应client模式,适用于对于启动速度敏感的应用(如java桌面应用);C2对应server模式,它的优化是为长时间运行的服务器端应用设计的。

3.3.1.4、AOT

AOT就是将javac编译器编译后的字节码直接编译成机器代码,避免了JIT预热等各方面的开销。Oracle JDK 9就引入了实验性的AOT特性,并增加了新的jaotc工具。

3.3.2、即时编译器

即时编译器的编译行为又被称为后端编译,Hotspot 一般采用的是混合模式,所以算是半解释半编译。
在HotSpot VM中内嵌有两个JIT编译器,分别为client CompilerserverCompiler,但大多数情况下我们简称为C1编译器和C2编译器。

  • -client:指定Java虚拟机运行在client模式下,并使用C1编译器。C1编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。
  • -server:指定Java虚拟机运行在server模式下,并使用C2编译器。C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。64位HotSpot是server模式

C1和C2编译器不同的优化策略:
1、c1编译器:

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
  • 去虚拟化:对唯一的实现类进行内联
  • 冗余消除:在运行期间把一些不会执行的代码折叠掉

2、C2的优化主要是在全局层面,逃逸分析是优化的基础。(以下基于逃逸分析的优化)

  • 标量替换:用标量值代替聚合对象的属性值
  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
  • 同步消除:清除同步操作,通常指synchronized

3.3.2.1、编译对象与触发条件

编译对象:

  • 被多次调用的方法
  • 被多次执行的循环体

触发条件:

  • 当解释执行超过一定的阈值

3.3.2.2、热点探测(用于判断是否需要触发即时编译器编译)

  • 基于采样的热点探测:
    • 判断:采用这种方法的虚拟机会周期性地检查各个线程栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是「热点方法」
    • 好处:实现简单、高效,还可以很容易地获取方法调用关系(将调用栈展开即可)
    • 坏处:很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因数的影响而扰乱热点探测
  • 基于计数器的热点探测:
    • 判断:采用这种方法的虚拟机会为每个方法(甚至代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是「热点方法」
    • 好处:统计结果相对来说更加精确和严谨
    • 坏处:这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系

HotSpot 虚拟机采用的是第二种:基于计数器的热点探测。因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter):

  • 方法调用计数器:这个计数器用于统计方法被调用的次数。当一个方法被调用时,会首先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在,则将此方法的调用计数器加 1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。如果超过阈值,将会向即时编译器提交一个该方法的代码编译请求。

    • 如果不做任何设置,执行引擎不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译完成后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。
    • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器值就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰期。进行热度衰减的动作是在虚拟机进行 GC 时顺便进行的,可以设置虚拟机参数来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。此外还可以设置虚拟机参数调整半衰期的时间。
  • 回边计数器:回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为「回边」(Back Edge)。

    • 建立回边计数器统计的目的是为了触发 OSR 编译。当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否已经有编译好的版本,如果有,它将优先执行已编译的代码,否则就把回边计数器值加 1,然后判断方法调用计数器和回边计数器值之和是否超过计数器的阈值。当超过阈值时,将会提交一个 OSR 编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
    • 与方法计数器不同,回边计数器没有计算热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出时,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

参考:

https://blog.csdn.net/shenwansangz/article/details/82422588
https://zhuanlan.zhihu.com/p/81941373

https://blog.csdn.net/qq_44891295/article/details/106539434
https://blog.csdn.net/qq_44891295/article/details/104303629

https://www.zhihu.com/question/65840849
https://www.51cto.com/article/608908.html
https://blog.csdn.net/See_Csdn_/article/details/109356669
https://www.jianshu.com/p/c0713884fb12