JVM 学习笔记:内存区域、类加载机制与垃圾回收

本文是一份 JVM 学习笔记,围绕三个主题展开:JVM 内存区域划分 、类加载机制 和 JVM 垃圾回收。内容以概念梳理为主,适合作为复习提纲快速过一遍。

一、JVM 内存区域划分

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

JVM 启动的时候,从操作系统申请了一大块内存,应用程序后续需要使用的时候,就可以从 JVM 的内存进行分配。

下面先看 JVM 内存结构的整体划分。

1. 各内存区域说明

内存区域 说明
程序计数器 很小的区域,只保存一个数据,即下一条要执行的 Java 字节码指令的地址。
虚拟机栈 给 Java 程序使用的栈,维护了方法调用之间的关系(局部变量)。
本地方法栈 JVM 的底层是 C++ 实现的,需要单独下载 JVM 的源码来查看,官网 JDK、JVM 是看不到的。
堆 new 的对象(普通的成员变量)。
方法区(元数据区) .java 文件编写类和方法,编译后生成 .class 二进制的字节码文件;存放类对象、static 修饰的属性。

2. 内存溢出

有些情况下,内存会溢出:

  • 栈溢出:包含了方法的调用关系(栈帧)太多了。
  • 堆溢出 :new 的对象太多了。

3. 内存区域的线程归属

针对上面这些内存区域:

  • 堆和方法区:一个进程中只有一份。
  • 程序计数器和栈:存在多份,每个线程都拥有一份自己的。

二、(最重要)类加载的机制

类加载就是把 .class 文件读取放到内存中,构造出类对象的过程。

1. 类加载的流程

官方文档分为 5 个步骤(也可以认为是 3 步,因为其中的第二步又分为了 3 步)。

  1. 加载 :全限定类名,即 Java 代码中要使用某个类需要 import。把 .class 文件找到,打开文件,并且把里面的数据读取到内存中,根据代码中的"全限定类名",找到相对应的 .class 文件。
  2. 验证:根据读取到的二进制内容,验证是否是合法的格式。
  3. 准备 :给要创建的类对象分配内存空间。在元数据区,Java 默认把新申请的、未初始化的内存全都置为 0,此时尝试获取 static 成员,得到的值也为 0。
  4. 针对字符串常量要直接引用 :需要把当前的 .class 中的字符串常量也放到内存中,此时字符串就有了起始地址,顺便可以把这些地址取出来,放到对应的字符串的地方了。
  5. 初始化:针对类对象进行初始化操作,初始化类的静态成员,执行静态代码块,对父类的加载。

2. 类加载的触发时机

用的时候才会加载,几个常用的地方:

  1. new 这个类的实例。
  2. 调用这个类的静态方法/访问静态成员。
  3. 针对子类的加载,也会触发父类的加载。

3. 双亲委派模型

出现在类加载的第一步,用来找 .class 文件,这时候就涉及到一个模块"类加载器",JVM 中默认包含了 3 个类加载器。

类加载器 负责加载的内容
BootstrapClassLoader 负责加载 Java 标准库中的类
ExtensionClassLoader 负责加载 Java 扩展库中的类(JDK 厂商做出的扩展)
ApplicationClassLoader 负责加载第三方库,以及你当前项目中的类(GitHub 上面自己找的一些库)

本质上约定了"类加载的优先级":

  1. 标准库最先加载。
  2. 其次是扩展库。
  3. 最后才是第三方库/当前项目。

三、JVM 的垃圾回收

Java 的 GC 要回收的内存是堆上存放的对象,GC 以对象为单位进行内存回收的,一个对象要么不释放,要么整个释放,不会出现释放一半的情况。

垃圾回收具体是怎么进行的?可以分成两步:找出谁是垃圾,以及释放垃圾对象对应的内存。

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

在 Java 中,使用对象只有一种方式,那就是引用的方式,如果一个对象没有引用指向,此时就可以认为这个对象不再使用了。

(1)第一种方案:引用计数

引用计数不是 Java 的方案,是 Python 的方案。

给每个对象身上安排一个空间,这个空间存储一个整数,表示指向这个对象引用的个数,围绕对象进行"引用复制"就会更新这个计数器,如果计数器为 0 就会释放。

但是这个方案有两个缺陷:

  1. 会消耗更多的内存空间。如果对象内存空间比较小,4 个字节的话,计数器就可能有两个字节,此时计数器就占据了 50%,对象比较大就可以忽略不计。
  2. 产生循环引用会出现误判。

A 对象里面保存 B 的引用,B 对象里面保存了 A 的引用,此时两个对象不为 0,就不会被释放,但是也无法被使用。

(2)第二种方案:可达性分析

在一个 Java 代码中,一系列对象引用存在一定关联的,类似于树状结构。

可达性分析就是从树的根节点(可能有多个)出发,尝试遍历这个对象树,遍历过程中凡是已经经过的对象视为"可达",剩下的就是可回收对象,这是周期性进行的。

GC Roots 包括:

  1. 栈上的局部变量。
  2. 常量池引用指向的对象。
  3. 所有的引用类型静态成员。

缺点:需要消耗更多的 CPU 资源和更多的时间。

2. 释放垃圾对象对应的内存

识别出垃圾后,如何释放内存呢?常见有三种算法。

(1)标记清除

进程中,哪些对象是垃圾是随机的,很可能产生大量的内存碎片。

这些被释放的内存在地址上是"离散"的,但是申请内存都是申请"连续内存",总的可利用空间很充足,但是尝试申请一大块空间就会失败。

(2)复制算法

可以有效解决内存碎片问题。

同一时刻,只使用其中一半空间,把不是垃圾的对象复制到另一边。

缺点:

  1. 空间利用率很低。
  2. 如果当前存活对象很多,复制开销就会增大。
(3)标记整理

把对象回收了以后,一个个往前搬运,对于空间利用率来说得到了改善。

缺点:开销同样很大。

三种回收算法对比:

算法 做法 缺点
标记清除 标记出垃圾对象后进行回收 释放的内存在地址上是离散的,会产生大量内存碎片
复制算法 同一时刻只使用一半空间,把不是垃圾的对象复制到另一边 空间利用率很低;如果当前存活对象很多,复制开销就会增大
标记整理 把对象回收后,把存活对象一个个往前搬运 开销同样很大

3. 分代回收

分代回收根据不同对象的情况/特点,采取不同的方案。

  1. 把新创建的对象放入伊甸区。
  2. 伊甸区的对象经过第一轮 GC 后,淘汰绝大部分;没有淘汰的对象,通过复制算法进入其中一个幸存区,每次只用其中一个区。
  3. 下一轮的 GC 也会针对幸存区扫描,把幸存区里面被淘汰的对象回收,没有淘汰的对象通过复制算法进入另一个幸存区,解决了复制算法开销过大的问题。
  4. 随着每一轮 GC 的进行,对象会在幸存区里面反复拷贝,年龄也会每次 +1,经过一定时间,计数到达了阈值,就会被转入年龄大的区域。
  5. 对象进入年龄大的区域以后,针对这个区域的 GC 频率就会减少很多,这时候就会采取标记整理的方案,由于频率比较低,所以开销还能接受。
  6. 如果对象内存占据比较大,可以直接进入年龄大的区域。

上述分代回收并不是真实情况,而是简化情况。

4. 常见的垃圾回收器

有不同的垃圾回收器可以使用:

垃圾回收器 特点
CMS 在不影响业务逻辑的情况下,能够尽可能多线程标记
G1 能够处理内存空间比较大的情况,把整个内存划分出更多的区域,一次 GC 只回收其中的一部分区域
ZGC 目前还没被 JVM 正式采纳,尽量使垃圾回收对于业务逻辑的影响时间缩短

总结

JVM 的学习可以沿着"内存怎么划分、类怎么加载、垃圾怎么回收"这条主线展开,先建立整体结构,再逐步深入每个细节。

相关推荐
akinggw16 小时前
libagentcore - 一个基于c++的AI多agent系统升级到2.0版本
开发语言·jvm·c++
yh弓长1 天前
JVM 核心机制
jvm
tg_xianheyun2 天前
腾讯云国际账号注册代充值适合哪些海外业务场景?
jvm
yuniko-n2 天前
【JVM】对象内存布局
java·开发语言·jvm
wuminyu2 天前
JVM执行引擎学习总结:热点字节码编译成本地机器码
java·linux·c语言·jvm·c++
wuminyu2 天前
JVM规范学习总结:解释器与JIT编译器如何处理JVM字节码
java·linux·c语言·jvm·c++
骑着蜗牛撵大象3272 天前
大模型 Agent 的沙箱护栏:gVisor 隔离、出口白名单与审计闭环
java·开发语言·jvm·安全·agent沙箱·gvisor·microvm
tryxr2 天前
JVM 垃圾回收机制
java·jvm·gc·垃圾回收
我就是不信3 天前
JVM 调优实战:从排查思路到落地参数
jvm