当前位置: 首页 > news >正文

通俗理解JVM细节-面试篇

文章目录

  • 前言
  • JVM概述
    • JVM是什么?解决了什么问题?
    • JVM运行流程
    • JVM 与 JRE,JDK的关系
  • JVM内存结构
    • JVM区域划分
      • 程序计数器
      • 方法区
  • 类加载机制
    • 五个阶段
    • 加载
    • 验证
    • 准备
    • 解析
    • 初始化
    • 总结
    • 双亲委派模型
  • 垃圾回收内存管理
    • 什么是GC?如何判定谁是垃圾?
      • 1.引用计数 判定
      • 2.可达性分析
    • 内存回收算法
      • 1.标记-清除
      • 2.复制算法
      • 3.标记-整理
    • JVM中的回收算法
    • Minor GC 与 Full GC 的区别与触发条件
  • JVM线程与锁机制
    • 程序计数器与线程的关系
    • 偏向锁、轻量级锁、重量级锁的演化过程
  • 例子:对象的创建全过程 从new到内存分配(类加载->内存分配)
  • 参考文章

前言

本来要开始学习Spring的,但听说最好学框架之前先把JVM,HTTP协议全都捋一遍,除了有助于Spring学习外,更重要的是这俩面试也经常会被问到,所以我必须本着“喜欢探索知识的个性”来学习了。

JVM概述

JVM是什么?解决了什么问题?

JVM(Java Virtual Machine,Java虚拟机)通俗一点说是一个假装是计算机的程序,专门运行.java程序。拟人化说就是它像一个翻译+保姆+管家的综合体,它可以解决什么问题呢?
翻译:把你写的.java程序翻译成别人能看懂的.class字节码
保姆:管理内存,帮你清理垃圾(垃圾回收)
管家:跨平台运行(可以在不同的OS上运行)
专业点来说,JVM 是运行 .class 字节码文件的虚拟计算机,它屏蔽了底层操作系统和硬件的差异,实现“一次编写,到处运行”。

有句话说得好,凡是都需要对比一下友商,让我们看看C++的相关替代品是啥。
答:C++不需要这些,因为C++是编译型语言,编译之后变成了机器码,直接可以运行在操作系统上。编译型语言执行效率更高,时间消耗更少。 这么一看是不是感觉踢到钢板了? 其实不然,友商虽然不需要这个就可以执行,这是它高性能的优点但同时也是一个很大的缺点。因为没有管家帮它接触各类操作系统,也没有保姆帮他回收垃圾。这些都是C++的痛点。具体来说,对比表格如下

特性JavaC++
编译结果字节码(.class)机器码(.exe / .out)
是否依赖虚拟机是,JVM 才能运行否,编译后直接运行
是否跨平台是(因为 JVM 屏蔽平台差异)否(不同平台要重新编译)
内存管理自动垃圾回收(GC)手动管理(new/delete)
类加载运行时加载(类加载器)编译时静态链接,没“加载器”

JVM运行流程

  1. 编译阶段(helloworld.java -> helloworld.class) 开发时
  2. 类加载阶段(helloworld.class 被类加载器加载进内存 JVM进行验证->准备->解析->初始化) 看看你合法吗,合法给你分配住的地方,分配完了之后准备给我干活。
  3. 执行阶段(将字节码翻译为机器码)穿上工装开始干活
  4. 内存管理,干着干着发现你的空间怎么越战越大,得给你释放一下了(垃圾回收)

JVM 与 JRE,JDK的关系

这就像一家三口 JDK最大包含 JRE JRE包含JVM

名称作用举例说明
JVMJava虚拟机,运行字节码就是你写的程序在哪执行
JREJava运行环境,包含 JVM + 核心类库就像一个 Java 程序能跑的最小环境
JDKJava开发工具包,包含 JRE + 编译器(javac)等工具你写程序、编译、运行都靠它

JVM内存结构

JVM区域划分

为什么要有区域划分?答:不同区域的内存有着不同的功能便于高效管理。比如你买了房子,你得给安排洗手间,厨房,客厅,卧室一样。不同的空间有不同的职责。

程序计数器

程序计数器的作用是保存指令地址的地方
什么是指令?指令就是字节码,也就是我们的写的代码,代码都是有作用的所以转化为字节码后也就是指令了,用来命令CPU下一步该干啥。什么是指令地址?就是存储指令的地方,我们执行指令的时候需要先将指令一个个从内存中取出来。

前面的文章中说过,线程调度,主要也靠程序计数器恢复上下文。这里的程序计数器也是存储当前线程执行的指令地址,那么JVM的程序计数器和线程的是一个嘛?显然不是,可以理解JVM是总的程序计数器,是当前程序执行到哪了,而线程中存的则是当前线程执行到什么位置了。如果线程发生阻塞,在调度回来执行的时候方便恢复现场。

栈是存储局部变量和函数调用信息的地方。什么是局部变量,顾名思义是指只在一个范围内生效的变量(比如一个函数内定义的变量,for循环内部定义的变量if语句内部定义的变量) 函数调用信息:传入函数的实参,函数内部的局部变量,调用函数的位置等。栈的基本属性是先进后出,比如在递归或函数内部调用函数中最内层调用函数最先出来,最外层调用函数最后出来。举个例子,判断树高度的例子
假设树长这样
在这里插入图片描述

    public  int deep(TreeNode root,int x){if(root==null){return x;}int x1=deep(root.left,x+1);int x2=deep(root.right,x+1);return Math.max(x1,x2);}int h = deep(root,0)+1;

栈的最底部 会存储实参(3,0),局部变量(x1,x2)这里说的是节点值,了解存储的那些节点就行,实际上是存储的一个个引用。依次类推往上分别存储(9,1) ,但调用此节点时发现已经没有孩子节点了 直接返回高度1,因此底部会变为(3,0),(1,x2) 。随后右孩子入栈 分别往上存储(20,1),(15,2),(7,2).最终逐个出栈(这里是(15,2)先出栈)。

同样的有多个线程调用函数的话,每个线程内部也是都有栈的。

堆是占用内存较大的数据结构了,其内部主要存储 new出来的对象实例,以及对象内部的成员变量。注意堆在线程可就没有了,因为线程太小了。

public void f(){
HashMap<Integer,Integer> hsm = new HashMap<>();
}

那这个例子中hsm 存储在哪呢? hsm 是局部引用 所以会存储在栈中,这里分清引用和对象实例,引用指的是对象在堆的存储位置,对象实例则是对象本身。 这也顺便解释 对象传参时引用调用为什么会改变原始对象本身,因为传参时传的是对象的地址的引用,而修改时会修改对象在堆中的本身,所以引用调用会改变其本身。而值传递,是将值传参了,不会改变原有的值(我理解的,不知道对不对,但有一种引用情况除外,那就是String类型,传参传入String的引用时不会改变原始值,这是因为一旦操作String类型都会创建一个副本,所以不会改变原始值)。

理解不了 没关系 这里就记住
堆存储new的对象实例 和 内部的成员变量
局部变量或引用都存储在栈中

方法区

方法区存储的是类对象,但我喜欢叫类属性。因为更好理解,类对象不是指前面的对象的实例。而是一个类中结构是啥样的,比如里边的变量的类型,方法的类型,有哪些方法,名字叫什么等。

类加载机制

类加载机制是指 JVM 在运行期间,将 .class 字节码文件加载到内存中,并转换成 Class 对象的整个过程。也就是说,当你在代码里用 new User() 或 User.class 的时候,JVM 要确保这个类的字节码已经在内存中了,这个“确保 + 加载 + 准备 + 连接 + 初始化”的过程,就是类加载机制。

五个阶段

加载-验证-准备-解析-初始化

加载

干了什么? 读取字节码内,创建一个class对象,存进方法区,此class对象是后面你反射的基础
目的:将类的字节码读取到JVM内存

举例子来说,你在运行Java对象前需要先把说明书(字节码) 从硬盘搬进内存,才能参考它干活

验证

干了什么?检查.class文件是否符合规范,是否非法越界,引用了不存在的类。
目的:保证虚拟机安全的运行,防止恶意或错误字节码破坏

就像你拿到了这个说明书,你得检查是不是骗人的。

准备

给类的静态变量分配内存,并初始化默认值。注意是初始化默认值,不是初始化。默认值指的是(0/null/false) 这种
比如 int x = 3 不会初始化为3,而是初始化为0.

就像你租一间房,房东先给你空房子和床,等你入住时再布置和摆东西(下一步才赋值)

解析

把常量池中的符号引用转化直接引用
例如:User u = new User(); 在常量池里是个字符串 “User”,解析后才变成真正的方法地址、内存地址等。

提前把类中引用的类/字段/方法都找好,变成可执行的“具体地址”
你看到说明书说“点这里”(符号引用),你得先知道“这里”是哪里(真正的函数地址)

初始化

这是真正的初始化,给类中的静态变量初始化提前设定的值
房间准备好了,现在你把桌子椅子搬进来、墙上挂个画,开始“入住

总结

阶段干了什么目的是否常考
加载读取 .class → 生成 Class 对象把类搬进 JVM
验证校验字节码合法性防止非法破坏 JVM
准备给 static 字段分配内存 & 默认值建立“类模板”
解析符号引用 → 真实引用做好准备执行⚠️(次要)
初始化执行 <clinit> 初始化代码正式让类“准备就绪”

双亲委派模型

这个属于加载阶段的部分,为什么单独要拿出来说。因为这部分理解对于你学习java有着重要意义,劣势基础,最终成为大牛。。。好了不说废话,因为这部分面试喜欢问。它的目的是找.class文件。 因为.class的文件可能存放在多个地方。比如在JDK中,比如自定义的在项目目录中。 类加载器有三个,每一个负责不同的区域。就像外卖小哥,每个区域可能会安排一个外卖小哥。
1、BootStrapClassLoader【模拟线路类加载器】 负责标准库常用的类
2、ExtensionClassLoader【扩展类加载器】加载JDK扩展的类,很少用
3、ApplicationClassLoader【应用类加载器】负责我们在项目中自定义的类

工作流程:首先进入应用类加载器,此时他会检查扩展类加载器是否加载过了。没有则进入扩展类加载器,进入之后,也会判断是模拟线路类加载器加载过了,没有则进入模拟线路类加载器。 就是一句话,如果没被加载过会先到自己的父类加载器去加载

为什么这样设计?因为这保证加载类时的一致性,不会出现自定义的类和标准库的类重名了不知道加载哪个。这里会优先加载标准库的类。

垃圾回收内存管理

垃圾回收这些一直是由JVM自动判定并回收,所以我们接触的很少。但对比友商的程序员(C),这就是不得不接触的了,因为C语言追求高性能,这种垃圾回收一类的东西它不care,所以只能辛苦程序员来完成了。看到这里是不是心里宽慰了一些

什么是GC?如何判定谁是垃圾?

什么是垃圾回收?我们写程序new的对象 创建的变量这些只要不用了就是垃圾,但垃圾依然占用着内存,我们需要清理它并回收内存。但不能乱回收,比如一个对象明明你还有用你却给它扔了这样会有大问题。因此如何判定谁是垃圾成为了重中之重。

如何判定谁是垃圾?
在判定之前,我们要明确目标,内存区域中只有堆中占用空间最大且是最需要回收垃圾释放内存的地方。所以我们默认垃圾回收针对的是堆中的数据。
如何判定?

1.引用计数 判定

此方法不是用在java中,了解如何运作的就可。引用计数对每个对象实例都会增加一份额外的空间用来计数,注意是对象实例,不是引用。例如:

Result re = new Result()  \\这里re 是引用,Result是对象实例我们针对的是对象实例,那有人会问引用如何回收\\引用的生命周期更短,只要超过作用域或者被定为null就回收了。
Result re1 =re; \\Result()的计数是2.因为有两个指向它

当引用计数变为0时,就回收这部分内存。也就是说取消一个引用时,其计数器减一。
这种方法存在什么问题呢? 具体看代码注释

public class RefCountDemo {public Object instance = null; // 引用另一个对象public static void main(String[] args) {//此时第一个RefCountDemo();计数器为1RefCountDemo objA = new RefCountDemo();此时第二个RefCountDemo();计数器为1RefCountDemo objB = new RefCountDemo();// A 引用了 B 此时第二个RefCountDemo();计数器为2objA.instance = objB;// B 又引用了 A 此时A的计数器为1 此时第1个RefCountDemo();计数器为2objB.instance = objA;// 断开外部引用  objA = null;objB = null;// 此时 objA 和 objB 相互引用,引用计数 ≠ 0// 但它们已经无用了,GC 无法识别(引用计数法失效)}
}

此外 空间利用率低,因为每次new 一个对象你都要分配额外的空间去计数。

2.可达性分析

于是为了解决以上两个问题,Java决定另辟蹊径。
可达性分析(Reachability Analysis) 是一种判断对象是否“存活”的算法:
从一组称为 GC Roots 的根对象出发,沿着引用链向下搜索,能被访问到的对象就是“可达的”,不可达的对象则认为“已经死亡”,可以被垃圾回收。

工作流程:
从 GC Roots 出发
沿着所有引用向下搜索(广度或深度优先)
标记所有能访问到的对象为“活着”
未被访问到的对象则是“死对象”,可以被回收

GCRoots有那些来源?

GC Roots 来源说明
栈帧中的本地变量表方法调用中的局部变量,如 new 出来的对象引用
方法区中静态变量如 static 字段的引用
方法区中常量引用final 常量等
JNI 引用(本地方法)通过 C/C++ 引用的 Java 对象
活跃线程对象每个正在运行的线程本身

public class ReachabilityDemo {
public Object ref = null;

public static void main(String[] args) {ReachabilityDemo a = new ReachabilityDemo();ReachabilityDemo b = new ReachabilityDemo();a.ref = b;b.ref = a;// 外部断开引用a = null;b = null;// 现在 JVM 会触发 GC,能正确识别 a 和 b 都不可达(虽然互相引用)
}

}
以上例子中 首先从栈帧中的本地变量表 a,b开始扫描 ,发现 a 和b 都为null了,所以第一个和第二个ReachabilityDemo(); 都无法访问到,于是回收这部分空间。同时将引用也回收

内存回收算法

找完垃圾后,就需要清理,如何清理呢?(释放内存)

1.标记-清除

通过可达性分析找到要回收的对象后,我们直接对垃圾占用内存释放。面试遇到的话,理解性记忆,标记了就清除嘛不就是,谁是垃圾就清楚谁其他的我不管,这就是标记清除。这种存在什么问题呢? 猛地一看貌似没毛病啊不就是谁是垃圾,谁就要扔啊,难不成还不扔垃圾?道理是这样地,但问题是扔了垃圾你不收拾一下房间嘛? 也就是说清理了垃圾,但会变成碎片化内存,就是隔一段有一小快是空闲,这些小块加起来是很大地,但分配这样大地空间 我们却无法分配(因为空间分配必须是连续地)。所以清理垃圾必须也得收拾房间,要不然杂乱无章,每地方都有东西放,但却又放不下大点地东西。

2.复制算法

此算法就是解决以上问题,整理内存。首先将内存一分为二,一半用,一半备用。当清理垃圾时,会先将不是垃圾地内存地值复制到另一把中去。然后在讲这一半地所有空间全部清除,这样就释放了这一半地所有空间。 复制算法,面试遇到就要想到加了复制俩字,就说明他会整理内存,变聪明了。但此时又会有什么问题呢? 这我好像只能用一半空间啊,内存这么金贵好不容易申请到,却只能用一半(空间利用率低)。此外,每次都要从这个房间把东西搬过去,万一这个房间垃圾不多有用地很多,全搬过去好累地(复制开销较大)。

3.标记-整理

对于复制出现地问题,标记整理解决了一部分(空间利用率低) 如何做呢?它将不是垃圾的内存的值覆盖到前边时垃圾的内存哪里,注意后者的内存一定满足大于等于前者才可直接覆盖。随后对后面的元素直接释放。此方法问题还是没有解决需要复制的问题

JVM中的回收算法

上面每一种算法单拎出来发现都不够完美,所以JVM采用了三者的结合分代回收! 顾名思义,将不同的对象根据存在时间的长短分为辈分大的和辈分小的。然后将堆内存一分为二, 其一存放辈分大的,其二存放辈分小的。 其中第二部分,又分为两部分,一部分伊甸区,一部分时幸存区。话不多说直接偷张图(这篇图的作者讲的特别好,文章最后我会表明引用他文章的)
在这里插入图片描述

1.刚new出来的对象直接放到伊甸区。
2.如果伊甸区对象熬过了可达性分析,则就放入幸村区。
3.幸存区继续开熬,来回复制
4.经过多轮后,幸存区已经有资格放到老年区,此时放到老年区。

Minor GC 与 Full GC 的区别与触发条件

Minor GC 针对年轻代判定并回收垃圾
Full GC 指 JVM 对整个堆空间进行回收

项目Minor GCFull GC
作用范围仅年轻代(Eden + Survivor)整个堆(新生代 + 老年代 + 方法区)
触发条件年轻 区满了,需要分配新对象老年代满、System.gc()、元空间不足、CMS失败等
耗时较短(几十 ms)较长(几百 ms 或秒级)
是否会 Stop-The-World✅ 是✅ 是
是否影响用户体验轻微明显卡顿,尤其是 Full GC 频繁时
回收频率高频尽量少

JVM线程与锁机制

程序计数器与线程的关系

  1. 程序计数器(Program Counter Register)
    每条线程都有独立的程序计数器,是线程私有的内存空间。
    是 JVM 中唯一一个线程私有的内存区域。
    用来记录当前线程正在执行的字节码指令地址。
    如果当前正在执行的是 native 方法,则该计数器值为 undefined。

  2. 为什么线程私有?
    Java 是多线程语言,而 JVM 采用线程隔离的方式来执行多线程代码。每个线程需要知道自己执行到哪一行代码了,所以每个线程必须有自己的 PC 寄存器,否则线程切换后无法恢复上下文。

偏向锁、轻量级锁、重量级锁的演化过程

无锁
↓(第一个线程获取)
偏向锁
↓(出现竞争)
轻量级锁
↓(竞争激烈,阻塞)
重量级锁

1 偏向锁(Biased Lock)
特点:偏向于第一个获取锁的线程
无需 CAS(Compare-And-Swap)操作,无锁竞争
将线程 ID 记录在对象头中

使用场景:大量线程反复进入同步块但没有竞争的情况
触发升级:如果另一个线程试图获取这个锁,则撤销偏向 → 升级为轻量级锁

2 轻量级锁(Lightweight Lock)
特点:多线程交替进入同步块时使用,使用 CAS 实现乐观锁
没有线程阻塞,尝试加锁失败的线程会 自旋 等待锁释放
使用场景:短时间同步,线程竞争不激烈(两个线程交替进入)
触发升级:如果 CAS 多次失败,自旋多次未成功 → 升级为重量级锁

3 重量级锁(Heavyweight Lock)
特点:使用操作系统 Mutex 来实现(synchronized 的早期实现)会使线程阻塞挂起和唤醒
问题:线程挂起、恢复的上下文切换代价非常大

例子:对象的创建全过程 从new到内存分配(类加载->内存分配)

public class JVMExample {public static void main(String[] args) {System.out.println("程序启动");User user = new User("Alice", 28);user.printInfo();user = null; // 使对象变为垃圾System.gc(); // 主动调用 GC(并不一定立即执行)System.out.println("程序结束");}
}class User {private String name;private int age;public User(String name, int age) {this.name = name; // 存在于堆中对象的属性this.age = age;}public void printInfo() {String info = "用户:" + name + ",年龄:" + age;System.out.println(info);}@Overrideprotected void finalize() throws Throwable {// finalize 可能被 GC 调用(不保证一定被调用)System.out.println("User 对象正在被 GC 回收!");}
}

1.类加载过程

阶段说明
加载从 .class 文件中加载字节码进方法区(元空间)
验证校验字节码正确性(如格式、指令合法)
准备分配静态变量的内存,并赋默认值
解析将常量池中符号引用替换为直接引用(如方法、字段地址)
初始化执行类的 <clinit> 静态初始化块或静态变量赋值

2.内存分配

区域内容及示例
程序计数器每个线程一个,当前执行指令地址。比如 System.out.println(...) 执行时,PC 保存当前 JVM 指令的地址。
虚拟机栈(Java栈)每个线程一个,保存方法调用栈帧。方法中的局部变量 user 就保存在此处。
堆(Heap)所有对象实例都在堆中分配,例如 new User(...) 创建的对象。
方法区(元空间)类的结构信息(方法表、常量池、字段表)被加载到这里,如 User.class
本地方法栈调用 Native 方法时使用,例:System.gc() 最终可能调用 native 函数触发 GC

3.垃圾回收
user = null;
System.gc();
上述代码使得 user 不再引用堆中的对象,变成“垃圾对象”。
GC 机制会:
通过可达性分析(引用链)找出不可达对象
标记-清除 / 标记-整理 / 分代收集(Young、Old、Perm)等策略执行清理
如果 User 类中定义了 finalize() 方法,GC 会调用它(只调用一次,不保证执行)

参考文章

最后参考一下细节佬的文章
JVM - JavaEE初阶最后一篇 - 细节狂魔

http://www.lqws.cn/news/607123.html

相关文章:

  • 自动化工具ansible,以及playbook剧本
  • OpenCV CUDA模块设备层-----高效地计算两个uint 类型值的平均值函数vavg2()
  • Notepad++ 复制宏、编辑宏的方法
  • 机器学习在智能能源管理中的应用:需求响应与可再生能源整合
  • 在Ubuntu上多网卡配置HTTP-HTTPS代理服务器
  • 基于SpringBoot和Leaflet的区域冲突可视化系统(2025企业级实战方案)
  • CAN从站转Modbus TCP主站总线协议转换网关
  • LeetCode 11.盛最多水的容器
  • 基于centOS9(redhat9)使用NGINX搭建discuz论坛
  • 深度解析Linux内核IPv4设备管理:net/ipv4/devinet.c
  • 创客匠人深度解构 IP 定位:从使命驱动到差异化落地的实践路径
  • 【RHCSA-Linux考试题目笔记(自用)】servera的题目
  • 云上配送革命:亚矩云手机如何重塑Uber Eats的全球外卖生态
  • vue中ref()和reactive()区别
  • 新手向:MySQL完全指南,从零开始掌握数据库操作
  • 洪水填充算法详解
  • 智能学号抽取系统 V3.7.5 —— 一个基于 Vue.js 的交互式网页应用
  • SpringCloud系列(46)--SpringCloud Bus实现动态刷新全局广播
  • Prompt Engineering Guide — 提示工程全方位指南
  • 博图SCL编程:数据隐式转换使用详解与实战案例
  • ABAP+记录一个BDC的BUG修改过程
  • moodle升级(4.5到5.0)
  • 数据结构学习之栈
  • 计算机视觉---视觉伺服控制
  • mac mini m4安装node.js@16以下版本方法
  • nignx+Tomcat+NFS负载均衡加共享储存服务脚本
  • 重塑智能体决策路径:深入理解 ReAct 框架
  • 使用OpenCV训练自有模型的实践
  • 金融安全生命线:用AWS EventBridge和CloudTrail构建主动式入侵检测系统
  • Chrome 下载文件时总是提示“已阻止不安全的下载”的解决方案