深入探索JVM:理解Java程序在虚拟机中的存储和管理

发布时间 2024-01-13 21:15:51作者: 十点以后就睡觉了

大家好,我是大圣,很高兴又和大家见面。

今天给大家带来图解 JVM 系列的第四篇文章,我们写的 Java 程序是怎么在JVM 里面存储的。本次大纲如下:

file

前面知识回顾
上一篇 图解JVM系列:揭秘运行时数据区的设计与实现 文章说了JVM 运行时数据区的设计理念,我们是通过 冯·诺依曼结构 来类比引出JVM 运行时数据区的各个区域,如下图:

file

本文会通过 java 代码的案例,来解释这段代码在运行时数据区中的方法区、堆、虚拟机栈、本地方法栈、程序计数器里面是怎么存放的。从而引出JVM 运行时数据区各个区域存放的内容。

Java 程序在 JVM中的存储
大家先看下面这段代码:

public class JVMExample {
    // 静态变量存储在方法区
    private static String staticVariable = "Static Variable";

    // 实例变量存储在堆
    private String instanceVariable;

    public JVMExample(String instanceVariable) {
        this.instanceVariable = instanceVariable;
    }

    public static void main(String[] args) {
        // 局部变量存储在虚拟机栈
        int localVariable = 10;

        // 对象存储在堆,引用存储在虚拟机栈
        JVMExample example = new JVMExample("Instance Variable");

        // 调用实例方法
        example.instanceMethod(localVariable);

        // 调用静态方法
        staticMethod();
    }

    public void instanceMethod(int localVar) {
        // 局部变量存储在虚拟机栈
        System.out.println("Instance Method");
        System.out.println("Local Variable: " + localVar);
        System.out.println("Instance Variable: " + this.instanceVariable);
    }

    public static void staticMethod() {
        // 局部变量存储在虚拟机栈
        System.out.println("Static Method");
        System.out.println("Static Variable: " + staticVariable);
    }
}

代码解释和运行时数据区对应
类定义 JVMExample
private static String staticVariable = "Static Variable";

静态变量:存储在方法区。方法区用于存储类相关的信息,如类名、方法数据、静态变量等。

private String instanceVariable;

实例变量:每个JVMExample对象的实例变量都存储在堆内存中。堆内存主要用于存储所有的对象实例。

构造函数 JVMExample(String instanceVariable)
这个构造函数用来初始化新创建的对象的实例变量。

main 方法
int localVariable = 10;

局部变量:存储在虚拟机栈的栈帧中。每当调用一个方法时,都会为这个方法创建一个栈帧。

JVMExample example = new JVMExample("Instance Variable");

创建了JVMExample类的一个新对象。对象存储在堆内存中,而example(对象的引用)存储在虚拟机栈中。

example.instanceMethod(localVariable);

调用instanceMethod实例方法,并将localVariable作为参数传递。

staticMethod();

调用静态方法staticMethod。

instanceMethod 实例方法
打印传入的局部变量和实例变量的值。localVar作为参数传入,存储在虚拟机栈中,而this.instanceVariable引用了存储在堆中的对象的实例变量。

staticMethod 静态方法
打印静态变量staticVariable的值。静态变量存储在方法区,与类JVMExample相关联,而不是与任何对象实例相关联。

运行时数据区解释
方法区
结合上面的 JVMExample 类的代码,我们可以看到如何将这些概念应用到 Java 虚拟机(JVM)的方法区:

类信息:

在方法区中,JVMExample 类的信息被存储,这包括了类的名称 JVMExample、它的父类(在 Java 中默认为 Object 类,除非另有指定)、类中定义的方法(如 main、instanceMethod、staticMethod)、以及类的变量(如实例变量 instanceVariable 和静态变量 staticVariable)。

常量池:

常量池存储了编译期生成的字面量和符号引用。例如,在 JVMExample 类中,字符串 "Static Variable" 和 "Instance Variable" 作为字面量可能会存储在常量池中。

静态变量:

类的静态变量 staticVariable 存储在方法区。这意味着 staticVariable 不属于 JVMExample 类的任何特定实例,而是与类本身关联的,因此在方法区中全局存储。

方法数据:

对于类中定义的每个方法,方法区存储了它们的字节码(即 Java 代码编译后的形式)、方法的参数(例如 instanceMethod 的参数 localVar)、返回值类型(在这个例子中,所有方法都返回 void,即无返回值)以及访问修饰符(如 public、private 等)。


在Java虚拟机(JVM)中,堆是运行时数据区的一个主要组成部分,主要用于存储对象实例和数组。在上面的 JVMExample 类中,与堆相关的数据主要包括:

对象实例:

当创建一个类的实例时,例如通过 new JVMExample("Instance Variable"),这个实例对象存储在堆内存中。这包括该对象的所有实例变量(非静态变量),在本例中是 instanceVariable。

堆是所有线程共享的内存区域,因此从任何线程都可以访问这些对象(前提是有它们的引用)。

数组:

如果在程序中创建了任何类型的数组(例如 int[]、Object[] 等),这些数组也会存储在堆中。

像对象实例一样,数组也是由所有线程共享的。

在堆内存中,对象和数组不仅仅是存储它们自身的数据(例如字段值),还包括一些额外的信息,如对象的类信息、对象的监视器(用于同步)等。由于堆是一个动态分配的内存区域,它可以在运行时根据需要扩展或收缩。

垃圾回收主要发生在堆内存中。当对象不再被任何线程引用时,JVM的垃圾回收器会自动清理这些对象,释放内存空间,以便该空间可以重新用于未来的对象分配。

因此,在 JVMExample 示例中,每当创建 JVMExample 类的一个新实例时,这个新对象就会在堆内存中分配空间。这使得JVM能够有效地管理各个对象的生命周期,同时优化内存使用和回收。

Java 虚拟机栈
在JVM(Java虚拟机)中,虚拟机栈(JVM Stack)是每个线程私有的内存区域,主要用于存储局部变量、操作数栈、动态链接信息、方法出入口等信息。结合上面提供的 JVMExample 类,我们可以具体探讨虚拟机栈中的数据类型:

局部变量:

在方法内部定义的变量称为局部变量。例如,在 main 方法中定义的 int localVariable = 10; 和 instanceMethod 方法中的 int localVar 都是局部变量。

这些变量存储在虚拟机栈的栈帧中,每个方法调用时都会创建一个新的栈帧。

对象引用:

虽然对象本身存储在堆内存中,但对这些对象的引用(即变量,它们保存了对象在堆中的地址)存储在虚拟机栈上。例如,在 main 方法中的 JVMExample example 是一个对象引用,指向堆中的 JVMExample 实例。

方法调用的信息:

当一个方法被调用时,相关的信息(如输入参数、返回地址等)存储在虚拟机栈的栈帧中。在 main 方法调用 instanceMethod 和 staticMethod 方法时,这些方法的调用信息被压入虚拟机栈。

操作数栈:

在执行方法时,虚拟机栈还用于存储操作数栈,这是一个用于存储临时变量和操作数的数据结构。例如,计算表达式时使用的临时变量和操作数。

动态链接信息:

每个栈帧还包含动态链接信息,用于支持方法调用中的动态绑定。

在 JVMExample 类中,当 main 方法执行时,它的局部变量 localVariable 和对象引用 example 存储在虚拟机栈中。当调用 instanceMethod 方法时,该方法的局部变量 localVar 也存储在虚拟机栈的一个新栈帧中。同样,任何在这些方法中进行的操作(如计算或方法调用)都会涉及虚拟机栈的操作。

总的来说,虚拟机栈是执行Java程序的核心组件之一,负责存储局部变量、控制方法执行流程、处理方法调用和返回操作等。这种设计有助于快速的方法调用处理和局部变量的高效管理。

本地方法栈和程序计数器
在Java虚拟机(JVM)中,本地方法栈(Native Method Stack)和程序计数器(Program Counter, PC)是两个关键的运行时数据区域,它们与虚拟机栈(JVM Stack)有着不同的作用和存储内容,结合上面的 JVMExample 类来具体了解它们存储的数据。

本地方法栈(Native Method Stack):

本地方法栈类似于虚拟机栈,但它是为虚拟机使用的本地(Native)方法服务的。

当JVM使用到本地方法(即使用Java Native Interface(JNI)调用的非Java代码,如C/C++代码)时,本地方法栈就会被使用。

在上面的 JVMExample 类中,并没有直接调用本地方法,所以本例中不涉及本地方法栈的使用。但如果有的代码中调用了像System.loadLibrary()这样的JNI方法,那么本地方法栈就会被使用。

程序计数器(Program Counter, PC):

程序计数器是一个较小的内存空间,它存储当前线程所执行的字节码的行号指示器。每个线程都有自己的程序计数器。

在任何时刻,每个线程都在执行一个方法(称为当前方法)。如果这个方法不是本地方法,程序计数器就存储正在执行的字节码指令的地址;如果是本地方法,程序计数器的值则是undefined。

在上面的 JVMExample 类中,当 main 方法、instanceMethod 或 staticMethod 被执行时,程序计数器会指向当前正在执行的字节码指令的地址。

总结来说,虽然上面的 JVMExample 类中没有直接涉及本地方法的调用,但如果有本地方法的调用,本地方法栈会被用于存储本地方法调用的状态。程序计数器则在任何时候都在工作,指向当前线程正在执行的字节码的具体位置,确保线程执行顺序的准确性和线程切换后能恢复到正确的执行位置。

在这里我给大家画了一张,上面的示例代码里面的对象在JVM运行时数据区里面的分布图,如下:

file

堆内存的深入讲解
在上篇文章中,我们只是说了在 JVM 运行时数据区里面有堆这个部分,用来存储实例对象的。其实堆在运行时数据区当中还会继续细分的,大家看我下面举得例子:

举例理解
想象一下,JVM的堆内存就像一个城市的垃圾处理系统。在这个系统中,有不同的处理区域,每个区域处理不同类型的垃圾。

年轻代(Young Generation) - 这就像城市中的临时垃圾收集点。新产生的垃圾(在JVM中,这对应于新创建的对象)首先被放在这里。这个区域相对较小,垃圾(对象)在这里不会停留太久。

Eden区:这就像一个主要的垃圾投放点。新垃圾被丢进这里。一旦这个区域满了,就需要进行一次清理(对应于JVM中的Minor GC)。

两个Survivor区(S0和S1):它们像是用于临时存放那些从主垃圾投放点筛选出来的、还需要进一步处理的垃圾。在JVM中,这对应于在一次Minor GC之后仍然存活的对象。

老年代(Old Generation) - 这可以想象成城市的主要垃圾填埋场。只有那些在临时收集点存放了很长时间,经过多次筛选仍然需要保留的垃圾(在JVM中,这对应于那些经历了多次Minor GC后仍然存活的对象)才会被转移到这里。这个区域更大,垃圾在这里停留的时间更长,清理频率也更低。

专业解释
JVM 运行时数据区里面堆的划分如下:

file

在JVM(Java虚拟机)的运行时数据区中,堆(Heap)是一个非常重要的部分,主要用于存储Java程序中创建的对象和数组。堆的内存由所有线程共享,是垃圾回收的主要区域。下面是关于堆及其子区域的详细介绍:

堆(Heap):

它是JVM内存管理的主要区域,几乎所有的对象实例都在这里分配内存。

堆的大小可以调整,这对性能调优至关重要。

堆内存分为年轻代(Young Generation)和老年代(Old Generation)。

年轻代(Young Generation):

新创建的对象通常首先被分配到年轻代。

年轻代包含了Eden区和两个Survivor区(通常被称为S0和S1)。

当Eden区满时,会进行一次Minor GC(垃圾回收),将还存活的对象转移到一个Survivor区(比如S0)。

Eden区(Eden Space):

是年轻代中对象最初分配的地方。

当对象在Eden区存活足够长的时间后,它们会被移动到Survivor区。

Survivor区(S0和S1):

这两个区域用于存储从Eden区转移过来的存活对象。

Survivor区分为两个部分:S0(Survivor Space 0)和S1(Survivor Space 1)。

在一次Minor GC后,存活的对象会从Eden区移到一个Survivor区(比如S0),在下一次GC中,这些对象可能会被移动到另一个Survivor区(比如S1)或者老年代。

老年代(Old Generation):

存储长时间存活的对象。

当年轻代中的对象经历了多次GC依然存活时,它们会被移到老年代。

老年代的垃圾回收频率通常低于年轻代,因为假设老年代中的对象通常会存活较长时间。

通过这样的设计,JVM能够有效管理内存。新对象被频繁创建和销毁,所以它们被放在年轻代,这样就可以快速清理掉那些很快就不再需要的对象。

而那些存活时间更长的对象则被移动到老年代,因为它们不太可能很快被回收,所以可以减少对这部分内存的频繁清理,提高效率。这就像在城市垃圾处理中,通过不同的处理区域和方法来优化处理效率和空间使用。

总结
本文回顾
本文说了通过一个 Java 代码入手,然后讲解了 JVM 运行时数据区各个区域存储的是 Java 代码里面的哪些代码,让大家对 JVM的运行时数据区有了更一步的认识。

然后又说了堆内存的再次划分,其中堆的话划分为 Young 区和 Old 区,Young 里面又分为Eden区和两个Survivor区(S0和S1),大家可以记上面话的堆的那张图。

下篇文章我会用一个对象实例,把它从实例化,创建,GC,Old 区回收这一整个流程讲一遍,大家还会有新的认知,就是从不同的角度给大家讲解 JVM 运行时数据区这块的内容,来让大家理解。

本文重点内容
本文的重点我觉得就是以后我们写完 Java 代码之后,我们要知道这个代码在 JVM 里面是怎么存储的。

比如哪些对象堆上面,哪些东西存储在栈上面,每次调用一个方法都会创建一个栈帧,一个栈帧里面包括哪些内容,哪些内容存储在方法区里面,堆内存的划分为什么要划分出来这么多区域,解决了什么问题。

大家面对自己写的代码要会画这个代码在 JVM 运行时数据区里面的分布图,比如我上面画的示例代码的分布图一样,这样可以更好地帮助我们分析程序。

预告与彩蛋
预告
下一篇文章会再说运行时数据区的一些东西,比如一个对象的整个生命周期,栈帧,GCRoot,强引用等,为后面讲JVM 垃圾回收器打个基础。因为这里面涉及到很多的概念,如果都放在一篇文章来说很容易看不下去,就劝退了。

还有一个我想说的就是这个 JVM 不是看一遍就学会了,因为这个东西里面涉及到很多概念性的东西,所以通常都要多看几遍,然后隔一段时间再看一遍 。

JVM 里面肯定还有没有讲到的,但是我讲的都是 JVM 里面最重要的东西了,但是我讲的肯定有不足的地方,大家如果想再深入的学习,可以自己再找资料看看。

彩蛋
如果你看到这里,你真的很棒了,没有被这么多枯燥的概念劝退。下面我想出一个 Java 的题目,大家可以看一下,题目如下:

public class Parent {

    public int value;

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

    public void addValue() {
        value += 2;
    }
}

class Child extends Parent {

    public int value;

    public Child(int value) {
        super(value);
        this.value = value;
    }

    public void addValue() {
        value += 6;
    }
}

class Main {
    public static void main(String[] args) {
        Parent parent = new Child(3);
        parent.addValue();
        System.out.println("value的值为:" + parent.value);
    }
}

问题如下:
1.请问打印出来value的值是多少?
2.请画出来这个程序在 JVM 里面的分布

欢迎大家来讨论,带上自己的答案来私聊我,如果两题都答对的话,可以来找我领红包。