(一).基础概念
JVM,即Java虚拟机。这里主要介绍三部分内容:①.JVM内存区域划分②.类加载机制③.垃圾回收机制
(二).JVM内存区域划分
1.JVM内存划分的原因
JVM在设计的时候,仿照的是真实的操作系统进行设计的。真实的操作系统中,对于进程的地址空间进行了"分区域"的设计,JVM也仿照了操作系统的情况,也进行了分区域的设计。可以通过图片来看

JVM从操作系统中申请了一些内存空间,JVM进程的代码是由自己来使用的。JVM内存空间的划分,就相当于JVM进程自身从操作系统申请到内存空间,然后再把内存进行不同的功能分配。
2.JVM的内存划分
在JVM中,核心区域有四个:①.程序计数器②.元数据区③.栈④.堆
**①.程序计数器,**是JVM中的一块很小的区域,只是用来记录当前指令执行到哪个地址了。
**②.元数据区,**保存了当前类被加载好的数据。一个.java文件,通过 javac 命令编译成了.class文件,此时需要把这个.class文件加载到内存中,变成类对象,加载的过程称为"类加载",此时这个类对象就保存在了"元数据区"。
③.栈, 保存了方法的调用关系。在写代码的时候,肯定会有方法的调用。每次调用方法,就会进入方法内部执行,当方法执行完毕的时候,此时就需要返回到调用的位置,然后继续往后走。
注意:此处说的栈和堆 与 数据结构中的栈和堆不一样
通过一个图来理解栈。栈,保存了方法的调用关系,所以在栈区中,也会有一个栈

栈这个空间不算很大,一般就是几MB,或几十MB,这都可以通过JVM的启动参数来配置。
此时,可能有人会问,操作系统中的栈帧和JVM中的栈帧有什么区别?可以通过一个图来看

**④.堆,**保存new的对象的。例如,Test t = new Test();

对于new Test()这个对象,它一定是在"堆"上的。对于t这个引用,则不一定。如果t是一个局部变量,则t就在栈上;如果t是一个成员变量,则t就在堆上;如果t是一个静态成员变量,则t就是在元数据区。
堆是JVM中最大的空间区域,如果堆上的对象不再使用的话,则需要被释放掉,即垃圾回收,这个在下面再介绍。

上图是JVM的内存分布图。关于堆区中的新生代和老年代,这个在下面介绍。
在元数据区中,有一个"常量池",这里面就保存了String常量池中的内容,包括还有Integer等引用类型的内容。"方法元信息"和"类元信息"都是由类对象提供的。"元信息"指的是一些属性。例如,类叫什么名字,是不是public,继承自哪些类,实现了哪些接口等等。"方法元信息"指的是,方法叫什么名字,参数有几个,都分别叫什么,都是什么类型,返回值是什么类型。
注意:元数据区和堆,整个Java进程共用同一份。程序计数器和栈,一个进程中可能有多份,即每个线程都有一份。
(三).JVM类加载
JVM中的类加载,本身是一个非常复杂的过程,这里只介绍个大概。
1."类加载"的步骤
(1).加载:先找到.class文件,根据**"类"的"全限定名"**(包名+类名,例如java.lang.String),先找到.class文件,然后再打开文件,然后再读取文件内容到内存里
(2).验证:主要是解析和校验。解析.class文件读到的内容是否是合法的,并且把这里的内容转成结构化的数据。注意:.class文件是一个二进制的文件,格式是有明确的要求的。

(3).准备:给"类对象"申请内存空间,此处申请的内存空间,相当于是"全0"的空间
(4).解析:针对字符串常量进行初始化。字符串常量本身就包含在.class文件中,此时就需要从.class文件中解析出字符串常量,然后放到内存空间("元数据区"的"常量池")里
(5).初始化:针对刚才申请空间的类对象进行最终的初始化,给类对象的各种属性进行填充,包括类中的静态成员,包括类中的静态成员。如果这个类还有父类,并且父类还没有加载,此时也会触发父类的类加载。
2."类加载"触发的时机
在Java中,并不是程序一启动,就会加载所有用到的类。而是"懒加载"的方式,用到哪个类,就会触发哪个类的加载。例如,①.构造这个类的实例 ②.调用/使用 类的静态属性或静态方法 ③.使用某个类的时候,如果它的父类还没有加载,也会触发父类的加载
3.介绍"双亲委派模型"
"双亲委派模型"主要是描述了在"类加载"的过程中,根据 "全限定类名",找到.class的过程
(1).类加载器
要想理解"双亲委派模型",就要先理解"类加载器"。"类加载器"是JVM中专门用于处理"类加载"的模块。
JVM默认提供了三种类加载器。分别为①.BootstrapClassLoader ②.ExtensionClassLoader ③.ApplacationClassLoader
这三个类加载器负责找的目录的范围是不同的,并且这三个类加载器是具有一定的"父子关系"的,并不是"父类子类"的关系,而是通过parent这个的引用来指向的。可以通过一个图来理解。

(2)."双亲委派模型"具体步骤
①.在进行类加载的时候,首先,通过全限定类名找.class文件的时候,就会从ApplicationClassLoader作为入口,如何把 加载类 这样的任务委托给ExtensionClassLoader。
②.ExtensionClassLoader也不立即进行查找,而是委托给BootstrapClassLoader。
③.由于BootstrapClassLoader没有"父亲",所以只能自己进行类加载,根据类名,找保准库范围,是否存在匹配的.class文件
④.BootstrapClassLoader如果找到就加载,如果没有找到,则把文件返回给ExtensionClassLoader,接下来ExtensionClassLoader来负责进行找.class文件
⑤.ExtensionClass Loader如果找到就加载,如果没有找到,则把文件返回给AppliactionClassLoader,接下来ApplicationClassLoader来负责进行找.class文件
⑥.ApplicationClassLoader如果找到就加载,如果没有找到则抛出异常
通过一个图来看

这三个类加载器,都是属于JVM自带的。同时,我们也可以自定义类加载器,自定义完成后,也可以把这个类加载器放到"双亲委派模型"中,也可以不放里面。例如,Tomcat内部就有自定义的类加载器,从指定的webapps目录加载对应的类
(四).垃圾回收
1.概念
垃圾回收,简称"GC",是Java中释放内存的手段,**回收的是JVM中"堆"的内存区域。**JVM会自动识别出某个内存,是不是后续不用了,如果是,则自动释放。这就不像C语言,每次还需要malloc()申请资源,最后还需要free()释放资源
但是GC中有一个严重的问题,就是"STW"问题(stop the world)。当触发了大规模的GC时,可能会因为GC,使得其他业务代码不得不暂停下来,等待GC结束后再继续走,简单的来说就是"卡了"。
但是,通过多年的发展,GC也改进了很多问题,在Java17及以上版本,可以做到让STW大部分情况下 <1ms的时间。
2.GC工作过程
(1).找到"垃圾"
对于"找垃圾",有两种策略,第一种是"引入计数",第二种是"可达性分析"
Ⅰ.引入计数
引用计数,指的是每个对象在new的时候,都会搭配一个小的内存空间,这个小的内存空间用于保存一个整数

如果t此时指向null

这个整数,表示的就是当前这个对象,有多少个引用指向它。每次进行引用赋值的时候,都会自动触发"引用计数"的修改,通过引用计数,记录有多少个引用。如果引用计数为0了,就说明没有引用指向这个对象了,这个对象就是"垃圾"。
这个策略是有缺点的。第一个缺点就是,内存消耗的更多。尤其是对象本身比较小,引用计数消耗的空间的比例就更大。例如,假设引用的计数是4个字节,对象本身是8个字节,此时引用计数就相当于提高了50%的空间占用率。
第二个缺点就是,可能出现"循环引用"的问题。通过图来看

现在是这种状态,当我添加代码 "a.t=b;" 和 "b.t=a;" 后

现在是这种状态,当我添加代码 "a=null;" 和 "b=null;" 后

此时,虽然这两个对象的引用都不为0,但是这两个对象都无法使用了。如果想要使用"0x100"这个对象,唯一指向"0x100"的这个引用是在"0x200"这个对象里面,由于无法使用"0x200"这个对象,所以"0x100"也无法使用了。有点类似于"死锁"
所以,Java并没有使用这个"引用计数"策略
Ⅱ.可达性分析
可达性分析,采用的策略是用时间换空间,Java采取的就是这个方案
可达性分析的具体步骤
①.以代码中的一些特定对象作为" 遍历的'起点' ",这个"起点"称为"GCRoots"。这些特定的对象包含①.栈上的局部变量(引用类型)②.常量池引用指向的对象③.静态成员(引用类型)
②.尽可能的进行遍历,判定某个对象是否能够访问到
③.每次访问到一个对象,都会把这个对象标记成"可达",当完成所有的对象的遍历之后,未被标记成"可达"的对象自然就是"不可达"。JVM中一共有多少个对象,JVM自身是知道的,通过可达性分析,就可以知道哪些对象是"可达的",剩下的就是"不可达"的了,这些"不可达"的对象就是要回收的"垃圾"。
通过两个例子来看
示例1:

示例2:

此时,如果root.right.right=null;

那么"f"这个对象就不可达了,此时,在下一轮的GC过程中,f这个对象就会被当作成垃圾释放掉。
注意:可达性分析这个过程是"周期性"的,每隔一定的时间,就会触发一次这样的可达性分析的遍历
(2).释放"垃圾"
释放"垃圾"的策略有三种,Ⅰ.标记-清除 Ⅱ.复制算法 Ⅲ.标记-整理
Ⅰ.标记-清除
把垃圾对象的内存,直接进行释放。这样会产生内存碎片问题

上图是没有清除前的样子

此时,清除之后,这些不可达对象对应的内存空间也不是连续的,所以当再次申请内存空间的时候,这些空闲出来的内存空间依然用不了,这个就是"内存碎片问题"。因为,在申请内存空间的时候,都是申请的连续的内存,并不是多个部分拼到一起的。如果内存碎片分厂多,总的空闲空间虽然很大,但是但凡想要申请一个稍微大一点的内存空间,都会失败。
Ⅱ.复制算法
复制算法,会将内存空间一切两半,一次只使用其中的一半,把不是垃圾的对象拷贝到另外一侧,然后把这一侧整体释放掉,此时就可以保证空闲的内存就都是连续的了。

上图是没有清除前的样子

复制算法有效的解决了"内存碎片"的问题。
但是复制算法也是有去缺点的,第一个缺点就是内存的利用率是很低的,每次都会空闲出一半的内存空间;第二个缺点就是一旦不是垃圾的对象较多,复制的成本就会很高,尤其是对象中包含大的对象的时候
Ⅲ.标记-整理
标记-整理,类似于顺序表的"搬运"


优点就是解决了内存碎片问题并且保证了内存的利用率
缺点就是内存搬运数据的操作,开销是很大的,复制成本问题仍然存在
Ⅳ.Java采取的策略
Java对于GC采取的策略是"分代回收",把上面的Ⅰ,Ⅱ,Ⅲ(主要是Ⅱ,Ⅲ)结合起来,扬长避短
"代"表示"对象的年龄",即"GC的轮次"。某个对象的初始情况为0,经历过依次GC可达性分析后,如果不是垃圾,此时对象的年龄就+1
根据堆上不同对象的年龄大小,把堆上的内存空间分成了两个部分,分别为"新生代"和"老年代"

针对不同的年龄的对象采取不同的策略。如果一个对象已经是一个年龄很大的对象了,此时大概率还会继续存在很久;如果一个对象是一个"小年轻",此时这个对象很可能快速就挂掉。此时,对于老年代的对象,GC的频次就可以降低了;对于新生代的对象,GC的频次可能比较高
在"新生代"这部分内存空间上,还划分了"伊甸区"和"幸存区"

比例分别为 8:1:1
如果对象刚被创建,此时就会被放到"伊甸区",绝大部分的"伊甸区"对象都活不过第一轮的GC。如果有的对象活下来的了,那么就会通过**"复制算法"**,放到了"幸存区"。由于活着的对象比较少,所以对于复制算法来说,复制的开销是可控的,这也是幸存区为什么比伊甸区小的原因,因为绝大多数的对象已经被回收了。
幸存区中的对象,也要经历GC的扫描,每一轮GC都会消灭一大部分对象,剩余的对象再次通过复制算法,复制到另一个幸存区。
如果某个对象在幸存区中经历了多次复制,都活了下来,此时,这个对象的年龄就大了,就会被放到"老年代"中,在老年代中,是通过"复制-整理"来进行回收的,由于老年代的对象大部分生命周期较长,舍得整理的开销也都可控
注意:如果一个对象特别大,则会直接进入老年代
在JVM中,有"垃圾收集器"模块,实现上述的分代回收的策略。分代回收,只是最基本的策略,落到集体的垃圾收集器上都会有一些特定的更进阶的策略。例如,CMS,G1,ZGC等等