JVM(1)-初识Java虚拟机

本文引用自文献:1)《深入理解Java虚拟机》,作者:周志明;

Java的跨平台运行

Java有一个口号就是:“Write Once, Run Anywhere”,是说Java程序具有跨平台运行的特性,这个特性的实现依赖于 Java 虚拟机(Java Virtual Machine,简称JVM),所以这里先介绍一下和Java跨平台运行相关的知识。

我们在编写Java程序时,编写的是一个个java文件(xxx.java),而计算机能够执行和识别的是机器指令码,要使计算机能够执行我们写好的程序,这中间主要经历了两个过程。第一步是通过编译器将Java源文件生成相应的.class文件,也就是字节码文件,这个工作是由 Javac 编译器完成,说白了 Javac 编译器就是将Java语言规范转换成字节码规范,因为这是属于前期的编译工作,所以它被我们被称为前端编译器。第二个则是 Java 虚拟机要将字节码转成机器码交给操作系统去执行,这个过程有两种选择,一种是使用解释器解释执行字节码,另一种则是使用 JIT 编译器将字节码转化为机器码。

JVM是运行在操作系统之上的,它不会直接和计算机硬件交互,不同的平台(即操作系统)有不同的JVM,内置了跟平台相匹配的解释器或者编译器,因此不同平台的JVM接收到同一份字节码文件后,可以转成对应平台的机器码,交给操作系统去执行,所以就达到了程序跨平台运行的效果。

除了Java语言,其它很多语言的程序也是可以运行在JVM上的,比如Groovy、Scala、JRuby等等,因为JVM是一套规范,只要是字节码文件它就可以识别和运行。对于这套规范,不同的公司有不同的实现,所以有很多种类的虚拟机,对于我们使用最多的Sun JDK和OpenJDK而言,内置的是HotSpot虚拟机,所以如果没有特别说明,我们所说的JVM通常就是指HotSpot虚拟机。

HotSpot虚拟机是用C++语言编写,所以它的源码是C++的代码,但有些虚拟机也是用Java编写的。

解释器和编译器

编译器(Compiler)的作用则是将一种代码编译成另外一种代码,后者往往是比前者更低级的代码,比如将Java源码编译成字节码,将字节码编译成机器码等。编译时,编译器会将整个程序的代码都事先编译好,然后在需要时将代码交给计算机去执行,这种方式时间主要是消耗在预编译的过程中。

解释器(Interpreter)的作用是将源代码或者中间代码(比如Java的字节码)解释执行,执行时每次读入一条语句直接生成机器码,然后让计算机去执行这条语句的操作并返回,然后再读取下一条语句,以此类推。
为了能够有好的执行效率,解释器往往使用相对简单的词法分析、语法分析,压缩解释的时间,所以解释器适合比较低级的语言。因为解释器是一边解释一边让计算机去执行指令,所以相对于先把文件预编译好成字节码,它的效率会没有那么高。如何减少解释的次数和复杂性,是提高解释器效率的难题。

一句话描述编译与解释:

  • 编译 Compile:把整个代码翻译成另外一种代码,然后等待被执行,发生在运行之前,产物是「另一份代码」。
  • 解释 Interpret:把代码一行一行的读懂然后执行,发生在运行时,产物是「运行结果」。

HotSpot虚拟机,最初是只通过解释器进行解释执行,后来变成了解释器和JIT编译器混合工作的模式。通过java -verison命令可以看到“mix mode”的字样。

JIT编译器是什么?当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,下文统称JIT编译器)。

注意:JIT编译器并不是虚拟机必须的部分,Java虚拟机规范并没有规定Java虚拟机内必须要有JIT编译器存在,更没有限定或指导JIT编译器应该如何去实现。但是,JIT编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。

为何HotSpot虚拟机要解释器和编译器并存?

尽管并不是所有的 Java 虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。

解释器的执行,抽象的看是这样的:输入的代码 -> [ 解释器 解释执行 ] -> 执行结果

而要JIT编译然后再执行的话,抽象的看则是:输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果

说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”:

  1. 只被调用一次,例如类的构造器(class initializer,())
  2. 没有循环

对只执行一次的代码做JIT编译再执行,可以说是得不偿失。对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。只有对频繁执行的代码,JIT编译才能保证有正面的收益。

对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的。同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”。这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。

HotSpot虚拟机如何判断热点代码

程序中的代码只有是热点代码时,才会编译为本地代码,那么什么是热点代码呢?运行过程中会被即时编译器编译的“热点代码”有两类:

  1. 被多次调用的方法。
  2. 被多次执行的循环体。

两种情况,编译器都是以整个方法作为编译对象。 这种编译方法因为编译发生在方法执行过程之中,因此形象的称之为栈上替换(On Stack Replacement,OSR),即方法栈帧还在栈上,方法就被替换了。

要知道方法或一段代码是不是热点代码,是不是需要触发即时编译,需要进行Hot Spot Detection(热点探测)。目前主要的热点探测方式有以下两种:

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

在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

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

如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进行解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。

回边计数器
它的作用就是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。

线程

JVM 中所说的线程指程序执行过程中的一个线程实体。 JVM 允许一个应用并发执行多个线程。Hotspot 虚拟机中的 Java 线程与原生操作系统线程有直接的映射关系。 当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程。Java 线程结束,原生线程随之被回收。操作系统负责调度所有线程,并把它们分配到任何可用的 CPU 上。当原生线程初始化完毕,就会调用 Java 线程的run()方法。当线程结束时,会释放原生线程和 Java 线程的所有资源。

Hotspot 虚拟机后台运行的系统线程主要有下面几个:

线程 简介
虚拟机线程(VM thread) 这个线程等待 JVM 到达安全点操作出现。这些操作必须要在独立的线程里执行,因为当堆修改无法进行时,线程都需要 JVM 位于安全点。这些操作的类型有: stop-theworld 垃圾回收、线程栈 dump、线程暂停、线程偏向锁(biased locking)解除。
周期性任务线程 这线程负责定时器事件(也就是中断),用来调度周期性操作的执行。
GC 线程 这些线程支持 JVM 中不同的垃圾回收活动。
编译器线程 这些线程在运行时将字节码动态编译成本地平台相关的机器码。
信号分发线程 这个线程接收发送到 JVM 的信号并调用适当的 JVM 方法处理。
------ 本文完 ------