为什么Java程序要在虚拟机上运行?

Java程序的运行离不开Java运行时环境,又称为JRE,它是Java程序的最小可运行环境,包含Java虚拟机和Java的核心类库。我们每天都在接触的JDK实际上就包含了JRE,同时在/bin目录下还有一些其它的小工具等。

它们的范围大小比较关系是:JDK > JRE > JVM

对于虚拟机来说,它所能运行的是以.class结尾的字节码文件,我们平时写的.java源文件都需要经过编译器编译成字节码。

通俗一点说,虚拟机只认字节码,不管你是不是Java语言,只要你能编译成字节码就能在我的虚拟机上运行,例如Scala语言,它可以被编译成字节码运行在Java虚拟机上,且可以调用Java的所有类库。

字节码的意义在于什么呢?在于只要有了字节码,就可以在不同平台的虚拟机实现上运行,即:“一次编译,到处运行”。

虚拟机的另一个特点是它能够进行自动内存管理和垃圾回收,不像C++语言那样需要开发者手动进行内存的分配与回收,这一点有利也有弊,“利”是方便了开发者,无需过多关注内存,将精力放在程序的业务逻辑上;“弊”是可能会由于开发者的小失误,导致内存溢出,从而使程序崩溃。于是内存管理和垃圾回收出现了一些可以调优的地方。

虚拟机是如何运行字节码的?

从虚拟机视角看,执行Java代码首先需要将它被编译而成的.class字节码文件加载到Java虚拟机中,加载后的Java类会被存放至方法区中,实际运行时,虚拟机会执行方法区内的代码。

对于虚拟机来说,在实际运行时会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有些区域的生命周期和虚拟机进程一致,而有些区域的生命周期和用户线程保持一致。

运行时数据区域

根据《Java虚拟机规范》的规定,运行时期虚拟机内部区域划分如下图所示:

运行时数据区

这也是我们常说的JVM内存模型。下面我们对每个区域进行详细说明。

  1. 程序计数器

这是一块比较小的内存区域,简单的理解就是记录了当前线程正在运行的字节码的行数。对于单核CPU来说,实现多线程的方式是进行线程上下文切换,线程在运行过程中被挂起,当再次切换回来时需要回到之前运行的位置,程序计数器就是来记录该位置的一块内存区域,每个线程各自记录各自的,不会出现OutOfMemoryError错误,它是线程隔离的数据区。

  1. 虚拟机栈

虚拟机栈描述的是Java方法执行的线程内存模型,在运行过程中,每当调用一个Java方法,虚拟机会在当前线程的虚拟机栈中同步创建一个栈帧用来存储该方法的信息,包括局部变量表、操作数栈、动态连接和方法出口等信息。当退出当前调用的方法时,虚拟机栈会弹出该栈帧,无论是正常返回还是异常退出。也就是说,每一个Java方法的调用到结束都对应着一个栈帧在当前线程的虚拟机栈中入栈和出栈的过程。

局部变量表存放了编译期可知的Java基本数据类型、对象引用和returnAddress类型(指向方法return返回后将要执行的字节码指令的地址)。其中基本数据类型(boolean/byte/char/short/int/float/long/double)在局部变量表中以局部变量槽的形式表示,长度为64doublelong类型会占用两个变量槽,其余的都只会占用一个变量槽。当一个方法开始执行时,栈帧中的局部变量表内存大小就已经确定了,不会在运行期间动态改变。

如果线程请求的栈深度大于虚拟机栈所允许的深度(递归调用),将抛出StackOverflowError异常;如果线程申请栈空间时空间不足,则会抛出OutOfMemoryError异常。

  1. 本地方法栈

本地方法栈描述的是本地Native方法执行的线程模型,其作用与虚拟机栈一致。

堆内存是虚拟机管理的内存中最大的一块,其生命周期和虚拟机进程保持一致。有一句话是这样说的:“new出来的对象都在堆上”。堆内存是垃圾收集器所管理的内存区域,现代垃圾收集器大部分是基于分代收集理论设计的,所以堆内存又被细分为“新生代”、“老年代”、“永久代”、“Eden区”、“From Survivor区”和“To Survivor区”。再次细划出这些区域的原因只是为了更好的的分配和回收内存。

堆内存是支持扩展的,可通过参数-Xmx(最大堆内存)和-Xms(最小堆内存)设置。为了避免GC后堆内存重新分配,通常将两者的值设为一样。

  1. 方法区

方法区是存放字节码的内存区域,当一个字节码文件被加载后,会将其对应类型信息、常量、静态变量和即时编译器编译后的代码缓存等数据保存至方法区。

说到方法区,不得不说到永久代的概念,在虚拟机的早期实现中,标准HotSpot虚拟机的垃圾收集器所管理的内存区域包含了方法区,所以方法区又被称为永久代,但实际上HotSpot虚拟机只是用永久代来实现方法区,为了省去专门为方法区提供内存管理的工作。在Java6时代,永久代就被放弃了,改为使用本地内存来实现方法区。到Java7时,原本存放在永久代的字符串常量池和静态变量被移动到堆内存中。而到了Java8时,永久代完全被抛弃,取而代之的是本地内存中的元空间,原本存在于永久代中的类型信息和代码缓存等数据被移到了元空间中。

  1. 运行时常量池

运行时常量池属于方法区的一部分,它存放的是常量池表,包含字面量和符号引用。该内存区域可以在运行期动态改变,常用的是String类的intern()方法,受方法区内存大小限制,该区域也会抛出OutOfMemoryError异常。

字节码如何被执行?

对于HotSpot虚拟机来说,它所做的工作是将字节码翻译成机器码。而翻译有两种形式:第一种是解释执行,逐条将字节码翻译成机器码并执行;第二种是即时编译,将一个方法所包含的所有字节码翻译成机器码后再执行。

前者的优点是无需等待编译,而后者的优点是实际运行速度更快。HotSpot虚拟机采用了混合模式,综合两种形式,先解释执行字节码,通过热点代码探测技术发现热点代码后,以方法为单位进行即时编译。

HotSpot虚拟机包含多个即时编译器:C1C2等。

C1又叫做Client编译器,面向的是对启动性能有要求的客户端GUI程序,采用的优化手段相对简单,因此编译时间较短。

C2又叫做Server编译器,面向的是对峰值性能有要求的服务端程序,采用的优化手段相对复杂,因此编译时间较长,但生成的机器码的执行效率更高。

Java7开始,HotSpot采用分层编译:热点方法首先会被C1编译,然后热点方法中的热点会进一步被C2编译。为了不影响程序的正常运行,热点方法的编译是放在子线程中进行的。HotSpot会根据CPU的核数设置即时编译的线程数,并按照1:2的比例分配给C1C2编译器。

参考

  • 《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 - 周志明
  • 《深入拆解Java虚拟机》 - 极客时间