目录
1. 前言
本篇博客的内容完全是面向面试的, 纯八股文内容.
因此, 死记硬背也要记住!!
2. JVM 简介
JVM(Java Virtual Machine), 即 Java 虚拟机.
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在⼀个完全隔离的环境中的完整计算机系统.
因此, JVM 就是一台虚拟的, 使用 C/C++ 代码模拟出来的, 现实中不存在的计算机系统.
我们这里仅讨论 JVM 的以下三个关键工作机制(面试中最常考的):
- JVM 内存划分
- 类加载机制
- 垃圾回收机制
接下来, 逐个为大家说明其中的要点内容.
3. JVM 内存划分
3.1 为什么要进行内存划分
JVM 为啥要划分区域呢??
因为 JVM 本就是仿照真实的操作系统来进行设计的, 而真实的操作系统就对进程的地址空间进行了分区.
于是 JVM 也就仿照操作系统的分区的思想, 也进行了内存划分的设计, 对内存进行不同的功能分配.
JVM 从操作系统申请一些内存空间, 其中一些空间供操作系统自身维护使用, 剩下的空间就是 JVM 进程自身来使用的.
JVM 再把这些自身使用的空间进行内存区域的划分(对这些空间进行功能的分配), 就称为 JVM 内存空间划分.
3.2 内存划分的核心区域
JVM 内存划分的核心区域有四个:
- 程序计数器
- 元数据区 / 方法区
- 栈
- 堆
其中, 元数据区和堆, 整个 Java 进程共用一份:
- 各线程类加载好的类对象,都放在同一个元数据区中.
- 各线程 new 出的对象, 都放在同一个 堆 中.
而, 程序计数器 和 栈, 一个进程中会存在多份:
- 每个线程都有各自的 程序计数器 和 栈.
3.2.1 核心区域一: 程序计数器
虽然名字上带有一个 "器" 字, 但是它也是 JVM 划分的一块内存区域.
程序计数器 是一块空间很小的内层区域, 记录的是下一个要执行的指令的地址.
这里 JVM 中的程序计数器和机组(计算机组成原理)中的程序计数器(PC 寄存器)功能很像, 但不是一个东西:
- JVM 中的程序计数器是位于内存中(JVM 内存划分的一部分)
- 机组中的程序计数器(PC)位于 CPU 中
3.2.2 核心区域二: 元数据区
元数据区, 在 Java 8 之前叫做方法区.
元数据区保存的是类加载完毕后的数据, 即 类对象.(类对象中有类元信息, 方法元信息)
类对象中包含了以下内容:
- 类的名称, 权限修饰限定符(public, private, ...), 继承了哪些类, 实现了哪些接口, .....
- 方法的名称, 参数的名称, 参数的类型, 返回值的类型, .....
类对象是反射的核心依据.
元数据区除保存类对象外, 还会保存 static 修饰的成员信息.
.java ==> .class ==> 加载到内存中
想运行 java 代码, 就必须进行类加载, 即: 将 .class 文件加载到内存中, 使得 .class => 类对象
3.2.3 核心区域三: 栈
栈中保存的是方法的调用产生的函数栈帧.
注意: JVM 中的 栈 并非数据结构中的 栈.
这里 JVM 中的 栈, 是数据结构中的 栈 的应用.
我们写的 Java 代码中肯定存在方法的调用, 而每调用一个方法, 就会在 JVM 的栈中产生一个该方法的栈帧, 当方法调用完毕后, 该方法的栈帧就会被销毁, 返回到调用位置, 代码就能继续往后执行.
栈帧中包含了这个方法的方法签名, 返回类型, 局部变量, 以及方法结束后代码应该回到哪里继续往后执行等信息.
(方法签名包括方法的名称和参数列表(参数的类型、顺序和数量))
调用一个方法, 栈中就产生一个栈帧; 方法调用结束, 栈中销毁一个栈帧.
注意: JVM 中的栈, 并非操作系统中的 栈. 这两个功能是相同的, 东西不是一个东西.
- 操作系统中原生的栈, 保存的是 C/C++ 代码中的函数调用产生的栈帧.
- JVM 中的栈, 是使用 C/C++ 代码构造出来的(虚拟的, 不是真实存在的), 保存的是 Java 代码中的方法调用产生的栈帧.
JVM 中栈的空间并不大, 大约几十 MB 的大小. 大部分情况下, 由于栈帧会快速的销毁, 这个空间是够用的. 但是在少数情况下, 会出现栈溢出(StackOverFlow), 比如: 死递归.
需要明确一点, JVM 本身就是由 C/C++ 代码实现的, 因此 JVM 中的栈也是通过 C/C++ 代码构造出来的.(也就是说, JVM 解释执行 .class 字节码, 本质是 C/C++ 代码执行的)
而 C/C++ 代码构造 JVM 时, 肯定存在 C/C++ 的函数调用, 也就在操作系统栈中存在函数栈帧. 因此, C/C++ 代码在操作系统栈产生的函数栈帧中, 又构造出了一个 JVM 的栈.
这个 JVM 的栈就是用来放 Java 代码的栈帧的.
但是, 由于 Java 代码中有时也会调用一些 C++ 的代码(native 本地方法, 如: Thread.sleep), 因此存在 操作系统原生的栈 和 JVM 的栈的联合使用.
3.2.4 核心区域四: 堆
JVM 的堆区域中保存的是以下信息:
- 类 new 出的对象
- 集合类中添加的元素
若有 Test test = new Test();
那么毫无疑问, 其中的 new Test() 一定就是保存在 堆 上的.
但是, 对于引用 test 所在的位置, 需要进行讨论:
- 若 test 是一个局部变量(方法中), 那么 test 在栈上.
- 若 test 是一个普通成员变量, 那么 test 在堆上.
- 若 test 是一个静态成员变量, 那么 test 在元数据区(方法区).
堆, 是 JVM 中内存最大的区域, 当堆上的元素不再使用的话, 需要进行释放(GC 垃圾回收机制).
4. JVM 类加载机制
JVM 类加载, 就是将字节码文件(.class 文件)加载到内存中, 并将 .calss 文件转换为 类对象(java.lang.Class
) 的过程, 以便JVM可以使用这个类.
4.1 类加载的步骤
类加载在 Java 官方文档上一共有三个阶段. 我们这里将第二个阶段分成三个步骤, 共分五个步骤来讨论:
- 加载
- 验证
- 准备
- 解析
- 初始化
4.1.1 步骤一: 加载
类加载需要将 .class 文件加载到内存中, 并将 .calss 文件转换为 类对象.
那么第一步就是要找到 .class 文件.
JVM 会根据 类 的全限定名(包名 + 类名, 如 java.lang.String) 来找到对应的 .class 文件, 将文件读取加载到内存中.
上述寻找 .class 文件并加载到内存的过程, 就是 "加载".
这里仅仅是将 .class 文件的内容读到内存中, 还没有对内容进行解析.
4.1.2 步骤二: 验证
验证, 即校验 .class 文件中的内容, 是否是合法的(是否符合官方要求的).
.class 文件的格式, 是 Oracle 官方是有明确要求的, 要求所有的 .class 都必须符合这个格式.
如果读取的 .class 文件符合这个格式, 那就继续往下执行; 如果不符合这个格式, 就会抛异常.
Oracle 官方要求的 .class 文件格式如下:
4.1.3 步骤三: 准备
类对象 是 类加载 最终要产出的内容.(类加载: .class => 类对象)
那么就需要给类对象申请内存空间, 有了空间后, 才能往里面填充内容.
注意: 此时申请的空间中的内容还未进行初始化, 是全 "0" 的空间.
4.1.4 步骤四: 解析
该步骤就是针对字符串常量进行初始化.
即: 将 .class 文件中的字符串常量解析出来(.class 文件中有包含常量池信息的属性), 放到元数据区中的常量池中.
4.1.5 步骤五: 初始化
该步骤是针对类对象进行最终的初始化.
对步骤三申请好的类对象的内存空间进行初始化, 对类对象的各种属性进行填充, 包括 static 修饰的静态成员.
如果该类具有父类, 且父类未进行类加载, 此环节也会触发父类的类加载.
4.2 类加载触发的时机
需要明确的是, 并不是程序一启动, 就会加载程序中所有存在的类.
Java 采取的是 懒汉模式/懒加载 去加载类的, 当类被以下情况使用时, 才会触发类加载:
- 构造了这个类的实例
- 调用了这个类的静态属性/静态方法
- 使用这个类的时候, 如果他的父类还没有加载, 也会触发父类的加载.
并且, 一个进程中, 一个类的加载, 只会触发一次.
4.3 双亲委派模型
4.3.1 三个类加载器
双亲委派模型, 更确切的说是 "单亲委派模型".
这里的 "父子关系" 不是 "父类子类" 的关系, 而是通过 parent 这样引用构成的 "父子关系"(类似二叉树中的父子关系).
双亲委派模型, 就是根据全限定类名(类似 java.lang.String), 寻找 .class 文件.
JVM 中专门负责类加载的模块, 称为类加载器(可以认为是 JVM 中的一部分代码).
JVM 中默认有三种类加载器:
- BootstrapClassLoader (爷)
- ExtensionClassLoader (父)
- ApplicationClassLoader (子)
三个类加载器之间, 构成的就是 双亲委派模型. 并且三个类之间具有的 "父子" 关系如下:
自然, 就是这三个类加载器, 来进行 .class 文件的寻找环节.
4.3.2 双亲委派模型的工作过程 [经典面试题]
上文说到, 三个类加载器, 首当其冲的任务就是寻找 .class 文件.
而三个类加载器, 负责寻找的目录的范围是不同的.
- BootstrapClassLoader (爷) ==> 寻找 Java标准库的目录
- ExtensionClassLoader (父) ==> 寻找 Java 拓展库的目录(目前很少用)
- ApplicationClassLoader (子) ==> 寻找 Java 第三方库/当前项目 的目录
其中, Java 拓展库目前已经很少用到了.
而 Java 第三方库, 我们可以理解为: 只要是通过 Maven 下载过来的, 就是第三方库.(例如: jdbc, Spring boot)
双亲委派模型的工作过程如下(类加载时, 根据全限定名, 找 .class 的过程):
- 从 ApplicationClassLoader 作为入口, 但不会立即寻找, 而是把类加载的任务交给它的父类 ExtensionClassLoader 来完成
- ExtensionClassLoader 也不会立即寻找, 而是也委托给它的父类 BootstrapClassLoader 来进行
- BootstrapClassLoader 也想委托给父亲, 但由于它没有父亲, 所以只能自己进行类加载.
- 于是, BootstrapClassLoader 根据全限定名, 在 Java 标准库中寻找是否存在匹配的 .class 文件. 找到就加载, 如果没找到, 就再把任务还给孩子 ExtensionClassLoader 来寻找.
- 接下来, ExtensionClassLoader 在 Java 拓展库 中寻找 .class 文件, 找到就加载; 没找到就把任务还给孩子 ApplicationClassLoader.
- 接下来, ApplicationClassLoader 在 第三方库 中寻找 .class 文件, 找到就加载; 没找到就抛出异常.
设置以上流程的目的是 为了约定 "优先级":
- 收到一个类后, 一定是先在标准库中找
- 再从扩展库中找
- 最后在第三方库找
5. 垃圾回收机制(GC)
5.1 什么是 GC
垃圾回收(GC) 是 Java 释放内存的方式.
并且 Java 之后的各个编程语言, 都引入了 GC.(例如: Python, PHP, js, ....)
为啥这么多语言都选择 GC 机制呢??
我们知道, 在 C 语言中需要使用 malloc 来申请内存空间. 并且申请后, 一定要手动调用 free 进行释放, 否则就会出现 内存泄漏.
C 语言的方式, 一方面, 可能会忘记调用 free; 另一方面, 即使写了 free, 但是在一些特殊场景下, 可能无法调用到(例如方法提前 return).
而 GC 可以自动进行内存的释放: JVM 会自动识别出, 某个后续不再使用的内存, 并自动释放掉该内存空间.
因此, GC 可以解决 手动调用 free 所导致的问题.(GC 可以理解是 自动挡, free 可以理解为 手动挡)
因此, 当前进行内存释放最主流的方案, 就是 GC 垃圾回收机制.
5.1.1 引入 GC 的代价 [拓展]
C++ 也是没有引入 GC 机制的.
为啥 GC 这么好, 而 C++ 却没有引入呢?? 因为, 引入 GC 也是需要代价的:
- 对程序运行的效率产生影响. (引入 GC 后, 会消耗一定的硬件资源, 拉低性能)
- STW(stop the world) 问题: 触发大量 GC 时, 会使得业务代码的执行暂停下来, 等待 GC 结束后再继续执行.(简而言之, 卡了~~)
我们知道 C++ 有两个核心设计理念:
- 和 C 兼容
- 极致的性能
而 C++ 为了保持它极致性能的理念, 因此没有采用 GC 机制.
虽然 GC 有拉低性能的代价, 但是 GC 发展了这么多年, 已经改进了之前很多的问题~~
在 Java 17 及以上的版本中, 可以做到让 STW < 1ms 的时间.
因此, Java 的性能其实和 C++ 也没差多少.
举个例子, 假设同一段程序:
- 用 C++ 实现需要 1 个单位的时间
- 用 Java 大概需要 1.5 - 2 个单位的时间
- 用 Go 大概 4 - 5 个单位的时间
- 用 Python 大概 100 个单位的时间
之所以 Python 这么慢, 主要的原因 Python 的多线程的 "假" 的.
虽然 Python 提供了线程库, 但是 Python 的线程是 "串行" 执行的(臭名昭著的 CIL(全局解释锁) 问题).
5.1.2 GC 回收的区域
GC 回收的是 JVM 堆 中的内存空间.(某个对象不再使用时, GC 会进行回收)
为啥只回收堆的内存, JVM 其他内存区域不用释放吗??
- 程序技术器: 线程销毁, 随之释放
- 元数据区: 存放类加载生成的类对象, 一般不释放
- 栈: 方法调用结束, 栈帧随之销毁
- 堆: 有新对象创建, 也有旧对象消亡 ==> GC 回收旧对象的内存空间
因此, GC 说是 "回收内存" , 其实本质上是 "回收对象". 不会出现把一个对象 "释放一半" 的情况.
5.2 GC 工作过程
GC 主要有以下两个关键工作:
- 找到垃圾(找到不再使用的对象)
- 释放垃圾(将对应的内存释放掉)
5.2.1 找到垃圾
GC 的第一步, 找到垃圾, 有以下两个策略:
- 引用计数 (Python, PHP)
- 可达性分析 (Java)
5.2.1.1 引用计数 [不太会考]
Python, PHP 采用的是 引用计数 的方案.
引用计数, 即每个对象在 new 的时候, 都搭配一个小的内存空间, 保存一个整数, 这个整数就代表当前对象, 有几个引用在指向它.
每次进行引用赋值的时候, 都会自动触发引用计数的修改.(如果一个对象有新的引用指向它, 那计数就 +1; 如果旧的引用不再指向它, 那计数就 -1)
但是, 这个策略具有以下缺陷:
- 内存消耗加多
搭配一个整数会消耗更多的空间, 尤其是当对象很小的情况下, 引用计数消耗的空间的比例就更大.
假设, 一个引用计数是 4 字节, 而对象本身才有 8 字节, 那么使用引入计数, 直接提高了 50% 的空间占用率.
- 可能出现 "循环引用" 的情况
情况如下图:
此时, 存在指向两个对象的引用(两个对象中的引用类型的成员互相指向对方), 既不能释放内存, 也无法使用.
(这里的情况有点像死锁, 僵住了~)
Python, PHP 中虽然使用的引用计数, 但其内部搭配了一些方案, 解决了引用计数的循环引用的问题.
5.2.1.2 可达性分析
Java 的 GC 采用的就是 "可达性分析" 这个方案.
上文说到, 引用计数会增加空间的开销; 而可达性分析, 则会增加时间的开销(使用时间环空间).
可达性分析是, 先使用类似算法中 BFS/DFS 的方式遍历来确定哪些对象可达(还在使用), 接着将不可达的对象释放掉.
"可达" 代表该对象还有引用指向它, 不用进行回收.
"不可达" 代表该对象没有引用指向它了, 可以进行释放了.
具体过程如下:
步骤一: 以代码中的一些特定的对象, 作为遍历的起点(GCRoots)
可以作为 GCRoots 的对象如下:
- 栈上的局部变量(引用类型)
- 常量池引用指向的对象
- 静态成员(引用类型)
以上的对象, JVM 都是可以获取到的.
步骤二: 尽可能的进行遍历 ==> 判断哪些对象可以访问到(哪些对象可达)
步骤三: 将每次访问到的对象, 标记为 "可达"
JVM 自身是知道一共有多少个对象的, 通过可达性分析, 知道了哪些是 "可达" 的, 那么剩下的那些对象就是 "不可达" 的, 也就是要回收的 "垃圾".
接着, 就可以对 "不可达" 的垃圾进行释放了.
因此, 可达性分析可以很好的解决 引用计数 内存占用和循环引用的问题.
这里再通过代码来演示一下可达性分析:
如上图, 将二叉树的根节点做为 GCRoot 进行可达性分析, 对能够访问到的对象标记为 "可达".
如果对代码这样的修改: root.right.right = null; 那么, 就 f 就不可达, 在下一轮的 GC 中, f 将会被当做垃圾回收.
如果这样操作: root.right = null; 那么此时 c 就不可达, c 的不可达也导致了 f 的不可达. 因此, 此时 c 和 f 均不可达, 下轮 GC 中, c 和 f, 均会被当做垃圾回收.
可达性分析, 是周期性的, 每隔一定的时间, 就会触发一次 GC.
一次 GC 遍历花费的时间都很长, 再进行周期性的重复, 那么将花费大量的时间, 这也是 C++ 放弃 GC 的原因.
5.2.2 释放垃圾
5.2.2.1 标记-清除
标记清除, 就是把垃圾对象(不可达对象)的内存, 直接进行释放.
这种策略, 导致内存中的空闲空间不是连续的, 存在内存碎片问题.
标记清除方式释放后的空闲空间, 不是连续的.(内存碎片)
要知道, 申请内存空间时, 只能申请连续的空间. 如果存在大量不连续的空间, 当申请的空间稍微大点时, 就会申请失败.
5.2.2.2 复制算法
在复制算法中, 将内存空间分为相同的两份, 使用的时候, 一次只使用其中的一半.
完成可达性分析后, 将不是垃圾的对象(可达的对象) 拷贝到另一半的内存中, 再把这一半的内存整体释放掉. 如下图所示:
复制算法的优点:
- 可以确保空闲的内存空间是连续的
复制算法的缺点:
- 内存空间利用率大幅度降低(只能使用一半)
- 当不是垃圾的对象很多时, 就需要进行大量的复制工作, 复制的成本很高.
5.2.2.3 标记-整理
标记整理 是对 标记清除 方式的优化.
清除垃圾对象后, 再对非垃圾对象进行搬运, 使得内存空间连续:
"搬运" 的过程, 顺序表中对元素的 移动.
标记-整理的优点:
- 解决了空闲内存不连续的问题(解决了 标记-清除 的问题)
- 解决了内存利用率低的问题(解决了 复制算法 的问题)
标记-整理的缺点:
- 依旧存在数据复制(搬运本质就是复制搬运)的开销.(复制成本的问题依旧存在)
5.2.2.4 分代回收 ★★★
上文所提到的三个方式均存在一定的缺陷, 于是, Java 将上述的方式(方式2和方式3)结合起来, 采用 "分代回收" 的方式, 来释放垃圾.
分代回收中的 "代", 指的是 "年龄", 也就是经历的 GC 的轮次. 假设一个对象, 经历一次 GC 后, 没有被当做垃圾清理掉, 那这个对象的年龄就 +1.
分代回收, 针对不同年龄的对象, 采取不同的策略.
为啥, 针对不同年龄的对象, 采取不同的策略呢?? 以下是根据经验的得出的结论:
- 如果一个对象是小年轻(年龄小), 这个对象绝大概率会快速挂掉.
- 如果一个对象是老油条(年龄大), 这个对象绝大概率会继续存在下去.
基于以上的经验规律, 我们就可以针对不同年龄的对象, 采取不同的策略:
- 对于年龄小的对象, GC 的频次可以提高.
- 对于年龄大的对象, GC 的频次就可以降低.
因此, Java 将内存空间分为以下几个部分:
- 新生代(存放年龄小的对象)
- 老年代(存放年龄大的对象)
其中, 新生代又分为:
- 伊甸区(存新创建的对象)
- 两个幸存区(空间大小相同)
其中, 幸存区要比伊甸区小的多(约 8:1).
将新创建出来的对象, 放入伊甸区中.
伊甸区中绝大部分的对象, 都活不过第一轮 GC(经验规律).
将伊甸区中幸存下来的对象, 使用复制算法, 移到 幸存区 中.
由于幸存下来的对象占少数, 因此幸存区比伊甸区小, 因此复制算法的开销是可控的
幸存区中的对象, 会经过多轮的 GC. 这样下来, 会回收走大部分的对象.
接下来, 再把幸存区 GC 活下来的对象, 使用复制算法移动到的另一个幸存区中.(两个幸存区是等大的, 幸存区很小, 开销也是可控的)
对象会在幸存区中经过多次 GC, 如果都存活了下来, 那么将这些存活下来的移入老年代.
进入老年代中, 说明这个对象极有可能持续存在下去, 因此 GC 频率就可以降低了, 因此开销也就减少了.(老年代中使用 整理-标记 的垃圾释放方式)
因此, JVM 分代回收的策略, 综合使用了各个方式, 使得开销最小化:
- 新生代中的对象, 大部分都会快速消亡, 使得 复制算法 的复制开销可控.(只需复制幸存下来的对象)
- 老年代中的对象, 大部分生命周期都较长, GC 频次低, 使得 标记整理 的复制开销可控.
其实, JVM 分代回收的机制, 和我们找工作的过程是很像的:
- 伊甸区: 公司收到大量的简历. 少数同学经过筛选, 进入笔试面试环节.
- 幸存区: 笔试面试环节. 面试大多有很多轮, 少则 2-3 轮, 多则 6-7 轮, 极少数同学通过重重的面试考验.
- 老年代: 通过重重考验, 拿到 offer, 称为正式员工. 但, 成为正式员工后, 也不是就稳了, 还有绩效考核, 末尾淘汰.(但是周期就长了, 半年/一年 才考一次)
Java EE 初阶 - END
加油!! 坚持就是胜利!!