Java虚拟机--运行时数据区

Updated on in 程序人生 with 0 views and 0 comments

一、定义

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为以下几个区域,并统称为运行时数据区

image.png

其中线程共享的区域有堆和方法区

线程不共享的区域有程序计数器、Java虚拟机栈和本地方法栈

二、程序计数器

程序计数器(Program Counter Register)是一个较小的内存空间,字节码解释器工作时就是通过改变这个计数器的值来选择下一条需要执行的字节码指令

每个线程都有一个独立的程序计数器,多线程环境下程序计数器用于跟踪每个线程执行的位置

如果线程正在执行的是一个Java方法,这个计数器记录的值是正在执行的虚拟机字节码指令的地址;乳沟正在执行的是本地native方法,这个计数器则值为空

在Java虚拟机规范中,这个内存区域是没有OutOfMemoryError情况的区域

三、栈

3.1 Java虚拟机栈

Java虚拟机栈也是线程私有的,他的生命周期与线程相同;

每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(stack frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息

栈帧是JVM栈的基本单位,每当调用一个方法时,就会为该方法创建一个新的栈帧,当方法返回时,该栈帧被弹出

在idea中编写如下方法

public class JvmStack {
    public static void main(String[] args){
        System.out.println("开始");
        People people = new People();
        people.eat();
        people.sleep();
    }


}
class People{
    public void eat(){
        System.out.println("eat");
    }

    public void sleep(){
        System.out.println("sleep");
    }
}

打上断点调试

image.png

image.png

image.png

最后main方法执行完成也会出栈

然后我们修改代码如下,让他抛出一个异常

public class JvmStack {
    public static void main(String[] args){
        System.out.println("开始");
        People people = new People();
        people.eat();
        people.sleep();
    }
}
class People{
    public void eat(){
        System.out.println("eat");
    }

    public void sleep(){
        System.out.println("sleep");
        throw new RuntimeException();
    }
}

这里注意到我们在sleep方法中抛出一个异常,捕获信息如下

image.png

程序执行到异常发生的位置,main线程中只有两个栈帧,异常就是从栈顶把这些栈帧依次出栈,并显示对应信息

3.1.1 局部变量表

Java虚拟机栈(Java Virtual Machine Stack, JVM Stack)中的局部变量表是用来存放方法参数和方法内部定义的局部变量的数据结构。局部变量表对于每一个线程都是独立的,并且随着方法调用的开始而创建,随着方法调用结束而销毁

局部变量表的特点:

  1. 作用域
    • 局部变量表只存在于方法的生命周期内,即从方法被调用开始到方法返回结束。
    • 方法中的局部变量只在该方法内可见,不会与其他方法共享。
  2. 存储位置
    • 局部变量表位于Java虚拟机栈中的一个栈帧里。
    • 每个线程有自己的Java虚拟机栈,因此也有自己的局部变量表。
  3. 数据类型
    • 局部变量表可以存储基本数据类型的变量(如int, double, boolean等)和对象引用。
    • 对象本身并不存储在局部变量表中,而是存储在堆中,局部变量表中存储的是指向堆中对象的引用。
    • 这些数据类型在局部变量表中存储空间以局部变量槽来标表示,long和doble站两个槽位,其他变量类型站一个槽位
  4. 索引
    • 局部变量表使用索引来标识每一个变量。
    • 索引从0开始,对于方法参数,参数会按照声明顺序依次放入局部变量表中,从索引0开始占用位置;对于方法内的局部变量,则根据声明的位置依次分配索引。
  5. 变量的有效性
    • 局部变量在声明后必须先赋值才能使用,否则会抛出 NullPointerException或遇到未初始化的局部变量错误。
    • 方法参数自动赋值,即在方法调用时参数就已经有了对应的值。
  6. 自动垃圾回收
    • 当方法执行完毕,局部变量表中的变量将不再可访问,随着栈帧的销毁,这些变量所占的内存会被自动回收。

参考如下代码

public class LocalVariable {
    public void test1(){
        int i=0;
        int j=1;
    }
}

编译后class文件方法内容如下,我们可以查看到字节码文件的局部变量表(这是Class文件的局部变量表,并不是真正的Java虚拟机中的局部变量表)

image.png

我们查看他的LocalVariableTable属性

image.png

图中观察到i的起始PC为2,长度为3,对应的其实时他在字节码中的定义行数和作用范围

image.png

Class文件的LocalVariableTable还有一个序号属性,这个序号就是变量的槽位,这里有i和j两个变量,序号依次是1和2,我们更改代码如下

public class LocalVariable {
    public void test1(){
        int i=0;
        long j=1L;
        int k=0;
    }
}

再看他的序号,发现k的序号是4,就是因为j的类型为long占据了两个槽位2和3

image.png

同样的方法参数也会占用局部变量槽

image.png

3.1.2 操作数栈

操作数栈用于存储方法执行过程中的中间结果,例如算术运算的结果、方法调用的参数和返回值等,Java字节码指令集中有许多指令用于操作操作数栈,例如 aload, istore, iadd等如果在执行字节码指令时操作数栈为空,而该指令需要从栈中弹出值,则会抛出 StackUnderflowError异常

考虑下面这个简单的Java方法:

public static int add(int a, int b) {
    return a + b;
}

作流程:

  1. 方法调用
    • 当方法 add被调用时,一个新的栈帧被创建,其中包括操作数栈和局部变量表。
    • 参数 ab被压入局部变量表。
  2. 执行字节码指令
    • 执行 aload_0(加载局部变量表中的第一个整型变量到操作数栈)。
    • 执行 aload_1(加载局部变量表中的第二个整型变量到操作数栈)。
    • 执行 iadd(执行栈顶两个整型值的加法运算,将结果放回操作数栈)。
  3. 返回值
    • 执行 ireturn(将操作数栈顶部的整型值作为方法的返回值)。
  4. 栈帧销毁
    • 当方法执行完毕后,其对应的栈帧被销毁,操作数栈也随之消失。

3.1.3 帧数据

Java虚拟机栈(JVM Stack)中的栈帧不仅包含了局部变量表和操作数栈等数据结构,还包括了动态链接信息、方法出口信息和异常表。这些信息对于支持方法调用和返回过程、异常处理至关重要。

3.1.3.1 动态链接(Dynamic Linking)

在方法调用过程中,动态链接的主要作用是记录方法调用链中的上下文信息,以便在方法返回时能够正确地回到调用者。具体来说,它可以帮助JVM找到方法调用链上的上一层方法,从而支持方法调用的返回和异常处理

动态链接的实现

  1. 方法调用时 :当一个方法被调用时,JVM 会在栈顶创建一个新的栈帧,并在栈帧中设置动态链接信息。这个信息通常是指向调用者栈帧的指针,或者是调用者栈帧中的一条指令,这条指令可以用来定位调用者的上下文。
  2. 方法返回时 :当方法完成执行并准备返回时,JVM 会使用动态链接信息来恢复调用者栈帧的状态。这包括恢复调用者栈帧中的操作数栈、局部变量表等,并将控制权交还给调用者。

如下代码

public class Example {
    public static void main(String[] args) {
        foo();
    }

    public static void foo() {
        bar();
    }

    public static void bar() {
        // 执行一些操作
    }
}

在这个例子中,当执行 foo() 方法时,JVM 会在栈中创建一个栈帧,并设置动态链接信息指向 main() 方法的栈帧。当 bar() 方法被调用时,又会在栈中创建一个新的栈帧,并设置动态链接信息指向 foo() 方法的栈帧。

bar() 方法完成执行并返回时,JVM 会使用动态链接信息来恢复 foo() 方法的栈帧状态,并继续执行 foo() 方法剩余的部分。同样,当 foo() 方法返回时,会恢复 main() 方法的栈帧状态。

通过这种方式,动态链接确保了方法调用链的正确管理和恢复,支持了Java语言中的方法调用和异常处理机制。

3.1.3.2 方法出口(Method Exit)

方法出口是指当方法执行完毕或发生异常时,如何从当前方法返回到调用者的方法。这涉及到保存返回值、恢复调用者状态以及跳转到正确的指令位置

正常返回

当方法正常执行完毕时,JVM 会执行以下步骤:

  1. 保存返回值 :如果方法有返回值,它会被压入当前栈帧的操作数栈顶部。
  2. 执行返回指令 :JVM 会执行 ireturn, lreturn, freturn, dreturn, 或 areturn 指令之一,这取决于返回值的数据类型。这些指令会弹出栈顶的返回值并将其复制到调用者的操作数栈中。
  3. 恢复调用者状态 :JVM 会从当前栈帧中弹出,并恢复调用者的栈帧。这通常包括恢复调用者栈帧中的操作数栈和局部变量表的状态。
  4. 跳转到返回地址 :JVM 会根据保存的返回地址跳转回调用者的方法继续执行。

异常处理

当方法抛出异常时,JVM 会执行以下步骤:

  1. 异常抛出 :当方法内部抛出一个异常时,JVM 会停止当前方法的执行,并查找适当的异常处理器。
  2. 异常处理器 :JVM 会查找当前栈帧中是否有异常处理器可以处理该异常。如果没有,JVM 会向上查找调用者栈帧,直到找到合适的异常处理器。
  3. 恢复状态 :一旦找到合适的异常处理器,JVM 会恢复相应的栈帧状态,并跳转到异常处理器的起始位置继续执行。

如下代码

public class Example {
    public static void main(String[] args) {
        try {
            foo();
        } catch (Exception e) {
            System.out.println("Caught exception: " + e.getMessage());
        }
    }

    public static void foo() throws Exception {
        throw new Exception("An exception occurred");
    }
}

在这个例子中,当执行 foo() 方法时,如果抛出了异常,JVM 会执行以下步骤:

  1. 异常抛出foo() 方法抛出了 Exception
  2. 异常处理器 :JVM 在当前栈帧中找不到异常处理器,因此它会查找调用者栈帧(main() 方法)中的异常处理器。
  3. 恢复状态 :一旦找到 main() 方法中的异常处理器,JVM 会恢复 main() 方法的栈帧状态,并跳转到异常处理器的起始位置继续执行。
3.1.3.3 异常表(Exception Table)

异常表是一组关于方法如何处理异常的信息。当一个方法中定义了 try-catch块时,异常表中会记录下这些块的起始位置、结束位置以及异常处理器的位置。

异常表的作用:

  1. 异常捕获
    • 当方法执行过程中发生异常时,JVM会查找异常表以确定哪个异常处理器应该处理这个异常。
    • 如果有多个 catch块可以处理相同的异常,JVM会选择最具体的那个。
  2. 异常传递
    • 如果没有合适的异常处理器,异常会被传递给调用者,直到找到一个可以处理该异常的异常处理器。
  3. 异常处理
    • 异常表中的信息指导JVM如何跳转到适当的异常处理代码段。

如下代码

public class Example {
    public static void main(String[] args) {
        try {
            foo();
        } catch (ArithmeticException e) {
            System.out.println("Caught ArithmeticException: " + e.getMessage());
        } catch (Exception e) {
            System.out.println("Caught Exception: " + e.getMessage());
        }
    }

    public static void foo() {
        int a = 10;
        int b = 0;
        int c = a / b;  // 抛出 ArithmeticException
    }
}

在这个例子中,main() 方法包含两个异常处理器:

  1. 第一个异常处理器可以捕获 ArithmeticException 类型的异常。
  2. 第二个异常处理器可以捕获 Exception 类型的异常,这是一个更通用的异常处理器。

字节码内容如下

image.png

字节码文件异常表信息如下

image.png

这里,异常表包含两个表项:

  1. 从第0条指令到第3条指令 :如果在这段范围内抛出 ArithmeticException,则跳转到第6条指令。
  2. 从第0条指令到第3条指令 :如果在这段范围内抛出任何异常,则跳转到第25条指令

3.1.4 栈内存溢出

编写代码

public class Example {
    public static int count = 0;
    public static void main(String[] args) {
        foo();

    }

    public static void foo() {
        System.out.println(++count);
        foo();
    }
}

这个代码不断调用foo(),就会一直创建栈帧,最终导致 StackOverflowError栈的内存溢出

3.1.5 JVM设置栈内存大小

-Xss : 这个参数用来设置每个线程的栈大小

单位:字节(默认,必须是1024的倍数)、k、m、g

下述代码在执行时,默认创建的的栈帧数量大概是一万多个,因为jvm默认的Xss值是1M

public class Example {
    public static int count = 0;
    public static void main(String[] args) {
        foo();

    }

    public static void foo() {
        System.out.println(++count);
        foo();
    }
}

image.png

此时,我们设置Xss为512k

image.png

在执行就发现栈帧数量少了很多

image.png

3.2 本地方法栈

image.png

四、堆内存

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,所有对象实例以及数组都应该在堆上分配,java堆是垃圾收集器管理的内存区域

  • 初始堆大小(Initial Heap Size) :可以通过 -Xms参数设置。
  • 最大堆大小(Maximum Heap Size) :可以通过 -Xmx参数设置。

单位:字节(默认,必须是1024的倍数)、k、m、g

内存溢出会抛出OutOfMemoryError异常

编写如下代码

public class Example {
    public static void main(String[] args) {
        while(true){
    
        }
    }
}

启动arthas查看内存

image.png

  • used 就是已经使用的内存
  • total 堆内存现在占用的空间,减去used就是还可以使用的空间,如果内存大小超过total,就可以在扩展total空间,但是最大不超过max
  • max 是虚拟机可使用的最大空间

五、方法区

方法区是JVM内存模型中的一个组成部分,它用于存储每个类的信息,包括类的方法和变量的数据类型、访问修饰符等信息,以及常量池(运行时常量池、字符串常量池)、静态变量等

  1. 持久性
    • 方法区在JVM启动时创建,并且通常在整个应用运行期间一直存在,直到JVM关闭。
  2. 共享性
    • 方法区被线程共享,也就是说所有的线程都可以访问到方法区中的数据。
  3. 持久代
    • 在Java 8及以前的版本中,方法区实现为永久代(Permanent Generation)。永久代也属于堆的一部分,但是它的大小可以通过JVM参数来设置,如 -XX:MaxPermSize。当永久代空间不足时,会抛出 OutOfMemoryError: PermGen space错误。
  4. 元空间
    • Java 8开始,永久代被元空间(Metaspace)所替代。元空间使用的是本地内存(Native Memory),而不是Java堆内存。这意味着它不再受JVM堆大小的限制,而是受限于本机系统的可用物理内存。如果元空间满了,则会抛出 OutOfMemoryError: Metaspace。通过 -XX:MaxMetaspaceSize参数可以设置元空间大小
  5. 存储内容
    • 类的结构信息(比如字段和方法信息)、常量池、静态变量、即时编译器编译后的代码缓存等。

方法区内存溢出演示代码如下

引入依赖

        <dependency>
            <groupId>net.bytebuddy</groupId>
            <artifactId>byte-buddy</artifactId>
            <version>1.12.23</version>
        </dependency>

编写代码

public class Demo1 extends ClassLoader{
    public static void main(String[] args){
        Demo1 demo1 = new Demo1();
        int count = 0;
        while(true){
            String className = "Class"+count;
            ClassWriter classWriter = new ClassWriter(0);
            classWriter.visit(Opcodes.V11,Opcodes.ACC_PUBLIC,className,null,"java/lang/Object",null);
            byte[] bytes = classWriter.toByteArray();
            demo1.defineClass(className,bytes,0,bytes.length);
            System.out.println(++count);
        }
    }
}

设置一个虚拟机参数 -XX:MaxMetaspaceSize=128M
启动运行一端时间后,爆出内存溢出错误

image.png

5.1 运行时常量池

  1. 定义 :
    • 运行时常量池是每个类或接口的一部分,它包含了类的所有常量信息,例如字段名、方法名、符号引用等。这些信息是在类加载阶段确定的,并且不会随着程序的运行而改变。
  2. 位置 :
    • 运行时常量池位于方法区中,即每个类或接口的元数据的一部分。
  3. 作用 :
    • 存储类的常量信息,这些信息对于JVM来说是必要的,用于支持类的加载和执行。
    • 当一个类被加载到JVM时,它的运行时常量池也会被加载。
  4. 示例 :
    • 假设有一个类 ExampleClass,它包含了一个字符串常量 "constant" 和一个整型常量 42,那么这些信息都会存储在该类的运行时常量池中。

5.2 字符串常量池

  1. 定义 :
    • 字符串常量池是一个特殊的内存区域,用于存储字符串字面量。当创建新的字符串时,JVM首先检查字符串常量池中是否存在该字符串,如果存在则直接返回引用,否则会在池中创建一个新的字符串实例并返回其引用。
  2. 位置 :
    • 在Java 7及之前的版本中,字符串常量池位于方法区(永久代)中。
    • 从Java 8开始,字符串常量池被移到了堆中。
  3. 作用 :
    • 减少内存消耗:通过重用相同的字符串实例,可以减少内存中的字符串副本数量。
    • 提高性能:比较字符串时可以直接比较引用,而不需要逐个字符比较。

实例:

String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // 输出 true

5.3 比较

  • 字符串常量池
    • 存储在堆中(Java 8及以上)或方法区(Java 7及以前)。
    • 包含字符串字面量。
    • 目的是减少字符串对象的数量,提高性能。
  • 运行时常量池
    • 存储在方法区中。
    • 包含类的常量信息,如字段名、方法名、符号引用等。
    • 目的是支持类的加载和执行。

六、直接内存

JVM直接内存(Direct Memory)是一种特殊的内存区域,它不属于JVM的常规内存区域(如堆、方法区等),而是直接分配在本地内存(Native Memory)中的内存。直接内存是由Java NIO(New I/O)包提供的,它允许Java应用程序直接访问本地内存,从而提高I/O操作的性能。

直接内存的特点:

  1. 分配方式 :
    • 直接内存是由Java NIO包提供的 java.nio.ByteBuffer类的 allocateDirect()方法分配的。这使得直接缓冲区能够直接在本地内存中创建和操作,而不需要通过JVM堆进行复制。
  2. 性能优势 :
    • 直接内存可以提高I/O操作的性能,因为数据可以直接从网络或磁盘传输到直接内存中,减少了数据复制的开销。
    • 由于直接内存位于JVM堆之外,因此它不受垃圾收集的影响,可以提高程序的响应速度。
  3. 内存管理 :
    • 直接内存的分配和释放由JVM负责,但它不受垃圾收集器的管理。这意味着一旦分配了直接内存,除非显式地释放或者JVM退出,否则这块内存将一直保留。
    • 由于直接内存不受JVM堆的管理,因此可能需要手动管理直接内存的生命周期,确保及时释放不再使用的直接内存资源。
  4. 限制 :
    • 直接内存的分配会受到系统物理内存的限制,如果分配过多的直接内存可能会导致系统内存耗尽。
    • 直接内存的分配和释放可能会导致性能问题,尤其是在频繁分配和释放大量直接内存的情况下。
  5. 参数控制 :
    • JVM提供了一些参数来控制直接内存的使用,例如:
      • -XX:MaxDirectMemorySize: 设置最大直接内存大小。如果不设置,默认值通常是系统物理内存的1/4。

编写如下代码

public class Demo1 extends ClassLoader{
    public static void main(String[] args){
        List<ByteBuffer> list = new ArrayList<>();
        while(true){
            // 分配直接内存缓冲区
            ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024*1024*100);
            list.add(directBuffer);
        }
    }
}

运行后爆出错误

image.png

打开arthas通过memory命令,可以看到直接内存

image.png

设置一个 -XX:MaxDirectMemorySize=256M在启动如下程序

public class Demo1 extends ClassLoader{
    public static void main(String[] args){
        List<ByteBuffer> list = new ArrayList<>();
        int count = 0;
        while(true){
            // 分配直接内存缓冲区
            ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024*1024*100);
            list.add(directBuffer);
            System.out.println(++count);
        }
    }
}

代码中每次分配100m内存,然后我们设置了最大直接内存只有256M因此,分配两次后直接报错了

image.png


标题:Java虚拟机--运行时数据区
作者:wenyl
地址:http://www.wenyoulong.com/articles/2024/08/13/1723542480573.html