美文网首页
程序运行时,内存到底是如何进行分配的?

程序运行时,内存到底是如何进行分配的?

作者: 沅兮 | 来源:发表于2021-11-29 18:06 被阅读0次

    Java运行时内存分配

    将 Java 内存分为 堆内存(heap)栈内存(Stack)并不准确,Java 的内存区域划分实际上更为复杂。

    Java 虚拟机在执行 Java 程序的过程中,会把它所管理的内存划分为不同的数据区域:

    Java运行时内存分配

    上图中:

    1. HelloWorld.java 会经过编辑生成 HelloWorld.class 字节码文件。
    2. Java 虚拟中要想访问 HelloWorld 这个类时,需要通过 类加载器(ClassLoader) 进行加载,将 HelloWorld.class 字节码文件加载到 JVM 内存中。
    3. JVM 内存可划分为:方法区、堆、虚拟机栈、本地方法栈、程序计数器几个区域。

    程序计数器

    因为 Java 程序是多线程的,CPU可以在多个线程中分配执行时间片段。(多线程运行时,多个线程执行需要靠 CPU 抢占资源来执行),所以 JVM 中设计 程序计数器 的作用就是为了记录代码执行的位置。

    程序计数器的作用

    当某一个线程被 CPU 挂起时,需要记录代码已经执行到的位置,方便 CPU 重新执行此线程时,知道从哪行开始执行。

    【程序计数器】是虚拟机中一块较小的内存空间,主要用于记录当前程序执行的位置。

    程序计数器执行流程

    上图展示了程序计数器在 CPU 中的作用,每个线程都会记录当前代码执行的位置,当下一次该线程继续执行时,会从程序计数器记录的位置继续往下执行。除了顺序执行外:分支操作、循环操作、跳转、异常处理等都需要依赖程序计数器来完成。

    关于程序计数器的几点注意:
    1. 在 Java 虚拟机规范中,对程序计数器这一区域没有规定任何 OutOfMemoryError 情况(或许没有必要)
    2. 程序计数器是线程私有的,每条线程内部都有一个私有程序计数器,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
    3. 当一个线程正在执行一个 Java 方法的时候,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是 Native 方法,这个计数器值则为空(Underined)

    虚拟机栈

    虚拟机栈 是线程私有的,与线程的生命周期同步。

    在 Java 虚拟机规范中,对这个区域规定了两种异常:

    StackOverflowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出。

    OutOfMemoryError:当 Java 虚拟机动态扩展到无法申请足够内存时抛出。

    学习JVM时会经常看到这一句话:【JVM 是基于栈的解释器执行的,DVM 是基于寄存器解释器执行的】

    上面的 基于栈 指的就是虚拟机栈。虚拟机栈的初衷是用来描述 Java 方法执行的内存模型,每个方法被执行的时候,JVM 都会在虚拟机栈中创建一个 栈帧

    栈帧

    栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行某个方法时,都会为这个方法创建一个栈帧。

    一个线程包含多个栈帧(因为会执行多个方法,每个方法都会创建一个栈帧),而每个栈帧内部包含:局部变量表、操作数栈、动态链接、返回地址。如下图展示:

    栈帧示意图

    局部变量表

    局部变量表 是变量值的存储空间。调用方法时传递的参数以及在方法内部创建的局部变量都保存在 局部变量表 中。

    在 Java 编译成 class 文件的时候,会在方法 Code 属性表中的 max_locals 数据项中确定该方法需要分配的最大局部变量表的容量。

    如下面示例:

    public static int add(int k){
        int i = 1;
        int j = 2;
        return i + j + k;
    }
    

    将上述代码通过 javac 命令编译成 class 文件,再通过 javap -v 命令进行反编译,结果如下:

      public static int add(int);
        descriptor: (I)I
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=3, args_size=1
             0: iconst_1
             1: istore_1
             2: iconst_2
             3: istore_2
             4: iload_1
             5: iload_2
             6: iadd
             7: iload_0
             8: iadd
             9: ireturn
          LineNumberTable:
            line 8: 0
            line 9: 2
            line 10: 4
    

    可以看到 locals 中定义的数就是局部变量表的长度为3。符合我们在代码中的定义。

    【注意】系统不会为局部变量赋予初始值,不存在类变量那样的准备阶段(实例变量和类变量都会被赋予初始值)

    操作数栈

    操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。

    操作数栈的最大深度也在编译的时候写入方法的 Code 属性表中的 max_stacks 数据项中,栈中的元素可以是任意 Java 数据类型,包括 longdouble

    当一个方法刚开始执行时,这个方法的操作数栈是空的,在方法执行过程中,会有各种字节码指令被压入和弹出操作数栈。例如:iadd 指令就是将操作树栈中栈顶的两个元素弹出执行加法运算,并将结果重新压回到操作数栈中。

    动态链接

    动态链接 主要目的为了支持方法调用过程中的动态链接。

    在一个 class 文件中,一个方法要调用其他方法:需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于 方法区 中。

    Java 虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用。

    返回地址

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

    正常退出:方法中代码正常完成,或者遇到一个方法返回的字节码指令(如 return )并退出没有抛出任何异常。

    异常退出:方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

    不管方法是 正常退出 还是 异常退出,都会返回到调用该方法的位置处。所以,虚拟机栈中的 返回地址 是用来帮助 当前方法恢复它的上层方法执行状态

    值得说明的是:

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

    实例讲解
    public class Hello {
        public static int add(){
            int i = 1;
            int j = 2;
            int result = i + j;
            return result + 10;
        }
    }
    

    将上述代码使用 javap 命令来查看字节码指令:

      public static int add();
        descriptor: ()I
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=3, args_size=0
             0: iconst_1    // 把常数 1 压入操作数栈栈顶
             1: istore_0    // 把操作数栈栈顶的出栈放入局部变量表索引为 1 的位置
             2: iconst_2    // 把常量 2 压缩操作数栈栈顶
             3: istore_1    // 把操作数栈栈顶的出栈放入局部变量表索引为 2 的位置
             4: iload_0     // 把局部变量表索引为 1 的值放入操作数栈栈顶
             5: iload_1     // 把局部变量表索引为 2 的值放入操作数栈栈顶
             6: iadd        // 将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶
             7: istore_2    // 把操作数栈栈顶的出栈放入局部变量表索引为 2 的位置
             8: iload_2     // 把局部变量表索引为 2 的值放入操作数栈栈顶
             9: bipush  10  // 把常量 10 压入操作数栈栈顶
            11: iadd        // 将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶
            12: ireturn     // 结束
    

    指令详解:

    iconstbipush 将常量压入操作数栈顶,区别是:当 int 取值为 -1 ~ 5 采用 iconst 指令,取值 -128 ~ 127 采用 bipush 指令。

    istore 将操作数栈顶的元素放入局部变量表的某索引位置。比如 istore_5 代表将操作数栈顶元素放入局部变量表下标为 5 的位置。

    iload 将局部变量表中某下标上的值加载到操作数栈顶中。比如 iload_2 代表将局部变量表索引为 2 上的值压入操作数栈顶。

    iadd 代表加法运算,具体是将操作数栈最上方的两个元素进行相加操作,然后将结果重新压入栈顶。

    上述的下标操作的逻辑是:在 **.java 被编译成 **.class的时候,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到了方法表的 Code 属性中

    本地方法栈

    本地方法栈和虚拟机栈基本相同,是针对本地(Native)方法,在开发中如果涉及 JNI 可能接触本地方法栈多一些,在有些虚拟机的实现中已经将两个合二为一了(比如:HotSpot)。

    堆(Heap)

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

    堆示意图

    方法区

    方法区是 JVM 规范里规定的一块运行时数据区。

    方法区主要是存储:

    • 已经被 JVM 加载的类信息(版本、字段、方法、接口)
    • 常量
    • 静态变量
    • 即时编译器编译后的代码
    • 数据

    方法区是被各个线程共享的内存区域。

    方法区与永久区:

    方法区是 JVM 规范中规定的一块区域,但是并不是实际实现,切忌将规范和实现混为一谈,不同的 JVM 厂商可以有不同的版本的“方法区”实现。

    例如:HotSpot 在 JDK1.7 以前使用 “永久区”(或者叫Perm区)来实现方法区,在 JDK1.8 后“永久区”就已经被移除了,取而代之的是一个叫做“元空间(metaspace)”的实现方式。

    【总结】

    方法区:是规范层面的东西,规定了这一个区域要存放哪些数据。

    永久区或者metaspace:是对方法区的不同实现,是实现层面的东西。

    异常

    StackOverflowError 栈溢出异常

    递归调用是造成 StackOverflowError 的一个常见场景。

    public class StackOver {
        private int number;
        
        public static void main(String[] args){
            StackOver so = new StackOver();
            try {
                so.method();
            } catch(StackOverflowError e){
                System.out.println("栈容量已经溢出!");
            }
        }
        
        public void method(){
            number++;
            method();
        }
    }
    

    每调用一次 method 方法,都会在虚拟机栈中创建出一个栈帧,因为是递归调用,method 方法并不会退出,也不会将栈帧销毁。

    OutOfMemoryError 内存溢出异常

    理论上,虚拟机栈、堆、方法区都有发生 OutOfMemoryError 的可能,但在实际项目中,大多发生在堆中:

    public class HeapError {
        public static void main(String[] args){
            ArrayList list = new ArrayList();
            while (true) {
                list.add(new HeapError());
            }
        }
    }
    

    总结

    上面说的 JVM 运行时内存5种布局只是 Java虚拟机规范中定义的规则,并不是虚拟机的具体实现。虚拟机的具体实现有很多:如 HotSpot、JRocket、IBM J9、Dalvik、ART等。

    JVM示意图



    相关文章

      网友评论

          本文标题:程序运行时,内存到底是如何进行分配的?

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