深入学习JVM-内存架构图(二)

JVM深入学习-内存架构图篇

本篇聚焦于对JVM内存架构图的深度总结与解析。文中将逐一详尽介绍内存架构图中的各部分,并深入理解JVM运行机制与内存管理策略。

内存架构图

image-20231102083110541

JVM架构图中包含了 类加载子系统(上篇JVM详细介绍了类加载系统)、运行时数据区、执行引擎、本地接口、本地方法库。

  • 对于JVM内存模型,在jdk1.8时做了调整,将Method Area(方法区/永久代)转换为元空间并放在本地内存中。
  • 类加载子系统负责将类的字节码加载至运行时数据区中的方法区中,并在堆内存中生成Class对象作为类信息的访问入口。

方法区

方法区(Method Area)是Java虚拟机内存结构中的一个重要组成部分,它是线程共享的区域。这意味着多个线程可以同时访问方法区中的信息。

方法区的主要作用是存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。它就像一个知识库,为后续Java程序的运行提供各种信息以确保程序可以运行。

  • 类信息
    • 类全限定名:完整类名(包含包名和类名),用于在JVM中表示唯一的标识一个类。如com.example.MyClass这个类,com.example是包名,MyClass是类名,这个全限定名可以帮助 JVM 在众多类中准确地定位和区分不同的类。
    • 字段信息:包含变量名称、类型、访问修饰符。用于JVM能够了解类中包含那些字段。
    • 方法信息:包含方法的名称、返回类型、参数列表(参数的类型和顺序)以及方法的访问修饰符等。JVM 通过这些信息来确定如何调用方法以及方法的调用规则。
    • 接口调用:如果一个类实现了接口,方法区会存储接口的相关信息,包括接口的全限定名、接口中定义的方法签名等。这有助于 JVM 检查类是否正确地实现了接口以及在运行时实现接口相关的多态调用。
  • 静态变量:类相关的静态变量是在类初始化阶段完成的。并且这些静态变量在整个程序的生命周期内都存储在方法区中,并可以被类的例实访问
  • JIT优化代码:JIT(Just - In - Time)编译是一种优化技术,JVM 在运行过程中会对频繁执行的热点代码(Hot - Spot Code)进行动态编译。这些被 JIT 编译后的代码会存储在方法区中。JIT 编译将字节码转换为机器码,提高了代码的执行效率。例如,对于一个频繁执行的循环体或者经常调用的方法,JVM 可能会对其进行 JIT 编译,使得后续的执行可以直接使用编译后的机器码,而不是每次都进行字节码解释。
  • 运行时常量池:是方法区的一部分,它是在类加载过程中由字节码文件中的常量池转换而来的。常量池中的信息包括字面量和符号引用,在类加载后,这些信息会被解析并存储到运行时常量池中。
    • 字面量:字面量是在代码中直接出现的常量值。例如,在String str = "hello";中,"hello"就是一个字面量。在类加载过程中,字面量会被存储到运行时常量池中,并将其转换为在内存中的实际表示形式,通常是一个指向常量值的内存地址。这样,在程序运行过程中,当需要访问这个字面量时,可以通过这个内存地址快速获取。
    • 符号引用:符号引用是一种在编译阶段对类、方法、字段等的引用表示方式。例如,在代码中的一个方法调用myMethod(),在编译阶段这只是一个符号引用,它表示需要调用一个名为myMethod的方法,但并没有确定这个方法的实际内存地址。在类加载过程中,符号引用会被解析并转换为直接引用,也就是确定方法的实际内存位置,这个过程也是在运行时常量池中完成的。这些直接引用信息会被存储在运行时常量池中,以便在程序运行时能够准确地调用相应的方法或访问相应的字段。

元空间替代永久代?

image-20241205163305081

内存大小:在jdk1.8之前,永久代是放在堆内存中的,也就是说在JVM内存中,但是随着项目的复杂度、框架使用、三方库的使用,永久代中需要存储的类越来越多,导致固定内存大小的永久代无法适用,所以这里把永久代转换成元空间,并把它放到本地内存中,不占用jvm内存空间。

垃圾回收:永久代的垃圾回收相对复杂。因为它里面存储的类信息等数据的生命周期和普通对象不同,垃圾回收器很难准确判断哪些类信息是可以回收的。例如,一个类加载后,即使没有任何实例对象存在了,只要这个类还在被其他类引用(比如通过反射),它在永久代中的信息就不能被回收。这种复杂的回收机制导致永久代的内存清理效率较低。

性能问题:永久代中的类数据和字符串常量池等内容混在一起,当进行垃圾回收或者内存整理时,会对整个永久代进行操作。永久代的垃圾回收会触发 Full GC,这是非常耗时的过程,在高负载系统中影响较大。而元空间独立于堆内存,大大减少了永久代相关的 Full GC 次数,因此在运行时减少了长时间的中断。

方法区和其他内存结构的关系?

  • 方法区与 Java 虚拟机栈(Java Virtual Machine Stack)也有关联。在方法调用过程中,Java 虚拟机栈中的栈帧会包含对方法区中方法信息的引用。例如,当一个方法被调用时,栈帧中的动态链接部分会根据方法的符号引用在方法区中查找并确定方法的实际执行版本,从而实现方法的正确调用。
  • 方法区与堆区(Heap)关系:堆区是对象实例的存储之地。当执行new操作创建对象时,JVM 需要依据方法区中存储的类信息来构建对象。在垃圾回收过程中,方法区中的类信息对堆区对象的回收策略起到关键作用。堆区对象的可达性分析(判定对象是否可被回收的重要步骤)不仅考虑对象之间的引用关系,还涉及对象与方法区中类信息的关联。例如,当垃圾回收器在堆区回收对象时,它需要根据对象的类信息(如是否有finalize方法等)来确定回收的方式和顺序。

栈区

栈区是JVM内存结构中的一个重要组成部分,负责管理方法调用和执行时的数据存储。

在Java虚拟机中,栈与线程密切相关。每个线程在创建时都会分配一个JVM栈。这个栈用于存储方法调用时的相关信息,包括局部变量表、操作数栈、动态链接、方法返回地址。

JVM栈中的每个方法调用都会创建一个栈帧(Stack Frame),栈帧是栈中的基本存储单位,用于存放方法调用时的信息。

image-20240522075754906

下面我将以一段代码简述栈帧中存储的各个部分的含义:

package focus.total;

public class JVMStudy {
    private static final int initData = 6;
    public static User user = new User();

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

    public static void main(String[] args) {
        JVMStudy jvmStudy = new JVMStudy();
        jvmStudy.compute();
        System.out.println("计算完成");
    }
}

当上述程序被执行时,JVM虚拟机会分配一个Main主线程,并为该线程分配一个栈内存空间,并创建main栈帧,程序计数器初始化到main方法字节码指令的第一条,并按照顺序执行,当执行到compute()方法时,创建compute方法的栈帧。在cmopute方法中我将对栈帧中的存储结构进行逐个介绍。

image-20240522083230466

反汇编JVMStudy.class代码观察JVM执行的指令

下面关于字节码指令的解释其实就已经把局部变量表和操作数栈的含义解释清楚了。

  • 字节码文件本身是 Java 源代码经过编译后生成的中间表示形式,它包含了一系列按照特定顺序排列的指令码,这些指令码就是 Java 程序在 JVM 中运行的基础。
  • 程序计数器的核心作用就是记录当前线程正在执行的字节码指令的地址。在上述反汇编得到的字节码执行顺序指令码中,程序计数器会依据字节码的执行流程依次指向对应的指令位置。
Compiled from "JVMStudy.java"
public class focus.total.JVMStudy {
  public static focus.total.User user;

  public focus.total.JVMStudy();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int compute();
    Code:
    //iconst_1 : 将int类型的1压入操作数栈中
    //istore_1 : 将int类型的值存入局部变量表中
    //两个组合起来其实就是我们首先在我们的操作数栈中存储int值1,然后在局部变量表中存入a,然后将操作数栈中的值取出,赋给局部变量表中的a  ----  int a=1;
       0: iconst_1
       1: istore_1
    //iconst_2 : 同上,区别是值为2
    //istore_2 : 同上
    // 这里思考一个问题,如果此时发生了线程切换,那么当重新返回这个线程时,如何知道从哪里继续执行?
    //程序计数器:记录下一行即将运行的代码的内存地址。
    // 程序计数器是每个程序在运行时都会给他分配的一段内存空间代码,存放下一次即将运行的指令内存地址。
    // 那么当我们程序在执行3的这行指令时,来了一个优先级高的线程将cpu抢占过去,那么此时该线程会执行完3指令之后,在程序计数器+1,然后让出CPU,并挂起。当抢占的线程运行完毕之后,该线程重新拿到cpu使用权,此时就会按照程序计数器中存储的位置区执行。
       2: iconst_2
       3: istore_2
   // iload_1 : 将局部变量表中第1个位置的整数值加载到操作数栈顶
   // iload_2 : 从局部变量2中装载int类型值
   // iadd :执行int类型的加法
   // 这三个指令其实就是 ,从局部变量表中分别取出a的值和b的值放入操作数栈,然后调用iadd命令,将两个操作数取出操作数栈并完成加法指令。把结果重新压回我们的操作数栈。
   // 此时我们的操作数栈中存放了 int 值 3
       4: iload_1
       5: iload_2
       6: iadd
    // bipush :向操作数栈中放入 int 值 10
    // imul : 乘法 3 * 10
       7: bipush        10
       9: imul
    // istore_3 : 将栈顶的整数值存储到局部变量表的第3个位置。
    // iload_3 :  将局部变量表中第3个位置的整数值加载到操作数栈顶。
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // 创建一个新的JVMStudy对象
       3: dup                                // 复制栈顶的JVMStudy对象引用
       4: invokespecial #3                  // 调用JVMStudy的构造方法 "<init>":()V
       7: astore_1                           // 将栈顶的JVMStudy对象引用存储到局部变量1
       8: aload_1                            // 将局部变量1中的JVMStudy对象引用加载到栈顶
       9: invokevirtual #
           
           
           4                  // 调用JVMStudy对象的compute方法,返回一个int
      12: pop                                // 弹出栈顶的int返回值(不使用)
      13: getstatic     #5                  // 获取System类的out字段(PrintStream对象)
      16: ldc           #6                  // 将字符串 "计算完成" 压入栈顶
      18: invokevirtual #7                  // 调用PrintStream的println方法,打印字符串
      21: return                             // 从main方法返回

  static {};
    Code:
       0: new           #8                  // 创建一个新的User对象
       3: dup                                // 复制栈顶的User对象引用
       4: invokespecial #9                  // 调用User的构造方法 "<init>":()V
       7: putstatic     #10                 // 将栈顶的User对象引用存储到静态字段user
      10: return                             // 从静态初始化块返回

动态链接:在 Java 虚拟机的运行机制中起着关键作用。在之前阐述对方法区的理解时,就已经涉及到栈与方法区之间存在的动态链接关系。我们都知道,在类加载阶段的解析过程中,会完成符号引用到直接引用的转换,这一转换实际上就是将方法区中的常量池转变为运行时常量池的过程。而这里所说的动态链接,其核心操作便是把方法的符号引用借助动态链接这种方式,准确地链接到方法在内存中的实际地址,从而为方法的成功调用奠定基础,确保在程序运行过程中,当需要调用某个方法时,能够通过这种动态链接机制迅速定位到方法的实际执行代码所在的内存位置并顺利执行。

那么此时就有一个疑问,在类加载阶段就已经完成转换了,为什么这里还需要进行转换?

那是因为这里的动态链接,主要用于处理在编译时无法确定具体调用目标类型的情况,特别是在多态(虚方法调用)下发挥作用。

多态场景下的动态链接示例(以Animal为例)

  • 假设存在一个Animal类,它有一个虚方法makeSound()。有两个子类DogCat,它们分别重写了makeSound()方法。

  • 当我们在代码中有这样的语句:Animal animal = new Dog(); animal.makeSound();,在编译阶段,编译器看到的是通过Animal类型的引用animal调用makeSound()方法,它生成的字节码中对于这个调用只有一个符号引用,这个符号引用指向Animal类的makeSound()方法。

  • 但是在运行时,因为animal实际指向的是Dog对象,动态链接就会发挥作用。它会根据对象头确定animal指向的是Dog类型,然后查找Dog类的虚方法表,在虚方法表中找到Dog类重写后的makeSound()方法的实际内存地址(直接引用),最后执行Dog类的makeSound()方法。这就是动态链接在多态场景下将符号引用转换为直接引用的具体过程,确保了根据对象的实际类型调用正确的方法。

动态链接与其他概念的关联

  • 动态链接与栈帧密切相关。在每个栈帧中,都有一个指向运行时常量池该栈帧所属方法的引用,这个引用用于支持动态链接。当一个方法被调用时,会创建栈帧,栈帧中的动态链接部分参与到寻找实际要调用的方法的过程中。
  • 动态链接也和类加载过程相互补充。类加载过程中的解析阶段主要处理那些在编译期就能确定唯一调用版本的方法(如静态方法、私有方法等),将它们的符号引用转换为直接引用。而动态链接侧重于在运行时处理虚方法和接口方法等需要根据实际情况确定调用版本的方法的符号引用到直接引用的转换。

image-20241205171601514

方法返回地址

就如下,我们调用compute方法时,就会在方法返回地址中记录,用于存储compute方法返回后的地址。

public static void main(String[] args) {
    JVMStudy jvmStudy = new JVMStudy();
    jvmStudy.compute();
    System.out.println("计算完成");
}

程序计数器

程序计数器是JVM内存模型中的一个重要部分,它是线程私有的,也就是说每个线程都会按照自己的程序计数器指向指令去按顺序执行。

程序计数器的主要职责是告诉JVM接下来应该执行哪条字节码指令。

在类加载阶段,程序计数器尚未发挥作用。而当程序启动时,主线程的程序计数器会初始化为指向 Main 方法字节码指令的首条。随后,随着字节码指令的逐步执行,它持续更新,每执行完一条指令,便精准指向下一条指令地址,以此确保方法中的字节码指令依序执行。若遭遇 if - else 分支语句,程序计数器会依据判断结果跳转至相应的字节码指令地址;当遇到方法调用时,它会先留存当前方法当前字节码指令的地址,而后跳转至被调用方法内继续执行,待被调用方法执行完毕,再重新回到之前留存的位置,从而保障程序执行流程的连贯性与准确性。

堆区

堆区(Heap Area)是JVM内存模型中的一个重要部分,它是线程共享的,这意味着多个线程可以同时访问该区域,获取对象、数组等相关信息。

堆区主要是用于存储Java对象实例(包括数组对象),在程序执行过程中,通过new关键字创建的对象都会在堆区种分配内存空间。

示例代码:

Person person = new Person(); 
person.setName("张三")
person.setAge(22);

当程序运行完这段代码后,就会在堆种存储person对象及name和age属性信息,在栈种存储Person类型的对象引用,该引用指向堆内存种实际的存储地址。

关于堆的内存模型:

在Java8中可以看到堆内存被划分为年轻代和老年代

  • 年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区,根据JVM策略,在经过几次垃圾回收之后,任然存活鱼Survivor区的对象将会被移动到老年代中。
  • 老年代:主要保存生命周期常的对象,一般是经历过多次gc都没有被回收的对象。
  • 年轻代和老年代(1:2),其中年轻代又分为 Eden 、s0 、s1(8:1:1)

image-20240522133556348

简述对象在堆中的一个简单历程:

当程序new一个新的对象,就会把它放在堆中的Eden区,但是当Eden区域放满之后,就需要进行GC -- (minor gc)

image-20240522153328985

这个gc是由执行引擎后台发起一个垃圾收集线程,去堆Eden中的对象进行回收(可达性算法),在回收的过程中如果仍有对象被引用那么就将这些对象复制到 幸存区(其中一个空的,这两者肯定有一个或者两个都是空的) ,然后就这样gc回收,如果一个对象在经过 15 次垃圾回收后依然存活于幸存区中,那么就会将这个对象放到老年代中。此后GC -- (full gc)也会对老年代的垃圾进行回收。

一段代码观察堆内存溢出的情况

下面这段代码肯定会内存溢出,因为我们新new的对象都是存放在lists集合中,而lists又是在main方法栈帧中的变量,是一个GC Root,所以这些新new的对象都不会被回收!!

public class HeapTest{
	public static void main(String[] args){
		ArrayList<HeapTest> lists = new ArrayList<>();
		while(true){
			lists.add(new HeapTest());
			Thread.sleep(5);
		}
	}
}

使用jvisualvm进行检测

本地方法栈

在Java虚拟机(JVM)中,本地方法栈(Native Method Stack)是专门为本地方法(native methods)服务的内存区域。当一个线程调用本地方法时,会使用本地方法栈来执行这些方法。

当Java程序调用本地方法时,JVM会保存当前栈帧,然后在本地方法栈空间中创建当前本地方法的栈帧,通过JNI调用本地方法,本地方法执行完毕之后,JVM回到之前的栈帧,继续执行Java代码。

简单一句话就是执行本地方法的。

示例:本地方法栈的使用
以下是一个简单的本地方法示例,展示了如何使用本地方法栈:

public class NativeExample {
    // 声明本地方法
    public native void nativePrint();

    static {
        // 加载本地库
        System.loadLibrary("NativeExample");
    }

    public static void main(String[] args) {
        new NativeExample().nativePrint();
    }
}

假设对应的C代码如下:
#include <jni.h>
#include <stdio.h>
#include "NativeExample.h"

// 实现本地方法
JNIEXPORT void JNICALL Java_NativeExample_nativePrint(JNIEnv *env, jobject obj) {
    printf("Hello from native code!\n");
}

执行引擎

执行引擎中包含了解释器、JIT即时编译器、垃圾回收

解释器:解释器是执行引擎的一个重要组成部分,它的主要工作方式是逐行读取字节码指令并进行解释执行。例如,当遇到字节码指令中的iload(将局部变量加载到操作数栈)时,解释器会根据指令的参数,从局部变量表中找到对应的变量并将其加载到操作数栈中,这个过程是一个一个指令依次进行的。

JIT即时编译器:JIT 即时编译器是为了提高 Java 程序的执行效率而引入的。它会在程序运行过程中,对那些频繁执行的热点代码(通过一些动态监测机制确定)进行编译。这个编译过程是将字节码转换为机器码,这样在后续执行这些代码时,就可以直接执行已经编译好的机器码,而不是每次都通过解释器解释字节码。

垃圾回收:这块主要是针对堆内存中的垃圾对象回收,以免随着程序的运行对象越来越多导致OOM,具体的GC会在下一篇JVM深入学习中提到。

posted @ 2024-12-05 18:09  柳~  阅读(91)  评论(0编辑  收藏  举报