JVM:内存、类加载与垃圾回收
目录
1. JVM简介
2. JVM运行流程
3. JVM内存区域划分
3.1 运行时数据区域
3.2 程序计数器
3.3 栈
3.4 堆
3.5 元数据区(方法区)
4. JVM类加载过程
4.1 加载
4.2 验证
4.3 准备
4.4 解析
4.5初始化
4.6总结
4.7双亲委派模型
5.JVM垃圾回收机制
5.1 引用计数法
5.2 可达性分析法
5.3 垃圾收集算法
标记清除法
复制算法
标记整理
分代收集
5.4垃圾收集器
CMS收集器
G1收集器
1. JVM简介
JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机。
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
常见的虚拟机:JVM、VMwave、Virtual Box。
JVM和其他两个虚拟机的区别:
- VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
- JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪。
JVM是一台被定制过的现实当中不存在的计算机。
2. JVM运行流程
JVM是Java运行的基础,也是实现一次编译到处执行的关键
JVM的执行流程:
程序在执行之前要把Java代码转换成字节码(class文件),JVM首先需要把字节码通过一定的方式类加载器(ClassLoader)把文件加载到内存中的运行时数据区(Runtime Data Area),而字节码文件是JVM的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解释器执行引擎将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其它语言的接口本地库接口来实现整个程序的功能,这就是这4个主要组成部分的职责与功能
Java 程序从代码到执行,就像是建造一座大厦。编译器把设计图纸(Java 代码)转化成施工蓝图(字节码),类加载器把蓝图放进仓库(内存),执行引擎把蓝图上的专业术语翻译给工人(CPU),调用本地库接口就像是从外部获取特殊的材料和工具,最终让大厦(程序)得以建成并正常运作。
3. JVM内存区域划分
在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机
3.1 运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存 (非运行时数据区的一部分)
在一个Java进程中,元数据和堆只有一份(同一个进程中的所有线程都是共用一份数据的)
程序计数器和栈则可能有多份(当一个Java进程中有多个线程的时候,每个线程都有自己的程序计数器和栈)
线程就代表一个"执行流",每个线程就需要保存自己的"程序计数器",记录自己的调用关系
3.2 程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所指向的字节码的行号指示器.字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
3.3 栈
Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。
栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。
局部变量表
方法运行时的 “临时储物柜”,专门用来放方法里用到的各种临时数据,而且这些数据的类型在编译时就已经确定好了
主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
基本数据类型:
boolean
(布尔值,真 / 假)、byte
(字节)、char
(字符)、short
(短整数)、int
(整数)、float
(单精度浮点数)、long
(长整数)、double
(双精度浮点数)。
比如方法里写int a = 5; float b = 3.14f;
,这两个变量a
和b
就存在局部变量表里,需要用的时候直接从这里拿。对象引用(reference 类型):不是对象本身,而是 “找到对象的线索”。
比如String str = new String("hello");
,这里的str
就是引用,它可能是:
- 指向对象在内存中起始地址的 “指针”(像门牌号,按号找得到家);
- 或者是一个 “句柄”(类似中介,通过它能间接找到对象)。
不管哪种形式,目的都是告诉程序:“这个对象在内存里藏哪儿了,要找它就按这个线索来”。
操作数栈
主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
方法执行时的 “临时工作台”,专门用来放计算过程中用到的 “数字” 和 “中间结果”,就像咱们做算术题时在草稿纸上写写画画的过程
动态链接
主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接 。
假设你要找 “隔壁部门那个戴眼镜的小张”—— 这是个 “符号引用”(用特征描述一个人,但不知道具体座位)。
但你不知道他具体在哪,于是问前台,前台告诉你 “他在 3 楼 302 办公室靠窗的位置”—— 这就变成了 “直接引用”(精准地址)。
你按这个地址就能找到人,这个 “问前台→拿到精准地址” 的过程,就类似动态链接。
程序运行中栈可能会出现两种错误:
StackOverFlowError
: 如果栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出StackOverFlowError
错误。OutOfMemoryError
: 如果栈的内存大小可以动态扩展, 那么当虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
异常。
本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
- Java 方法:就是咱们用 Java 代码写的方法(比如
public void sayHello()
),编译后变成字节码(.class 文件里的指令),由 JVM(虚拟机)直接执行。- Native 方法:带
native
关键字的方法(比如public native void start()
),它的代码不是 Java 写的(可能是 C、C++),JVM 自己执行不了,得调用操作系统或其他底层语言的代码。
- 虚拟机栈 → 伺候 Java 方法(字节码);
- 本地方法栈 → 伺候 Native 方法(底层代码)。
3.4 堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永久代(Permanent Generation)
3.5 元数据区(方法区)
当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
类加载的 “三步走”:读文件→解析→存方法区
简单说,方法区就是虚拟机的 “类档案库”,存着类的 “基因蓝图”(结构、变量、方法等),所有用到类的地方,都从这里查 “说明书”,保证程序能正确创建对象、调用方法~
4. JVM类加载过程
Java程序被编译为.class文件后存储在硬盘上。
当运行Java程序时,JVM需要读取.class文件的内容并执行其中的指令。这个过程称为类加载,即将类相关的字节码从硬盘加载到内存中的元数据区。
类加载生成类对象,把硬盘上的
.class
文件,变成 JVM 内存里可用的类类对象的两个关键作用:
反射(Reflection):通过类对象,在运行时 “动态操作类的信息”(比如创建对象、调用方法、访问字段),而不需要提前写死代码
多线程中给静态方法加锁(
synchronized
修饰静态方法):静态方法属于 “类” 而不是 “对象”,所以用synchronized
修饰静态方法时,锁的是 类对象(而不是某个实例对象)
多个线程调用public class User {public static synchronized void login() { // 锁的是 User.class 这个类对象} }
User.login()
时,会竞争 “类对象的锁”,保证同一时间只有一个线程能执行这个方法,避免多线程混乱。补充: 类锁 vs 对象锁
- 静态方法的锁(类锁):全类只有 1 把锁(类对象),所有线程共享这把锁,保证静态方法 “全局唯一” 执行。
- 实例方法的锁(对象锁):每个对象实例 1 把锁,线程操作不同对象时互不干扰。
静态方法的
synchronized
,就像 “全网点共用的大门锁”—— 不管多少人来,同一时间只能放一个人进 ATM 机;而实例方法的synchronized
,像 “每个用户自己的小房间锁”—— 各自锁各自的,互不影响。
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。
这 7 个阶段的顺序如下图所示
4.1 加载
把 .class文件找到,代码中先见到类的名字,然后进一步的找到对应的 .class 文件(设计一系列的目录查找过程),打开并读取文件内容
4.2 验证
验证读到的 .class 文件的数据是否正确,是否合法
验证阶段主要由四个检验阶段组成:
- 文件格式验证(Class 文件格式检查)
- 元数据验证(字节码语义检查)
- 字节码验证(程序语义检查)
- 符号引用验证(类的正确性检查)
文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
4.3 准备
根据刚才读取到的内容,确定出类对象需要的内存空间,申请这样的内存空间,并且把内存空间当中的所有内容都初始化为0
4.4 解析
针对类中的字符串常量进行处理
虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量
符号引用是 “名字”(写在文件里的符号),直接引用是 “实际地址”(内存里的定位)。解析阶段就是 “把名字换成地址”,让程序运行时能直接找到目标,就像用导航把 “商场名字” 换成 “精准定位”,跑起来更快!
4.5初始化
针对类对象做最终的初始化操作,执行静态成员的赋值语句
- 执行静态成员的赋值语句(比如
static int a = 10;
,真正把10
赋值给a
);- 执行静态代码块(
static {...}
里的逻辑);- 顺带触发父类的初始化(如果父类没加载过)。
4.6总结
类加载就是:取快递(加载)→ 验货(验证)→ 腾货架(准备)→ 查字典找位置(解析)→ 摆商品 / 执行逻辑(初始化),最终把
.class
文件变成 JVM 能用的类,随时创建对象、调用方法
4.7双亲委派模型
类加载五个步骤中第一个步骤中的一个环节
规定了类加载器如何协作加载 Java 类,主要目的是确保 Java 核心类的安全性和唯一性。简单来说,就是 “遇到类加载请求时,先让父加载器尝试加载,父加载器无法加载时才由自己加载”。
拿着类的全限定名(类似java.lang.String,包名+类名),通过 “父加载器优先尝试” 的规则,最终找到并加载对应的
.class
文件的过程
核心结构(三层类加载器)
- 启动类加载器(Bootstrap ClassLoader):加载Java核心类库(如,java.lang.*),是最顶层的加载器
- 扩展类加载器(Extension ClassLoader):加载Java扩展类库(如lib目录下的类)
父加载器为启动类加载器- 应用类加载器(Application ClassLoader):加载应用程序的类,是默认的类加载器
父加载器为扩展类加载器
5.JVM垃圾回收机制
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 堆 内存中对象的分配与回收。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。
程序计数器和栈都是跟随线程的不需要GC,元数据区中的类对象要进行类加载,不会出现无限增长的情况
垃圾回收是以对象为维度进行回收的
GC具体是怎么回收的?
- 先找出谁是垃圾
- 释放垃圾的内存空间
5.1 引用计数法
给对象中添加一个引用计数器:
- 每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 任何时候计数器为 0 的对象就是不可能再被使用的,就是垃圾。
存在的问题:
- 消耗额外的空间
- 可能导致"循环引用"导致上述的判定出错
5.2 可达性分析法
Java采用的方案
用时间来换空间
再JVM中专门高了一波周期性的线程来扫描代码中所有的对象,判断某个对象是否是"可达"(可以被访问到),对应的不可达的对象就是垃圾了
- JVM中有一个所有对象的总名单
- JVM针对当前教室里的对象进行点名操作(被点到的回答到),没有回答到的就是垃圾
基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。
哪些对象可以作为GC root
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(Native方法)引I用的对象。
5.3 垃圾收集算法
标记清除法
直接针对内存中的对象进行释放
这样的做法会引入"内存碎片问题",释放的对象是随机的而不是连续的,虽然把上述内存释放掉了,但是整体的空闲内存并没有连在一起,后续申请内存的时候就可能申请不了(申请的内存一定是连续的)
复制算法
为了解决标记清除算法的效率和内存碎片问题,复制收集算法就出现了,它可以将内存分为大小相同的两块,每次使用其中的一块,当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉,这样就使每次的内存回收都是对内存区间的一半进行回收
缺点
- 内存空间利用率低
- 如果存活下来的对象比较多,复制成本也比较大
标记整理
是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
分代收集
JVM根据对象的年龄把对象进行区分,分为新生代和老年代
对象的年龄,靠 “可达性分析 + 存活次数” 计算:
- 可达性分析:JVM 定期扫描内存,判断对象是否 “存活”(是否被引用,不是垃圾);
- 年龄 +1:每次扫描发现对象还活着(没被回收),年龄就 +1,类似学生每升一级,年级 +1。
- 绝大部分的新对象活不过第一轮GC,留存下来的对象拷贝到幸存区
- 幸存区是两个相等的空间,也是按照复制算法反复进行多次
- 如果一个对象再幸存区,已经反复拷贝多次也不是垃圾,年龄不断增长达到一定程度后对象就要拷贝到老年代了
- 根据经验规律,老年代中的对象生命周期长
老年代仍然进行可达性分析,但GC的频率就会降低,老年代也是通过标记整理(整理的次数也不多)
延伸面试问题: HotSpot 为什么要分为新生代和老年代?
当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择“复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
5.4垃圾收集器
分代回收是JVM的GC中的基本思想方法,具体落实到了JVM的实现层面上,JVM还提供了多种"垃圾回收器"
CMS收集器
- CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
- CMS收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
- CMS 收集器是一种 “标记-清除”算法实现的
- CMS 垃圾回收器在 Java 9 中已经被标记为过时(deprecated),并在 Java 14 中被移除
CMS收集器的运作步骤
把 GC(垃圾回收)想象成 “家里大扫除”,root 对象就是 “必须留着的重要东西”(比如冰箱、电视),其他东西是不是垃圾,得看和这些重要东西有没有关联
- 初始标记: 短暂停顿,标记直接与 root 相连的对象(根对象);
- 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
优点
- 优点:并发收集、低停顿
缺点:
- 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生
- 并发阶段占用 CPU 资源,可能降低应用吞吐量(尤其 CPU 核心较少时)
- 无法处理浮动垃圾(在你清理过程中新产生的垃圾文件,就是 “浮动垃圾”—— 它们在你标记之后才变成垃圾,但你已经完成了标记,无法再处理它们,只能等下一次 GC。)
G1收集器
- G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
- 从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器
- 初始标记: 短暂停顿(Stop-The-World,STW),标记从 GC Roots 可直接引用的对象,即标记所有直接可达的活跃对象
- 并发标记:与应用并发运行,标记所有可达对象。 这一阶段可能持续较长时间,取决于堆的大小和对象的数量。
- 最终标记: 短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。
- 筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度。