JVM内存模型剖析

先上图 JVM内存区域分为线程私有和线程共有,主要为: 线程私有 :栈、本地方法栈、程序计数器 线程共有:堆、方法区

下面我将通过一个示例来详细说明JVM内存里面各个区域的作用。

示例类

java 复制代码
package com.demo;

public class MathTest {

    public int compute() {
        int a = 5;
        int b = 7;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        MathTest math = new MathTest();
        math.compute();
    }

}

可以通过javac 来生成class文件,然后通过javap 来查看生成的具体class文件

通过javap -c Math.class,生成的指令如下(-c参数不包含常量池的信息,需要更加详细的信息,可以使用-v参数)

yaml 复制代码
Compiled from "MathTest.java"
public class com.demo.MathTest {
  public com.demo.MathTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int compute();
    Code:
       0: iconst_5
       1: istore_1
       2: bipush        7
       4: istore_2
       5: iload_1
       6: iload_2
       7: iadd
       8: bipush        10
      10: imul
      11: istore_3
      12: iload_3
      13: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class com/demo/MathTest
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method compute:()I
      12: pop
      13: return
}

执行java Math.class命令后,系统底层会生成一个JVM虚拟机,然后通过JVM的类装载子系统,加载并解析我们的class文件。解析后的类信息、常量、静态变量、JIT (just in time,即时编译技术)编译后的代码等数据,以及编译期间生成的各种字面常量符号引用都会放到方法区。

类加载完成后,JVM会执行该类的main方法入口。此时,main线程会生成main()方法的栈帧。 通过字节码执行引擎,逐行执行代码。

程序计数器也会随着指令的执行开始计数。

为什么需要程序计数器?

CPU执行是有时间片的,所以线程都是交互执行的。程序计数器用来在线程切换后能恢复到正确的执行位置。

执行 new 、dup 、invokespecial #3 指令后,会在堆中创建一个MathTest对象,并初始化完成。 执行 astore_1后,会把当前对象的引用地址 存放在局部变量表中的第2个位置。(局部变量表中第1个位置是this引用) 执行aload_1,invokevirtual #4,会把局部变量表第2个位置的引用压入操作数栈中,并且通过解析对应的符号引用,找到对应compute()方法的直接引用(这个就是动态链接的过程)。

执行compute()方法,创建相应的栈帧。

每执行一次方法就会在线程里面创建一个对应的方法栈帧进入线程栈,会占用线程的内存空间。所以,线程的内存越大,可以创建的方法栈帧就越多。当线程请求的栈深度大于虚拟机允许的深度,将抛出StackOverflowError异常。虚拟机默认线程栈大小是1M,我们可以通过-Xss参数来控制线程栈的大小。

java 复制代码
public class ThreadSizeTest {

    public static int count = 0;

    public static void retry() {
        count++;
        retry();
    }

    public static void main(String[] args) {
        try {
            retry();
        }catch (Throwable e){
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

默认输出 修改JVM启动参数-Xss128K 输出

执行compute()方法

iconst_5 // 将常数5压入操作数栈栈顶。 istore_1 // 弹出操作数栈里面的int类型常量,放入局部变量表第2个位置。 bipush 7,// 将常数7压入栈顶

istore_2 ,// 弹出操作数栈里面的int类型常量,放入局部变量表第3个位置 iload_1 // 将局部变量表第2个位置int常量压入栈顶

iload_2 // 将局部变量表第3个位置int常量压入栈顶 iadd // 将栈顶两int型数值相加并将结果压入栈顶 bipush 10 // 将常数10压入栈顶 imul // 将栈顶两int型数值相乘并将结果压入栈顶 istore_3 // 弹出操作数栈里面的int类型常量,放入局部变量表第4个位置

iload_3 // 将局部变量表第4个位置int常量压入栈顶 ireturn // 弹出操作数栈里面的int类型常量,并返回

compute()方法执行完毕,通过方法出口跳转到main()栈帧。 执行main()方法

pop // 将操作数栈栈顶的第1个slot弹出,并丢弃

return // main()方法执行结束

本地方法栈呢?

跟虚拟机栈(线程栈)类似,不过是虚拟机使用到的Native方法服务的时候会创建。

例如: 线程启动的start()方法,其中调用start0()方法,就是一个native方法,执行到该语句时,就会创建本机方法栈

总结

程序计数器(Program Counter Register)

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机概念模型里(概念模型,各种虚拟机可能会通过一些更高效的方式实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令:分支、跳转、循环、异常处理、线程恢复等基础操作都会依赖这个计数器来完成。每个线程都有独立的程序计数器,用来在线程切换后能恢复到正确的执行位置 ,各条线程之间的计数器互不影响,独立存储。所以它是一个"线程私有"的内存区域。此内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域

虚拟机栈(VM Stack)

JVM栈是线程私有的内存区域。它描述的是java方法执行的内存模型每个方法执行的同时都会创建一个栈帧 (Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口 等信息。每个方法从调用直至完成的过程,都对应着一个栈帧从入栈到出栈的过程。每当一个方法执行完成时,该栈帧就会弹出栈帧的元素作为这个方法的返回值,并且清除这个栈帧,Java栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法。就像是组成动画的一帧一帧的图片,方法的调用过程也是由栈帧切换来产生结果。 局部变量表存放了编译器可知的各种基本数据类型(int、short、byte、char、double、float、long、boolean)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一跳字节码指令的地址)。 在JVM规范中,对这个区域规定了两种异常情况:如果线程请求的栈深度大于虚拟机允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,在扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈( Native Method Stack)

本地方法栈和虚拟机栈所发挥的作用是很相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。Sun HotSpot 直接就把本地方法栈和虚拟机栈合二为一。本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。

堆(Heap)

Heap是OOM故障最主要的发源地,它存储着几乎所有的实例对象堆由垃圾收集器自动回收,堆区由各子线程共享使用;通常情况下,它占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间;堆的内存空间既可以固定大小,也可运行时动态地调整,通过参数-Xms设定初始值、-Xmx设定最大值。

方法区(Method Area)

方法区是被所有线程共享的内存区域,用来存储已被虚拟机加载的类信息、常量、静态变量、JIT(just in time,即时编译技术)编译后的代码等数据。运行时常量池是方法区的一部分,用于存放编译期间生成的各种字面常量和符号引用。 通过反射获取到的类型、方法名、字段名称、访问修饰符等信息就是从方法区获取到的。在使用到CGLib对类进行增强时,增强的类越多,就需要越大的方法区类存储动态生成的Class信息,当存放方法区数据的内存溢出时,会报OutOfMemoryError异常。在jdk1.8中也就是Metaspace内存溢出,可以通过参数JVM参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize设置Metaspace的空间大小。jdk1.8后永久代被元空间(Metaspace)代替。

其他

静态链接:

当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变。 将调用方法的符号引用转换为直接引用的过程称为静态链接。

动态链接:

被调用的目标方法在编译期无法被确定下来,只能够在程序运行期将方法的符号引用转换为直接引用,这种引用转换的过程具备动态性,称为动态链接。 方法的绑定机制分为早期绑定(Early Binding)和晚期绑定(Late Bingind)。绑定是一个字段、方法或类在符号引用被替换为直接引用的过程。

早期绑定:

被调用的目标方法在编译期可知,且运行保持不变。

晚期绑定:

被调用方法在编译期无法被确定下来,只能够在程序运行期根据实际类型绑定相关的方法

虚方法与非虚方法:

如果方法在编译期就确定具体调用版本,这个版本在运行期间是不可变的。静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法,其它方法都为虚方法。

虚拟机中方法调用指令:

普通调用指令:
  1. invokestatic:调用静态方法,解析阶段确定唯一方法版本。
  2. invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本。
  3. invokevirtual:调用所有虚方法。
  4. invokeinterface:调用接口方法。
动态调用指令:
  1. invokedynamic:动态解析出需要调用的方法,然后执行
  2. invokeinterface:固化在虚拟机内部,方法的调用执行不可人为干预。
  3. invokedynamic:指令支持用户确定方法版本。
  4. invokestatic:指令和invokespecial指令调用的方法称为非虚方法,其余(final修饰除外)称为虚方法。
相关推荐
ThisIsClark14 分钟前
【后端面试总结】深入解析进程和线程的区别
java·jvm·面试
王佑辉27 分钟前
【jvm】内存泄漏与内存溢出的区别
jvm
大G哥3 小时前
深入理解.NET内存回收机制
jvm·.net
泰勒今天不想展开3 小时前
jvm接入prometheus监控
jvm·windows·prometheus
东阳马生架构1 天前
JVM简介—3.JVM的执行子系统
jvm
程序员志哥1 天前
JVM系列(十三) -常用调优工具介绍
jvm
后台技术汇1 天前
JavaAgent技术应用和原理:JVM持久化监控
jvm
程序员志哥1 天前
JVM系列(十二) -常用调优命令汇总
jvm
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭1 天前
聊聊volatile的实现原理?
java·jvm·redis