JVM——Java虚拟机学习

发布于:2025-09-04 ⋅ 阅读:(13) ⋅ 点赞:(0)

JVM学习



前言

  • 因为JVM内部涉及的内容很广泛,我们这里主要讨论三个问题
  1. JVM内存区域的划分
  2. JVM中类加载的过程
  3. JVM的垃圾回收机制

内存区域划分

  • 一个运行起来的Java进程,就是一个JVM虚拟机,就需要从操作系统申请一大块内存
  • 会把这一块内存,划分成不同的区域,每块区域都有不同的作用

JVM 申请了一大块内存之后,也会划分成不同的内存区域~~

  1. 方法区(1.7 及其之前) / 元数据区 (1.8 开始)
  • 这里存储的内容,就是类对象
  • class 文件,加载到内存之后,就成了类对象了。
  • 这里存储的内容,就是代码中 new 的对象。
    (占据空间最大的区域)
  • 这里存储的内容,就是代码执行过程中,方法之间的调用关系

在这里插入图片描述

  • 每个元素,称为 “栈帧”
  • 每个栈帧,就代表了一个方法调用
  • 栈帧里就包含了方法的入口,方法返回的位置
    方法的形参,方法的返回值,局部变量……
  1. 程序计数器
  • 比较小的空间,主要就是存放一个“地址”,表示,下一条要执行的指令,在内存中的哪个地方
  • 刚开始调用方法,程序计数器,记录的就是方法的入口的地址。随着一条一条的执行指令,每执行一条,程序计数器的值都会自动更新去指向下一条指令
  • 方法区里:
    每个方法,里面的指令,都是以二进制的形式,保存到对应的类对象中的
class Test {
    public void a() { ... }
    public void b() { ... }
}
  • 这里的方法 a 和 方法 b 都会被编译成二进制的指令,就会放到 .class 文件中
    执行 类加载 的时候,就能够把 .class 文件里的内容,给加载起来,放到类对象里
  • 此时,方法的二进制指令,也就进入类对象了.

注意:

  1. 如果是一个顺序执行的代码,下一条指令就是把指令地址进行递增
  2. 如果是条件/循环代码 ,下一条指令就可能会跳转到一个比较远的地址
  3. 本地方法,指的是使用 native 关键字修饰的方法.这个方法不是使用 Java 实现,而是在 jvm 内部通过 C++ 代码实现的,
    JVM 内部的 C++ 代码调用关系~~

变量储存区域问题:

一个变量处于哪个区域,和变量的形态密切相关

  • 局部变量 处于 栈上
  • 成员变量 处于 堆上
  • 静态变量 (也叫做 类属性)
    包含在类对象中,也是在方法区/元数据区里头

JVM中线程的内存空间特点:

  • 一个 JVM 进程里,可能有多个线程.
    每个线程,有自己的程序计数器,和栈空间.
    这些线程共用同一份 堆 和 方法区.

  • 衍生说法:每个线程都有自己私有的栈空间~~
    这种说法,也可以认为是对的~~

在这里插入图片描述

类加载过程

  • java代码会被编译成.class文件(包含了一些字节码),java程序要想运行起来,就需要让jvm读取到这些.class文件,并且把里面的内容,构造成类对象,保存到内存的方法区中.
  • 所谓的“执行代码”,就是调用方法.
  • 就需要先知道每个方法,编译后生成的指令都是啥.

以下内容,大家就要当做八股文来 背诵了.

  1. 加载: 找到 .class 文件, 打开文件, 读取文件内容.
    往往代码中, 会给定某个类的, “全限定类名”
    例如 java.lang.String, java.util.ArrayList
    jvm 就会根据这个类名, 在一些指定的目录范围内,
    查找.
  2. 验证: .class 文件是一个二进制的格式. (某个字节, 都是有某些特定含义的)
    就需要验证你当前读到的这个格式是否符合要求.

在这里插入图片描述

  1. 准备阶段:给类对象分配内存空间(最终的目标,是要构造出类对象)。这里只是分配内存空间,还没有初始化呢。此时这个空间上的内存的数值,就是全 0 的
    (此时如果尝试打印类的 static 成员,就是全 0 的)

  2. 解析阶段:针对类对象中包含的字符串常量进行处理,进行一些初始化操作。java 代码中用到的字符串常量,在编译之后,也会进入到 .class 文件中

  • 比如代码 final String s = "test";
    于此同时,.class 文件的二进制指令中,也会有一个 s 这样的引用被创建出来~~

  • 由于引用里本质上保存的是一个变量的地址。在 .class 文件中,这是文件,不涉及到内存地址。因此在 .class 文件中,s 的初始化语句,就会先被设置成一个 “文件的偏移量”,通过偏移量,就能找到 “test” 这个字符串所在的位置

  • 当我们这个类真正被加载到内存中的时候,再把这个偏移量,替换回真正的内存地址

  1. 初始化 针对类对象进行初始化.把类对象中需要的各个属性都设置好,还需要初始化好 static 成员,还需要执行静态代码块,以及还可能需要加载一下 父类 .

文件偏移量

文件偏移量是指从文件开头到目标数据所在位置的字节数

.class 文件中,像字符串常量这类数据会被存储在特定位置。当代码里有对字符串常量的引用(比如 final String s = "test"; 中的 s 引用字符串常量 "test")时,由于编译后的 .class 文件还未加载到内存,不存在内存地址的概念,所以会用“文件偏移量”来标记该字符串常量在 .class 文件里的位置。当类被加载到内存时,这个文件偏移量会被替换成真正的内存地址,从而能正确访问到字符串常量

在这里插入图片描述

双亲委派模型

  • 属于类加载中,第一个步骤"加载" 过程中,其中的一个环节
    负责根据 全限定类名 找到 .class 文件.

  • 类加载器。是 JVM 中的一个模块.
    JVM 中,内置了,三个类加载器.

  1. BootStrap ClassLoader ——爷
  2. Extension ClassLoader ——父
  3. Application ClassLoader ——子

注意⚠️:
这个 父子 关系,不是 “继承” 构成的.而是这几个 ClassLoader 里有一个 parent 这样的属性,指向了一个父“类加载器”

类加载的过程 (找 .class 文件的过程)

  1. 给定一个类的全限定类名,形如 java.lang.String.

  2. 从 Application ClassLoader 作为入口,开始执行查找的逻辑.

  3. Application ClassLoader, 不会立即去扫描自己负责的目录 (负责的是 搜索项目当前目录和第三方库对应目录),而是把查找的任务,交给它的父亲,Extension ClassLoader

  4. Extension ClassLoader, 也不会立即扫描自己负责的目录 (负责的是 JDK 中一些扩展的库,对应的目录),而是把查找的任务,交给它的父亲,BootStrap ClassLoader

  5. BootStrap ClassLoader, 也不想立即扫描自己负责的目录 (负责的是 标准库 的目录),也想把任务交给它的父亲。结果发现,自己没有父亲!!!因此 BootStrap ClassLoader 只能亲自负责扫描,标准库的目录~~

  6. 没有扫描到, 就会回到 Extension ClassLoader.
    Extension ClassLoader 就会扫描负责的扩展库的目录.
    如果找到, 就执行后续的类加载操作, 此时查找过程结束.
    如果没找到, 还是把任务交给孩子来执行.

  7. 没有扫描到, 就会回到 Application ClassLoader
    Application ClassLoader 就会负责扫描当前项目和第三方库的目录.
    如果找到, 就执行后续的类加载操作.
    如果没找到, 就会抛出一个 ClassNotFoundException

注意⚠️:

  • 之所以搞这一套流程, 主要的目的, 为了确保, 标准库的类, 被加载的优先级最高
    其次是扩展库, 其次是自己写的类和第三方库

  • 假设你在自己的代码中, 写了一个 java.lang.String, 实际 JVM 加载的时候
    就不会加载到你自己写的这个类, 而是加载的标准库的类.

  • 双亲委派模型, 也不是不可以打破的.
    如果咱们自己写一个类加载器, 不一定非要遵守上述的流程.

  • tomcat 里, 加载 webapp 的时候就是用的自定义的类加载器.
    就只能在 webapp 指定目录中查找, 这里找不到, 就算了, 直接抛异常
    不会去 标准库啥的里面去找了……

垃圾回收

  • Java 给出了一个方案, 垃圾回收机制 (GC),让 JVM 自行判定, 某个内存, 是否就不再使用了.如果这个内存后面确实不用了, JVM 就自动的把这个内存给回收掉~~,此时就不必让程序猿自己手动写代码回收~~

  • GC 回收的目标, 其实是 内存中的 对象.
    对于 Java 来说, 就是 new 出来的这些对象.

  • 栈里的局部变量, 是跟随这栈帧的生命周期走的. (方法执行结束, 栈帧销毁, 内存自然释放)

  • 静态变量, 生命周期就是整个程序. 这个始终存在, 就意味着 静态变量是无需释放的.

  • 因此, 真正需要 gc 释放的, 就是 堆 上的对象了.

GC可以理解成两个大的步骤:

  1. 找到垃圾
  2. 释放垃圾

找到垃圾

在GC中,有两种主流的方案:

  1. 引用计数
class MyObject {
    // 简单示例类
}

public class ReferenceCountingExample {
    public static void main(String[] args) {
        MyObject obj1 = new MyObject(); // obj1 引用 MyObject 对象,此时对象引用计数为 1
        MyObject obj2 = obj1;         // obj2 也引用该对象,引用计数变为 2
        obj1 = null;                  // obj1 不再引用对象,引用计数减 1,变为 1
        obj2 = null;                  // obj2 也不再引用对象,引用计数减 1,变为 0,此时对象可被回收
    }
}
  • 出了{}之后,obj1和obj2就都要销毁了,引用计数就要归零。当对象的引用计数归零,此时代码中就不可能访问到这个对象

但是在java中一般不使用引用计数

  1. 无法解决 “循环引用” 问题(最致命缺陷)
  • 引用计数的核心逻辑是 “通过对象的引用计数器是否为 0 判断是否为垃圾”,但当两个或多个对象形成闭环引用(即相互引用,且无外部引用指向这个闭环)时,即使它们已完全无用,计数器也始终无法归零,最终导致内存泄漏(无用对象长期占用内存,无法释放)
  1. 比较浪费内存.
    计数器, 咋说也得 2 个字节.
    如果你的对象本身就很小, 这个计数器占据的空间比例就很大了.
    比如对象本身就 2 个字节, 计数器占据的空间就是 50%
    如果对象本身 4 个字节? 计数器占据的空间就是 33% ~~
  • 如果对象很少, 或者对象比较大, 都影响不大.
    如果对象小并且很多, 计数器, 占据的空间就难以忽视~~
  1. 可达性分析
  • 可达性分析,本质上 时间换空间 这样的手段
  • 有一个/一组线程,周期性的扫描我们代码中所有的对象.
    从一些特定的对象出发,尽可能的进行访问的遍历
    把所有能够访问到的对象,都标记成 “可达”
    反之,经过扫描之后,未被标记的对象,就是 垃圾了.

可达性分析,出发点,有很多

  • 不仅仅是所有的局部变量,还有常量池中引用的对象,还有方法区中的静态引用类型引用的变量,统称为 GCRoots

  • 当然,这里的遍历 不一定是二叉树,大概率是 N 叉树.
    就是看你访问的某个对象,里面有多少个 引用类型的成员.
    针对每个引用类型的成员都需要进一步的进行遍历

  • 这里的可达性分析,都是周期性进行的(可达性分析比较消耗系统资源,开销比较大.)

  • 当前某个对象是否是垃圾,是随着代码的执行,会发生改变

回收垃圾

三种基本回收思路

  1. 标记清除:比较简单粗暴地释放方式
  • 把对应的对象,直接释放掉,就是标记清除的方案.

  • 但是这个方案其实非常不好.产生很多的内存碎片~
    释放内存,目的是为了让别的代码能够申请.
    申请内存,都是申请到 “连续” 的内存空间.

  • 比如:总的空闲空间虽然是 2MB
    但是申请内存的时候,只能申请 <= 1MB 的空间

随着时间的推移,内存碎片的情况,就会越演越烈.就会导致后续内存申请举步维艰.

  1. 复制算法:通过复制的方式,把有效的对象归类的一起,再统一释放剩下的内存
  • 把内存分成两份,一次只用其中的一半.这个方案,可以有效解决内存碎片的问题.
  • 但是,缺点也很明显。内存要浪费一半,利用率不高。如果有效的对象非常多,拷贝开销就很大.
  1. 标记整理:即能够解决内存碎片化问题,又能处理复制算法中的利用率
  • 类似于顺序表删除元素的搬运操作
  • 搬运开销仍很大

实际上,JVM采取的释放思路,是上述基本思路的集合体,又称为分代回收
在这里插入图片描述

  • 新的对象放到伊甸区

  • 从对象诞生, 到第一轮可达性分析扫描, 这个过程中虽然时间不长 (往往就是 毫秒-秒). 但是, 在这个时间里大部分的对象都会成为垃圾~~

  • 这个时间维度, 在程序的眼中也挺长了.
    你创建的对象, 指向对象的引用很快就随着方法执行完毕就消亡了

  1. 伊甸区 => 幸存区 复制算法
    每一轮 GC 扫描之后, 都把有效对象
    复制到 伊甸区就可以整个释放了.
    由于经验规律中, 真正需要复制的对象不多
    非常适合复制算法~~

  2. GC 扫描线程也会扫描 幸存区.
    就会把 活过 GC 扫描的对象(扫描过程中可达)
    拷贝到幸存区的另一个部分!!!
    幸存区之间的拷贝, 每一轮会拷贝多个对象
    每一轮也会淘汰掉一批对象 (有些对象随着时间的推移, 就成了垃圾了)

  3. 当这个对象已经在幸存区存活过很多轮 GC 扫描之后
    JVM 就认为这个对象, 短时间内应该是释放不掉了.
    就会把这个对象拷贝到 老年代

  4. 进入老年代的对象, 虽然也会被 GC 扫描
    老年代 GC 扫描的频率就会比新生代, 低很多.
    新生代的对象, 更容易挂掉 (经验规律) 要挂早挂了
    老年代的对象, 更容易继续存活.
    也是为了减少 GC 扫描的开销

  • 分代回收,是JVM中主要的回收思想方法。但是在垃圾回收器具体体现的时候,可能还会有一些调整和优化

网站公告

今日签到

点亮在社区的每一天
去签到