高频八股——JVM的内存模型老是记不住?

155 阅读9分钟

之前笔者在参加面试的时候,发现在八股拷打中最常见的一个问题就是:JVM的内存模型,而内存模型中涉及到的概念:虚拟机栈、本地方法栈、堆、方法区(元空间、永久代)、程序计数器等,以及垃圾回收器涉及到的新生代、老年代,这些错综复杂的八股,我看着都头大,于是便自己整理了JVM的内存模型,希望可以帮到大家。

JVM的内存模型是Java在程序运行时管理内存的规范,解决多线程环境下的可见性、有序性和原子性问题,所以我们在理解JVM时可以重点去关注:每部分内存是否线程公有、线程如何访问共享变量等线程执行角度相关的问题。

1.线程私有区:虚拟机栈、本地方法栈、程序计数器

虚拟机栈(也叫Java栈)和本地方法栈的作用和原理是一样的,只不过服务对象不同。虚拟机栈为虚拟机执行Java方法服务,本地方栈为虚拟机使用到的本地方法服务,一个线程的生命周期中,可能都在执行Java方法,操作虚拟机栈;也有可能在执行Java方法的过程中又调用了本地方法,所以在虚拟机栈和本地方法栈之间切换。

所谓本地方法,即Native方法,其实就是一种交流机制,当我们的Java应用需要与其他环境交互时,比如与底层操作系统或者硬件系统交换信息,本地方法这时就可以发挥作用,为我们提供一个接口。

虚拟机中对本地方法栈中的方法所使用的编程语言、数据结构等并没有规范,不同的虚拟机有不同的实现方法,例如Sun HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一了。

虚拟机栈是线程私有的,和线程同时创建,用于存储栈帧,每个方法被调用时都会创建一个新的栈帧压入栈,栈帧包括局部变量表、操作数栈、动态链接、返回地址等。方法执行完毕后,栈帧会被弹出。

  • 局部变量表存放方法执行过程中使用的局部变量,包括方法参数和方法内部的局部变量。存储类型包括基本数据类型(int、long、double等)和对象的引用(引用类型变量,指向对象的内存地址,而不是对象本身)。
  • 操作数栈用于存储方法执行过程中的操作数以及中间计算结果,例如,在进行a + b操作时,ab首先被推入操作数栈,执行完毕后结果也会被压入栈中。
  • 动态链接负责在运行时解析方法和字段的引用,当JVM调用一个方法时,它会根据方法区中的方法和字段的实际位置来解析符号引用,在查找到相应方法的内存地址后,将其转换为直接引用。
  • 返回地址用于保存方法调用完成后,程序需要返回的位置。每个方法调用时,栈帧会保存一个返回地址,它是调用方法后返回到的代码位置,通常是一个指向字节码指令的指针。

虚拟机栈的运行过程,图片来源于《深入理解高并发编程:核心原理与案例实战》


那么当一个Java线程调用Java方法和本地方法时从虚拟机栈到本地方法栈发生了什么样的切换?,如下图所示,当前线程依次调用了两个Java方法,第二个Java方法调用了本地方法,从而产生了本地方法栈。

本地方法栈也是线程私有的,和虚拟机栈有着相同的权限,可以通过本地方法接口来访问虚拟机内部的运行时数据区,也可以调用任何Java方法,所以在图中本地方法调用了新的Java方法,在虚拟机栈中产生了两个新的栈帧。

一个线程调用Java方法和本地方法时的栈,图片来源于网络


程序计数器也是线程私有的。在任何一个确定的时刻,一个处理器只会执行一条线程中的指令,为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,并且生命周期和线程的生命周期保持一致,程序计数器用来存储指向下一条指令的地址,当线程切换时,由执行引擎读取当前线程的下一条指令,就知道从哪接着执行。

程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等功能都要依赖程序计数器完成。

程序计数器是整块内存中很小的一块空间,几乎可以忽略不计,也是运行速度最快的存储区域。

2.线程共享区:堆和方法区

堆和方法区都是线程共享区,多个线程可以同时访问堆中的对象或方法区中的类元数据,并且堆和方法区在JVM内存管理中都占据着相对较大的内存区域。

堆(Heap)是JVM中最大的一块内存区域,所有由我们创建的对象实例数组(包括基本类型数组,如int[]char[]和对象类型数组,如String[]Object[])都存放在堆区。

对象的回收由垃圾回收器负责。JVM的堆分为新生代和老年代,而堆的垃圾回收机制通过分代回收(Minor GC、Major GC等)来优化性能。

(1)新生代默认占整个堆的1/3,包含Eden区和两个Survivor区(S0、S1),对象创建时,优先分配在Eden区。当Eden区满时,会触发垃圾回收(minor GC),将存活对象移动到Survivor区。

(2)老年代占2/3堆空间,存放生命周期较长的对象。当对象在Survivor区经历多次GC仍然存活(默认15次GC),会晋升到老年代。老年代Eden区满时,会触发垃圾回收(Major GC)进行老年代的内存清理,否则报OOM异常。

堆空间,图片来源网络


方法区是JVM运行时数据区的一部分,方法区的作用:(1)用来存放类的结构信息(类名、访问修饰符、字段、方法、接口等)(2)存放类的静态变量和JIT即时编译后的代码。(3)运行时常量池,存储编译期生成的常量和运行时动态生成的常量。

方法区只是JVM规范中定义的一个概念,用来存放(1)类信息(类名、访问修饰符、字段、方法、接口等)。(2)存放类的静态变量和JIT即时编译后的代码。(3)运行时常量池和类常量池,存储编译期生成的常量和运行时动态生成的常量。

在JDK1.6及之前的版本,字符串常量池存放在方法区中,在JDK1.7版本之后,字符串常量池被移到堆中。


那么方法区和永久代、元空间有什么关系呢?

永久代和元空间是方法区的两种实现,在JDK 1.7及之前的版本,HotSpot JVM实现了方法区,将其放在了永久代中,永久代大小固定,容易导致OutOfMemoryError: PermGen space。在JDK1.8及之后的版本,HotSpot JVM引入了元空间来替代永久代,元空间使用本地内存(而非JVM堆内存),提高了可扩展性。

3.从线程执行看JVM内存模型

首先,我们总结上文内容,JVM内存模型可以分为线程私有区和线程共享区两部分,如下图所示。

堆空间,图片来源网络


以下面的多线程程序为例,我们可以总结JVM内存模型是如何工作的。

  • 虚拟机栈是线程私有的,存储方法调用时的栈帧,线程每调用一个方法,就在栈中创建一个新的栈帧。比如程序中的main方法、MyObject类的构造方法、modifyValue方法等都对应着各自的栈帧,每个栈帧都有它自己的局部变量表操作数栈。例如当thread1执行MyObject obj1 = new MyObject(localVar1);时,JVM为thread1创建一个新的栈帧。栈帧的局部变量表会包含:

    • localVar1(值为10)
    • obj1(指向MyObject对象的引用)

    此时,MyObject对象会在堆上分配内存,并且obj1变量存储的是指向这个对象的引用。

  • 这段程序没有调用任何本地方法,所以也就没有显示地使用本地方法栈。

  • 程序计数器也是线程私有的,在thread1thread2的执行过程中,程序计数器会随每个线程的字节码指令的执行而变化。每当线程从栈中获取方法时,程序计数器的值指向当前执行的方法的位置。

  • 堆用来存储动态分配的对象,当thread1thread2分别创建MyObject实例时,这些对象会存储在堆内存中。

  • 方法区主要存储类的结构信息、静态变量、常量池等。程序中,JvmMemoryModelExampleMyObject这两个类的结构信息、静态变量(如staticVar)都存储在方法区(或元空间)中。

我们一定要注意在这个程序中staticVar和``value`变量的区别,前者是静态变量,与类相关联,而不是与某个具体的对象实例相关,所以保存在负责存储类信息的方法区中。而后者是对象的成员变量,与对象实例相关,所以保存在堆中。

public class JvmMemoryModelExample {
    
    private static int staticVar = 0; // 静态变量存储在方法区
    
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            // 创建线程1,执行一些局部变量和堆内存操作
            int localVar1 = 10;  // 局部变量存储在栈中
            MyObject obj1 = new MyObject(localVar1); // 对象存储在堆中
            obj1.modifyValue();
            System.out.println("Thread1: " + obj1.getValue());
        });

        Thread thread2 = new Thread(() -> {
            // 创建线程2,执行类似的操作
            int localVar2 = 20;
            MyObject obj2 = new MyObject(localVar2);
            obj2.modifyValue();
            System.out.println("Thread2: " + obj2.getValue());
        });

        thread1.start();
        thread2.start();
    }
}

class MyObject {
    private int value; // 对象的成员变量存储在堆中

    public MyObject(int value) {
        this.value = value;
    }

    public void modifyValue() {
        this.value += 5;
    }

    public int getValue() {
        return value;
    }
}

JVM的内存模型是Java开发者必须掌握的基础知识,尤其在面试中经常被问到。理解虚拟机栈、本地方法栈、方法区、程序计数器,以及堆的分代回收机制,不仅有助于优化Java应用的性能,也能在面试中脱颖而出。

如果您觉得这篇文章有帮助,欢迎点赞、分享、在看,更多高频八股解析,敬请关注!