当前位置:   article > 正文

Java虚拟机:运行时内存结构

Java虚拟机:运行时内存结构

大家好,我是栗筝i,这篇文章是我的 “栗筝i 的 Java 技术栈” 专栏的第 035 篇文章,在 “栗筝i 的 Java 技术栈” 这个专栏中我会持续为大家更新 Java 技术相关全套技术栈内容。专栏的主要目标是已经有一定 Java 开发经验,并希望进一步完善自己对整个 Java 技术体系来充实自己的技术栈的同学。与此同时,本专栏的所有文章,也都会准备充足的代码示例和完善的知识点梳理,因此也十分适合零基础的小白和要准备工作面试的同学学习。当然,我也会在必要的时候进行相关技术深度的技术解读,相信即使是拥有多年 Java 开发经验的从业者和大佬们也会有所收获并找到乐趣。

运行时内存结构是 JVM 在执行 Java 程序时管理和分配内存资源的基础。不同的内存区域负责存储不同类型的数据,如堆内存用于对象实例,方法区用于类信息等。本篇文章将详细探讨 JVM 的运行时内存结构,帮助你理解内存管理的原理,以及如何避免常见的内存相关问题。



1、Java自动分配内存结构

在编程世界里,内存管理一直是一个令人着迷的话题。在 C 语言中,内存的使用主要涉及到手动分配、使用和释放内存。这与 Java 的自动垃圾回收机制不同。C 语言通过指针直接操作内存,而 Java 隐藏了这些底层细节。

1.1、C语言:精确掌控内存的雕刻师

想象一下,你是一个雕刻家,手中拿着凿子和锤子,面对一块未经雕琢的大理石。在C语言中,内存管理就像这样的雕刻过程。你需要精确地决定从哪里切割、去除多少材料,每一下都要小心翼翼。

看看这个 C 语言的例子:

#include <stdio.h>
#include <stdlib.h>

int main() {
    int *ptr; // 这就像是指向你的大理石块的指针
    int n = 5; // 你想雕刻的数字数量

    ptr = (int*)malloc(n * sizeof(int)); // 你在大理石块上划出你要工作的区域
    if (ptr == NULL) {
        printf("Memory allocation failed.\n"); // 如果大理石块有问题,你就停止工作
        exit(1);
    }

    for (int i = 0; i < n; i++) {
        ptr[i] = i + 1;  // 在每个区域雕刻一个数字
    }

    free(ptr); // 完成后,清理掉所有碎屑
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

在这个例子中,我们使用 malloc(内存分配)函数在内存中划出一块区域,就像在大理石上划出我们要雕刻的部分。然后我们在这块区域上工作,最后用 free 函数清理掉所有碎屑,释放这块区域。这需要精确的控制和细致的关注,一点小错误就可能导致整个作品毁坏。

1.2、Java:Jvm自动内存舞蹈的舞伴

现在,想象你是一个舞者,参加一场精心编排的舞蹈。在 Java 世界中,Jvm 像是你的舞伴,它自动地引导你,你只需要专注于舞蹈的步伐和节奏。

看看这个 Java 的例子:

public class Main {
    public static void main(String[] args) {
        int n = 5;
        int[] arr = new int[n]; // Jvm 为你准备了舞台

        for (int i = 0; i < arr.length; i++) {
            arr[i] = i + 1; // 你在舞台上跳舞,每一步都被自动记录
        }

        // 舞蹈结束后,Jvm 会清理舞台,你无需担心
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

在这个例子中,当我们创建一个数组时,Jvm 自动在内存中为它分配空间。这就像是自动为你准备舞台。你只需要在这个空间上"跳舞"(编写代码)。当舞蹈结束,也就是当我们的数组不再需要时,Jvm 会自动进行"清理工作"(回收内存)。这个过程完全自动化,免去了你手动管理内存的麻烦。

1.3、从C到Java:内存管理的进化

通过这两个例子,我们看到了 C 和 Java 在内存管理上的根本不同。在 C 中,程序员必须像雕刻家一样精确地掌握每一块内存。而在 Java 中,Jvm 的垃圾回收机制就像是一位神奇的舞伴,它自动地引导着内存的分配和释放。这不仅简化了编程过程,也减少了错误的可能性,让程序员可以专注于创造性的工作。

这种从 C 到 Java 的转变,不仅是从手动到自动的变化,更是一种编程哲学的进化。在C中,你是内存的主宰,每一个决定都由你控制。而在 Java 中,你交出了部分控制,以换取更高的安全性和开发效率。Java 的自动内存管理减少了错误的可能性,使程序员可以更自由地探索更复杂的逻辑和架构。


2、Java运行时的内存结构

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(Java SE 7版)》的规定,Java 虚拟机所管理的内存将会包括以下几个运行时数据区域:

image-20231112135840755

2.1、堆(Heap)

这是 Jvm 中最大的一块内存区域。也是被所有线程共享的一块内存区。用于存储 Java 应用程序创建的对象实例和数组,堆是也在虚拟机启动时创建的,并且是垃圾收集器管理的主要区域,这里的对象不需要手动释放。

Java 堆在存储内容的构成上主要有:

  1. 对象实例:几乎所有通过 new 关键字创建的对象都在堆上分配。这包括用户自定义类的对象以及内置类的对象实例(如 ObjectIntegerString 等)。

  2. 数组:无论是基本类型数组还是对象数组,都在堆上分配。

  3. 类实例变量:对象的非静态字段(无论是基本类型还是对象引用)随对象实例一起存储在堆中。

  4. 字符串常量池:从 Java 7 开始,字符串常量池被存储在堆中。包括所有的字符串字面量和通过 String.intern() 方法显式添加到池中的字符串实例。字符串常量池帮助节省内存,因为它存储了唯一的字符串实例,当创建已存在于池中的字符串时,直接返回池中的引用。

  5. Jvm 内部结构:包括但不限于,某些 Jvm 内部维护的结构,如方法调用时的参数、返回值等。

  6. 垃圾收集根节点:活跃的线程、静态字段引用的对象、JNI 引用等,这些作为 GC Roots,垃圾收集器会从这些根节点开始搜索,以确定哪些对象是可达的,哪些是垃圾。

  7. 其他可能的结构或对象:根据不同 Jvm 的实现细节,可能还会有其他类型的数据结构或对象存储在堆上。

总结来说,Java 堆是存储 Java 应用程序创建的大部分动态数据的地方。这不仅包括各种类型的对象和数组,还包括特殊的结构如字符串常量池。堆的管理和垃圾回收机制对于 Java 应用程序的性能和稳定性至关重要。

此外 Java 堆也是垃圾回收的主要场所。因此很多时候也被称做 GC 堆(Garbage Collected Heap)。

从内存回收的角度来看 Jvm 的内存分区:

  • Java 堆通常分为年轻代(Young Generation)和老年代(Old Generation)。
  • 年轻代又被进一步分为一个 Eden 空间和两个 Survivor 空间(通常称为 S0 和 S1)。
  • 对象最初在年轻代中创建,随着垃圾回收的进行和对象的存活时间增加,它们可能会被移动到老年代。
2.2、方法区(Method Area)/ 元空间(Metaspace)

方法区(Method Area)也常常被称为永久代(Permanent Generation,简称 PermGen),但实际上是两个不同的概念,在 Java 的不同版本中扮演不同的角色。

  • 方法区(Method Area):方法区是 Java 虚拟机规范中定义的一个内存区域。它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区是 JVM 规范的一部分,因此所有的 JVM 实现都必须有方法区,但具体实现可以有所不同。
  • 永久代(PermGen):永久代是方法区的一种实现,出现在 Sun/Oracle 的 Jvm 实现中,直到 Java 8 为止。它使用固定的内存大小来存储类的元数据,容易导致 OutOfMemoryError 异常,如果这部分内存分配不足。
  • 在 Java 8 中,Oracle 抛弃了永久代的概念,引入了一个名为"元空间"(Metaspace)的新内存区域来替代永久代。元空间使用本地内存(而非虚拟机内存),因此不受 Java 堆大小的限制。

总结来说,方法区是一个抽象概念,而永久代是这个概念在某些 Jvm 版本中的具体实现。从 Java 8 开始,永久代被元空间所取代,这是一种更灵活的内存管理方式。

以 JDK 8 之前的 HotSpot 实现中的方法区举例,它的主要内容有:

  1. 类信息:类的名称、访问修饰符、常量池、字段描述、方法描述等。
  2. 静态变量:类中定义的静态变量,因为它们不随对象实例存在而存在,而是随类的加载和卸载而存在。
  3. 常量池:包括各种字面量和符号引用,这部分内容在类加载后进入方法区的常量池。
  4. 即时编译后的代码:如果使用即时编译器(JIT),编译器编译后的代码也可能存储在此区域。
2.3、运行时常量池

Java运行时常量池(Runtime Constant Pool)是Java虚拟机在运行时存储常量的一块区域。它是方法区的一部分,用于存储编译期生成的各种字面量和符号引用。

Java运行时常量池的主要作用有以下几个方面:

  1. 存储字面量:Java中的字符串、整数、浮点数等字面量都会被存储在运行时常量池中。这些字面量在编译期就确定了,存储在常量池中可以提高运行效率。

  2. 存储符号引用:Java中的类、方法、字段等符号引用也会被存储在运行时常量池中。符号引用是一种直接或间接指向目标的引用,存储在常量池中可以方便解析和使用。

  3. 动态生成的常量:在运行时,Java虚拟机还可以动态生成一些常量,并将其存储在运行时常量池中。例如通过String类的intern()方法将字符串对象加入常量池。

需要注意的是,运行时常量池是每个类的独立部分,它在类加载时被创建并初始化。不同类的常量池是相互独立的,即使两个类的常量内容相同,它们在运行时常量池中的地址也是不同的。

运行时常量池的存在可以提高Java程序的性能和效率,减少了重复的常量创建和存储。同时,它也为Java提供了一些特性,如字符串常量池、类的符号引用等。

在Java虚拟机中,符号引用(Symbolic Reference)是一种用来表示对类、方法、字段等符号的引用的数据类型。它是一种字面量形式的引用,与直接引用(Direct Reference)相对。

符号引用包含了以下信息:

类的全限定名(Fully Qualified Name)用于唯一标识一个类com.example.MyClass
方法的名称和描述符用于唯一标识一个方法,包括方法名和参数类型列表doSomething(int, String)
字段的名称和描述符用于唯一标识一个字段,包括字段名和字段类型count:int

符号引用是在编译期生成的,它是一种与具体内存地址无关的引用。在虚拟机执行时,需要将符号引用解析为直接引用,即将符号引用转化为内存中的实际指针或偏移量。

通过符号引用,虚拟机可以在运行时动态地加载、解析和初始化类,以及调用类的方法和访问类的字段。符号引用的解析过程是虚拟机对类的动态链接的一部分。

需要注意的是,符号引用是在编译期确定的,而直接引用是在运行时确定的。符号引用是一种抽象的引用,而直接引用是具体的内存地址或偏移量。虚拟机在执行时会将符号引用解析为直接引用,以便进行具体的操作。

总的来说,符号引用是一种用于表示对类、方法、字段等符号的引用的数据类型,它包含了类的全限定名、方法的名称和描述符、字段的名称和描述符等信息。虚拟机在执行时会将符号引用解析为直接引用,以便进行具体的操作。

2.4、虚拟机栈(Java Virtual Machine Stacks)

Java 虚拟机栈(Java Virtual Machine Stacks)是专门用于存储每个线程运行时的栈帧。每个 Java 线程在创建时都会创建自己的 Jvm 栈。这些栈在 Jvm 规范中被描述为线程私有的内存区域。

image-20231112143211131

栈帧(Stack Frame):栈帧是 Jvm 栈的基本单位,每个栈帧对应着一个方法调用:

  1. 局部变量表(Local Variable Table):存储所有局部变量,包括各种基本数据类型、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)。局部变量表的大小在编译期间确定,因此是固定的。
  2. 操作数栈(Operand Stack):一个 LIFO(后进先出)的栈,用于存储操作指令过程中的各种操作数。比如,在执行算术运算或调用方法时,操作数栈用于传递参数和存储返回结果。
  3. 动态链接(Dynamic Linking):每个栈帧内部都包含对运行时常量池的引用,以支持方法调用期间的动态链接。
  4. 方法返回地址(Return Address):当一个方法被调用时,需要知道返回到调用者的位置。这个信息被存储在栈帧中。

虚拟机栈的入栈和出栈操作主要与方法的调用和返回有关。这些操作围绕栈帧(Stack Frame)进行,每个栈帧代表一个方法的调用环境。

以下是虚拟机栈的入栈和出栈过程的详细解释:

入栈(Push):当一个方法被调用时,一个新的栈帧被创建并入栈。这个过程包括以下步骤:

  1. 栈帧创建:对于每次方法调用,Jvm 创建一个新的栈帧。栈帧包含局部变量表、操作数栈、动态链接信息和方法返回地址等。
  2. 局部变量初始化:在栈帧中,局部变量表被初始化。对于实例方法,this 引用被存储在局部变量表的第一个位置。方法的参数按顺序被存储在局部变量表中。
  3. 压入虚拟机栈:创建的栈帧被压入当前线程的虚拟机栈中。Jvm 栈指针移动,指向新的栈顶。

出栈(Pop):当一个方法完成执行后,它的栈帧需要从虚拟机栈中出栈。这个过程包括:

  1. 方法结束:方法可以通过正常完成或抛出异常来结束。如果方法有返回值,该值被压入调用者的操作数栈中。
  2. 栈帧弹出:方法完成后,当前栈帧被从虚拟机栈中弹出。Jvm 栈指针回退,指向前一个栈帧。
  3. 返回值处理(如果有):如果方法有返回值,这个值会在栈帧被弹出后留在调用者的操作数栈顶部。调用者可以从其操作数栈中获取这个返回值。

此外虚拟机栈存在着两种主要的异常情况;

  • 栈溢出(StackOverflowError);如果方法调用的深度过大,超过了 Jvm 栈的最大深度,会发生栈溢出。
  • 内存不足(OutOfMemoryError):如果 Jvm 栈无法获得足够的内存,可能会发生内存不足错误。
2.5、本地方法栈(Native Method Stack)

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如 Sun HotSpo虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

2.6、程序计数器(Program Counter Register)

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为"线程私有"的内存;
  • 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
  • 如果正在执行的是 Native 方法,这个计数器值则为空(Undefined);
  • 程序计数器 是在 Jvm 规范中,唯一一个没有规定任何 OutOfMemoryError 情况的区域(只存下一个字节码指令的地址,消耗内存小且固定,无论方法多深,它只存一条)。
2.7、直接内存(Direct Memory)

直接内存(Direct Memory)在 Java 虚拟机(Jvm)的上下文中指的是一块不是由 Jvm 直接管理的内存区域,其主要通过 Native 方法直接分配在操作系统的物理内存上。以下是直接内存的一些重要特点和用途:

特点:

  1. 非 Jvm 堆内存:直接内存不是 Jvm 堆内存的一部分,因此其大小不受 Jvm 最大堆大小设置(通过 -Xmx)的限制。
  2. 高效数据处理:直接内存访问通常比 Jvm 堆内存访问更高效,因为它避免了 Jvm 堆和 Native 堆之间的数据复制。
  3. 由操作系统直接管理:这部分内存的分配和回收是由操作系统直接管理的,不受 Jvm 垃圾回收器的影响。
  4. 手动管理:使用直接内存通常需要显式地分配和释放内存,这增加了编程的复杂性。

用途:

  1. NIO(New Input/Output):Java NIO 的一大特性是能够使用直接内存进行高效的 I/O 操作。通过将数据存储在直接内存中,可以减少在 Java 堆内存和 Native 内存之间复制数据的次数,从而提高 I/O 操作的效率。
  2. 大型缓存需求:对于需要大量内存且要求高效访问的场景(如某些缓存机制),直接内存是一个理想的选择。

可以使用 ByteBuffer.allocateDirect(int capacity) 方法来创建一个直接缓冲区,这将在直接内存中分配空间。以下是一个示例:

import java.nio.ByteBuffer;

public class DirectMemoryCache {
    public static void main(String[] args) {
        // 分配直接内存
        int capacity = 1024 * 1024 * 10; // 10MB
        ByteBuffer directBuffer = ByteBuffer.allocateDirect(capacity);

        // 使用直接内存缓存
        // 例如,写入数据
        directBuffer.put((byte) 123);

        // 在后续操作中读取数据
        directBuffer.flip(); // 切换为读模式
        byte b = directBuffer.get();

        // 清理缓存以待再次使用
        directBuffer.clear();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

注意事项:

  1. 内存泄漏风险:直接内存的分配和释放完全在开发者的控制之下,不当的管理可能导致内存泄漏。
  2. 内存溢出:如果分配过多的直接内存,超过了物理内存的限制,可能会导致 OutOfMemoryError 或者系统的内存溢出。
  3. 性能考虑:虽然直接内存访问速度快,但其分配和释放的成本比 Jvm 堆内存要高。因此,在不需要高效 I/O 的情况下,使用 Jvm 堆内存可能更合适。

总结:直接内存提供了一种在 Java 中使用高效内存的方式,特别适合于需要快速、大量 I/O 操作的应用程序。然而,它的使用需要仔细考量,以避免内存泄漏和其他内存管理问题。直接内存的使用是一种权衡,需要在性能提升和编程复杂度、资源管理之间找到平衡点。


3、相关参数设定

Java 运行时内存结构可以通过多种 Jvm(Java 虚拟机)参数进行配置和优化。这些参数允许开发者调整内存大小、控制垃圾回收行为等,以适应不同的应用需求和提升性能。以下是一些常用的 Jvm 内存相关参数:

3.1、堆内存设置
  • 初始堆大小 -Xms:设置 JVM 启动时堆的初始内存大小。例如,-Xms256m 设置初始堆大小为 256MB。
  • 最大堆大小 -Xmx:设置 JVM 可以使用的最大堆内存大小。例如,-Xmx1024m 设置最大堆大小为 1024MB。
  • 新生代大小 -Xmn:设置年轻代的大小。这个值可以影响老年代的大小,因为整个堆大小是固定的。
3.2、元空间(Metaspace)设置
  • 元空间初始大小 -XX:MetaspaceSize:设置元空间的初始大小。例如,-XX:MetaspaceSize=128m 设置元空间的初始大小为 128MB。
  • 元空间最大大小 -XX:MaxMetaspaceSize:设置元空间的最大大小。默认情况下,元空间大小不限制。
3.3、垃圾回收器设置
  • 选择垃圾回收器 -XX:+UseG1GC -XX:+UseParallelGC 等:选择使用特定的垃圾回收器。不同的垃圾回收器对内存管理策略有不同的影响。
3.4、直接内存设置
  • 最大直接内存大小 -XX:MaxDirectMemorySize:设置直接内存的最大大小。这对于使用 NIO 进行 I/O 操作的应用程序特别重要。
3.5、其他性能调优参数
  • 堆内存占用率阈值 -XX:MinHeapFreeRatio-XX:MaxHeapFreeRatio:设置堆空闲空间的最小和最大百分比。JVM 将在这些阈值内动态调整堆的大小。
  • 垃圾收集日志 -verbose:gc, -XX:+PrintGCDetails, -XX:+PrintGCTimeStamps 等:启用和配置垃圾回收日志,以监控和调试垃圾收集行为。

注意事项:这些参数会根据不同的 Jvm 实现(如 HotSpot、OpenJ9)和版本有所不同。使用这些参数时,需要根据应用程序的具体需求和资源限制进行调整和优化。

调整 Jvm 参数是一个需要考虑多方面因素的过程,需要根据应用程序的行为、垃圾回收日志、性能指标等信息进行综合分析和调优。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/代码探险家/article/detail/1002313
推荐阅读
相关标签
  

闽ICP备14008679号