JVM 内存布局深度解析,你所不知道的一面


作为Java开发者,想要写出高质量的代码,理解JVM的内存结构是必修课。本文将为您深度解析 Java 虚拟机(JVM)中的内存布局及其细节分析,让你在内存管理的道路上行稳致远。希望通过本文能让你彻底理解其中的奥秘。


一、内存布局概览


在我们深入具体内容之前,先让我们从整体上看一下 JVM 内存的布局。

JVM 的内存区域主要被划分为以下几个部分:

  • 堆(Heap): 这是 JVM 内存中最大的一块区域,所有对象实例以及数组都在这里分配内存。

  • 方法区/元空间(Method Area/Metaspace): 用于存储已被虚拟机加载的类型信息、常量、静态变量等数据。

  • 虚拟机栈(VM Stack): 每个线程在创建时候都会创建一个私有的虚拟机栈,其存储着每个方法运行过程中所需的内存(例如局部变量、操作数栈等),随着方法的进入和退出进行调节。

  • 本地方法栈(Native Method Stack): 为虚拟机使用的 Native 方法服务。

  • 程序计数器(Program Counter Register): 一个相当小的内存区域,可以看作是当前线程执行字节码的行号指示器。


如图:

在这里插入图片描述

让我们从最重要的堆内存开始探索吧。


二、堆内存探秘


1、堆内存是什么?

JVM(Java虚拟机)堆内存是Java程序运行时数据区的一个重要组成部分。

它在JVM启动时被创建,并在JVM生命周期内保持存在。

堆内存是虚拟机中最大的一块内存区域,主要用于存储对象实例和数组,被所有线程共享,是GC(垃圾收集器)主要关注的区域。


2、JVM堆内存的主要特点
  • 运行时数据区:堆内存是JVM运行时数据区的一部分,与方法区、栈、本地方法栈等一起构成了JVM内存模型。

  • 存储对象和数组:所有的对象实例和数组都是在堆中分配的。

  • 垃圾回收:堆内存是垃圾回收的主要区域,因为对象的生命周期往往不确定,需要垃圾回收器定期清理不再使用的对象。

  • 线程共享:堆内存是被JVM中所有线程共享的内存区域。

  • 大小可配置:JVM堆内存的大小可以通过启动参数(如-Xms和-Xmx)进行配置。


3、JVM堆内存的内部结构

在这里插入图片描述


JVM堆内存通常被分为以下几个部分:

(1)、新生代(Young Generation)

  • 新生代是新创建的对象存储的地方。

  • 它进一步被分为一个Eden区和两个Survivor区(S0和S1)。

  • 大部分新对象在Eden区被创建。

  • 在垃圾回收发生时,存活的对象会从Eden区移动到Survivor区,然后再根据它们的年龄移动到另一个Survivor区或老年代。


(2)、老年代(Old Generation)

  • 老年代存储长时间存活的对象。

  • 这些对象在新生代经过多次垃圾回收后,由于仍然存活,被提升到老年代。

  • 老年代的垃圾回收频率通常低于新生代。


(3)、元空间(Metaspace)

  • 从JDK 8开始,方法区被移到了本地内存中的元空间。
  • 元空间用于存储类的元数据,如类的常量池、字段、方法、构造函数等。
  • 与方法区不同,元空间的大小不是固定的,可以根据需要动态扩展。

(4)、新生代、老年代内存分配原理

新生代内存区域又可以细分为 Eden 区、From Survivor 区和 To Survivor区。大部分对象都是在 Eden 区进行内存分配的。

当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC(新生代GC),Economic GC(年轻代 GC)。

GC 期间,首先对 Eden 区进行垃圾回收,如果有存活的对象,就会被复制到其中一个 Survivor 区(比如 From Survivor 区),如果这个 Survivor 区内存不足以容纳所有存活对象,就会通过分配担保机制向老年代空间申请内存。

如果多次 GC 之后,Survivor 区的某些对象仍然存活,并且已经存活的时间足够长,就会被晋升到老年代中。

老年代的对象存活时间较长,在新生代频繁触发 GC 之后,老年代就会临时启动一个 Major GC(老年代 GC)。Major GC 操作步骤比较耗时,所以应该尽量减少这种 GC 的发生。


示例代码:

/**
 * VM Args:-Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOMTest {
    public static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        List<byte[]> byteList = new ArrayList<>(10);
        for (int i = 0; i < 10; i++) {
            byte[] bytes = new byte[2 * _1MB]; // 申请2MB内存空间
            byteList.add(bytes);
        }
    }
}

执行上述代码,可以模拟堆内存溢出,并导致 java.lang.OutOfMemoryError: Java heap space 异常。


4、堆内存分配探究

有人可能会问,在创建一个新对象时,JVM 是怎样在堆内存中进行内存分配的呢?让我们用一个简单的例子来说明:

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

我们对该方法进行编译,并反汇编,就可以看到如下的虚拟机指令:

public int sum(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3 // 最大栈深度为2,局部变量个数为3
         0: iload_1 // 局部变量1 压栈  
         1: iload_2 // 局部变量2 压栈
         2: iadd    // 栈顶两个元素相加,计算结果压栈
         3: ireturn
      LineNumberTable:
        line 10: 0

从中可以看出,局部变量表(locals)中存储了方法参数和在方法内部定义的局部变量,而操作数栈(stack)则用于完成方法的计算工作。创建对象并将其引用存入局部变量表时,实例对象本身是存放在堆内存中的。


5、GC垃圾回收

JVM堆内存管理的一个关键方面是垃圾回收(GC)。垃圾回收器定期执行,以识别和回收不再被引用的对象,从而释放内存。垃圾回收的类型包括:

  • Minor GC:发生在新生代,通常频繁执行,速度较快。

  • Major GC/Full GC:发生在老年代或整个堆内存,执行频率较低,速度较慢。


6、案例演示:JVM堆内存使用
public class HeapMemoryExample {
    public static void main(String[] args) {
        // 创建一个对象数组
        Object[] objects = new Object[1000000];
        for (int i = 0; i < objects.length; i++) {
            objects[i] = new Object(); // 在堆内存中创建新对象
        }
    }
}

在这个示例中,我们创建了一个包含100万个对象的数组。这些对象实例都将在JVM堆内存中分配。

通过这个示例,可以清楚地理解JVM堆内存的作用和垃圾回收的重要性。当程序创建新对象时,它们被分配在堆内存中。随着对象的创建和销毁,垃圾回收器将介入以释放不再使用的对象占用的内存,从而防止内存泄漏和程序崩溃。


三、元空间的前世今生


在 JDK 7 之前,常量池是存储在永久代(PermGen)中,而在 JDK 7 后就直接存储在了堆内存中。再到 JDK 8,随着元空间(Metaspace)的引入,类元数据信息、字段、方法、常量等都被移到了这里。

与以往的永久代不同,元空间使用的是本地内存,所以默认情况下元空间的大小只受本地内存限制。你可以使用 -XX:MetaspaceSize-XX:MaxMetaspaceSize 参数来调整它的初始大小和最大大小。


1、元空间的主要特点
  • 本地内存:元空间位于本地内存(Native Memory),而不是虚拟机内存中。

  • 动态扩展:与永久代不同,元空间的大小可以动态扩展,直到达到本地内存的限制。

  • 避免溢出:由于元空间可以动态扩展,因此减少了因固定大小而导致的内存溢出问题。

  • 类元数据存储:元空间存储类的元数据,包括类的常量池、字段、方法、构造函数等信息。

  • 垃圾回收:元空间中的数据也受到垃圾回收的影响,但是垃圾回收的机制和堆内存中的垃圾回收不同。


#### 2、元空间的内部结构

元空间没有像堆内存那样的内部细分,但是它存储的数据类型可以大致分为以下几类:

  • 类的元数据:包括类的声明、字段、方法、构造函数等。

  • 常量池:存储类中的字面量和符号引用。

  • 字符串常量:字符串常量也存储在元空间中,而不是永久代。

  • 静态变量:类的静态变量现在存储在堆内存中。


#### 3、垃圾回收

元空间中的垃圾回收与堆内存中的垃圾回收不同。由于元空间使用的是本地内存,它不受JVM堆内存大小的限制。元空间的垃圾回收主要关注类的卸载和内存整理。

  • 类的卸载:当一个类不再被使用时,可以被垃圾回收器卸载,释放其占用的元空间。

  • 内存整理:元空间的内存整理不如堆内存频繁,因为本地内存的压力通常不如堆内存大。


#### 4、案例演示:元空间的使用
public class MetaspaceExample {
    public static void main(String[] args) {
        // 创建字符串常量
        String constant = "Metaspace stores constant pool";

        // 创建类实例
        new MetaspaceExample();
    }
}

在这个示例中,字符串常量"Metaspace stores constant pool"MetaspaceExample类的元数据将存储在元空间中。


通过这个示例,可以清楚地理解元空间的作用和它在JVM中的位置。

元空间的引入解决了永久代固定大小带来的问题,使得JVM能够更灵活地管理内存,减少了内存溢出的风险。同时,它也使得JVM的性能更加稳定,因为内存分配和回收不再受限于永久代的大小。

元空间是JVM内存模型的重要组成部分,对于理解Java程序的内存管理和性能调优至关重要。


四、虚拟机栈的精髓

每个线程在创建时,JVM 都会为其创建一个单独的虚拟机栈,虚拟机栈是线程私有的内存区域。对于一个线程来说,栈中存放着这个线程所执行的每个方法的内存模型,即栈帧(Stack Frame)。

栈帧不仅存储了方法运行过程中所需的内存(如局部变量表和操作数栈),还存储了动态连接信息和方法返回地址等。所有正在执行或恢复的方法的栈帧都是连续排列的,被称为"当前栈帧"。

当线程执行一个方法时,就会创建这个方法对应的栈帧,方法执行结束后相应的栈帧也会被移出栈。如果线程请求的栈深度超出虚拟机栈容量,就会抛出StackOverflowError异常。


在这里插入图片描述


1、虚拟机栈存放内容详解

(1)、局部变量表

主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。


在这里插入图片描述


(2)、操作数栈

主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。


(3)、动态链接

主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接


在这里插入图片描述


1、虚拟机栈的主要特点

  • 线程私有:每个线程都有自己的虚拟机栈,这意味着栈内容是线程隔离的,互不干扰。

  • 方法调用:虚拟机栈用于跟踪方法调用,每个方法在被调用时,都会在栈中创建一个栈帧(Stack Frame)。

  • 后进先出(LIFO):虚拟机栈遵循后进先出的原则,即最后调用的方法的栈帧会最先被销毁。

  • 存储局部变量和操作数栈:每个栈帧都包含局部变量表和操作数栈,用于存储方法调用期间的局部变量和中间计算结果。

  • 异常处理:在方法调用过程中,如果发生了异常,虚拟机栈用于确定异常处理的位置。

  • 大小可配置:虚拟机栈的大小可以在JVM启动时通过启动参数(如-Xss)配置。


2、虚拟机栈的内部结构

虚拟机栈由多个栈帧组成,每个栈帧包含以下信息

  • 局部变量表:存储方法的局部变量,如基本数据类型、对象引用等。

  • 操作数栈:用于存储操作指令的操作数,比如加法操作需要两个操作数。

  • 动态链接信息:存储方法调用时的动态链接信息,确保方法调用的正确性。

  • 方法调用信息:包含方法的返回地址,用于方法执行完毕后返回到正确的位置。

  • 附加信息:可能包含一些额外的信息,比如对齐填充、锁信息等。


3、垃圾回收

虚拟机栈的垃圾回收相对简单,因为它遵循后进先出的原则。当一个方法执行完毕时,它的栈帧会被弹出,局部变量和操作数栈中的数据将变得无用,可以被垃圾回收。


4、程序运行中栈可能会出现两种错误
  • StackOverFlowError 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。

  • OutOfMemoryError 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。


5、案例演示:虚拟机栈的使用
public class JVMStackExample {
    public static void main(String[] args) {
        methodA();
    }

    static void methodA() {
        methodB();
    }

    static void methodB() {
        methodC();
    }

    static void methodC() {
        // 方法执行,局部变量存储在当前栈帧的局部变量表中
        int localVar = 10;
        // 当methodC执行完毕,它的栈帧将被销毁,局部变量localVar将不再存在
    }
}

在这个示例中,main方法调用methodA,然后逐层调用到methodC。每个方法调用都会在虚拟机栈中创建一个新的栈帧。

虚拟机栈是线程执行方法调用的关键内存区域,它确保了方法调用的有序性和局部变量的安全存储。通过虚拟机栈,JVM能够跟踪每个线程的方法调用状态,并在方法执行完毕后正确地进行资源清理和返回。


五、本地方法栈


本地方法栈(Native Method Stack)是Java虚拟机(JVM)中的一种内存区域,用于支持本地方法的执行。本地方法是指使用Java Native Interface(JNI)或其他机制实现的,用Java以外的语言(如C或C++)编写的方法。本地方法栈与Java虚拟机栈类似,但它服务于不同的方法调用。


1、本地方法栈的主要特点
  • 线程私有:与虚拟机栈一样,每个线程都有自己的本地方法栈,确保线程执行的隔离性。
  • 支持本地方法:本地方法栈用于管理非Java语言实现的方法调用,使得Java程序可以与用其他语言编写的代码交互。
  • 后进先出(LIFO):本地方法栈同样遵循后进先出的原则,最后调用的本地方法最先结束。
  • 存储调用信息:本地方法栈存储本地方法调用的参数、局部变量和中间计算结果。
  • 异常处理:本地方法栈也负责处理本地方法执行过程中的异常。
  • 大小可配置:本地方法栈的大小同样可以在JVM启动时通过启动参数配置。

2、垃圾回收

本地方法栈的垃圾回收主要涉及到栈帧的销毁。当本地方法执行完毕时,它的栈帧会被弹出,局部变量和计算结果将被清理,内存被回收。


3、案例演示:本地方法栈的使用

在Java中,本地方法通常通过JNI来实现。以下是使用本地方法的一个简单示例:


步骤1: 定义本地方法

首先,我们需要定义一个本地方法。这通常在C或C++中完成,并使用JNIEXPORTJava_前缀来标识:

// ExampleNativeMethod.c
#include <jni.h>

JNIEXPORT void JNICALL
Java_MyClass_nativeMethod(JNIEnv *env, jobject obj) {
    // 本地方法实现
    printf("Native method is called!\n");
}

步骤2: 编译本地方法

使用适当的编译器编译本地方法,生成共享库(如.so.dll.dylib文件)。


步骤3: 加载和使用本地方法

在Java代码中,我们加载并调用本地方法:

public class MyExample {
    static {
        // 加载本地库
        System.loadLibrary("ExampleNativeMethod");
    }

    // 定义本地方法的声明
    private native void nativeMethod();

    public static void main(String[] args) {
        MyExample example = new MyExample();
        example.nativeMethod();
    }
}

在这个示例中,nativeMethod是一个本地方法,它在Java代码中被声明,并在C代码中实现。当调用nativeMethod时,JVM会切换到本地方法栈来执行这个方法。

本地方法栈使得Java程序能够利用用其他语言编写的代码,扩展了Java程序的功能。它为本地方法提供了执行环境,包括参数传递、局部变量存储和异常处理。

本地方法栈是JVM内存模型的一个重要组成部分,对于理解Java程序如何与其他语言编写的代码交互至关重要。通过JNI,Java开发者可以访问大量的本地库和系统资源,从而创建功能强大的应用程序。


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

程序计数器(Program Counter Register)是Java虚拟机(JVM)中一个非常小但非常重要的内存区域。它是线程私有的,每个线程都有自己的程序计数器,用于跟踪当前线程执行的字节码指令的地址或行号。


1、程序计数器的主要特点
  • 线程私有:每个线程都有一个独立的程序计数器,它们之间互不干扰。

  • 字节码执行:程序计数器用于存储当前正在执行的字节码指令的地址或偏移量。

  • 方法调用:当线程调用一个方法时,程序计数器会保存下一条将执行的指令的地址。

  • 方法返回:当方法执行完毕后,程序计数器会从调用者的指令流中恢复下一条指令的地址。

  • 异常处理:在异常发生时,程序计数器也会帮助JVM确定异常处理的代码位置。

  • 内存大小:程序计数器的大小与平台相关,但通常足够存储一个地址或行号。

  • 唯一没有OutOfMemoryError:程序计数器是唯一一个不会抛出OutOfMemoryError的JVM内存区域。


2、程序计数器的工作机制

程序计数器在方法调用和方法执行过程中发挥着关键作用:

  • 方法调用:当一个线程调用一个方法时,程序计数器会记录下这个方法的起始地址。

  • 分支操作:在执行分支操作(如条件分支、循环等)时,程序计数器会更新为新的指令地址。

  • 方法返回:当方法执行完毕并返回时,程序计数器会从方法调用者的指令流中恢复到方法调用后的下一条指令。

  • 递归调用:在递归调用中,程序计数器会为每次递归调用保存新的地址。


3、案例演示:程序计数器的使用
public class ProgramCounterExample {
    public static void main(String[] args) {
        methodA();
    }

    static void methodA() {
        methodB();
    }

    static void methodB() {
        methodC();
    }

    static void methodC() {
        // 程序计数器指向methodC的下一条指令
    }
}

在这个示例中,程序计数器随着方法的调用和返回而更新。当main调用methodA时,程序计数器记录methodA的起始地址。随着调用链的深入,程序计数器不断更新。当方法返回时,程序计数器帮助线程恢复到正确的执行位置。

程序计数器是线程执行字节码指令的指针,它确保了线程能够正确地执行和返回方法调用。程序计数器的独立性保证了多线程环境下线程执行的连续性和正确性。

程序计数器是理解JVM线程执行和方法调用机制的基础,对于学习Java虚拟机的工作原理至关重要。由于其简单性和唯一性(不会抛出内存溢出错误),程序计数器是JVM内存模型中一个非常特殊的部分。


七、codecache(代码缓存)

codecache(代码缓存)是Java虚拟机(JVM)中的一个内存区域,用于存储编译后的本地机器代码,主要是即时编译器(JIT编译器)编译的热点代码。热点代码通常指的是那些被频繁调用的方法,比如循环体或者经常使用的接口方法。

即时编译器会将这些热点代码从字节码转换为本地机器码,以提高程序的执行效率。编译后的机器码存储在codecache中,如果方法再次被调用,可以直接执行本地机器码,而不需要重新编译,这样可以显著提高性能。


1、codecache的主要特点
  • 存储编译后的代码codecache用于存储即时编译器生成的本地机器代码。

  • 提高性能:通过重用编译后的代码,减少解释执行字节码的时间,提高程序的执行速度。

  • 内存限制codecache的大小是有限的,并且可以根据需要进行调整。

  • 垃圾回收codecache中不再需要的代码条目可以被回收,以释放内存空间。

  • 可配置codecache的大小可以通过JVM参数进行配置,如-XX:ReservedCodeCacheSize=-XX:InitialCodeCacheSize=


2、codecache的工作机制
  • 代码编译:当一个方法被频繁调用时,即时编译器会将其编译为本地机器码。

  • 代码存储:编译后的机器码被存储在codecache中。

  • 代码重用:当同一个方法再次被调用时,可以直接从codecache中获取编译后的机器码执行。

  • 内存管理:如果codecache满了,JVM会进行内存管理,可能会根据一定的策略淘汰一些老的或不常用的编译代码。

  • 异常处理:如果编译后的代码导致异常,JVM需要确保能够正确地处理这些异常,并且可能需要重新编译方法。


3、案例演示:codecache的使用
public class CodeCacheExample {
    public static void hotMethod() {
        // 这个方法被频繁调用,成为热点代码
        for (int i = 0; i < 10000; i++) {
            // 执行一些计算
        }
    }

    public static void main(String[] args) {
        // 多次调用hotMethod,使其成为热点代码
        for (int i = 0; i < 100000; i++) {
            hotMethod();
        }
    }
}

在这个示例中,hotMethod是一个被频繁调用的方法,即时编译器可能会将其编译为本地机器码并存储在codecache中。当hotMethod再次被调用时,可以直接执行编译后的机器码,从而提高性能。

codecache是JVM优化程序执行的一个重要机制,特别是在处理需要高性能的应用程序时,它的作用尤为明显。

codecache是现代JVM中不可或缺的一部分,对于理解JVM如何执行和优化字节码至关重要。通过调整codecache的大小和即时编译器的参数,开发者可以对JVM的性能进行调优,以满足不同的性能需求。


八、总结

通过本文,我们深入探究了 JVM 内存布局的方方面面。从最重要的堆内存,到方法区的元数据信息,再到虚拟机栈和本地方法栈中的线程私有区域,相信你现在对 JVM 的内存分配和管理有了全新的认识。

不过,JVM内存管理远不止这些。比如上文中提到的垃圾收集器,它是如何判断对象是否需要回收?各种垃圾收集算法的优缺点是什么?分代收集器工作原理又是怎样的?这些都将是我们下一篇文章探讨的热点话题,敬请期待!

同时,如果你对本文还有任何疑问,也欢迎在评论区留言交流。


  • 25
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

w风雨无阻w

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值