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对象

    java 复制代码
    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(默认初始化)

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

  4. 字符串常量初始化

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

  5. 类对象初始化

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

双亲委派模型

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

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

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

所以优先级是先加载标准库的,其次是扩展库,最后才是第三方库

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

JVM的垃圾回收 GC

垃圾回收问题主要是应对内存泄漏的问题

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

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

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

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

GC 回收的基本单位是对象,不是以字节为单位。一个对象要么整个释放,要么不释放,不会出现释放一半的情况,所以如果对象有使用一半的情况都不会被释放。

GC 回收一般分为两步

找出谁是垃圾(不再使用的对象)

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

复制代码
  #### 引用计数

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

  ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/e1221b2bf60e41ca91c5c0e87a30768e.png)

  但是引用计数方案也会存在两个缺点
  * **消耗更多的内存空间**

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

    A:如果对象很大,那这点小空间确实可以忽略不计。但如果对象本身就很小,只有 4 个字节,如果计数器使用 2 个字节,那就占据了对象的 50% 了,这样的对象数量可能会很多,累积起来就会占据非常多的内存空间。
  * **产生循环引用,可能会产生误判**

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

    ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/f067f67e96c24b079fdace5f8720c5d3.png)

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

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

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

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

  ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/6a0ca8bfcca147faa9b1156cf4e443d0.png)

  上面说到对象树可能有很多个,Java 中用 GCRoots来表示多个对象树根,它通常指
  * 栈上的局部变量

    栈有很多个,栈里的栈帧也有很多,栈帧里的局部变量也有很多,每个局部变量都是一个 root
  * 常量池引用指向的对象

    这里通常有字符串常量、Integer值等,JVM会把 -128 \~ 127 这个范围的数字提前创建好 Integer 对象
  * 全部的引用类型的静态成员

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

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

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


2.

释放对应的内存

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

复制代码
  #### 标记清除

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

  ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/1bc40ebdeae7421b866c835142e08d2b.png)
复制代码
  #### 复制算法

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

  ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/a25a1ddd2bba47939cb716496d3aed14.png)
复制代码
  #### 标记整理

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

  ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/d06787a6d7c94ba4a1ec935cfce603b5.png)
复制代码
  #### 分代回收

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

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

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

  ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/345eeb8f449844f7a326f72a3fa67f64.png)

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

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

相关推荐
朝新_7 小时前
【SpringMVC】SpringMVC 请求与响应全解析:从 Cookie/Session 到状态码、Header 配置
java·开发语言·笔记·springmvc·javaee
杜子不疼.7 小时前
仓颉语言构造函数深度实践指南
java·服务器·前端
风一样的美狼子7 小时前
仓颉语言 LinkedList 链表实现深度解析
java·服务器·前端
无敌最俊朗@7 小时前
SQLite 约束:INTEGER PRIMARY KEY 与 ROWID
java·开发语言
默 语14 小时前
MySQL中的数据去重,该用DISTINCT还是GROUP BY?
java·数据库·mysql·distinct·group by·1024程序员节·数据去重
oDeviloo15 小时前
新版IntelliJ IDEA个性化设置兼容老版习惯
java·ide·intellij-idea
一只小透明啊啊啊啊15 小时前
Java Web 开发的核心组件:Servlet, JSP,Filter,Listener
java·前端·servlet
spencer_tseng16 小时前
Eclipse Uninstall Software
java·ide·eclipse