美文网首页Android进阶之路
JVM运行时内存管理

JVM运行时内存管理

作者: Joker_Wan | 来源:发表于2020-04-13 13:40 被阅读0次

    JVM在执行Java程序时会把其所管理的内存划分成多个不同的数据区域,有的内存区域是所有线程共享的,而有的内存区域是线程隔离的。线程隔离的区域就会随着线程的启动和结束而创建和销毁。

    一个 HelloWorld.java 文件被 JVM 加载到内存中的过程如下图

    1. HelloWorld.java 文件首先需要经过编译器编译,生成 HelloWorld.class 字节码文件。
    2. Java 程序中访问HelloWorld这个类时,需要通过 ClassLoader(类加载器)将HelloWorld.class 加载到 JVM 的内存中。
    3. JVM 中的内存的数据区域主要分为:虚拟机栈本地方法栈程序计数器方法区
    4. 其中方法区是线程共享的数据区域,虚拟机栈本地方法栈程序计数器是线程私有的数据区域

    1 程序计数器

    • 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。
    • 每个线程挂起时都会记录一个当前方法执行到的行号,当 CPU 切换回某一个线程上时,则根据程序计数器记录的行号,继续向下执行指令。
    • 实际上除了恢复线程操作之外,我们熟悉的分支操作、循环操作、跳转、异常处理等也都需要依赖这个计数器来完成。
    • 程序计数器是线程私有的,每条线程内部都有一个私有程序计数器。它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
    • 当一个线程正在执行一个 Java 方法的时候,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。
    • 此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域

    2 虚拟机栈

    Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,与线程的生命周期同步。在 Java 虚拟机规范中,对这个区域规定了两种异常状况:

    1. StackOverflowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出。如:递归调用了自身,并且没有设置递归结束条件,则会产生该异常。
    2. OutOfMemoryError:当 Java 虚拟机动态扩展时无法申请足够内存时抛出。如:在一个无限循环中,动态的向ArrayList中添加新的对象。这会不断的占用堆中的内存,当堆内存不够时,会产生该异常。

    “JVM 是基于栈的解释器执行的,DVM 是基于寄存器解释器执行的”,这里的“栈”指的就是虚拟机栈。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执行的时候,JVM 都会在虚拟机栈中创建一个栈帧

    2.1 栈帧

    栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机栈的栈元素,线程在执行某个方法时,都会为这个方法创建一个栈帧。每一个方法从调用直至执行完成的过程,都对应着一个栈帧在虚拟机栈中入栈到出栈的过程。一个线程中会有多个方法调用和执行,所以一个线程中会有多个栈帧,每个栈帧包含:局部变量表、操作数栈、动态链接、返回地址等,下图展示的就是栈帧的结构。

    2.1.1 局部变量表

    局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。它只在当前函数调用中有效,当函数调用结束,随着函数栈帧的销毁,局部变量表也随之消失。在 Java 程序编译为 Class 文件时,在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。

    2.1.2 操作数栈

    操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO),用于存放方法运行过程中的各种中间变量和字节码指令。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的 max_stacks数据项中。栈中的元素可以是任意Java数据类型,包括long和double。

    当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中会有各种字节码指令往操作数栈中入栈和出栈(比如:iadd指令就是将操作数栈中栈顶的两个元素弹出,执行加法运算,并将相加结果入栈)。

    2.1.3 动态链接

    每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持方法调用过程中的动态链接(Dynamic Linking)。

    在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用(直接引用包含:直接指向目标的指针、相对偏移量、能间接定位到目标的句柄等),这部分称为动态链接。

    全部静态解析不是更好,为何会存在动态连接?Java多态的实现会导致一个引用变量到底指向哪个类的实例对象,或者说该引用变量发出的方法调用到底是调用哪个类中实现方法都需要在运行期间才能确定。因此,有些符号引用在类加载阶段是不知道它对应的直接引用的。

    2.1.4 方法返回地址

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

    • 正常完成出口:指方法中的代码正常完成,或者遇到任意一个方法返回的字节码指令(如return)并退出,没有抛出任何异常。
    • 异常完成出口:指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

    无论当前方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。而虚拟机栈的栈帧中的“返回地址”就是用来帮助当前方法恢复它的上层方法执行状态。

    一般来说,方法正常退出时,调用者的 PC 计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

    2.1.5 实际案例讲解

    一个简单的 add() 方法讲解

    public int add() {
        int i = 1;
        int j = 2;
        int result = i + j;
        return result + 10;
    }
    

    使用 javap 命令来查看add() 方法的字节码指令如下:

    0: iconst_1    (把常量 1 压入操作数栈栈顶)
    1: istore_1    (把操作数栈栈顶的出栈放入局部变量表索引为 1 的位置)
    2: iconst_2    (把常量 2 压入操作数栈栈顶)
    3: istore_2    (把操作数栈栈顶的出栈放入局部变量表索引为 2 的位置)
    4: iload_1     (把局部变量表索引为 1 的值放入操作数栈栈顶)
    5: iload_2     (把局部变量表索引为 2 的值放入操作数栈栈顶)
    6: iadd        (将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶)
    7: istore_3    (把操作数栈栈顶的出栈放入局部变量表索引为 3 的位置)
    8: iload_3     (把局部变量表索引为 3 的值放入操作数栈栈顶)
    9: bipush10    (把常量10压入操作数栈栈顶)
    10: iadd       (将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶)
    11: ireturn    (结束)
    
    • iconst和bipush,这两个指令都是将常量压入操作数栈顶,区别就是:当int取值-15采用iconst指令,取值-128127采用bipush指令。
    • istore 将操作数栈顶的元素放入局部变量表的某索引位置,比如 istore_5 代表将操作数栈顶元素放入局部变量表下标为 5 的位置。
    • iload 将局部变量表中某下标上的值加载到操作数栈顶中,比如 iload_2 代表将局部变量表索引为 2 上的值压入操作数栈顶。
    • iadd 代表加法运算,具体是将操作数栈最上方的两个元素进行相加操作,然后将结果重新压入栈顶。

    从上面字节码指令也可以看到,其实局部变量表和操作数栈在代码执行期间是协同合作来达到某一运算效果的。

    首先在Add.java被编译成Add.class的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方法表的Code属性中。因此这会局部变量表的大小是确定的,add() 方法中有 3 个局部变量,因此局部变量表的大小为 3,但是操作数栈此时为空。

    所以代码刚执行到 add 方法时,局部变量表和操作数栈的情况如下:


    icons_1 把常量 1 压入操作数栈顶,结果如下:


    istore_1 把操作数栈顶的元素出栈并放入局部变量表下标为 1 的位置,结果如下:


    iconst_2 把常量 2 压入操作数栈顶,结果如下:


    istore_2 把操作数栈顶的元素出栈并放入局部变量表下标为 2 的位置,结果如下:


    接下来是两步 iload 操作,分别是 iload_1 和 iload_2。分别代表的是将局部变量表中下标为 1 和下标为 2 的元素重新压入操作数栈中,结果如下:


    接下来进行 iadd 操作,这个操作会将栈顶最上方的两个元素(也就是 1、2)进行加法操作,然后将结果重新压入到栈顶,执行完之后的结果如下:


    istor_3 将操作数栈顶的元素出栈,并保存在局部变量表下标为 3 的位置。结果如下:


    iload_3 将局部变量表中下标为 3 的元素重新压入到操作数栈顶,结果如下:


    bipush 10 将常量 10 压入到操作数栈中,结果如下:


    再次执行 iadd 操作,注意此时栈顶最上方的两个元素为 3 和 10,所以执行完结果如下:


    最后执行 return 指令,将操作数栈顶的元素 13 返回给上层方法。至此 add() 方法执行完毕。局部变量表和操作数栈也会相继被销毁。

    3 本地方法栈

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

    4 堆

    Java堆(Heap)是JVM所管理的内存中最大的一块内存,该区域唯一目的就是存放对象实例,几乎所有对象的实例都在堆里面分配,因此它也是Java垃圾收集器(GC)管理的主要区域,有时候也叫作“GC 堆”(Garbage Collected Heap)。同时它也是所有线程共享的内存区域,因此被分配在此区域的对象如果被多个线程访问的话,需要考虑线程安全问题。

    从内存回收的角度来看,由于现在的收集器基本都采用分代收集算法,按照对象存储时间的不同,堆中的内存可以细分为新生代(Young)和老年代(Old),其中新生代又细划分为 Eden空间、From Survivor 空间和 To Survivor空间。具体如下图所示:


    需要注意的是:Java 堆空间只是在逻辑上是连续的,在物理上并不一定是连续的内存空间。

    默认情况下,新生代中Eden空间与Survivor空间的比例是8:1,可以使用参数-XX:SurvivorRatio对其进行配置。大多数情况下,新生对象在新生代Eden区中分配,当Eden区没有足够的空间进行分配时,则触发一次Minor GC,将对象Copy到Survivor区,如果Survivor区没有足够的空间来容纳,则会提前转移到老年代去。

    Survivor区作为Eden区和老年代的缓冲区域,常规情况下,在Survivor区的对象经过若干次垃圾回收仍然存活的话,才会被转移到老年代。JVM通过这种方式,将大部分命短的对象放在一起,将少数命长的对象放在一起,分别采取不同的回收策略。

    图中不同的区域存放具有不同生命周期的对象。这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,进而提高垃圾回收效率。

    5 方法区

    方法区(Method Area)与 Java 堆一样,也是各个线程共享的内存区域,是JVM规范里规定的一块运行时数据区。方法区主要是存储已经被JVM加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。大家平时开发中通过反射获取到的类名、方法名、字段名称、访问修饰符等信息都是从这块区域获取的。

    关于方法区,很多开发者会将其跟“永久代”混淆。本质上两者并不等价

    • 方法区是JVM规范中规定的一块区域,但是并不是实际实现。不同的JVM厂商可以有不同版本的“方法区”的实现。方法区是一种规范。
    • HotSpot在JDK1.7以前使用“永久代”来实现方法区,在JDK1.8之后“永久代”就已经被移除了,取而代之的是一个叫作“元空间(Metaspace)”的实现方式。“永久代”是一种具体实现。

    相关文章

      网友评论

        本文标题:JVM运行时内存管理

        本文链接:https://www.haomeiwen.com/subject/krugmhtx.html