JVM(上)

说明

接下来会用三个篇幅来学习总结 JVM,所涉及内容如下图:

定义

JVM 全称 Java Virtual Machine,也就是我们耳熟能详的 Java 虚拟机。它能识别 .class后缀的文件,并且能够将其解析成具体的机器指令,最终调用操作系统上的函数,完成我们想要的操作。

特性

JVM 的存在使得 Java 程序具有运行平台无关性的特点。

JVM、JRE、JDK 的关系

JVM只是一个翻译,把Class翻译成机器识别的代码,但是需要注意,JVM 不会自己生成代码,需要大家编写代码,同时需要很多依赖类库,这个时候就需要用到JRE。

JRE是什么,它除了包含JVM之外,提供了很多的类库(就是我们说的jar包,它可以提供一些即插即用的功能,比如读取或者操作文件,连接网络,使用I/O等等之类的)这些东西就是JRE提供的基础类库。JVM 标准加上实现的一大堆基础类库,就组成了 Java 的运行时环境,也就是我们常说的 JRE(Java Runtime Environment)。

但对于程序员来说,JRE还不够。我写完要编译代码,还需要调试代码,还需要打包代码、有时候还需要反编译代码。所以我们会使用JDK,因为JDK还提供了一些非常好用的小工具,比如 javac(编译代码)、java、jar (打包代码)、javap(反编译<反汇编>)等。这个就是JDK。

其实他们是种包含关系,如下图:

角色

Java程序运行在操作系统上,JVM 的作用是将源程序编译后的class、jar 包等字节码文件,翻译生成机器指令。如下图:


构成

JVM 是由,类装载器子系统、运行时数据区、执行引擎、本地方法接口和垃圾收集模块等五大模块组成。如下图:

类加载器

单独成章

执行引擎

单独成章

本地方法接口

后续单独成章

垃圾回收器

见 JVM(下)

运行时数据区

见上图,运行时数据区是在程序运行过程中操作系统划分给 JVM 的一块内存区域,分为 5 个部分,虚拟机栈、本地方法栈、程序计数器、堆和方法区。这 5 部分又归为 2 种,其中前 3 个为线程独有区,而堆和方法区为线程共享区域。

程序计数器

每个线程都有一个单独的程序计数器,用来记录当前线程执行的字节码的位置。其实如果不考虑 OS 多线程的话,是不需要程序计数器的,一个线程可以直接跑完结束。但是现实情况同时运行的线程数目大于 OS 核心数 *2 时,就需要 CPU 时间片轮转来分配线程轮流执行,这个时候就需要它来记录每个线程的执行位置了。它有以下几个特点需要注意:

  • 程序计数器具有线程隔离性
  • 程序计数器占用的内存空间非常小,可以忽略不计,而且它是运行时数据区唯一不会出现 OOM 的区域
  • 程序执行的时候,程序计数器是有值的,其记录的是程序正在执行的字节码的地址
  • 执行native本地方法时,程序计数器的值为空。原因是native方法是java通过jni调用本地C/C++库来实现,非java字节码实现,所以无法统计
虚拟机栈

栈,是一种后入先出的数据结构,其中存放一个或者多个,当前线程正在执行的方法包装成的栈帧,这些栈帧是按照后入在上的顺序存放的。当前执行的栈帧在最上边。每个栈帧是由局部变量表、操作数栈、动态链接、完成出口组成的。如下虚拟机栈示意图:

我们来写一段代码,来看一下,方法是怎样在栈中执行的:

public class Person {
  
  public int work(){
    int x = 1;
    int y = 2;
    int z = (x + y)*10;
    return z;
  }
  
  public static void main(String[] args){
    Person person = new Person();
    person.work();
  }

}

我们将上边代码在编辑器运行一遍,在项目根目录的 out文件夹下对应的包名会有编译好的 Person.class 文件,找到该文件拖到终端命令行,class 文件同级目录下执行javap -c Person.class 反汇编命令,会在终端得到该段代码的汇编指令如下:

我们先口述一下这段代码的执行过程:

1、main-code-0: new 首先 JVM 会先将这个 Person.class 元数据加载到 内存中的方法区 (Method Area) 中(信息都包括常量池信息,方法的定义 以及编译后的方法实现的二进制形式的机器指令,所有的线程共享一个方法区,从中读取方法定义和方法的指令集),将其引用压入操作数栈(new指令并不能完全创建一个对象,对象只有在初,只有在调用初始化方法完成后(也就是调用了invokespecial指令之后),对象才创建成功)

2、main-code-3: dup 将操作数栈定的数据复制一份,并压入栈,此时栈中有两个引用值

3、main-code-4: invokespecial pop出栈引用值,调用其构造函数,完成对象的初始化

4、main-code-7: astore_1pop出栈引用值,将 person 赋值给局部变量表中的 index = 1的位置

5、main-code-8: aload_1将局部变量表中的 person 压入栈,因为 person 调用了 work方法

6、main-code-9: invokespecial将 person 出栈,调用 main 中的 invokespecial 指令:

首先进行方法符号引用校验,查找是否有 work() 这个方法的定义

然后为新的方法调用创建新的栈帧,JVM 会为此方法 greeting 创建一个新的栈帧(VM stack),并根据 greeting 中操作数栈的大小和局部变量的数量分别创建相应大小的操作数栈;然后将此栈帧推到虚拟机栈的栈顶。

更新PC指令计数器的值,将当前 PC 程序计数器的值记录到 greeting 栈帧中,当 greeting 执行完成后,以便恢复PC值。更新PC的值,使下一条执行的指令地址指向 greeting 方法的指令开始部分。
这条语句会使当前的 main 方法执行暂停,使 JVM 进入对 greeting 方法的执行当中当 greeting 方法执行完成后,才会恢复 PC 程序计数器的值指向当前下一条指令。

7、此时,main 方法的栈帧被压在下边,work 方法的栈帧在栈顶开始执行

8、以下截图为 work 方法的执行过程:

work 栈帧执行时示意图如下所示:


9、work-code-12: ireturn 将计算结果出栈并且压入到该方法的调用的栈中也就是 main 的栈帧的操作数栈中,将 work 栈帧弹出

10、main-code-12: pop将栈顶数值弹出

11、main-code-13: return 方法执行结束,main 栈帧弹出

  • 还可以参考其他示例

    本地方法栈

    本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用 Java 实现的,而是由 C 语言实现的。

    堆是 JVM 上最大的内存区域,这块区域该进程的所有线程共享,我们申请的几乎所有的对象和数组,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。

    那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。

    Java 的对象可以分为基本数据类型和普通对象。

    对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。

    对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。

    方法区

    1、方法区的发展过程

    方法区存放类信息、静态变量、常量和即时编译器编译后的代码等线程共享数据。

    我个人在学习 JVM 分区中感觉方法区是最不容易理解的,因为需要动态的去观察它的变化。

    方法区经常被称作永久代和静态存储区,其实HotSpot 虚拟机在 JDK8 以前使用永久代来实现方法区,但在其它虚拟机中,例如,Oracle 的 JRockit、IBM 的 J9 就不存在永久代一说。因此,方法区只是 JVM 中规范的一部分,可以说,在 HotSpot 虚拟机中,设计人员使用了永久代来实现了 JVM 规范的方法区。

    在JDk8中,取消了永久代,用元空间代替之。也就是说,用元空间来实现方法区。

    为什么要用元空间代替永久区:

    • 字符串存在永久代中,容易出现性能问题和内存溢出

    • 永久代大小不容易确定,PermSize 指定太小容易造成永久代OOM

    • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低

    • 便于将 HotSpot 与 JRockit 合二为一 (JRockit 中并没有永久代)

    2、方法区的组成

    方法区经常和静态变量、常量池等,同时出现,而常量池,又有静态常量池、动态常量池、字符串常量池、class文件常量池等,而这些常量还可以分为字面量和符号引用两类。而类信息是不是都保存在常量池中?这些概念很多,逻辑也比较乱,我们需要好好捋一捋。先分别看一下上边的概念

    • 常量是指用 final 修饰的成员变量,值一旦给定就无法改变

    • java 3 种变量及其存放位置分别为 静态变量(独立于方法之外的变量,用 static 修饰)存放在方法区,实例变量(方法外部,无 static 修饰)作为对象的一部分,存放在堆中,局部变量(方法内部变量)保存于栈中,栈随线程的创建而被分配。

    • final 修饰的 3 种变量都会将字面量或者引用放置到方法区的静态常量池中,此处能展开,如果修饰的是基本类型数据,则方法区常量池存放的是字面量,但如果是对象引用或者 String,在执行时会将字符引用替换成直接引用并且将其移至动态常量池中。

    • 字面量包括:文本字符串、final 修饰的常量、基本数据类型的值

    • 符号引用包括:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符

    • 字符串常量池中存放字符串的引用,字符串对象存放在堆中。此处也可以展开,字符串有两种创建方式分别为:String str=“abc”;String str = new String(“abc”); 这两种是有区别的,前者会优先去常量池中寻找,如果找到,直接返回,找不到才会去创建。后者在类加载时会先创建一个 “abc” 的字符串创建在常量池中,在执行到 new 才会调用 String 的构造函数,同时 String 对象中的 char 数组将会引用常量池中字符串,在堆内存中创建一个 String 对象,最后,str 将引用 String 对象 。这个过程需要好好理解一下。

    • class 文件常量池存放类的元数据比如,类的方法代码、访问权限、全限定名等。 class 对象是在堆中的。

    3、总结:

    以上这些比较分散混乱,我们来对照类的加载过程简单做一下总结,我们这里只关注在类的生命周期中,方法区中数据的变化,不过多讨论类的生命周期。

加载:在加载之前,堆中的 classloader 其实已经将 A 的元数据装载到了方法区,这时方法区已经将类信息、字段信息、方法信息等放到了静态常量池中,加载其实是对应汇编指令invokespecial 的,此时应该是执行了 new A(); jvm 会根据方法区中的类的全限命名获取该类的二进制流,并且将此二进制流静态的存储结构转化为运行时数据结构,还会在堆中创建一个 A.class 的对象作为此方法区中此类各种数据的访问入口。

验证:此过程是验证二进制流是否符合虚拟机的各项规范方法区的值并没有变化

准备:此过程 JVM 为类的静态变量分配内存并初始化为默认值

解析:该阶段把类在常量池中的符号引用转为直接引用

初始化:为类的静态变量赋予程序设定的初始值

其实在加载之后,方法区中的常量只有数据结构的改变,从静态常量变为运行时常量。只有在类被卸载后(很难)方法区中的常量才会被回收(前提是方法区实现了垃圾回收),而静态变量我们知道,它们的生命周期和进程一致,只有程序停止运行才会被回收。

详细方法区的学习可以看这篇文章

直接内存

不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域;如果使用了NIO,这块区域会被频繁使用,在java堆内可以用directByteBuffer对象直接引用并操作。

这块内存不受 java 堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize来设置(默认与堆内存最大值一样),所以也会出现OOM异常。

你可能感兴趣的:(JVM(上))