深入解析:JVM的内存区域划分、类加载机制与垃圾回收原理

JVM

基本概念

JVM是Java的虚拟机,构建Java的时候,我们通常都需要安装JDK,JDK中就包含了JRE,JRE里就囊括了JVM

在这里插入图片描述

从Java源码到JVM的运行之中有不可分割的关系:

  1. 编译阶段:Java的源文件.java​经过javac编译器生成字节码文件.class,并保存到硬盘中

  2. 类加载阶段:此时JVM开始运行——当程序第一次使用到这个类,比如创建对象、访问静态成员时,JVM的类加载器会再次翻译成 二进制 机器指令

    1. .class文件读取到内存中
    2. 经过 “加载——连接——初始化“ 三个阶段
    3. 最后以类对象的结构存储到元空间中
  3. 执行阶段:类的信息加载好后,程序中运行需要创建的对象,包括调用的方法栈帧等时候,就会按规则分配在

    • 元数据区
    • 程序计数器等

通过上面简单的描述,对JVM有了初步的认识,那上面提到的堆 / 栈 / 元数据区 / 应用计数器等等,这些又是什么?这就关系到JVM的内存划分了。除此之外,我们必须对JVM的三个核心内容有个详细的了解

JVM的内存区域划分

每次运行Java程序,内存都是由JVM凭借操作系统申请分配的一大块内存,内存中又有不同的模块

进而的,每次运行Java程序,本质上就创建了一个对应的JVM,每个Java进程内部都包含了JVM

内存中主要含有以下核心模块

在这里插入图片描述

  1. 程序计数器

    下一条要执行的Java字节码指令的地址。依据该数字Java能顺利执行下一跳就是这是一块很小的区域,只保存了一个数字,

  2. 这里的栈和包括下面提到的堆跟数据结构的栈和堆不一样!在计算机中,同一个术语中,可能有不同的含义,所以当面试的时候问道栈和堆,要分清楚是数据结构的还是JVM的还是操作系统的栈和堆。

    这里的栈也是 “后进先出” 的结构,维护了方法调用的关系,每调用一次方法就开辟一个栈帧main手段——Java的方法入口从main开始)就是,手段执行完就自动消除(底层

    栈帧里保存了

    • 调用办法的实参
    • 方法内部的局部变量
    • 手段结束后要返回的上层方法的位置
    • 返回值等等

    JVM的栈分为虚拟机栈和本地办法栈

    • 虚拟机栈——给Java程序使用的栈,写的程序基本都在这里

      在这里插入图片描述

    • 本地方法栈——给C++代码运用的,因为JVM的底层是借助C++搭建的,Java中写的代码,通过一层一层调用,最终就回到C++的范围了

      如在JVM类中的方法,有native修饰的,就是C++建立的技巧在这里插入图片描述

  3. 这是JVM中最大的区域,主要保存着对象的实例、数组

    在运行的过程中,对象随时都有可能在创建和销毁,销毁的过程中也会产生性能消耗。而且有些对象实例存留时间长,有些短,对销毁的机制产生了挑战性。为了缓解这个难题,在堆区分出了新生区和老年区,这点在JVM的垃圾回收中会提到,这里做一个简单的了解

  4. 元信息区 / 元空间

    只存 “类的信息”(Class、方法、常量池、元数据)

    不会存储具体的数值(比如 “这个类中有个static int x”),但x的值是放到堆里(或者是特定的数据区)

    JVM运行时,在需要时会加载.class​文件,并读取到内存中,在这过程中,还需要通过特定的结构来表示,即 “类对象”,将类的信息存入元空间如:Class、手段、常量池、元素材

    在运行过程中:

    • 创建对象时,在堆中分配实例
    • 调用技巧时,在栈中开辟栈帧
    • 类对象和静态变量(经static修饰的属性)都常驻元数据区

以上的内存区域,针对程序计数器和栈,都是存在多份的(每一个Java线程都会有自己的程序计数器和栈)

而堆和元数据区只有一份了,一个Java进程中只有一份了,这就能解释为什么在一个线程中new一个对象,是允许被另一个线程直接使用的。

内存溢出困难

有些情况下会导致内存溢出问题,主要分为栈溢出和堆溢出

  • 栈溢出:栈帧太多了——比如写递归方法的时候结束条件有错误,导致无限递归,创建了太大的局部变量
  • 堆溢出:new的对象太多了,需要排查是在哪个地方哪个对象被创建的太多了

JVM的类加载机制

类加载机制就是描述对****​ .class材料,读取到内存中,构建出 “类对象” 的过程

类加载机制中分为两部分,分别是类加载的流程 与 双亲委派模型(严格意义上其实是单亲,只有一个父亲)

类加载的流程

那什么时候才会加载某个类呢?

此处采用的是 “懒汉” 思想,需要使用的时候才加载,场景有

  • new 这个类的实例
  • 调用这个类的静态方法 / 访问静态成员
  • 针对子类的加载,也会出发父类的加载

单例的就是类加载是单例的,每个类的类对象,在一个JVM进程中,也

Q:如何理解类加载是单例的?

  • 类加载是单例的

    当某一个类被使用到的时候,JVM会依据类加载器ClassLoader​去加载.class​文件,并创建唯一的类对象(以Class<Test>举例),然后放入元数据区中,描述了这个类的结构信息(方法、字段、常量池等)

    这个过程只会执行一次,此后不管是多个线程、创建多少个对象、在多少地方用到Test​,都是拿Class<Test>​这个类对象复用,只存在一个类元信息对象(Class<Test>​这个**类实例**)

    追问:那如果我用多线程new多几个出来的对象呢,他们的地址一样吗?

    • new​操作其实是根据这个唯一的类实例对象作为 “模板”,在堆区分配一块实例内存,然后初始化。所以如果使用多线程去new​多次对象,每个线程得到的实例对象的地址都不同,但他们的 “类模板” 都来自同一个Class<Test>~

    它们之间的关系如表格所示

    类加载阶段实例创建阶段
    造出“模具” (Class<Test>)用模具反复“生产”对象 (new Test())
    只有一个可以有很多
    位于方法区位于堆内存
    线程共享各实例独立
  • 单例的就是每个类的类对象在一个JVM进程中也

    当一个类被加载到JVM中,JVM会给他创建一个对应的Class对象,放在堆中。这个类对象可以用来

    • 代表该类的运行时类型信息
    • 用来支持反射(如 MyClass.class​, obj.getClass()

    同一个类加载器加载的同一个类,只对应一个唯一的Class对象

    Class<?> c1 = Test.class;
      Class<?> c2 = new Test().getClass();
        System.out.println(c1 == c2); // true ✅
  1. 加载

    就是把.class文件找到,并且读取文件的数据到内存中

  2. 验证

    根据从文件读到的二进制内容,验证是否为合法的格式,Java有一套.class文件的结构规范

    在这里插入图片描述

  3. 准备

    给要创建的类对象分配空间。JVM 在加载类时,会在元数据区中分配存储类元信息的空间,同时在中创建一个代表这个类的 Class 对象。

    Java默认把新申请的未初始化的内存,全部都置为 0(默认初始化)

    0就是此时如果尝试获得static成员(因为static成员的结构信息最先被放入元数据区,静态变量比实例变量先初始化),类刚刚加载,还没执行成员的静态初始化的时候,得到的值就

  4. 字符串常量初始化

    针对字符串常量进行初始化,把当前.class的字符串常量放到内存中。放到内存中,故字符串就有了起始地址。运行时当某个变量引用到这个字符串常量时,就把地址取出来,赋值到对应的变量

  5. 类对象初始化

    针对类对象进行初始化,包括类的静态成员、静态代码块、父类加载等等…

双亲委派模型

双亲委派模型出现在类加载的第一步,用来找.class文件,它涉及到一个模块—— “类加载器”

在JVM中,默认含有了三个类加载器

  • BootstrapClassLoader —— 负责加载 Java 标准库中的类
  • ExtensionClassLoader —— 负责加载 Java 扩展库中的类
  • ApplicationClassLoader —— 负责加载第三方库,以及你当前计划中的

在这里插入图片描述

因此优先级是先加载标准库的,其次是扩展库,最终才是第三方库

不会加载你自己的类的,因为同名的String类已经在标准库中加载了,不会执行到 ApplicationClassLoader就是因而倘若自己包装了个java.lang.String类的话,

JVM的垃圾回收 GC

垃圾回收问题关键是应对内存泄漏的问题

不同的语言针对内存泄漏的问题都有不同的解决方法,像 C++ 引入了智能指针的机制,能一定解决内存泄露的问题( C++ RAII 机制 ) ,但 C++ 为了追求性能极致化,所以是没有引入GC的。而 Java 就专门引入了 垃圾回收 的机制,更好的应对内存泄露

那 Java 首要针对 JVM 的哪一部分回收内存呢?

  • 程序计数器吗? 它只记录了一个数字,不需要。栈?内存会随着栈帧的销毁而自动释放,不要求。元数据区?类对象通常只需要加载,不需要卸载,所以也不需要。

  • 许多的对象就是故 GC 的主战场在堆上,堆上存放的

GC 回收的基本单位是对象,不是以字节为单位。一个对象要么整个释放,要么不释放,不会出现释放一半的情况,因而假设对象有利用一半的情况都不会被释放。

GC 回收一般分为两步

  1. 找出谁是垃圾(不再运用的对象)

    那判断谁是垃圾对象有两种解决方法

    1. 引用计数

      它的原理是给每个对象在身上安排一个空间,这个空间存储一个整数,表示这个对象被引用的次数,围绕这个对象进行 “引用 / 复制” 都会更新这个计数,当计数为 0 时,就能释放了。

      在这里插入图片描述

      但是引用计数方案也会存在两个缺点

      • 消耗更多的内存空间

        Q:有人不理解,开辟一个存储数字的空间也用不到几个字节的空间,为什么就消耗更多的内存空间呢?

        A:若是对象很大,那这点小空间确实许可忽略不计。但如果对象本身就很小,只有 4 个字节,如果计数器应用 2 个字节,那就占据了对象的 50% 了,这样的对象数量可能会很多,累积起来就会占据很多的内存空间。

      • 产生循环引用,可能会产生误判

        这个是最重要的,由于垃圾回收是 “宁可放过也不可杀错” ,销毁错了对象后果会非常严重,而循环引用可能就会出现这样的情况

        在这里插入图片描述

        此处这两对象的引用计数都不为 0,都无法释放,但是这两对象都无法被使用了,有点像死锁的感觉。

        针对这个问题,Python / PHP 同时也引入了环路检测的机制,识别出上述的循环引用,那有没有其他的方案呢?有的有的!

    2. 可达性分析

      可达性就避免了这种循环引用的问题,它的原理是遍历 “对象树”,因为在 Java 代码中,一系列的对象都存在着一定的关联性 => 类似于树形的结构。

      从树根节点出发,尝试尽可能地遍历该 ”对象树“(可能会有多个),遍历过程中经过了对象都被标记成 “可达”,另一方面,JVM 也知道自身有哪些对象,除去这些,剩下的都是 “不可达”,就能当作垃圾回收了。

      在这里插入图片描述

      上面说到对象树可能有很多个,Java 中用 GCRoots来表示多个对象树根,它通常指

      • 栈上的局部变量

        栈有很多个,栈里的栈帧也有很多,栈帧里的局部变量也有很多,每个局部变量都是一个 root

      • 常量池引用指向的对象

        这里通常有字符串常量、Integer值等,JVM会把 -128 ~ 127 该范围的数字提前创建好 Integer 对象

      • 全部的引用类型的静态成员

        内置类型的静态成员不需要,再往下没法引用其他的值了~

      像这样遍历整个树是比较消耗 cpu 的算力的,但是节省了内存空间,属于是时间换空间

    因而通过引用识别 / 可达性分析的方式,从 GCRoots 出发,尽可能遍历,标记可达对象,剩下的就是不可达

  2. 释放对应的内存

    如何释放内存呢?也有几个方案

    1. 标记清除

      把标记出的垃圾直接释放掉,但得到的内存是离散的

      在这里插入图片描述

    2. 复制算法

      能够有效解决内存碎片化挑战,但是空间利用率很低,因为需要开辟相同大小的内存

      在这里插入图片描述

    3. 标记整理

      这种途径虽然能有效解决空间利用率的问题,但是因为需要每次删除后都搬运对象,消耗的资源也很多

      在这里插入图片描述

    4. 分代回收

      对于 Java 的实际情况来说,是综合了以上的方案,根据不同对象的情况 / 特点,采取不同的方案,这里描述不同对象的情况 / 特点,通常用 “年龄” 来描述,这里的年龄指的是对象 “存活” 的时间。

      倘若一个对象经过了多轮 GC 扫描都还没有被清除,那就说明这个对象年龄是比较大的。根据规律来看,“年龄” 越大,继续 “存活” 的概率越大。

      分代回收是综合了以上三种方案而做出的方案,结构如图所示

      在这里插入图片描述

以上的分代回收机制严格来说只是一个 “简化版”, 代表着一种思想方式,其中还有更加复杂深入的机制。因为 GC 回收不只是考虑效率,有时还需要考虑回收会不会对业务代码有影响。

‍希望看到这里对你有所帮助,如有错误欢迎指出,祝愿各位身体健康~~(∠・ω< )⌒★

posted @ 2025-11-30 09:45  yangykaifa  阅读(0)  评论(0)    收藏  举报