JVM 一文详解

目录

[JVM 简介](#JVM 简介)

[JVM 中的内存区域划分](#JVM 中的内存区域划分)

[1. 堆(一个进程只有一份 ------ 线程共享)](#1. 堆(一个进程只有一份 ------ 线程共享))

[2. 栈(一个进程可以有 N 份 ------ 线程私有)](#2. 栈(一个进程可以有 N 份 ------ 线程私有))

[Java 虚拟机栈:](#Java 虚拟机栈:)

本机方法栈:

[3. 程序计数器(一个线程可以有 N 份 -------- 线程私有)](#3. 程序计数器(一个线程可以有 N 份 -------- 线程私有))

[4. 元数据区(一个线程只有一份 -------- 线程共享)](#4. 元数据区(一个线程只有一份 -------- 线程共享))

一道经典的笔试题:

内存布局中的异常问题

[Java 堆溢出](#Java 堆溢出)

虚拟机栈和本地方法栈溢出

[JVM 类加载](#JVM 类加载)

类加载的过程

[1. 加载](#1. 加载)

[2. 验证](#2. 验证)

[3. 准备](#3. 准备)

[4. 解析](#4. 解析)

[5. 初始化](#5. 初始化)

双亲委派模型(加载环节)

双亲委派模型工作流程:

双亲委派模型的作用:

垃圾回收机制(GC)

引入

垃圾回收,是回收内存

垃圾回收的过程:

第一步:识别出垃圾

[1. 引用计数算法](#1. 引用计数算法)

引用计数算法的描述

引用计数的缺点

[2. 可达性分析](#2. 可达性分析)

第二步:把标记为垃圾的对象的内存空间进行释放

[a. 标记 - 清除](#a. 标记 - 清除)

[b. 复制算法](#b. 复制算法)

[c. 标记 - 整理](#c. 标记 - 整理)

[JVM 中使用的方案 --- 分代回收(依据不同种类的对象,采取不同的方案)](#JVM 中使用的方案 --- 分代回收(依据不同种类的对象,采取不同的方案))

总结:一个对象的一生

JMM

1)主内存与工作内存

2)内存间交互操作

完!!!


JVM 简介

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

虚拟机是指通过软件模拟的,具有完整硬件功能的,运行在一个完全隔离的环境中的完整计算机系统。

我们在学习 Java SE 的时候,简单了解过 JVM,还有两个相关的概念,jdk,Java 开发工具包,jre,Java 运行时环境,其中,jvm 虚拟机包括在 jre 其中,而 jre 又包含在 jdk 其中。所以我们在编写 Java 代码中,下载 jdk 即可在记事本中编写代码,然后通过命令行运行进程。

编译语言,在以前大致可以分为两种:

编译型语言,在程序执行前,需要通过编译器将源代码一次性编译成目标机器的机器码,之后就可以直接运行生成的可执行文件。

解释型语言,在程序运行时,由解释器逐行读取源代码,并将其解释成目标机器能够理解的指令后立即执行。

上述的说法,如今其实已经不适用了,如果按照上述经典的划分方法,Java 属于是"半编译,半解释型"语言。

Java 这么做的最主要目的,是为了实现"跨平台"!!!

C++ 这样的语言,是直接编译成了二进制的机器指令,但需要注意的是,不同的 CPU 中,支持的指令是不一样的,而且,生成的可执行程序,在不同的系统上也由不同的格式。

Java 不想在不同的 CPU 中进行重新编译,而是期望能够直接执行~~

还记得我们最开始用记事本写的 hello world 吗???

创建一个记事本,写出代码

将记事本的后缀 .text 改为 .java

然后在对应目录下的命令行中先运行 javac,将 java 文件 ==》 .class 文件

然后再输入 java 即可运行~~

上面的 .class 文件,是字节码文件,包含的就是 Java 字节码(是 Java 自己搞的一套"CPU 指令"),然后再某个具体的系统平台上执行,此时再通过 jvm,把上述的字节码转换成对应的 CPU 能识别的机器指令。(在这个过程中,jvm 相当于一个"翻译官"的角色)。

因此,我们编写和发布一个 Java 程序,其实就只需要发布 .class 文件即可,jvm 拿到 .class 文件,就知道如何进行转换了~~~

windows 上的 jvm 就可以把 .class 转换成 windows 上支持的可执行指令了

linus 上的 jvm 就可以把 .class 转换成 linux 上支持的可执行指令了

..................................

不同平台的 jvm 是存在差异的,不是同一个~~~

补充:

jvm 也是由许多许多版本的,,目前 HotSpot VM 是占用绝的市场地位,称霸武林~~~所以我们下面的内容中,默认都是使用 HotSpot 的~~


JVM 本身的一个非常复杂的东西,涉及到很多和底层密切相关的内容,我们这里主要关注三个话题:

1) JVM 中的内存区域划分

2) JVM 中的类加载机制

3) JVM 中的垃圾回收机算法


JVM 中的内存区域划分

JVM 其实也是一个进程,我们可以随便运行一个之前的多线程代码不结束,在任务管理器中,就可以看到 Java 进程。

进程运行的过程中,需要从操作系统中申请一些资源(内存就是其中的典型资源),这些内存空间,就支撑了后续 Java 程序的执行。比如,在 Java 中定义变量,就会申请内存,内存,其实是 jvm 从系统这边申请到的内存~~

jvm 从系统中申请到了一大块内存,这一大块内存给 Java 的程序所使用,但也会根据实际的使用用途来分出不同的空间 ==》 也就是所谓的区域划分~

就类似于我们的我们的学校,占有一大块土地面积,要把整个空间分成不同的区域:

类似的,JVM 申请到的空间,也会划分出几个区域,每个区域都有不同的作用。

1. 堆(一个进程只有一份 ------ 线程共享)

我们代码中 new 出来的对象,都是在堆里面的对象中持有的非静态成员变量,也是在堆里面的。

我们常见的 JVM 参数设置 -Xms10ms 最小启动内存就是针对堆的,-Xmx10m 最大运行内容也是针对堆的(ms 是 memory start 的简称,mx 是 memory max 的简称)

2. 栈(一个进程可以有 N 份 ------ 线程私有)

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

Java 虚拟机栈:

通过 C++ 实现的代码,调用关系和局部变量。Java 虚拟机栈的生命周期和线程相同,Java 虚拟机栈描述的是 Java 方法指向的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态连接,方法出口等信息。我们经常所说的堆内存,栈内存,其中,栈内存指的是就算虚拟机栈。

  1. 局部变量表,存放了编译器可知的各种基本数据类型(8 大基本数据类型),对象引用。 局部变量所需的内存空间在编译间完成分配。当进入一个方法的时候,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表的大小。简单来说,这里存放方法参数和局部变量

  2. 操作帧:每个方法会生成一个先出后进的操作帧。

  3. 动态连接:指向运行时常量池的方法引用。

  4. 方法返回地址:PC 寄存器的地址。

什么是线程私有???

由于 JVM 的多线程是通过线程轮流切换并分配处理器的执行时间来实现,因此在任何一个确定的时刻,一个处理器(多核处理器的话,则指的是其中的一个内核)都只会执行线程中的一条指令。因此为了切换县城后能恢复到正确的位置,每个线程都需要独立的程序计数器,各个线程之间的计数器互不影响,独立存储。我们就把类似这样的区域,称之为"线程私有"的内存~~

本机方法栈:

本地方法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM 使用的,而本地方法栈,则是记录了 Java 代码的调用关系和 Java 代码中的局部变量~~

我们一般不是很关注本地方法栈,一般谈说到栈,默认指的是虚拟机栈。

3. 程序计数器(一个线程可以有 N 份 -------- 线程私有)

记录当前线程所执行的字节码指令的地址。在多线程环境中,每个线程都有独立的程序计数器,用于确保线程切换之后能够恢复到正确的执行位置。当线程执行到 Java 方法的时候,程序计数器记录的是正在执行的虚拟机字节码指令的地址。而当线程执行本地(Native)方法的时候,程序计数器的值为空(Undefined)。(本地方法指的是使用 Java 意外的语言编写的方法)

4. 元数据区(一个线程只有一份 -------- 线程共享)

(以前的 Java 版本中,也叫做"方法区",从 1.8 开始,改为了元数据区~~)

"元数据"是计算机中的一个常见术语(meta data),往往指的是一些辅助性质的,描述性质的属性~~

比如我们的硬盘,硬盘中不仅仅要存储文件的数据本体,还需要存储一些辅助信息,比如:文件的大小,文件的位置,文件的拥有者,文件的修改时间,文件的权限信息等等.....这些辅助信息统称为"辅助信息"。

JVM 中的元数据区:存储被虚拟机加载的类信息,方法的信息,常量,静态变量。即一个程序有那些类,每个类中有那些方法,每个方法的里面都要包含那些指令,都会记录在元数据区中。

我们写的 Java 代码,if while for 等等各种逻辑运算,这些操作最终都会被转换成 java 字节码 ==》 javac 就会完成上述操作~~

此时,这些字节码在程序运行的时候,就会被 JVM 加载到内存中,放到元数据(方法)区中。

此时,当前程序要如何执行,要做那些事情,就会按照上述元数据区里面记录的字节码依次执行了。

一道经典的笔试题:

有伪代码如下:

java 复制代码
class Test{
    private int n;
    private static int m;
}

main() {
    Test t = new Test();
}

问:上述代码中,t n m 各自都存储在 JVM 内存中的那个区域???

n 是 Test 类中的非静态成员变量,是处于 堆 上的。

t 是一个引用类型的局部变量,本身是在 栈 上的。

而 m 是带有 static 修饰的变量,是在类对象中,也就是在 元数据区 中。

static 修饰的变量,称为 类属性。

static 修饰的方法,称为 类方法。

非 static 的变量,称为 实例属性。

非 static 的方法,称为实例方法。

类对象,我们前面提到过 ==》 类名.class。即例子中的 Test.class,JVM 把 .class 文件加载到内存之后,就会把这里的信息使用对象来表示,此时这样的对象就是类对象。类对象里面包含了一些信息,包括但不限于:类的名称,类继承自那个类,实现了那些接口,都有那些属性,都叫什么名字,都是什么类型,都是什么权限。都有那些方法,都叫什么名字,都需要什么参数,都是什么权限...... .java 文件中涉及到的信息,都会在 .class 中有所体现(注释不会包含~~)

总结:

内存布局中的异常问题

Java 堆溢出

Java 堆用于存储对象实例,只要不断创建对象,并且保证 GC(垃圾回收) Roots 到 对象之间有可达路径,来避免 GC 清除这些对象,那么在对象数量达到最大堆容量后就会产生内存溢出异常。

我们前面已经讲到,可以设置 JVM 参数 -Xms 设置堆的最小值,-Xmx 设置堆的最大值。我们下面来看一个 Java 堆 OOM(OutOfMemoryError 的缩写,表示内存溢出错误)的测试。测试一下代码之前,我们可以先设置 idea 的启动参数:

JVM 参数为 -Xmx20m-Xms20m-XX:+HeapDumpOnOutOfMemoryError

-Xmx20m :设定 JVM 堆内存的最大可用空间为 20 MB。当 Java 程序在运行过程中需要更多的堆内存,而当前使用的堆内存已经达到这个最大值时,无法进行有效的垃圾回收以释放内存,就会抛出 OutOfMemoryError 异常

-Xms20m:设定 JVM 堆内存的初始大小为 20 MB,JVM 在启动时会为堆内存分配这么大的空间。

-XX:+HeapDumpOnOutOfMemoryError:-XX 是 JVM 的高级参数前缀,用于设定一些特定的 JVM 行为。+ 表示弃用该参数对应的功能,这里的 HeapDumpOnOutOfMemoryError 表示当 JVM 抛出 OutOfMemoryError 异常的时候,自动生成堆转储文件(Heap Dump),堆转储文件包含了当时堆内存中所有对象的信息,是分析内存泄漏和性能问题的重要依据。

main 方法里面创建了一个 ArrayList 集合,接着通过一个无限循环不断的往集合中添加 OOMObject 对象,由于这些对象都被集合引用着,垃圾回收器无法回收他们,随着对象数量的持续增加,堆内存最终被耗尽,从而触发 OutOfMemoryError 异常

Java 堆内存的 OOM 异常是实际应用中最常见的内存溢出情况。当出现 Java 堆内存溢出的时候,异常堆信息"java.lang.OutOfMemoryError"会进一步的提示"Java heap space"。当出现"Java heap space"的时候,就是很明确的告知我们,OOM 发生在堆上。

此时要对 Dump 出来的文件进行分析(会 Dump 出一个 hprof 文件,可以使用工具 VisualVM进行分析)。分析问题的产生到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

内存泄漏:泄漏的对象无法被 GC

内存溢出:内存对象确实还应该存活。此时要根据 JVM 堆参数与物理内存相比较,检查是否还应该将 JVM 的内存调大一些,或者检查对象的生命周期是否过长。

虚拟机栈和本地方法栈溢出

HotSpot 虚拟机是将虚拟机栈与本地方法栈合二为一,因此对于 HotSpot 来说,栈容量只需要由 -Xss 参数设置。

关于虚拟机栈会产生的两种异常:

如果线程请求的栈深度大于虚拟机所允许的最大深度,会抛出 StackOverFlow 异常

如果虚拟机在拓展栈无法申请到足够的内存空间,则会抛出 OOM 异常。

补充:栈深度:

栈帧:JVM 中方法调用和执行的基本数据结构,每个方法调用都会创建一个对应的栈帧。当一个方法被调用时,JVM 会对该方法对应的栈帧压入调用栈的栈顶,当方法执行完毕返回时,栈帧会从栈顶弹出。比如,方法 methodA 中调用了方法 methodB,此时 methodB 创建一个新的栈帧并压入栈顶,位于 methodA 栈帧之上。

栈深度:就是当前线程的调用栈中的栈帧的数量。例如:主线程先调用 methodA,methodA 调用 methodB,methodB 中又调用 methodC,此时调用栈中就依次存在:主线程,methodA,methodB,methodC 的栈帧,栈深度为 4。

限制:JVM 对栈深度有一定的限制,一般可以通过 -Xss 参数来调整(-Xss1m 表示将每个线程的栈大小设置为 1MB,栈大小会间接的影响栈深度~~)

举例:观察 StackOverFlow 异常(单线程环境下)

上面的 Java 代码通过一个递归方法不断调用自身,每次调用时增加计数器 stackLength 的值,以此来模拟栈帧的不断入栈过程,当站深度超过了 JVM 所允许的最大深度,会抛出 StackOverFlowError 异常,捕获该异常后输出当前的栈深度。

出现 StackOverflowError 异常的时候,有错误堆栈可以阅读,比较好找到问题所在。如果使用虚拟机的默认参数,栈深度一般情况下可以到达 1000 - 2000,对于正常的方法调用(包括递归)。完全够用了~~

如果是因为多线程导致的内存溢出问题,在不能减少线程数的情况下,只能减少最大堆和减少栈容量的方式来换取更多线程。

举例:观察多线程下的内存溢出异常

该 Java 代码的核心目的是演示因创建大量线程而导致内存溢出的问题。当不断创建新线程时,每个线程都会占用一定的栈内存。由于系统的内存资源是有限的,当创建的线程数量过多,使得所有线程的栈内存综合超过了系统所能提供的内存时,就会抛出 OutOfMemoryError 异常,提示"unable to create native thread",也就是无法创建新的本地线程。

注意:不要轻易尝试上述代码~~

JVM 类加载

类加载,指的是 Java 进程运行的时候,需要把 .class 文件从硬盘读取到内存,并进行一系列校验解析的过程。 .class 文件 ==》 类对象

类加载的过程

类加载的过程其实是 Java 官方文档中给出的说明

跳转网址如下:Java SE Specifications

红色圈住的表示,该版本的 Java 语言规范(语法是什么样的)

蓝色圈主的表示,该版本的 Java 虚拟机规范(虚拟机是什么样的)

其实正常来说,我们作为程序员来说,是不需要关注这些具体的加载过程的,需要了解的时候直接来翻一翻文档即可,但是面试可能要考~~

对于一个类来说,他的生命周期是这样的:

其中前 5 步是固定的顺序,同时也是累加载的过程,中间 3 步都属于连接过程,所以对于类加载来说,总共分为如下的几个步骤:

  1. 加载 2. 连接(a. 验证 b. 准备 c. 解析)3. 初始化

1. 加载

加载(Loading)阶段是整个类加载(Class Loading)过程中的一个阶段,和类加载是有所不同的,注意不要把二者混为一谈~~

把硬盘上的 .class 文件,找到,打开文件,读取到文件内容(认为读取到的是二进制的数据)

(找到硬盘上的 .class 文件这一步还有一些事项注意,我们后面介绍)

2. 验证

验证是连接阶段的第一步,这一阶段的目的是要确保,读到的 .class 文件中的字节流中包含的信息,是符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后,不会危害虚拟机自身的安全~~

上述为 Java 类文件结构的描述,这里的描述方式,类似于 C 语言的结构体。

u4 表示 四个字节的无符号整数,u2 表示 两个字节的无符号整数。Java 中,int 就是四个字节,short 就是两个字节,但是 C++ 并不是,在 C++ 中程序员往往就会字节通过 typedef 定义出一些类型,往往就是 u2 u4 之类的~~

第一个 magic,也叫做 magic number -- 魔幻数字,广泛应用于二进制文件格式中,用来表示当前二进制文件的格式是那种类型的。

2,3 用来表示版本号,我们平时说的 Java 8 Java 17 Java 23 什么的,是我们使用的版本,实际上 JVM 开发还有内部的版本。JVM 执行 .class 文件就会验证版本是否符合要求,一般来说,高版本的 JVM 是可以运行低版本的 .class 的~~

剩下的就是一些类具体的信息啦~~

3. 准备

正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存并且设置类变量初始值的阶段,但此时申请到的内存空间,里面的默认值都是全 0 的。

比如有这样的代码: public static int value = 123;

此时初始化 value 的 int 值为 0,而不是 123

4. 解析

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

上面这句话似乎非常拗口难懂,我们可以举个栗子来解释一下:

女神电脑坏了,给我们打电话让我们上门服务,我靠,好机会呀。

1. 符号引用

我们拿着女神的地址:XX小区 X 号楼 X 单元 XXX 室就去了~~~

此时,女神给我们的地址,就是符号引用。

2. 解析过程

我们有了地址,但我们是第一次去女神家,打开了地图 APP,输入地址 XX小区 X 号楼 X 单元 XXX 室,地图通过数据库查询,将地址转换为了 经纬度坐标 (北纬 XX.X°,东经 XX.X°),并规划从当前位置到目的地址的路线。这就类似于虚拟机查找常量池,将符号引用绑定到实际内存地址。

3. 直接引用

地图最终会给我们显示具体的路线:沿人民路直行 500 米然后右转,我们就可以直接按照直接路线找到女神家啦~~

那 Java 虚拟机中是怎么做的呢???

假如有如下代码:

java 复制代码
class Test {
    private String s = "hello";
}

我们上面的代码中,是很明确的知道,s 变量里面相当于保存了 "hello"字符串常量的地址。

但是,在文件中 ,是不存在"地址"这样的概念的,地址是内存的地址,我们是文件,是在硬盘中的,没有地址这个概念~~

虽然没有地址,我们可以存储一个类似于地址"偏移量"这样的概念,此时文件中和填充给 s 的"hello"的偏移量,就可以认为是**"符号引用"**。

接下来,把 .class 问价加载到内存中,就会先把 "hello" 这个字符串加载到内存中,加载到内存中后,"hello" 就有地址了,接下来,s 里面的值就可以替换成当前"hello"真实的地址了,也就是**"直接引用"**了。

5. 初始化

初始化阶段,Java 虚拟机真正开始执行类中编写的 Java 代码。同时也针对类对象完成后续的初始化,还要执行静态代码块的逻辑,也可能会触发父类的初始化。

双亲委派模型(加载环节)

我们在加载的时候,说要在硬盘上找到 .class 文件打开,这个双亲委派模型就描述了如何查找 .class 文件的策略。

JVM 中进行类加载的操作,是有一个专门的模块,称为"类加载器"(ClassLoader)。

类加载器的作用:给他一个"全限定类名"(带有报名的类名),例如 java.lang.String ==》 给定全限定类名之后,能找到对应的 .class 文件。

全限定类名(Fully Qualified Class Name)是指包括包名在内的类的完整名称,用于在程序中唯一的标识一个类,可以避免类名冲突的问题。

JVM 中的类加载器默认是有 三个 的。(也可以进行自定义)

BootstrapClassLoader --------- 负责查找标准库的目录

ExtensionClassLoader --------- 负责查找扩展库的目录

(Java 语法规范里面描述了标准库里面应该有那些功能,但是实现 JVM 的厂商,会在标准库的基础上再扩充一些额外的功能~~~不同的厂商扩展可能不太一样,上古时期用处较大,现在极少用)

ApplicationClassLoader --------- 负责查找当前项目的代码目录以及第三方库的目录

上面的三个类加载器,存在"父子关系"(不是面向对象中的,父类 子类之间的继承关系),是类似于"二叉树",有一个指针(引用)parent ,指向自己的"父"类加载器。

启动类加载器:加载 JDK 中 lib 目录中 Java 的核心类库,即 JAVA_HOME/lib 目录。

扩展类加载器:加载 lib/ext 目录的类

应用户程序类加载器:加载我们写的应用程序

自定义类加载器:根据自己的需求定制类加载器

双亲委派模型,描述了上述类加载器之间是如何配合工作的。

双亲委派模型工作流程:
  1. 从 ApplicationClassLoader 作为入口,先开始工作。
  1. ApplicationClassLoader 不会立即搜索自己负责的目录,会把搜索的任务交给自己的父亲。
  1. 代码进入到 ExtensionClassLoader 范畴了,ExtensionClassLoader 也不会立即搜索自己负责的目录,而是也把搜索的任务交给自己的父亲。
  1. 代码就进入到了 BootstrapClassLoader 范畴了,BootstrapClassLoader 也不想立即搜索自己负责的目录,也要把搜索的任务交给自己的父亲。
  1. BootstrapClassLoader 发现自己没有父亲了,才会真正的搜索负责的目录(标准库目录),通过全限定类名,尝试在标准目录中找到符合要求的 .class 文件。

即双亲委派模型,会先以 ApplicationClassLoader 为入口,一点点先向上找父亲:

到最上面的 BootstrapClassLoader ,如果他找到了,接下来就直接进入到打开文件 / 读文件等流程中。

如果没找到,就会回到孩子这一辈的类加载器中,继续尝试加载

  1. ExtensionClassLoader 收到父亲交回给他的任务之后,就开始进行搜索自己负责的目录(扩展库的目录)

如果找到了,就进入到后续的流程中。

如果没找到,也是回到孩子这一辈的类加载器中继续尝试加载。

7. ApplicationClassLoader 收到父亲交回给他的任务之后,就开始搜索自己负责的目录(当前项目目录 / 第三方库目录)

如果周到了,接下来进入后续流程。

如果没找到,也是回到孩子这一辈的类加载器中继续尝试加载,由于默认情况下,ApplicationClassLoader 没有孩子了。此时就说明类加载的过程失败了!!!就会抛出 ClassNotFoundException 的异常了。

则整个工作流程图为:

双亲委派模型的作用:

确保类的唯一性,避免重复加载:

当类加载器收到加载类的请求时,先将请求委派给父类加载器。若父类加载器已经加载过该类,就不会重复加载,直接返回已经加载的类对象。

例如:在一个大型 Java 项目中,多个模块可能都依赖同一个类,通过双亲委派模型,这个类只会被加载一次,节省了内存资源。防止了同一个类被不同类加载器加载多次到 JVM 中。

保障核心类库安全,防止核心类被篡改:

Java 核心类库(如 java.lang 包下面的类)由启动类加载器加载。即使有程序员编写了与核心类库同名的类,由于双亲委派机制,自定义类加载器在接到加载请求时候,会向上委派给父类加载器,最终由启动类加载器优先加载核心类库中的类,而不是加载程序员自定义的同名类,保证了 JVM 运行的安全性和一致性。

提供更好的模块化支持,实现模块隔离:

在 Java 应用程序中,不同模块可能存在同名类。双亲委派模型使得不同模块的同名类由不同的类加载器(类加载器是由层级关系的),这些类在内存中是相互隔离的。例如,在 Web 应用服务器中,不同 Web 应用的类加载通过双亲委派模型实现隔离,每个 Web 应用的类加载器加载到自己应用路径下的类,同时共享服务器的公共库。

上述这一系列规则,只是 JVM 自带的类加载器默认遵守的规则。如果我们自己写类加载器,也可以打破上述规则~~

垃圾回收机制(GC)

引入

我们在 C 语言中,学习过动态内存管理,malloc 函数申请内存,free 释放内存。在 malloc 中,申请到的内存,生命周期是跟随整个进程的。这一带你对于 7 * 24 的服务器程序是非常不友好的。服务器每个请求都去 malloc 一块内存,如果不 free 释放,就会导致申请的内存越来越多,后续要向申请内存就没得申请了 ==》 内存泄漏问题。

而我们在实际开发中,的确很容易出现一不小心就忘记调用 free 了,或者是因为一些情况,比如 if -> return 导致 free 没有被执行到的情况~~

我们能否让释放内存的操作,让程序自动负责完成,而不是依赖于程序员的手工释放呢???

Java 就属于早期支持 垃圾回收 的语言。

引入垃圾回收这样的机制,就不需要手动来进行释放了,程序会自动判定,某个内存是否会继续使用,如果内存后续不使用了,就会自动释放掉。

后世的各种编程语言,大部分都是带有垃圾回收机制的~~~

垃圾回收机制中还有一个很重要的问题:STW(stop the world)问题。即触发垃圾回收的时候,很可能会使当前程序的其他的业务逻辑被暂停。

但是随着 Java 语言这么多年的发展,这么多大佬的不断风险,GC 的技术积累也越来越强大,有办法将 STW 的时间控制在 1ms 之内~~

垃圾回收,是回收内存

对于程序计数器,虚拟机栈,本地方法栈这三部分区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性。元数据区,一般都是涉及到"类加载",很少涉及到"类卸载"。因此我们这里所讲的内存分配和回收重点关注的是 Java 堆这个区域,这个区域也是 GC 的主要战场。

这里的垃圾回收,说是回收内存,其实更准确的说是"回收对象"。每次垃圾回收的时候,释放的若干个对象(实际的单位都是对象)。


垃圾回收,具体是怎样进行展开的,大致分为两步:

1)识别出垃圾,那些对象是垃圾(不再进行使用),那些对象不是垃圾

2)把标记为垃圾的对象的内存空间进行释放。


垃圾回收的过程:

第一步:识别出垃圾

即判定这个对象后续是否还要继续使用,在 Java 中,使用对象,就一定需要通过引用的方式来使用(当然,有一个例外是 匿名对象,即 new MyThread().start(); 但是,当这行代码执行完毕之后,对应的 MyThread 对象就会被当作垃圾~~)

如果一个对象没有任何引用指向他,我们就视为无法被代码中使用了,就可以作为垃圾了~~

java 复制代码
void fun() {
    Test t = new Test();
    t.testFun();
}

有上述代码,Test t = new Test()。通过 new Test 就是在对上创建了对象。

与此同时,因为创建了类型为 Test 的局部变量 t,所以 t 会在栈上有空间,存储 0x1002 这个地址

当代码执行到 } 这个右花括弧的时候,此时局部变量 t 就直接被释放了。此时再进一步,上述的 new Test() 对象,也就没有引用再指向他了。此时,这个代码就无法再访问使用这个对象了,这个 对象就可以被认为是垃圾了~~

如果代码更加复杂一些,这里的判定过程也就更加麻烦了~~

java 复制代码
Test t1 = new Test();
Test t2 = t1;
Test t3 = t2;
Test t4 = t3;

此时就会有很多的引用指向 new Test() 同一个对象了,也就是此时有很多的引用,都保存了 Test 对象的地址。

此时通过任意的引用都能够访问 Test 对象,需要确保所有的指向都销毁了,才能把 Test 对象视为垃圾。

如果代码更加复杂,上述这些引用的生命周期各不相同,此时情况就不好办了~~

1. 引用计数算法
引用计数算法的描述

给对象增加一个引用计数器,每当有一个地方引用它的时候,计数器就 +1,当引失效时,计数器就 -1,任何时刻计数器为 0 的对象就是不能再使用的,即对象已"死"。

代码如下:

Test a = new Test();

当 new Test() 的时候,还是在堆上有 Test 对象的位置,此时还没有引用变量指向这个 Test 对象,所以计数器的值为 0.

当创建一个 Test 类型的局部变量 a 的时候,Test 对象的前面的引用计数器就会变为 1

此时如果有 Test b = a;的代码,栈和堆就会产生如下的变化:

当 有代码 a = null;栈和堆就会产生如下的变化:

同样的,再把 null 赋值给 b,就会有如下变化:

此时就就可把 Test 对象视为垃圾了~~

在垃圾回收机制,会有专门的扫描线程,去获取到当前每个对象的引用计数的情况,发现对象的引用计数为 0,说明这个对象就可以释放了。

引用计数的缺点

引用计数算法实现是非常简单的,判定的效率也十分高,在大部分情况下,都是一个不错的算法,比如 Python 语言就采用引用计数法来进行内存管理。但是,主流的 JVM 中并没有选用引用计数法来管理内存。

问题一:消耗额外的内存空间

要给每个对象都安排一个计数器(如果计数器按照 2 个字节算),如果整个程序中的对象数目非常多,计数器总的消耗的空间也会非常多。尤其是如果每个对象体积比较小(假设每个对象 4 个字节),计数器消耗的空间,已经达到对象的空间的一般了~~

问题二: 引用计数器可能会产生"循环引用"的问题,此时,引用计数器就无法正确工作了。

例如有如下代码:Test 类中有 Test 类型的成员变量 t。

java 复制代码
class Test {
    Test t;
}

Test a = new Test();
Test b = new Test();

a.t = b;
b.t = a;

a = null;
b = null;

Test a = new Test(); 和 Test b = new Test();代码执行完毕后,栈和堆上的状态如下:

当执行 a.t = b;这行代码后,栈和堆上的状态如下:

同样的,当实行 b.t = a;这行代码后,栈和堆上的状态如下:

但是再执行到 a = null;这行代码,堆和栈上的状态如下:

再执行 b = null;这行代码,堆和栈上的状态如下:

此时代码就出现问题了,此时的两个 Test 对象,无法被使用,没有引用指向他们。但与此同时,他们的引用计数器却都不是 0!!!

2. 可达性分析

JVM 中使用的就是可达性分析来识别出垃圾~~

这种算法,其实本质上是使用"时间"来换取"空间"的。相比于引用计数,可达性分析需要消耗更多的额外时间,但是总体来说,来是可控的~~· 不会产生类似于"循环引用"这样的问题。

我们在写代码的过程中,会定义很多的变量。

比如,栈上的局部变量/方法区中的静态类型的变量/常量池中引用的对象...

可以从这些变量为起点,尝试去进行"遍历 ",所谓比的遍历,就是沿着这些变量中持有的引用类型的成员,再进一步的往下进行访问

所有能被访问到的对象,自然就不是垃圾,剩下的遍历一圈也找不到的对象,自然就是垃圾~~

比如有如下代码:

java 复制代码
class Node {
    char val;
    Node left;
    Node right;
}

Node buildTree() {
    Node a = new Node();
    Node b = new Node();
    Node c = new Node();
    Node d = new Node();
    Node e = new Node();
    Node f = new Node();
    Node g = new Node();

    a.left = b;
    a.right = c;
    
    b.left = d;
    b.right = e;
    
    e.left = g;
    c.right = f;

    return a;
}

Node root = buildTree();

会创建出如下图的二叉树:

最后一行代码 Node root = buildTree();虽然这个代码中,只有一个 root 这样的引用了,但是,实际上上述 7 个节点对象都是"可达的"。

JVM 中存在扫描线程,会不停的尝试对代码中已有的这些变量去进行这些遍历,尽可能多的去访问到对象。

如果代码中出现: root.right = null;此时,c 就不可达了,由于 f 访问必须要通过 c,c 不可达,就会造成 f 也不可达,此时就会认为 c 和 f 都是垃圾了~~


第二步:把标记为垃圾的对象的内存空间进行释放

具体如何对标记为垃圾的对象进行释放,还有一些说法~~

具体的释放方式有三种

a. 标记 - 清除

把标记为垃圾的对象,直接释放掉(最朴素的做法)

但一般不会使用这个方案,因为存在 内存碎片化问题,比较致命~~

如上图,此时就是把标记为垃圾的对象对应的内存空间直接释放掉 ==》 会产生很多 小的 并且是 离散的空闲内存空间 ==》 就会导致后续申请内存失败!!!

比如:内存申请,都是一次申请一个连续的内存空间。申请 1M 的内存空间,此时,1M 字节,都是连续的。如果存在很多内存碎片,就可能导致,总的空闲空间,远远超过 1M,但是并不存在比 1M 大的连续的空间,此时,虽然有空闲空间,但是我们去申请空间就会失败~~

注意:我们这里说的是,总的空闲空间比 1M 大,比如此时有 1000 个碎片,每个碎片的大小是 10K,此时总的空闲空间是 10M,但是由于每个碎片最大都是 10K,没有超过 1M 的,所以我们申请 1M 连续的空闲空间会失败~~

b. 复制算法

复制算法是为了解决标记 - 清理的效率问题。它会将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收的时候,会将区域中还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。

这样做的好处是:每次都是对整个半区进行内存回收,内存分配时也就不需要考虑到内存碎片等复杂情况,只需要移动堆顶指针,按照顺序分配即可~~

但缺点也很明显:1. 总的可用内存变少了(豆浆买两碗,吃一碗倒一碗)~~~ 2. 如果每次要复制的对象比较多,此时复制的开销也就很大了。需要是再当前这一轮 GC 的过程中,大部分对象都释放,少数对象都存活的情况下,适合使用复制算法。

c. 标记 - 整理

这个算法的标记过程于 标记 - 清除 过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存即可。

举个例子理解:想象有一排座位,座位上有的人在(对应存活对象),有的人离开(对应可回收对象)。现在要对作为进行整理,把还在座位上的人往一端集中。比如都往最左边集中,这样原本分散在各处的人就都挨在一起了。在这个过程中,每个人要清楚的知道自己的新的位置在哪里,并且其他人如果和自己有联系(类似对象间的引用关系),也要知道自己的新位置。等所有人都集中到一端后,右边空出来的作为(对应内存空间)就可以重新安排使用了~~

这个过程,也能有效的解决内存碎片的问题,并且,这个算法,也不会像复制算法一样,需要浪费过多的内存空间。

但是!这里因为要进行移动对象,搬运内存的开销也会很大。

因此,JVM 也没有直接采用这种方案,而是结合上面的思想,搞出了一种"综合性"的方案,取长补短~~~


JVM 中使用的方案 --- 分代回收(依据不同种类的对象,采取不同的方案)

在这种方案中,引入了一个概念 -- 对象的年龄

JVM 中有专门的线程负责周期性的扫描/释放。

一个对象,如果被线程扫描到了一次,可达了(不是垃圾),年龄就 +1(初始年龄相当于是 0)

JVM 中就会根据对象年龄的差异,把整个堆内存分成两个大的部分

==》

新生代(年龄小的对象) / 老年代(年龄大的对象)

在新生代中,又分出三块区域,一块称为 伊甸区,另外两块都称为 生存区/幸存区(两块大小相等的空间)

1)当代码中 new 出一个新的对象,这个对象就是被创建在伊甸区的。伊甸区中就会有很多的对象。

一个经验规律:伊甸区中的对象,大部分是活不过第一轮 GC 的。这些对象都是"朝生夕死"的,生命周期非常短!!!

2)第一轮 GC 扫描完成之后,少数伊甸区中幸存的对象,就会通过复制算法,拷贝到幸存区。

后续 GC 的扫描线程还会继续进行扫描,不仅要扫描伊甸区,也要扫描幸存区的对象。幸存区中的大部分对象也会在扫描中被标记为垃圾,少数存活的,就会再继续使用复制算法,拷贝到另外一个幸存区中去。

只要这个对象能够在幸村区中继续存活,就会被复制算法继续拷贝到另一半的幸存区中。

每次经历一轮 GC 扫描,对象的年龄都会 +1

3)如果这个对象在幸存区中,经历了若干轮 GC 仍然健在~~~

JVM 就会认为,这个对象的生命周期大概率很长,就会把这个对象从幸存区,拷贝到老年代~~~

4)老年代的对象,当然也要被 GC 扫描,但是,扫描的频次就会大大降低了。

5)对象在老年代"寿终正寝",此时 JVM 就会按照标记整理的方式,释放内存~~


即,新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高,就采用"标记 - 清理"或者"标记 - 整理"算法。

上述的分代回收是 JVM 中 GC 的核心思想,但是 JVM 实际的垃圾回收的实现细节上,还会有一定的优化~~~

总结:一个对象的一生

我是一个普通的 Java 对象,出生在 Eden 区,在 Eden 区,我还看到了很多和我长的很像的小兄弟,我们在 Eden 区中玩了挺长时间的。有一天 Eden 区中的人实在是太多了,我就被迫区了 Survivoir 的 "From" 区(S0 区),自从去了 Survivor 区,我就开始飘飘然了,有时候在 Survivor 的"From" 区,有时候在 Survivor 的"To"区(S1 区),居无定所。知道我 18 岁那年,爸爸说我成年了,该到社会上闯荡一下了。于是我就去了老年代那边,老年代里面,人很多,并且年龄都挺大的,我也在这里认识了很多人。在老年代里面,我生活了很多年(每次 GC 加一岁),最终被回收了~~~


补充:

JMM

JVM 定义了一种 Java 内存模型(Java Memory Model ==》 JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能一次编译到处访问。

在此之前,C/C++ 是直接使用物理硬件和操作系统的内存模型,因此,由于不同平台下的内存模型的差异,有可能导致程序在一套平台上并发完全正常,却在另一台平台上并发访问经常出错。

1)主内存与工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在 JVM 中将变量存储到内存和从内存中取出变量这样的底层细节。 此处的变量包括 实例字段,静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后两者是线程私有的,不会被线程共享。

Java 内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取 赋值...),都必须在工作内存中进行,而不能直接读取主内存中的变量。

不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

其关系如下图所示:

2)内存间交互操作

关于主内存与⼯作内存之间的具体交互协议,即⼀个变量如何从主内存中拷⻉到⼯作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了如下8种操作来完成。

JVM实现时必须保证下面提及的每⼀种操作的原⼦的、不可再分的。

  • lock(锁定):作⽤于主内存的变量,它把⼀个变量标识为⼀条线程独占的状态。
  • unlock(解锁):作⽤于主内存的变量,它把⼀个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作⽤于主内存的变量,它把⼀个变量的值从主内存传输到线程的⼯作内存中,以便随 后的load动作使⽤。
  • load(载⼊):作⽤于⼯作内存的变量,它把read操作从主内存中得到的变量值放⼊⼯作内存的变量 副本中。
  • use(使⽤):作⽤于⼯作内存的变量,它把⼯作内存中⼀个变量的值传递给执⾏引擎。
  • assign(赋值):作⽤于⼯作内存的变量,它把⼀个从执⾏引擎接收到的值赋给⼯作内存的变量。
  • store(存储):作⽤于⼯作内存的变量,它把⼯作内存中⼀个变量的值传送到主内存中,以便后续的 write操作使⽤。
  • write(写⼊):作⽤于主内存的变量,它把store操作从⼯作内存中得到的变量的值放⼊主内存的变量 中。

Java 内存模型的三大特性:

  • **原⼦性:**由Java内存模型来直接保证的原⼦性变量操作包括read、load、assign、use、store和 read。⼤致可以认为,基本数据类型的访问读写是具备原⼦性的。如若需要更⼤范围的原⼦性,需 要synchronized关键字约束。(即⼀个操作或者多个操作要么全部执⾏并且执⾏的过程不会被任何 因素打断,要么就都不执⾏。
  • 可⻅性:可⻅性是指当⼀个线程修改了共享变量的值,其他线程能够⽴即得知这个修改。volatile、 synchronized、final三个关键字可以实现可⻅性。
  • **有序性:**如果在本线程内观察,所有的操作都是有序的;如果在线程中观察另外⼀个线程,所有的 操作都是⽆序的。前半句是指"线程内表现为串⾏",后半句是指"指令重排序"和"⼯作内存与主内存同步延迟"现象。

完!!!

相关推荐
Stay Passion1 小时前
Java 实用工具类:Apache Commons IO 的 IOUtils
java·开发语言·apache
今天也是元气满满的一天呢2 小时前
java学习之数据结构:一、数组
java·数据结构·学习
heyCHEEMS3 小时前
最大子段和 Java
java·开发语言·算法
-曾牛3 小时前
探索 Spring AI 的 ChatClient API:构建智能对话应用的利器
java·人工智能·spring boot·后端·spring·springai·ai指南
白露与泡影3 小时前
使用OAuth2保护Spring AI MCP服务!
java·后端·spring
magic 2453 小时前
Spring 命名空间注入:p、c 与 .util 的深度解析
java·前端·spring
橘猫云计算机设计4 小时前
基于springboot的金院银行厅预约系统的设计及实现(源码+lw+部署文档+讲解),源码可白嫖!
java·数据库·spring boot·后端·爬虫·spring·毕业设计
Aurora_NeAr4 小时前
Spring IoC容器的设计与实现
java·后端·spring
5174 小时前
pymysql
java·数据库·oracle
八股文领域大手子4 小时前
单机 vs 分布式:Java 后端限流的选择题
java·开发语言·数据结构·算法·spring