☰
深入解析JVM内存管理与垃圾回收机制
2026/10/4 12:20:51 网站建设 项目流程

目录

一. JVM简介

二. JVM内存区域划分

2.1 程序计数器

2.2 栈区

2.2.1 虚拟机栈

2.2.2 本地方法栈

2.3 堆区

2.4 元数据区

三. JVM的类加载机制

3.1 类加载流程

3.1.1 加载

3.1.2 验证

3.1.3 准备

3.1.4 解析

3.1.5 初始化

3.2 双亲委派模型

四. JVM垃圾回收机制(GC)

五. JVM的垃圾回收器


一. JVM简介

JVM是Java Virtual Machine的简称,意为Java虚拟机。

各组件核心作用

- JVM(Java虚拟机):最核心底层,仅负责运行Java字节码,无编译、无类库,单独无法运行程序。
​
- JRE(Java运行环境):= JVM + 核心类库(java.lang/java.util等) + 运行时支撑工具,满足仅运行Java程序的全部需求(普通用户/服务器仅需JRE)。
​
- JDK(Java开发工具包):= JRE + 开发编译工具(javac编译器、javadoc文档工具、jdb调试器等),是开发者的完整工具包,能编、能调、能运行。

注意:开发Java代码,安装JDK(包含了JRE)。不需要开发,只需要运行一个Java程序(已编译并打包好的字节码文件),安装JRE即可。

在我们把代码写好并点击运行时,会先触发JDK的javac编辑器,将代码编译成 .class字节码文件,编译成功后,再调用Java命令运行字节码,创建出独立的JVM实例,在JVM中字节码文件会被解释/翻译成二进制机器指令,最后再在CPU上运行。

  • JDK包含JRE,JRE又包含JVM
  • JRE是运行时环境,全程提供运行支撑。
  • JVM是JRE中预制的基础运行环境,执行Java程序时,会基于这个预制环境为当前程序创建一个独立的JVM实例来专属使用。
  • 在CPU执行机器指令的整个过程中,JVM会全程做底层核心管理工作,而非转码后闲置。
  • JVM负责Java程序运行的所有专属管理逻辑(线程调度,垃圾回收等),CPU只负责无脑执行二进制机器指令,不参与任何程序层面的管理。
  • JVM不是“只有一个”,有很多版本(Windows版本 ,Linux版本,Macos版本 ,Android版本......)----都能解释执行相同的字节码文件。

JAVA引入java虚拟机(JVM)的目的:

一次编译,到处执行”是引入JVM最核心、最根本的目的,也是Java跨平台特性的底层支撑。

即直接把写好的并编译好的字节码文件放在不同平台上执行,平台上有预装专属JVM(JRE中的),运行字节码时创建JVM实例完成本地翻译,完全不需要修改代码,更不能重新编译。

二. JVM内存区域划分

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

Java程序中使用的“内存”其实是JVM的内存

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

JVM内存划分如下:

2.1 程序计数器

很小的区域,只保存一个数字,即下一条要执行的java字节码指令的地址。在内存中由软件维护(JVM源码)

注意:一个JVM中不是只有一个程序计数器,而是java程序中每个线程都有自己的程序计数器

2.2 栈区

栈区分为虚拟机栈和本地方法栈

2.2.1 虚拟机栈

虚拟机栈是给java程序使用的栈,维护了方法调用的关系(嵌套关系)

虚拟机栈仅服务java方法(除了native本地方法以外的方法,包括自定义的方法以及Java核心库中的方法)

一个Java线程执行过程中,调用过多少个Java方法(含自定义、系统内置),虚拟机栈中就会对应创建多少个栈帧;仅嵌套调用的方法栈帧会同时存在,无嵌套的方法栈帧会按执行顺序依次创建、执行、销毁,彼此不会共存。

注意:

  • 一个栈帧对应了一个方法,每个栈帧存储着对应Java方法的调用方法的实参,方法内部的局部变量,方法结束后要返回的上层方法的位置,返回值等。
  • 同一时间永远只有一个栈帧在执行

2.2.2 本地方法栈

本地方法栈是为Java调用的native本地方法(底层多由C/C++实现) 专属准备的线程私有内存。

JVM底层就是C++实现的,我们在Java中写的代码,往下调用着就调用到了C++的范围(native方法)

比如说 Thread 的 sleep 方法,就是在本地方法栈中开出的栈帧调用并执行的。

main函数执行到Thread.sleep后的流程:

1. Java主线程启动,虚拟机栈中压入 main 栈帧(栈顶,正在执行);
​
2. main 执行到 Thread.sleep() 时,JVM立刻在当前线程的本地方法栈中,为这个native的sleep方法开辟专属栈帧并压入(成为本地方法栈的栈顶);
​
3. 执行引擎切换到本地方法栈,执行sleep的C/C++底层逻辑(系统级休眠);
​
4. 休眠结束,sleep执行完毕,本地方法栈立即弹出sleep的栈帧并销毁;
​
5. 执行引擎切回虚拟机栈,回到 main 方法中调用sleep的下一行代码,继续执行。

注意:

  • 虚拟机栈和本地方法栈都是JVM为每一个线程都开辟出的独属于这一个线程的内存空间
  • 虚拟机栈和本地方法栈虽然相互独立但同属于一个线程

2.3 堆区

堆区是JVM中最大的内存区域。我们新new的对象都放在堆中

2.4 元数据区

元数据区在之前也称为“方法区”

元数据区的作用:用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的。

JVM在运行时,就会把 .class文件放到内存中,还需要通过一些特定的结构来表示------即构成 类对象(不是我们new出来的实例化对象。一个类只有唯一一个类对象,相当于描述类的“模板”)

总结Java程序中常见java变量,对象等的存储区域

栈:存放局部变量(开辟的临时空间,即栈帧)。栈中还存储了this(this作用:this 就是非静态方法的“对象身份证”,存着当前操作对象的堆地址,靠这个身份证,方法才能精准操作堆中对应的对象,而不是其他对象,也不是凭空操作。)

堆:new出来的对象,以及new出来对象的对象头和其中的非静态成员变量的实际值/地址,不包含非静态成员变量的类型,访问限定修饰符以及名称。非静态成员方法以及静态成员方法都不在堆中(在调用方法时)

元数据区:存放类对象,其中就包含静态成员变量的存储描述+初始目标值,非静态成员变量的存储描述,以及静态/非静态成员方法的结构描述(方法的执行临时数据都在虚拟机栈帧中)

注意:

  • 方法里 new 的对象本体永远在堆里,和局部变量无关;只有指向这个堆对象的引用变量,才是存放在栈帧局部变量表中的局部变量。
  • 上面这些内存区域,针对程序计数器和栈,存在多份(每个线程又一份自己的);针对堆和元数据区,一个进程只有一份(在同一个进程中的多个线程共享一份堆和元数据区)

什么情况下,内存溢出:

  1. 栈溢出:包含了方法的调用关系(栈帧)太多了。比如递归代码时,递归的结束条件有错误,导致无限递归。又比如创建了太多的局部变量(不容易触发)
  2. 堆溢出:new的对象太多了。比如无限循环的往某个集合类中添加元素

三. JVM的类加载机制

JVM的类加载机制就是把 .class文件读取到内存中,构建出类对象的过程(放在元数据区)

3.1 类加载流程

类加载的流程分为以下5个步骤:

3.1.1 加载

根据代码中写的“全限定类名”(Java中要使用哪个类就要import全限定类名),找到对应的 .class文件,然后打开文件并读取文件中的二进制数据到内存中.(最终到元数据区)

3.1.2 验证

根据读到的二进制内容,验证是否是合法的格式

JAVA官方有明确规定的格式

https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-4.html#jvms-4.1

注意:JVM是先有要加载类的全限定类名,再去找对应的 .class文件,并且一个Java程序中有多个全限定类名,每一个全限定类名都要找到对应的 .class文件,且每一个 .class文件都要验证

3.1.3 准备

给要创建的类对象分配内存空间(元数据区)

  • 非静态成员→分配内存。仅存描述信息(元数据区,无实际内存分配)
  • 静态成员→分配内存+赋默认值(均在元数据区完成)
  • 非静态/静态成员方法→分配内存(存储完整的描述信息)

注意:由于这里只是给静态成员变量赋予默认值,并不是目标值

对于静态/非静态方法也只是在元数据区中保存了它的结构信息,直到真正执行时才会在栈上开辟空间

3.1.4 解析

解析阶段是Java虚拟机将常量池内的符号引⽤替换为直接引⽤的过程,也就是初始化常量的过程。

注意:元数据区存储的是字符串常量的地址(指向的是在堆中的字符串常量这个的对象),字符串常量这个对象实际存在于堆中的字符串常量池中

3.1.5 初始化

初始化类的静态成员变量(赋目标值),执行静态代码块以及对父类的加载(非静态成员变量赋目标值是在new对象时,而不是在类加载时)

触发类加载的时机:(类加载遵循“懒汉”思想,用到的时候才加载)

  1. new这个类的实例时
  2. 调用这个类的静态方法/访问静态成员
  3. 针对子类的加载,也会触发父类的加载
  4. 当加载一个类时,如果它引用了其他类(比如自定义类中包含了标准库类或其他自定义类),这些被引用的类也会被触发加载

类加载一次即可,且每个类的类对象在一个JVM进程中也是单例的

3.2 双亲委派模型

双亲委派模型出现在类加载的第一步(找到对应 .class文件)----涉及到一个模板---类加载器

如果⼀个类加载器收到了类加载的请求,它⾸先不会⾃⼰去尝试加载这个类,⽽是把这个请求委派给 ⽗类加载器去完成,每⼀个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层 的启动类加载器中,只有当⽗加载器反馈⾃⼰⽆法完成这个加载请求(它的搜索范围中没有找到所需 的类)时,⼦加载器才会尝试⾃⼰去完成加载。

JVM中默认包含了三个类加载器(名称与层级关系(自上而下)):BootstrapclassLoader ,ExtensionclassLoader,ApplicationLoader

这三个类加载器有父子关系(不是父亲子类的关系,只是这个类加载器中有一个parent这样的属性,指向父亲的引用)

三个类加载器的作用:

  • BootstrapclassLoader:负责加载Java标准库中的类(标准库类的 .class文件都放在特定的目录中,BootstrapclassLoader就负责在这些特定目录中找到对应的 .class文件)
  • ExtensionclassLoader:负责加载Java扩展库中的类(是由JDK厂商做出的扩展--同样也是由Java官方做出的扩展)
  • ApplicationLoader:负责加载第三方库中的类,以及你当前项目中的类(自定义的类)

注意:程序员还可以自定义类加载器(很少涉及到)

双亲委派模型的工作流程:

比如,代码中由一个类,my.app.User (我们项目中自定义的类的全限定类名)

优先级:优先加载标准库的,其次是扩展库,最后是第三方库/自己项目中

双亲委派模型既负责“找到”文件,也负责“加载”文件。

双亲委派模型的本质是“先委派查找,确定加载者;最后才由确定的加载器执行真正的加载”。

四. JVM垃圾回收机制(GC)

JVM垃圾回收机制是为了更好的应对内存泄漏(GC只负责堆内存中的对象)

JVM专门指派一些线程,这些线程周期性的扫描你的代码中已经申请内存(new出来的对象)并自动判定,当前这个内存是否不再使用。如果不再使用,就释放掉对象/内存

java的GC要回收的内容是啥-----堆中存放的没有引用指向的对象

注意:GC是以对象为单位进行内存回收的(不是以字节为单位),一个对象要么整个释放,要么不释放,不会出现”释放一半“的情况

GC的进行首先要找到那些对象是不再使用的对象(如何判断:看某个对象是否有引用指向---在Java中,使用对象只有通过引用这一种方式)。但是有一些情况下不太好确定,某个对象到底是不是垃圾,但是宁可放过,也不要错杀(错杀后果会非常严重)

如何判定对象有没有引用指向呢?

有以下几个方案:

方案一:引用计数(不是Java的方案,是Python/PHP的方案)

给每个对象身上再安排一个空间,这个空间存储一个整数,用来表示指向这个对象的引用的个数

当围绕对象进行”引用复制“时就会更新这个数。如果计数是0了,此时就可以释放了

但是这个方案窜在两个缺点:

  1. 消耗更多的内存空间(每个对象都要有一个这样的计数器)
  2. 在产生循环引用时,会导致出现误判。如下图所示

方案二:可达性分析(java方案)

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

可达性分析的过程就是从根节点出发(根节点可能有多个),尝试遍历这个引用树所指向的对象,遍历的过程中凡是经过的对象都标记成”可达“。另一方面,JVM自身知道自己一共有那些对象,除去”可达“的对象,剩下的就是”不可达“的需要被回收的对象。

哪些可以是根节点(GCRoots):

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

注意:

  • 根节点(GCRoots)是对象的引用,而不是对象本身
  • 谁new的对象(谁持有引用),对象的地址就存在谁那里。如常量池对象的引用存在元数据中(常量池对象本身存在于堆中)
  • 由于随着代码的运行,对象之间的引用关系实时变化,所以上述”可达性分析“需要周期性的进行

可达性分析的优点:

  • 可达性分析没有引入额外的内存空间
  • 没有循环引用问题----原因:1. 以 GC Roots(外部引用)为判断标准,不受内部循环影响(遍历的是引用,而不是对象本身)。2. 一次扫描中每个对象只遍历一次,不会遍历第二次,避免死循环。(即使是图状这样复杂的关系也能有限避免)

可达性分析的缺点:需要消耗更多的CPU资源/更多的时间(拿时间换空间)

识别出垃圾之后,如何释放内存?

有以下4个方法:

法一:标记 - 清除(不推荐)

把标记出来的垃圾,直接释放掉

当我们申请内存时,都是申请”连续内存“,总的空闲空间充足,但是由于离散的空间太多了,尝试申请一大块内存时就可能会申请失败

法二:复制算法(不推荐)

将内存空间一分为二,同一时刻,只使用其中的一半,把不是垃圾的对象复制到另外一半,然后将全是垃圾对象的那一半的空间全部一起释放

随着GC的周期执行,将对象左右两边来回复制(复制非垃圾的对象)

复制算法的缺点:

  • 空间利用率很低(最多只能用到50%的空间)
  • 如果当前存活的对象很多,复制开销就会很大

复制算法的优点:有效的解决内存碎片的问题

法三:标记-整理(不推荐)

标记-整理 类似于顺序表删除中间元素(搬运)---消耗可能大,可能小

标记-整理 对于空间利用率有所改善

法四:分代回收(最终GC使用)

分代回收就是根据不同对象的情况/特点(即对象的”年龄“),采取不同的方案(综合上面三个方案)。

经验规律:如果某个对象年龄比较大,有很大概率继续存活下去(对象的”年龄“用GC扫描的轮次来描述)

分代回收是将整个堆分为两个大的部分---新生代和老年代。其中的新生代还分为一个伊甸区和两个幸存区

分代回收的流程:

1. 新new的对象放在伊甸区
2. 伊甸区中的对象经过第一次的GC大部分都会被淘汰掉(经验规律,大部分的新对象生命周期都很短),而没有被淘汰掉的对象,通过复制算法进入幸存区(幸存区有两部分,一次只用一部分,另一部分会被清除)(伊甸区的垃圾回收是复制算法的一部分---将不是垃圾的对象复制到幸存区,再将伊甸区全部清空)
3.下一轮的GC也会针对幸存区进行扫描,以此还会淘汰掉一把部分的对象,没有淘汰对象通过复制算法,进入另外一个幸存(与复制算法的方法一致)
4.随着每一次GC的进行,对象就会在新生代的幸存区来回拷贝,每经过一次拷贝,对象的年龄就+1
5.经过一定时间之后,对象的年龄就达到一定的阈值,此时就会把这个对象直接搬运到老年代
6.对象进入老年代之后,针对老年代的GC频率就很低了,以此减少扫描的开销(老年代发现对象是垃圾,采用标记-整理的方式处理)---虽然整理一次比较消耗资源,但是整理的频率低

针对老年代的扫描称为Major GC(开销比较大,但是频率低---存活的对象极多)

针对新生代的扫描称为Minor GC(开销比较,但是频率高---存活的对象极少)

注意:如果一个对象的内存特别大,就直接放入老年代---避免复制开销巨大

上面的分代回收只是属于一种”思想方法“,并且严格来说不是”真实情况“,而是一个简化版本(每种垃圾回收器的机制之间有差异)

五. JVM的垃圾回收器

JVM的垃圾回收器有很多:

比较老的,已被淘汰的垃圾回收器:Serial收集器,ParNew收集器,Parallel Scavenge收集器,Serial Old收集器,Parallel Old收集器

如今还存在的垃圾回收器:

  • CMS:并发标记、并发清除,追求低停顿,产生内存碎片
  • G1:分区域回收,优先处理垃圾多的区域,可控制停顿,无碎片
  • ZGC:几乎全程并发,停顿极短,支持超大堆,无内存碎片

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询