浅析虚拟机内存管理模型

Java虚拟机在执行Java程序的过程中会把Java程序所管理的内存划分为若干个不同的数据区域,这些区域可以划分为5各部分:虚拟机栈、堆、方法区、本地方法栈、程序计数器,如图:

2002319-20210203210949922-1046773488

虚拟机栈

Java虚拟机栈(Java Virtual Machine Stack)是线程私有的,它的生命周期与线程相同。也就是,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧 (Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。下面讲解一下虚拟机栈中的内容:

2002319-20210203211037086-2046105052

局部变量表

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。

returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址。虽然long以及double是分配在两个变量槽中,但是由于在线程内部,所以不会有数据竞争和线程安全问题。

操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。

写个小案例:

package com.courage;
public class DeOperandStack {
    public static void main(String[] args) {
        int i = 1;
        int j = 2;
        int k = i + j;
    }
}

此时DeOperandStack类中只有一个线程(main),局部变量表中拥有的变量:

默认args为0号变量,所以这个线程中有四个局部变量,那么是如何利用操作数栈进行加减的呢?

首先将第一个常数压入栈,然后存储局部变量表1号变量,然后将第二个常数压入栈,然后存储局部变量表2号变量,然后将局部变量表1,2两个数值加载进栈,弹出相加之后将结果压入栈,在将栈顶数据存储到3号变量。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

方法出口

当一个方法开始执行后,只有两种方式退出这个方法:

  • 遇到方法返回的字节码指令
  • 遇到了异常

第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion)。

另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成(Abrupt Method Invocation Completion)”。

一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java
世界里“几乎”所有的对象实例以及数组都在这里分配内存。

从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的,此区域未完全回收会导致内存泄漏。

方法区、永久代、元空间的关系

之所以将这三个放一起,是这儿很容易混淆,对于Hotspot虚拟机,JDK6、JDK7 时方法区是 PermGen(永久代),JDK8 时,方法区是 Metaspace(元空间),怎么回事呢?

方法区 是JVM的规范,所有虚拟机必须遵守的。常见的JVM虚拟机Hotspot 、JRockit(Oracle)、J9(IBM)

PermGen space则是 HotSpot 虚拟机 基于 JVM 规范对 方法区 的一个落地实现, 并且只有 HotSpot 才有 PermGen space。而如 JRockit(Oracle)、J9(IBM) 虚拟机有 方法区 ,但是就没有 PermGen space。

PermGen space 是JDK7及之前, HotSpot 虚拟机 对 方法区 的一个落地实现,在JDK8被移除。

Metaspace(元空间)是 JDK8及之后, HotSpot 虚拟机对方法区 的新的实现。

永久代以及元空间,可以用来存放堆中存活很久的对象。元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存

类信息

每一个类有一个Class对象,编译期生成,保存在同名的.class文件中。这些Class对象包含了这个类型的父类、接口、构造函数、方法、属性等详细信息,这些class文件在程序运行时会被ClassLoader加载到JVM中,在JVM中就表现为一个Class对象,JVM使用该Class对象创建该类的所有常规对象,而这个对象的信息则保存在方法区的类信息中。

常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

静态变量区

静态变量也叫类变量,类的所有实例都共享,这个区专门存放静态变量和静态块。

static 修饰的 在JVM运行时就加载到内存中了 所以不需要实例类。

静态变量在类加载的准备阶段分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了,关于这部分内容,笔者已在4.3.1节介绍并且验证过。

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

posted @ 2021-02-03 21:16  等不到的口琴  阅读(301)  评论(0编辑  收藏  举报