JVM系列文章(二)——进入 JVM 的世界

unimof 2021年02月06日 379次浏览

在前一篇文章Java系列文章(一)——Java 程序运行机制和流程中,我们了解了从 Java 源码 ---> Java字节码 ---> 机器码的操作链,知道了 Java 字节码的基本结构,从本篇开始, 将学习 Java 字节码是如何运行起来的,这里面的核心就是 Java 虚拟机: Java Virtual Machine ,简称 JVM。

1. JVM

上一张经典图来说明:

image.png

  • JVM是Java Virtual Machine(Java虚拟机)的缩写,是一个虚机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
  • Java虚拟机主要由字节码指令集、寄存器、栈、垃圾回收堆和存储方法域等构成。
  • JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
  • JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。
  • JVM伴随Java程序的开始而开始,程序的结束而停止。一个Java程序会开启一个JVM进程,一台计算机上可以运行多个程序,也就可以运行多个JVM进程。
  • JVM针对每个操作系统开发其对应的解释器,所以只要其操作系统有对应版本的JVM,那么这份Java编译后的代码就能够运行起来,这就是Java能一次编译,到处运行的原因。
  • JVM中的线程分为两种:守护线程和普通线程
    守护线程是JVM自己使用的线程,比如垃圾回收(GC)就是一个守护线程。
    普通线程一般是Java程序的线程,只要JVM中有普通线程在执行,那么JVM就不会停止。

总结来说,JVM 读入 Java 字节码,并将字节码翻译成所在操作系统的字节码(屏蔽操作系统差异,使得上层代码编写可以忽略操作系统的差异,至少是大部分差异),并进行解释执行。

所以每个JVM都必须有两种机制:类装载子系统和执行引擎,
类装载子系统:装载具有适合名称的类或接口;执行引擎:负责执行包含在已装载的类或接口中的指令 。

2. JVM结构

架构图:
image.png

更详细的架构图:
image.png

2.1 类加载器

类加载的过程为: 加载-->连接(验证-->准备-->解析)-->初始化。下面介绍其中的几个过程。

  • 加载
      这个过程主要是通过类的全限定名,例如 java.lang.String 这样带上包路径的类名,获取到字节码文件;然后将这个字节码文件代表的静态存储结构(可简单理解为对象创建的模板)存在方法区,并在堆中生成一个代表此类的 Class 类型的对象,作为访问方法区中“模板”的入口,往后创建对象的时候就按照这个模板创建。
      举个例子,有时候通过反射创建对象,像当初学 JDBC 时会通过 Class.getName("com.mysql.jdbc.Driver.class").newInstance() 创建对象,通过 Class 和相应的全限定类名获取到方法区中的“模板”然后创建对象。
    image.png

  • 验证
      验证过程主要确保被加载的类的正确性。首先要先验证文件格式是否规范,如果只是通过 .class 后缀来辨别,那随便把后缀名改一下就可以跑程序了,那岂不是很容易出事。来看看字节码文件大概是长什么样的:
    image.png

  • 准备
      这个阶段主要是给类变量(静态变量)分配方法区的内存并初始化。实例变量不是在这个阶段分配内存,实例变量是随着对象一起分配在堆中。另外,给静态变量初始化为零值或空值,比如public static int n=5;这里并不是马上给 n 这个变量赋值为 5,而是先将其赋值为 0,类似的,如果是引用数据类型,则默认为 null。还有一点需要注意的是,对于 final 类型的数据,必须在程序内给它赋值,系统不会自动初始化,例如 static String str = "hello" + “world”;String 是 final 类型的,在编译阶段就给它优化成 static String str = "helloworld” ,并且将 "helloworld" 放进了常量池。

  • 初始化
      这个阶段就是将静态变量赋值为初始值,还是 public static int n=5; 这回给 n 赋值为 5 了。

2.3 运行时数据区(内存)

2.3.1 方法区(Method Area)

  • 方法区(Method Area)与 堆内存一样,是所有线程共享的内存区域。
  • JDK7 之前(永久代)用于存储已被虚拟机加载的类信息、常量、字符串常量、类静态变量、即时编译器编译后的代码等数据。
  • Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是 String.intern() 方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

Java 中基本类型的包装类的大部分都实现了常量池技术,这些类是 Byte、Short、Integer、Long、Character、Boolean,另外 Float 和 Double 类型的包装类则没有实现。另外 Byte、Short、Integer、Long、Character 这5种整型的包装类也只是在对应值在-128到127之间时才可使用对象池。

JDK 8 JVM 方法区的内存结构示意图:
image.png

2.3.2 堆内存(Heap Memory)

JDK 8以后 Heap Memory 的内存结构示意图:
image.png

Java中堆内存用于存储对象的数据,堆内存分为新生代、老年代、永久代(1.8之前)/元空间(1.8之后):

其中新生代用于分配给刚刚产生的对象,老年代则是分配给已经存货很久的对象,或者在新生代中经常被使用的对象(后面解释什么时候会出现这种情况),元空间则是直接使用的是物理内存,比如说32G的内存,分配给java的为24G,则元空间指的是剩下的8G,而新生代和老年代则是位于24G内。1.8之前的永久带也是位于24G内,这是前后的不同之处。GC垃圾回收只处理新生代和老年代中的垃圾,但是并不是说元空间(方法区就不存在GC),方法区也存在有GC。

关于 GC 和 JVM 启动参数配置(和堆内存相关)的细节,在后续专门探讨。

2.3.3 Java 虚拟机栈 (Java Virtual Machine Stacks)

  • 栈与栈帧
    每一个方法的执行到执行完成,对应着一个栈帧在虚拟机中从入栈到出栈的过程。java虚拟机栈栈顶的栈帧就是当前执行方法的栈帧。PC寄存器会指向该地址。当这个方法调用其他方法的时候久会创建一个新的栈帧,这个新的栈帧会被方法Java虚拟机栈的栈顶,变为当前的活动栈,在当前只有当前活动栈的本地变量才能被使用,当这个栈帧所有指令都完成的时候,这个栈帧被移除,之前的栈帧变为活动栈,前面移除栈帧的返回值变为这个栈帧的一个操作数。
    栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。第一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
    每一个栈帧都包括了局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息。在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现。
    一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧的概念结构如下图所示:

  • 线程私有
    Java 虚拟机栈是线程私有的,他与线程的声明周期同步。虚拟机栈描述的是Java方法执行的内存模型,每个方法执行都会创建一个栈帧,栈帧包含局部变量表、操作数栈、动态连接、方法出口等。

  • 栈结构图
    image.png

注意:

在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。

执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

2.3.4 PC寄存器 (Program Counter (PC) Register)

  • 线程私有
    每个线程启动的时候,都会创建一个PC(Program Counter,程序计数器)寄存器。PC寄存器里保存有当前正在执行的JVM指令的地址。 每一个线程都有它自己的PC寄存器,也是该线程启动时创建的。保存下一条将要执行的指令地址的寄存器是 :PC寄存器。PC寄存器的内容总是指向下一条将被执行指令的地址,这里的地址可以是一个本地指针,也可以是在方法区中相对应于该方法起始指令的偏移量。

  • 占用空间很小
    PC寄存器就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
    它是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。

  • Native方法为空
    如果执行的是一个Native方法,那这个计数器是空的。

举例:如值日表,又如test0()调用test1(),test1()调用test2(),顺序执行,就是pc寄存器的作用。

2.3.5 本地方法栈 (Native Method Stacks)

该区域在于 Java 调用 Native 方法 (比如 C 语言开发的本地库)时会用到,暂时不做深入了解!感兴趣的朋友请自行查找相关资料。

3. 类加载器

image.png

  启动类加载器是由C/C++写的,主要负责加载 jre\lib 目录下的类;扩展类加载器主要负责加载 jre\lib\ext 目录下的类;而应用程序类加载器主要负责加载我们自己编写的类;当然还能自己写类加载器,即自定义加载器。程序主要由前面三个类加载器相互配合加载的。

public class Main {
    public static void main(String[] args) {
        Main main = new Main();
        System.out.println(main.getClass().getClassLoader());
        System.out.println(main.getClass().getClassLoader().getParent());
        System.out.println(main.getClass().getClassLoader().getParent().getParent());
    }
}

 双亲委派机制
  在类加载的过程中,存在着双亲委派机制,即当要加载一个类时,先由父类加载器加载,当父类加载器没办法加载时,才由下面的加载器加载,来看一个程序:

package java.lang;   // 自定义的包

public class String {
    public static void main(String[] args) {
        System.out.println("这是自定义的java.lang.String类");
    }
}

image.png

  由于 jre\lib\ext 中存在 java.lang.String 类,当加载该类的时候,根据全限定名进行查找,找到后由启动类加载器加载,发现 String 类中不包含 main() 方法,因此程序出错。

4. 解释执行

通过前面的步骤,我们了解了Java 的 .class 字节码文件是如何加载到 JVM 虚拟机内的,JVM 虚拟机的组成部分,内存分布及管理等,后续我们将了解 JVM 的另一个重要部件:执行器,重点内容是 JVM 的 解释执行 和 编译执行,Java 为何被称为半编译半解释。

请移步下期 Java系列文章(三)——深入 JVM 的世界