JVM虚拟机内存模型——详解

JVM虚拟机内存模型

1.JVM体系架构

  • 根据下图我们就可以知道JVM的内存区域一共分为五个部分:
    1)堆
    2)虚拟机栈
    3)本地方法栈
    4)程序计数器
    5)方法区
  • 根据线程私有和公有可以分为两类:
    1)每个线程私有的内存区域:虚拟机栈,程序计数器,本地方法栈
    2)所有线程共享的内存区域:方法区,堆

JVM虚拟机内存模型——详解

2.堆内存

  • 堆内存中存储的全部都是对象,也就是说所有使用new创建的对象都会存放在堆中
  • 堆内存中的数据是所有线程共享的
  • 堆的内存是动态分配的

2.1 堆内存体系架构

  • 堆内存中主要可以分为三个区域:新生代,老年代
  • 新生代又分为:一个Eden区(生存区),两个Survivor区(S1,S2区)(幸存区)

JVM虚拟机内存模型——详解

2.2 新生代

  • 大多数的对象都在Eden(生存区)被创建,但是有很多对象生命周期很短,在垃圾回收后只有少量的对象存活下来
  • 经过多次的GC回收后(有一个阈值),在Eden区仍然存活的对象被转移到Survivor1区,S1区经过多次GC回收之后存活的对象转移到S2区,这样就会保证S1和S2一定有一个区是有内存的,因为Eden区会转移对象进来
  • 在Survivor区经过多次GC回收之后仍然存活的对象就进入老年代

2.3 老年代

  • 在新生区经历多次GC仍然存活的对象进入老年代
  • 该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代)。

2.4 永久代

  • 永久代不是堆内存中的,jdk1.7以前永久代是方法区中
  • 主要存放元数据,例如Class、Method的元信息,与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。
  • 在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。

3. java虚拟机栈

这篇博客写Java 虚拟机栈讲的非常好,大家可以看看:
JVM 系列 - 内存区域 - Java 虚拟机栈(三)

3.1 java虚拟机栈概述

  • 虚拟机栈是线程私有的,生命周期随着线程的启动而启动,线程的消亡而消亡
  • 虚拟机栈的主要作用是描述java方法执行的内存模型。用于存储栈帧。线程启动时会创建虚拟机栈,线程中的每个方法在执行时会在虚拟机栈中创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法返回地址、附加信息等信息。每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈(压栈)到出栈(弹栈)的过程。
  • 换句话来说就是每执行一个方法,就会创建一个栈帧,进行压栈,里面存储了方法执行的一些变量,引用等,进行方法执行的描述,方法执行完之后就进行出栈

3.2 栈帧

  • 栈帧存在于 Java 虚拟机栈中,是 Java 虚拟机栈中的单位元素,每个线程中调用同一个方法或者不同的方法,都会创建不同的栈帧(可以简单理解为,一个线程调用一个方法创建一个栈帧),所以,调用的方法链越多,创建的栈帧越多(代表作:递归)。在 Running 的线程,只有当前栈帧有效(Java 虚拟机栈中栈顶的栈帧),与当前栈帧相关联的方法称为当前方法。每调用一个新的方法,被调用方法对应的栈帧就会被放到栈顶(入栈),也就是成为新的当前栈帧。当一个方法执行完成退出的时候,此方法对应的栈帧也相应销毁(出栈)。
  • 栈帧结构如图:

JVM虚拟机内存模型——详解

3.2.1 局部变量表

  • 每个栈帧中都包含一组称为局部变量表的变量列表,用于存放方法参数和方法内部定义的局部变量。在 Java 程序编译成 Class 文件时,在 Class 文件格式属性表中 Code 属性的 max_locals(局部变量表所需的存储空间,单位是 Slot) 数据项中确定了需要分配的局部变量表的最大容量。

3.2.2 操作数栈

  • 操作数栈是一个后入先出(Last In First Out)栈,方法的执行操作在操作数栈中完成,每一个字节码指令往操作数栈进行写入和提取的过程,就是入栈和出栈的过程。
  • 同局部变量表一样,操作数栈的最大深度也是Java 程序编译成 Class 文件时被写入到 Class 文件格式属性表的 Code 属性的 max_stacks 数据项中。
  • 操作数栈的每一个元素可以是任意的 Java 数据类型,32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2,在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值(指的是进入操作数栈的 “同一批操作” 的数据类型的栈容量的和)。
  • 当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,通过一些字节码指令从局部变量表或者对象实例字段中复制常量或者变量值到操作数栈中,也提供一些指令向操作数栈中写入和提取值,及结果入栈,也用于存放调用方法需要的参数及接受方法返回的结果。例如,整数加法的字节码指令 iadd(使用 iadd 指令时,相加的两个元素也必须是 int 型) 在运行的时候将操作数栈中最接近栈顶的两个 int 数值元素出栈相加,然后将相加结果入栈。
  • 换句话说就是方法执行的数据操作的栈

3.2.3 动态连接

  • 每个栈帧都包含一个指向运行时常量池(JVM 运行时数据区域)中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
  • 在 Class 文件格式的常量池(存储字面量和符号引用)中存有大量的符号引用(1.类的全限定名,2.字段名和属性,3.方法名和属性),字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载过程的解析阶段的时候转化为直接引用(指向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄),这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

3.2.4 方法返回地址

  • 当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令(例如:areturn),这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
  • 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常处理器表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
  • 无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的程序计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
  • 方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。

4. 程序计数器

JVM 系列 - 内存区域 - 程序计数器(PC寄存器)(二)

  • 程序计数器是一个以线程私有的一块较小的内存空间,用于记录所属线程所执行的字节码的行号指示器;字节码解释器工作时,通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳准、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成。
  • 在多线程中,就会存在线程上下文切换(CPU 时间片[1])执行,为了线程切换后能恢复正确的执行位置,所以需要从程序计数器中获取该线程需要执行的字节码的偏移地址(简单来说,可以先理解为执行的代码行号,但实际并不是所看到的代码行号,后续学习了字节码指令即明白了)。程序计数器是具备线程隔离性,每个线程工作时都有属于自己的独立程序计数器。
  • 如果线程执行 Java 方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址。如果执行 Navtive 方法,程序计数器值则为空(Undefined)。因为 Navtive 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法。由于该方法是通过 C/C++ 而不是 Java 进行实现。那么自然无法产生相应的字节码,并且 C/C++ 执行时的内存分配是由自己语言决定的,而不是由 JVM 决定的。

5. 方法区

JVM 系列 - 内存区域 - 方法区(六)

  • 方法区和堆一样都是所有线程共享的内存区域
  • jdk1.7之前用永久代存储已被虚拟机加载的类信息、常量、字符串常量、类静态变量、即时编译器编译后的代码等数据。
  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。
  • 实现区域
    1)永久代:存储包括类信息、常量、字符串常量、类静态变量、即时编译器编译后的代码等数据。可以通过 -XX:PermSize 和 -XX:MaxPermSize 来进行调节。当内存不足时,会导致 OutOfMemoryError 异常。JDK8 彻底将永久代移除出 HotSpot JVM,将其原有的数据迁移至 Java Heap 或 Native Heap(Metaspace),取代它的是另一个内存区域被称为元空间(Metaspace)。
    2)元空间(Metaspace):元空间是方法区的在 HotSpot JVM 中的实现,方法区主要用于存储类信息、常量池、方法数据、方法代码、符号引用等。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。,理论上取决于32位/64位系统内存大小,可以通过 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 配置内存大小。

6.本地方法栈

  • 本地方法栈(Native Method Stacks)与 Java 虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 Native 方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以*实现它。
  • Navtive 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法。当线程调用 Java 方法时,虚拟机会创建一个栈帧并压入 Java 虚拟机栈。然而当它调用的是 native 方法时,虚拟机会保持 Java 虚拟机栈不变,也不会向 Java 虚拟机栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的 native 方法。

7.解释内存中的栈(stack)、堆(heap)和静态区(static area)的用法。

  • 通常我们定义一个基本数据类型的变量,一个对象的引用,还有就是函数调用的现场保存都使用内存中的栈空间;而通过new关键字和构造器创建的对象放在堆空间;程序中的字面量(literal)如直接书写的100、"hello"和常量都是放在静态区中。栈空间操作起来最快但是栈很小,通常大量的对象都是放在堆空间,理论上整个内存没有被其他进程使用的空间甚至硬盘上的虚拟内存都可以被当成堆空间来使用。
  • String str = new String(“hello”);
  • 上面的语句中变量str放在栈上,用new创建出来的字符串对象放在堆上,而"hello"这个字面量放在静态区。
  • 也就是说栈中存储的对象引用,函数调用都是需要去根据地址去在堆和静态区寻找相应的数据对象