🥰🥰🥰来都来了,不妨点个关注叭!
👉博客主页:欢迎各位大佬!👈
文章目录
- 1. JVM 中的内存区域划分
- 2. JVM 的类加载机制
- 2.1 加载(Loading)
- ✨双亲委派模型
- 2.2 验证(Verification)
- 2.3 准备(Preparation)
- 2.4 解析(Resolution)
- 2.5 初始化(Initialization)
- 3. JVM 中的垃圾回收
- 3.1 找 —— 谁是垃圾
- 3.1.1 引用计数法
- 3.1.2 可达性分析
- 3.2 释放 —— 把垃圾对象的内存给释放掉
- 3.2.1 标记清除算法
- 3.2.2 复制算法
- 3.2.3 标记整理算法
- 3.2.4 分代算法
本期内容主要介绍有关 JVM 相关知识,这里只是简单介绍,JVM 的知识有很多,具体可看参考书《深入理解Java虚拟机》,作为一名普通的 Java 程序猿,日常开发中的时候几乎是涉及不到 JVM 相关内容哒~
JVM 设计的初心就是为了让 Java 程序猿感知不到系统层面的一些内容,程序猿只需关注业务逻辑,不要关注底层的实现细节!!!
JVM 里的内容是非常多的,需要研究 JVM 的源代码,其源代码由 C++ 编写,本期内容针对以下三个方面进行介绍 JVM,我们一起来看看吧~
1. JVM 中的内存区域划分
JVM 其实是一个 Java 进程,Java 进程会从操作系统这里申请一大块内存区域给 Java 代码使用,对这块内存区域进一步划分为几个最核心的内存区域,给出不同的用途:
- 堆:存放 new 出来的对象(成员变量)
- 栈:存放维护方法之间的调用关系(也会存一些局部变量)
- 方法区/元数据区:存放类加载之后的类对象(这里的类对象就包含了静态变量)
(方法区是旧的叫法,最新叫法为元数据区)
【类对象】
类对象是什么?即类加载之后的东西,本来一个类是.class 文件,把它加载在内存里,需要有一个数据结构来表示它,可以通过类名.class,比如 Test.class 来获取 Test 类对象
这里的主要考点就是给一段代码,判断某个变量处于内存的哪个区域~
【规则】是看这个变量的形态!比如是局部变量还是成员变量,还是静态变量呢?如果是局部变量就存放在栈上,如果是成员变量就存放在堆上,如果是静态变量就存放在方法区内~
【注意】和变量的类型是无关的!!! 并不是内置类型变量就在栈上,引用类型变量就在堆上
举一个具体的栗子,代码如下:
// Test类
class Test {
}
void func() {
Test t = new Test();
}
分析如下:t 本身是一个引用类型,它是一个局部变量,因此存放在栈上,而 new 出来的对象,对象本身是成员变量,存放在堆上
以下是 JVM 的执行流程,下面系统重点介绍 JVM 运行时数据区,JVM 运行时数据区域也叫内存布局,如图:
这里需要注意:
1)堆和方法区在一个 JVM 进程中,只有一份,但是栈(包括虚拟机栈与本地方法栈)和程序计数器则存在多份,每一个线程都有一份!
2)JVM 的线程和操作系统的线程是一一对应的关系,每次在 Java 代码中创建线程,必然会在系统中有一个对应的线程
JVM 内存划分区域:
- 本地方法栈:是给 JVM 内部的本地方法使用的,其中 JVM 内部通过 C++ 代码实现的方法
- 虚拟机栈:是给 Java 代码使用的,它的生命周期和线程一样,当线程执行一个方法的时候,会创建一个对应的栈帧,用于存储局部变量等信息,然后栈帧被压入栈中,当方法执行完毕后,栈帧会从栈中移除
- 程序计数器:也称 PC 计数器,用途是记录当前程序指定到哪个指令了,是一个简单的 long 类型的变量存了一个内存地址,内存地址就是下一个要执行的字节码所在的地址
- 堆:堆是 JVM 中最大的一块内存区域,被所有线程共享,在 JVM 启动时创建,用来存储对象
- 方法区:方法区也是所有线程共享的,用于存储类加载之后的对象
2. JVM 的类加载机制
【类加载】就是把 .class 文件加载到内存中,得到类对象的这样一个过程,程序想要运行就需要把依赖的"指令和数据"加载到内存中
类加载的步骤其实非常复杂,本期内容开头提到的书把类加载的过程总结为 5 个词,也是 Java 虚拟机规范,分为:加载 —— 验证 —— 准备 —— 解析 —— 初始化,如下图:
下面具体介绍这五个步骤:
2.1 加载(Loading)
【加载】加载阶段就是找到 .class 文件,并且读文件的内容
在加载(Loading)阶段,Java虚拟机需要完成以下三件事情:
- 通过类名.class 获取二进制字节流
- 把这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
【注意】加载是类加载的一个阶段,加载是加载,类加载是类加载,注意区分两者~
如何找到这个 .class 文件呢?这里会涉及到一个经典的考点:双亲委派模型
✨双亲委派模型
在介绍双亲委派模型之前,需要先知道 JVM 加载类时需要用到一组特殊的模块 —— 类加载器
类加载器用于动态加载 Java 类到 Java 虚拟机中,主要有以下 4 种类型类加载器,其中在 JVM 中内置了三个类加载器,即如下的前三个:
- 启动类加载器(Bootstrap ClassLoader):负责加载 Java 标准库中的类
- 扩展类加载器(Extension ClassLoader):负责加载一些非标准库但是由 Sun / Oracle 扩展的库的类
- 应用程序类加载器(Application ClassLoader):负责加载项目中自己写的类以及第三方库中的类
- 用户自定义类加载器(User-Defined ClassLoader):可以通过继承 java.lang。ClassLoader 类来创建自己的类加载器
类加载中最关键的一个考点是双亲委派模型,双亲委派模型做的工作就是在类加载的第一个阶段 —— 加载阶段,找 .class 文件这个过程 ~
【双亲委派模型】
这种模型指的是,一个类加载器在尝试加载某个类的时候,首先会将加载任务委托为其父类加载器去完成,只有当父类加载器无法完成这个加载请求,即父类加载器找不到指定的类,子类加载器才会尝试去自己加载这个类
这里仅考虑在 JVM 中内置的三个类加载器,具体流程如下:
- 当一个类加载器需要加载某个类的时候,它首先会请求其父类加载器加载这个类
- Bootstrap ClassLoader 启动类加载器,没有父加载器了,因此只能自己来搜索自己负责的片区,如果搜索到,就直接进行后续加载步骤,如果没有搜索到,再交给孩子处理
- Extension ClassLoader 扩展类加载器,收到了父亲的反馈,自己来找,如果搜索到,就直接进行后续加载步骤,如果没有搜索到,再交给孩子处理
- Application ClassLoader 应用程序类加载器收到了父亲的反馈,自己来找,如果搜索到,就直接进行后续加载步骤,如果没有搜索到,再交给孩子处理,但是这里没有孩子了,就会抛出一个 ClassNotFoundException
这个流程在日常中也经常存在,把 Bootstrap 想象成公司老板,Extension 想象成主管,Application 想象成基层员工!
【双亲委派模型的优点】
-
避免重复加载类:比如 A 类和 B 类都有共同的父类 C 类,当 A 启动时就会将 C 类加载起来,在 B 类进行加载时就不需要在重复加载 C 类
-
保证安全性:使用双亲委派模型也可以保证 Java 的核心 API 不被篡改
【注意】
1)双亲委派模型也是可以打破的,比如自己实现的类加载器,可以继续遵守双亲委派模型,也可以不遵守,比如 Tomcat 里针对 webapp 的类加载器就没有遵守双亲委派模型
2)反射和类加载的关系是,类加载得到的类对象是反射机制的前提条件
2.2 验证(Verification)
【验证】.class 文件有明确的数据格式(二进制),该阶段是确保.class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
验证选项有很多,比如有:
- 文件格式验证
- 字节码验证
- 符号引用验证…
打开这个官方文档,可以查看虚拟机规范
这里找的是 Java SE 8 早一点版本的虚拟机规范,在第 4 章可以看到 .class 文件有明确的数据格式,如下:
2.3 准备(Preparation)
【准备】给类对象分配内存空间,未初始化的空间,即初始值为数据类型的默认初始值,简单认为内存空间中的数据全为 0,类对象中的静态成员变量等全为 0
2.4 解析(Resolution)
【解析】针对字符串常量进行初始化,即虚拟机将常量池中的符号引用替换为直接引用的过程
解释说明:
字符串常量在 .class 文件中就存在了,但是它们只是知道彼此之间的相对位置,即偏移量,并不知道自己在内存中的实际地址,此时的字符串常量就是符号引用,真正加载到内存中,就是把字符串常量填充到内存的特定的地址上,字符串常量之间的相对位置还是一样的,但是这些字符串常量之间相对位置仍然是一样的,但是这些字符串有了真正的内存地址,此时的字符串常量就是直接引用了,即 Java 中的普通引用
举个例子,更能理解:
比如小丁和小万是好朋友,大课间从教室去操场做操,先在教室门口排好队,由体育委员带头过去,但是走着走着队伍就变形了,小丁就需要走位,保持和小万是同行的,这样小丁和小万一直走在一块,相对位置不变,到达操场后,小丁和小万还是在一块,此时两个好朋友就挨着了,一起做操了~ 在到达操场前,小丁和小万并不知道待会在操场上站在哪里,但是两个人的相对距离彼此清楚的,真正到达操场后,小丁和小万确定了自己的站位,位置就固定了,此时两个人还是挨着的,相对位置不变~ 这就类比符号引用替换为直接引用的过程
2.5 初始化(Initialization)
【初始化】针对类对象进行初始化,比如初始化静态成员,执行静态代码,如果这个类还有父类,需要先把父类加载出来
类加载这个动作,什么时候会触发呢?
并不是 JVM 一启动,就把所有的 .class 都加载了,坚持着 “非必要,不加载” 的原则,即需要的时候再加载~
什么时候是必要?
- 创建了这个类的实例
- 使用了这个类的静态方法/静态属性
- 使用子类,会触发父类的加载
3. JVM 中的垃圾回收
【含义】
垃圾回收(Garbage Collection,GC),从字面上来看,就是释放垃圾占用的空间,防止内存爆掉,JVM 垃圾回收是帮助程序员自动释放掉内存的~
在 C 语言中,malloc 的内存必须手动 free,否则就容易出现内存泄露问题,即光申请内存不进行释放,内存逐渐用完,导致程序崩溃,Java 等后续的编程语言,引入 GC 来解决上述问题,能够有效的减少内存泄露的出现概率
【作用】
实际上,内存的释放是一个比较纠结的事情:
申请的时机是明确的 —— 使用到了就必须申请
释放的时机是模糊的 —— 彻底不使用了才能释放(如果还没使用完就释放,就会导致一些问题)
C/C++ 的做法是完全让程序员来决定,特别依赖程序员的水平,Java 通过 JVM 自动判定,基于一系列策略,就可以提高释放时机的准确性,但是同时也会付出一些代价
【JVM 中的垃圾回收的对象】
Q:JVM 中的内存有好几个区域,是释放哪个部分的空间呢?
A:堆,new 出来的对象
程序计数器就是一个单纯存地址的整数,不需要释放,随着线程一起销毁,栈也是随着线程一起销毁,方法的局部变量也就自然随着出栈操作销毁了,方法区/元数据区存的类对象,很少会将其"卸载"掉
GC 主要分为以下两个阶段:
这里涉及到垃圾回收算法,基本的思想方法,不代表 JVM 真实的实现方式,JVM 的真实实现方式,是基于这些思想方法,但是同时又做出很多细节上的调整和优化~
3.1 找 —— 谁是垃圾
【如何确认垃圾】 一个对象,如果后续不再用了,就可以认为是垃圾
在 Java 中使用一个对象,只能通过引用,如下:
如果一个对象没有引用指向它,此时这个对象一定是无法被使用的,被视为垃圾
如果一个对象已经不想用了,但是这个引用可能还指向着(这里就不当做垃圾处理)
Java 中只是单纯通过引用有没有指向这个操作,来判定是否为垃圾,Java 中对于垃圾对象的识别是比较保守的,最大程度上避免“误杀”,释放不及时是小事,误杀是大事!
【如何找垃圾】
具体来说 Java 如何知道一个对象是否有指向引用呢?有两种算法:
3.1.1 引用计数法
【引用计数法】给对象安排一个额外空间,保存一个整数,表示该对象有几个引用指向
(Java 实际上没有使用这个方案,Python、PHP采取该方案)
图解如下:
随着引用的增加,计数器就增加,引用销毁,计数器就减少,当计数器为0的时候,则认为该对象没有引用了,就是垃圾
【注意】是每个对象都有一个单独的计数器,不是每个类都有一个
但是这会带来一个问题:
此时,如果 a 和 b 销毁了,这个时候两个对象的引用计数各自减1,它们的计数器值为 2 - 1,为 1,这个时候这两个对象引用计数不是 0,不能作为垃圾,而这两个对象却无法使用了,陷入一个逻辑上的循环
【优点】实现简单
【缺点】1. 浪费空间 2.存在循环引用的情况,会导致引用计数的判定逻辑出错
3.1.2 可达性分析
【可达性分析】
把对象之间的引用关系,理解成了一个树形结构,从一些特殊的起点出发,进行遍历,只要能遍历访问到的对象,就是"可达"的,再把"不可达的"当做垃圾即可
(Java没有使用引用计数,采用了可达性分析)
可达性分析的关键要点,就是进行上述遍历,需要有"起点",可以作为起点的:
- 栈上的局部变量,每个栈上的局部变量都是起点
- 常量池中引用的对象
- 方法区中静态成员引用的对象
可达性分析,简单来说,就是从所有的 gcroots 的起点出发,通过遍历,看看该对象又通过引用能访问哪些对象,把所有可以访问的对象都遍历一遍,遍历的同时将对象标记成"可达",剩下的就"不可达"
【优点】克服了引用计数的两个缺点
【缺点】
1)消耗更多的时间,因此某个对象成为了垃圾,不一定能及时发现,因为扫描的过程中,需要消耗时间
2)在进行可达性分析的时候,遍历过程中,一旦这个过程中,当前代码的对象引用关系发生变化,此时就更加麻烦, 因此,为更准确完成这个过程,需要让其它业务线程暂停工作!
3.2 释放 —— 把垃圾对象的内存给释放掉
通过上述分析,将可回收的对象标记出来了,标记出来之后,就可以进行垃圾回收操作了,如何进行高效的垃圾回收呢?以下介绍 4 种算法:
3.2.1 标记清除算法
【标记清除算法】直接把垃圾对象内存释放掉
【优点】实现简单
【缺点】这种方式会产生内存碎片,申请空间都是申请"整块的连续的空间",比如总的空闲空间可能超过 1G,但是这些空闲的空间都是离散的、独立的,可能想申请到 500M 空间都申请不到~
3.2.2 复制算法
【复制算法】由上述标记清理算法演化而来,它将内存容量等大小分为两块,每次只使用其中的一块,把不是垃圾的对象,拷贝到另一边,再整个释放这个垃圾区域
【优点】解决内存碎片问题
【缺点】空间利用率低,因为复制算法每次只能使用一半的内存,如果当前对象大部分都是要保留的,垃圾很少,则复制成本较高
3.2.3 标记整理算法
【标记整理算法】与标记清除法一样,但是不是直接清理可回收对象,先有一个移动的的过程,将所有存活的对象都向一端移动,再清理掉边界以外的内存区域
【优点】解决内存碎片问题,比复制算法空间利用率高
【缺点】类似于顺序表有一个搬运的过程,因为有局部对象的移动,开销比较大,效率不是很高
3.2.4 分代算法
实际上,JVM 的实现思路,结合了上述几种思想方法,针对不同的情况使用不同的策略,到达取长补短的效果,下面具体介绍给算法 —— 分代回收算法
【分代算法】根据对象存活周期不同将内存划分为几块,一般将 Java 堆 分为新生代和老年代,根据各个年代选择适合的垃圾收集算法
给对象设定了"年龄",描述这个对象存在的时间,如果一个对象刚诞生,认为是 0 岁,每次经过一轮扫描,即可达性分析,没有被标记成垃圾,这个时候对象就涨一岁,通过年龄来区分这个对象的存活时间,由经验规律可以得到:如果一个对象存活时间很长,它将可以继续存在很长时间
由于新生代存放的大部分数据都是朝生夕死的,因此,新生代使用效率高的复制算法,而老生代使用标记清除/标记清理算法(哪个好使用哪个)
具体过程如下:
1)新创建的对象放到伊甸区,当垃圾回收扫描到伊甸区后,绝大部分的对象都会在 GC 中被干掉,大部分对象是活不过一岁的,由经验规律得知;
2)如果伊甸区的对象,撑过第一轮 GC,就会通过复制算法,把存活的对象拷贝到生存区
3)当这个对象在生存区,撑过多轮 GC 后,年龄增长到一定程度,就会通过复制算法拷贝到老年代;
4)进入老年代的对象,年龄都较大,再消亡的概率较低,针对老年代的 GC 扫描频次就会降低,如果发现老年代中某个对象是垃圾,可以使用标记清除/标记清理算法,进行清除
垃圾收集器是具体的实现方式,具体实现往往是基于上述思想方法做出一些优化和改进,包含更多的实现字节,Java 版本的变更,垃圾回收器也在不断变化~
✨✨✨本期内容到此结束啦~