JAVA JVM详细总结

338 阅读37分钟

一. JVM简介

JVM是Java Virtual Machine(Java虚拟机)的缩写,也就是指的JVM虚拟机,是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。 总所周知,java语言是跨平台的,而JVM是java跨平台的关键之所在。JVM上执行java字节码,执行时这些字节码可以解释成具体平台的机器码,因此java拥有“一次编译,处处运行”这一跨平台能力。

二、JRE、JDK和JVM的关系

**JRE(Java Runtime Environment, Java运行环境)**是Java平台,所有的程序都要在JRE下才能够运行。包括JVM和Java核心类库和支持文件。

**JDK(Java Development Kit,Java开发工具包)**是用来编译、调试Java程序的开发工具包。包括Java工具(javac/java/jdb等)和Java基础的类库(java API )。

**JVM(Java Virtual Machine, Java虚拟机)**是JRE的一部分。JVM主要工作是解释自己的指令集(即字节码)并映射到本地的CPU指令集和OS的系统调用。Java语言是跨平台运行的,不同的操作系统会有不同的JVM映射规则,使之与操作系统无关,完成跨平台性。

有两个概念和JVM息息相关并且很容易搞混,那就是JRE和JDK。其中JRE(JavaRuntimeEnvironment,Java运行环境),指的是Java平台。所有的Java 程序都要在JRE下才能运行。普通用户运行已开发好的java程序,只要安装JRE即可。而JDK(JavaDevelopmentKit)是程序开发者用来编译、调试java程序用的开发工具包。JDK工具包里面的工具也是Java写的程序,因此也需要JRE才能运行。为了保持JDK的独立性和完整性,在JDK的安装过程中,JRE也是安装的一部分。所以,在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。而JVM是JRE的一部分。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。使用JDK(调用JAVA API)开发JAVA程序后,通过JDK中的编译程序(javac)将Java程序编译为Java字节码,在JRE上运行这些字节码,JVM会解析并映射到真实操作系统的CPU指令集和OS的系统调用。

从上面我们可以看出java运行主要分几个步骤:

  • 1、java源码编译。
  • 2、类加载。
  • 3、类执行。

三. java 编译过程及分类

所谓”编译“,通俗来讲就是把我们写的代码“翻译“成机器可以读懂的机器码。Java 技术中的编译器可以分为如下两类:

  • 前端编译器:把 *.java 文件转变为 *.class 文件的过程。比如 JDK 的 Javac。

  • 后端编译器(两类):

    1. 即时编译器:Just In Time Compiler,常称 JIT 编译器,在「运行期」把字节码转变为本地机器码的过程。比如 HotSpot VM 的 C1、C2 编译器,Graal 编译器。

    2.提前编译器:Ahead Of Time Compiler,常称 AOT 编译器,直接把程序编译成与目标机器指令集相关的二进制代码的过程。比如 JDK 的 Jaotc,GNU Compiler for the Java。

我们可以把将.java文件编译成.class的编译过程称之为前端编译。把将.class文件翻译成机器指令的编译过程称之为后端编译。

Javac编译器对代码的运行效率几乎没做什么优化,虚拟机设计者把对代码性能的优化集中到了后端的JIT编译器中。之所以这样设计,因为Class文件拥有虚拟机规范严格定义的通用格式,只要符合Class文件格式,就可以被虚拟机正确加载,因此不只是Java语言,其他如JRuby、Groovy、Kotlin等语言也可以被编译成Class文件。但不同语言使用的前端编译器(将源码文件编译成Class文件)可能是不同的,故将优化过程放到即时编译器过程,可以让不同语言的字节码都能享受到性能优化的好处。Javac编译器本身是由Java语言编写的,Javac编译器针对程序编码过程做了很多优化措施,目的是改善程序员的编码风格和提高编码效率。

1、前端编译

把Java源码文件(.java)编译成Class文件(.class)的过程;也即把满足Java语言规范的程序转化为满足JVM规范所要求格式的能力,称为前端编译。前端编译阶段中,最重要的一个编译器就是javac 编译器, 在命令行执行javac命令,其实本质是运行了javac.exe这个应用。Android工程师可能对于 Gradle构建过程更为熟悉,构建过程中有一个Task:compileDebugJavaWithJavac,其实也用到了javac 编译器,编译中间产物路径在build/intermediates/javac/debug/classes。

优点:

  1. 这阶段的优化是指程序编码方面的;
  2. 许多Java语法新特性("语法糖":泛型、内部类等等),是靠前端编译器实现的。
  3. 编译成的Class文件可以直接给JVM解释器解释执行,省去编译时间,加快启动速度;

缺点:

  1. 对代码运行效率几乎没有任何优化措施;
  2. 解释执行效率较低,所以需要结合下面的JIT编译;

Javac编译的基本流程

  • 准备过程 初始化插入式注解处理器。

  • 解析与填充符号表

    1. 语法、词法分析 词法分析将源代码的字符转成标记(Token)集合,单个字符是程序编写的最小单位,而标记则是编译过程的最小单位。如“int a = b + 2”这句代码可拆分为int、a、=、b、+、2共6个标记。 语法分析是根据Token序列构造抽象语法树(AST,Abstract Syntax Tree)的过程。AST是一种用来描述程序代码语法结构的树形表示形式,语法树的每一个节点都代表着程序代码中的一个语法结构,如包、类型、修饰符、运算符、接口、返回值、代码注释等。抽象语法树建立之后,编译器基本不会再对源码文件进行操作了,后续的操作都建立在抽象语法树之上。
    2. 填充符号表 符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,其中保存的信息在编译的不同阶段都要用到。以下是符号表的两个应用场景: 1. 在语义分析中,符号表登记的内容将用于语义检查和产生中间代码。 2. 在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。
  • 注解处理器

    1. JDK1.5之后,Java语言提供了对注解的支持。
    2. JDK1.6中提供了一组插入式注解处理器的标准API,支持在编译期间对注解进行处理。
    3. 注解处理器可将其看做编译器的插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素,如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止。
    4. 有了编译器注解处理的标准API支持,我们的代码才有可能干涉编译器的行为。
  • 语义分析 语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查,因为抽象语法树虽然能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。

  • 标注检查 标注检查步骤检查的内容如变量使用前是否已经被声明、变量与赋值之间的数据类型是否匹配等。

  • 数据及控制流分析 数据及控制流分析是对程序上下文逻辑更进一步的验证,可以检查出诸如程序局部变量是否在使用前有赋值、方法的每条路径是否都有返回值、是否所有的受检异常都被正确处理等。

  • 解语法糖 所谓语法糖,指在计算机语言中添加某种语法,只是为了更方便程序员使用,如提高编码效率或减少出错,但对语言功能没有影响。 所谓解语法糖(desugar),是指在编译阶段将糖衣语法还原回简单的基础语法结构,因为虚拟机运行时不支持这些语法。

    1. Java中常见的语法糖有:
      1. 泛型(JDK 1.5添加)——Java中的泛型其实是伪泛型,编译后就会被替换为原生类型了,并在相应的地方插入了强制转型代码。因此Java中的泛型实现方法也被称为类型擦除。
      2. 自动装箱、拆箱——编译后被转化成了对应的包装和还原方法。
      3. 循环遍历——编译后代码被转成了迭代器实现,这也是被遍历的类需要实现Iterable接口的原因。
      4. 变长参数——编译后实际上被转成数组类型的参数。
    2. Java中常见的其他语法糖:
      1. 其他语法糖还有内部类、枚举类、断言语句、对枚举和字符串的switch支持(JDK1.7)等,可以通过跟踪Javac源码、反编译Class文件等方式了解它们的实现本质。
  • 字节码生成 字节码生成是Javac编译过程的最后一个阶段,字节码生成阶段不仅仅是把前面各个步骤生成的信息(AST、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。完成了了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交给com.sun.tools.javac.jvm.ClassWriter类,由这个类的writeClass()方法输出字节码,生成最终的Class文件,到此Javac的编译过程结束。

Javac前端编译器 Oracle javac、Eclipse JDT中的增量式编译器(ECJ)等。

2、后端编译之即时(JIT)编译

根据前面的内容,我们知道编译前端的核心编译产物是:Class 文件。但是对于CPU来说,它是不认得字节码的。每种CPU只能“读懂”自身支持的机器语言或者本地代码(native code)。因此,Java 虚拟机在执行字节码时,需要将字节码翻译为当前平台的本地代码,可以分为:解释执行 & 编译执行。

  • 解释执行 解释执行,就像python一样,代码运行到哪里,就把代码解释到哪里。这么做的优点和缺点都很明显。
    • 解释执行优点

      1. 方便更新。代码可以在程序执行的过程中修改.
      2. 启动快。拿到代码就可以跑,没有其他多余操作。
      3. 平台无关。所有操作都基于jvm,全平台通用。
    • 解释执行缺点

      1. 平台效率低。由于程序执行性能只依赖jvm,导致不同平台特有的优化无法发挥。
      2. 代码效率低。无法对代码动态优化,只能拿到什么执行什么。

JVM 通过字节码解释器将其翻译成对应的机器指令,逐条读入,逐条解释翻译。很显然,经过解释执行,其执行速度必然会比可执行的二进制字节码程序慢很多。这就是传统的JVM的**解释器(Interpreter)**的功能。为了解决这种效率问题,引入了 JIT 技术。JIT 技术指JAVA程序还是通过解释器进行解释执行,当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。

通过Java虚拟机(JVM)内置的即时编译器(Just In Time Compiler,JIT编译器);在运行时把Class文件字节码编译成本地机器码的过程;
优点:

  1. 通过在运行时收集监控信息,把"热点代码"(Hot Spot Code)编译成与本地平台相关的机器码,并进行各种层次的优化;
  2. 可以大大提高执行效率;

缺点:

  1. 收集监控信息影响程序运行;
  2. 编译过程占用程序运行时间(如使得启动速度变慢);
  3. 编译机器码占用内存;
  4. JIT编译器:HotSpot虚拟机的C1、C2编译器等;

那么又一个问题出现了,既然实时编译慢,那为什么不将代码全部进行JIT预编译后再扔到机器上去跑呢,这样就没有这些问题了,只是预编译时间长而已。这个思路其实就是c和c++的做法,直接在不同机器上编译出不同优化方向的代码,但是这样导致了编译后的代码无法跨平台执行,而jvm的最大特性就是跨平台,所以这是不可行的。

注意,JIT编译速度及编译结果的优劣,是衡量一个JVM性能的很重要指标;所以对程序运行性能优化集中到这个阶段;也就是说可以对这个阶段进行JVM调优;

3、后端编译之静态提前编译(Ahead Of Time,AOT编译)

程序运行前,直接把Java源码文件(.java)编译成本地机器码的过程; 优点:

  1. 编译不占用运行时间,可以做一些较耗时的优化,并可加快程序启动;
  2. 把编译的本地机器码保存磁盘,不占用内存,并可多次使用;

缺点:

  1. 因为Java语言的动态性(如反射)带来了额外的复杂性,影响了静态编译代码的质量;
  2. 一般静态编译不如JIT编译的质量,这种方式用得比较少;
  3. 牺牲Java的一致性

静态提前编译器(AOT编译器):JAOTC、GCJ、Excelsior JET、ART (Android Runtime)等;

4、前端编译+后端JIT编译

到这里,我们知道目前Java体系中主要还是采用前端编译+JIT编译的方式,如JDK中的HotSpot虚拟机。 前端编译+JIT编译方式的运作过程大体如下:

  1. 首先通过前端编译把符合Java语言规范的程序代码转化为满足JVM规范所要求Class格式;
  2. 然后程序启动时Class格式文件发挥作用,解释执行,省去编译时间,加快启动速度;
  3. 针对Class解释执行效率低的问题,在运行中收集性能监控信息,得知"热点代码";
  4. JIT逐渐发挥作用,把越来越多的热点代码"编译优化成本地代码,提高执行效率;

四. 类加载机制

".java"文件经过Java编译器编译成拓展名为".class"的文件,".class"文件中保存着Java代码经转换后的虚拟机指令,当需要使用某个类时,虚拟机将会加载它的".class"文件,并创建对应的class对象,将class文件加载到虚拟机的内存,这个过程称为类加载。举个通俗点的例子来说,JVM在执行某段代码时,遇到了class A, 然而此时内存中并没有class A的相关信息,于是JVM就会到相应的class文件中去寻找class A的类信息,并加载进内存中,这就是我们所说的类加载过程。

由此可见,JVM不是一开始就把所有的类都加载进内存中,而是只有第一次遇到某个需要运行的类时才会加载,且只加载一次。

从类被加载到虚拟机内存中开始,到卸御出内存为止,它的整个生命周期分为7个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。其中验证、准备、解析三个部分统称为连接。

1.(装载) 加载

类的装载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

加载.class文件的来源方式:

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件
2. 连接
  • 验证
    验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。

  • 准备 为类的静态变量分配内存,并将其初始化为默认值,这些内存都将在方法区中分配。

    1. 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
    2. 这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

    为类变量(即static修饰的字段变量)分配内存并且设置该类变量的初始值,这里不包含用final修饰的static,因为final在编译的时候就会分配了,注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。 例如:

      public String firstName = "苏";
      public static String middleName = "东";
      public static final String lastName = "坡";
    

    firstName 不会被分配内存,而 middleName 会;但 middleName 的初始值不是“东”而是 null。 需要注意的是,static final 修饰的变量被称作为常量,和类变量不同。常量一旦赋值就不会改变了,所以 lastName 在准备阶段的值为“坡”而不是 null。

  • 解析 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用就是class文件中的:

    • CONSTANT_Class_info
    • CONSTANT_Field_info
    • CONSTANT_Method_info 等类型的常量。

    在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。

3. 初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了加载(Loading)阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。类的初始化过程,简单地说就是执行类的()方法的过程。 JVM初始化步骤:

  1. 假如这个类还没有被加载和连接,则程序先加载并连接该类
  2. 假如该类的直接父类还没有被初始化,则先初始化其直接父类
  3. 假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

  1. 创建类的实例,也就是new的方式
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(如Class.forName(“com.ttx.Test”))
  5. 初始化某个类的子类,则其父类也会被初始化
  6. Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
4. 使用

当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码。这个使用阶段也只是了解一下就可以了。

5. 卸载

当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存。这个卸载阶段也只是了解一下就可以了。

6. 结束生命周期

在如下几种情况下,Java虚拟机将结束生命周期

  • 执行了 System.exit()方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止

五. JVM类加载器

类加载器的任务是根据一个类的全限定名来读取此类的二进制字节流到JVM中,然后转换为一个与目标类对应的java.lang.Class对象实例,一旦一个类被加载如JVM中,同一个类就不会被再次载入了。正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。

例如,如果在pg的包中有一个名为Person的类,被类加载器ClassLoader的实例kl负责加载,则该Person类对应的Class对象在JVM中表示为(Person.pg.kl)。这意味着两个类加载器加载的同名类:(Person.pg.kl)和(Person.pg.kl2)是不同的、它们所加载的类也是完全不同、互不兼容的。

总的来说,类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。类加载器负责读取 Java 字节代码,并转换成 java.lang.Class类的一个实例。有了Class类实例,就可以通过newInstance方法创建该类的对象。一般来说,默认类加载器为当前类的类加载器。比如A类中引用B类,A的类加载器为C,那么B的类加载器也为C。

在虚拟机提供了3种类加载器,引导(Bootstrap)类加载器、扩展(Extension)类加载器、系统(System)类加载器(也称应用类加载器)

1. Bootstrap ClassLoader

加载JVM自身工作需要的类,它由JVM自己实现。它会加载$JAVA_HOME/jre/lib下的文件

2. ExtClassLoader

它是JVM的一部分,由sun.misc.Launcher[图片上传失败...(image-f2fdd2-1597653733533)] JAVA_HOME/jre/lib/ext目录中的文件(或由System.getProperty("java.ext.dirs")所指定的文件)。

3. AppClassLoader

应用类加载器,我们工作中接触最多的也是这个类加载器,它由sun.misc.Launcher$AppClassLoader实现。它加载由System.getProperty("java.class.path")指定目录下的文件,也就是我们通常说的classpath路径。

4. 双亲委派模型

如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式(接下来的源码可以看出这个流程

自定义Java类加载器 从上面源码的分析,可以知道:实现自定义类加载器需要继承ClassLoader,如果想保证自定义的类加载器符合双亲委派机制,则覆写findClass方法;如果想打破双亲委派机制,则覆写loadClass方法。

5. 何时出发类加载动作?

类加载的触发可以分为隐式加载和显示加载。

  1. 隐式加载 隐式加载包括以下几种情况:

    • 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时
    • 对类进行反射调用时
    • 当初始化一个类时,如果其父类还没有初始化,优先加载其父类并初始化
    • 虚拟机启动时,需指定一个包含main函数的主类,优先加载并初始化这个主类
  2. 显示加载 显示加载包含以下几种情况:

    • 通过ClassLoader的loadClass方法
    • 通过Class.forName
    • 通过ClassLoader的findClass方法
6. 编写自定义类加载器的意义何在?
  • 当class文件不在ClassPath路径下,默认系统类加载器无法找到该class文件,在这种情况下我们需要实现一个自定义的ClassLoader来加载特定路径下的class文件生成class对象。
  • 当一个class文件是通过网络传输并且可能会进行相应的加密操作时,需要先对class文件进行相应的解密后再加载到JVM内存中,这种情况下也需要编写自定义的ClassLoader并实现相应的逻辑。
  • 当需要实现热部署功能时(一个class文件通过不同的类加载器产生不同class对象从而实现热部署功能),需要实现自定义ClassLoader的逻辑。

六. JVM运行时数据区及内存模型(JMM)

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

计算机会提前给将内存分配给软件,由软件控制自己内存区域的管理。如果软件当前内存已经被占满,我们需要将不活跃的数据清除,然后载入新的数据,内存都是可以重复被使用的。JVM也是计算机内存中的一个程序,所以计算机会分配一定的内存给JVM。以堆内存为例:Xmx-最多分配内存的大小2048M Xms-最少分配内存的大小512M 。

首先Java源代码文件(.java后缀)会被Java编译器编译为字节码文件(.class后缀),然后由JVM中的类加载器加载各个类的字节码文件,加载完毕之后,交由JVM执行引擎执行。在整个程序执行过程中,JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存。因此,在Java中我们常常说到的内存管理就是针对这段空间进行管理(如何分配和回收内存空间)。

Java软件在运行时JVM会运行很多的类,但是计算机给我们分配的内存又有一定的限制。所以JVM也需要管理class占用空间的大小或者通过class生成对象占用空间的大小。比如当前JVM的大小是4G 我们不可能时时刻刻对4G的空间进行遍历或者资源的回收JVM为了方便管理对象占用的内存空间,于是将内存运行时数据区进行划分。 线程独享:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享:

  • 堆内存
  • 方法区
  • 堆外内存( metadata元数据区)

1. PC寄存器(程序计数器)

由于JVM同时可以处理多个线程所以就涉及到一些线程调度,当cpu暂停运行线程A把时间片让给线程B的时候我们需要保存线程A被暂停执行前的一些现场状态,需要记录当前执行到那一行字节码了,所以PC寄存器会实时记录当前线程执行的代码行数。

虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)是线程私有的,其生命周期和线程同步,随着线程的启动而创建,随线程的结束而销毁。Java虚拟机栈和线程同时创建,用于存储栈帧。每个方法在执行时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直到执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。此区域有两个异常:当栈深度超过虚拟机的规定时,StackOverFlowError;当扩展时无法申请到足够的内存,OutOfMemeryError。

栈帧(Stack Frame)

每一个方法从调用到方法返回(结束)都对应着一个栈帧入栈出栈的过程(栈帧随着方法调用而创建,随着方法结束而销毁)。最顶部的栈帧称为当前栈帧,当前栈帧所关联的方法称为当前方法,定义当前方法的类称为当前类,该线程中,虚拟机有且也只会对当前栈帧进行操作,如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈帧。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢弃此栈帧。在编译代码时,栈帧需要多大的局部变量表,多深的操作数栈都可以完全确定的,并写入到Class 文件的方法表的 Code 属性中。

  • 局部变量表 是一组变量的存储空间,用于存放 方法参数 和 局部变量。在Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量。虚拟机通过索引定位法的方式使用局部变量表,索引值的范围是从0到Slot的最大数量。在方法执行时,特别是执行实例方法时,那么实例变量表的第0位索引默认是方法所属的实例对象的引用“this”对象,接着是1到Slot参数变量到方法内部的局部变量。局部变量表的基本单位为变量槽(Variable Slot),Java虚拟机规范并没有定义一个槽所应该占用内存空间的大小,但是规定了一个槽应该可以存放一个32位以内的数据类型。如果Slot是32位的,则遇到一个64位数据类型的变量(如long或double型),则会连续使用两个连续的Slot来存储。

  • 操作数栈 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。也常称为操作栈,它是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的max_stacks数据项中。举例来说,在JVM中 执行 a = b + c 的字节码执行过程中操作数栈以及局部变量表的变化如下图所示。局部变量表中存储着a、b、c 三个局部变量,首先将b和c分别入栈。 将栈顶的两个数出栈执行加法操作,并将结果保存至栈顶,之后将栈顶的数出栈赋值给a

  • 动态连接 动态链接主要就是指向运行时常量池的方法引用。因为 Java 是在运行期间动态链接的,所以为了支持动态链接,需要将方法区里面的符号引用转为直接引用(即:给出地址),这就叫动态链接。

  • 方法返回地址 存放调用该方法的PC寄存器的值。一个方法的结束,有两种方式:正常执行完成,出现未处理的异常,非正常退出。方法执行完以后,根据这个值决定返回到哪里去。

2.本地方法栈

JVM运行native方法准备的空间,由于很多native方法都是用C语言实现的,所以通常又叫C栈,它与Java虚拟机栈实现的功能类似,只不过本地方法栈描述本地方法运行过程的内存模型。与虚拟机栈的区别是,虚拟机栈是为执行Java方法服务,而本地方法栈是为执行Native方法服务,同样这个区域也会抛出StackOverFlowError、OutOfMemeryError。

3.堆内存

堆内存理论上是JVM中占用内存最大的一块区域,里面存放了java创建的各种引用数据类型(几乎所有的对象、数组都在这个内存区域分配)。堆内存被所有线程共享,虚拟机启动时就会创建。堆内存中的数据经常会被回收,每次GC的垃圾占总量的90%以上,因此堆是垃圾收集器管理的主要区域。假设本次堆内存大小为4G,为了找出垃圾对象,所花费的时间是比较长的,堆内存为了更好的管理对象,又将堆内存重新进行了区域的划分:分为新生代(Young),老年代(Old), 新生代又被划分为三个区域Eden、From Survivor, To Survivor。当堆中没有足够的内存完成实例分配且无法扩展时,抛出OutOfMemoryError。

新生代(Young)

所有的对象创建都是在新生区创建的,每当JVM进行一次GC,新生代里面的对象的标识就会进行累加+1,如果累计超过15次GC都没有被回收掉,说明这个对象不容易被回收,将被移入老年代。如果新生区太小,会导致每次垃圾回收特别频繁。于是为了更好的管理新生区,将新生区进行区域的划分Eden、From Survivor, To Survivor三个区域的比例为8:1:1。

当对象在 Eden 创建后,在经过一次 GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域(假设为from 区域)所容纳,则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代。然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 )。但这也不是一定的,对于一些较大的对象 ( 即需要分配一块较大的连续内存空间 ) 新生代放不下,则是直接进入到老年代,JVM 认为,一般大对象的存活时间一般比较久远。

From Survivor区域与To Survivor区域是交替切换空间,在同一时间内两者中只有一个不为空。

老年代(Old)

年老代里存放的都是存活时间较久的,大小较大的对象,因此年老代使用标记整理算法。当年老代容量满的时候,会触发一次Major GC(full GC),回收年老代和年轻代中不再被使用的对象资源。老年区的GC不是很频繁,只有进行full GC的时候才会操作老年区。

3.方法区

方法区也是线程共享,在虚拟机启动时创建。 用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。 此区域包含运行时常量池(Runtime Constant Pool)。 当方法区无法满足内存分配需求时,将抛出OutOfMemoryError。

对象的创建(HotSpot) 通过new关键字创建(或克隆、反序列化) (1.) 检查指令的参数(即工作中我们New的对象),能否在常量池中找到它的符号引用。 (2.) 如果存在,检查符号引用代表的类是否被加载、解析、初始化过。如果没有则执行类的加载。 (3.) 加载通过后,虚拟机将为新生对象分配内存。(所需内存大小在类加载完成后便可确定)

七. JVM垃圾回收机制

大家都知道JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

1. 判断对象是否存活的算法

Java堆中存放着几乎所有的对象实例,垃圾回收器在堆进行垃圾回收前,首先要判断这些对象那些还存活,那些已经“死去”。判断对象是否已“死”有如下几种算法:

(1)引用计数法 给每个对象添加一个计数器,当有地方引用该对象时计数器加1,当引用失效时计数器减1。用对象计数器是否为0来判断对象是否可被回收。缺点:无法解决循环引用的问题。

**优点:**引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利(OC的内存管理使用该算法)。

**缺点:**无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。同时,引用计数器增加了程序执行的开销。

(2)可达性分析算法 可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。

2. 常用的垃圾回收算法

(1)标记-清除算法

“标记-清除”算法是最基础的收集算法。算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。后续的收集算法都是基于这种思路并对其不足加以改进而已。 “标记-清除”算法的不足主要有两个: 效率问题:标记和清除这两个过程的效率都不高 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。

(2)复制算法(新生代回收算法) “复制”算法是为了解决“标记-清除”的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等的复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。

(3)分代收集算法 当前JVM垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。 一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。

八. JVM的生命周期

JVM实例对应了一个独立运行的java程序它是进程级别

  • 启动。启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以作为JVM实例运行的起点。

  • 运行。main()作为该程序初始线程的起点,任何其他线程均由该线程启动。JVM内部有两种线程:守护线程和非守护线程,main()属于非守护线程,守护线程通常由JVM自己使用,java程序也可以表明自己创建的线程是守护线程。

  • 消亡。当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出。

参考资料: 《深入理解Java虚拟机》 Java代码到底是如何编译成机器指令的 Java编译方式总结:前端编译、JIT编译、AOT编译 AOT上手 我竟然不再抗拒 Java 的类加载机制了 jvm之后端编译与优化 Java的编译原理! JVM笔记-后端编译与优化