目录
JVM 的简介
JVM 执行流程
JVM 运行时数据区
由五部分组成
JVM 的类加载机制
类加载的过程(五个)
双亲委派模型
类加载器
双亲委派模型的优点
JVM 中的垃圾回收策略 GC
GC 中主要分成两个阶段
死亡对象的判断算法
引用计数算法
可达性分析算法
垃圾回收算法
标记清楚算法
复制算法
标记整理算法
分代回收算法
一个对象的一生
Java Virtual Machine(Java虚拟机)
JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
虚拟机介绍: 通过软件模拟的具有完整硬件的功能, 运行在一个完全隔离的环境中的完整计算机系统.
常见的虚拟机: JVM, VMwave, Virtual Box.....
JVM 与其他两个虚拟机之间的区别:
- VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
- JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进
行了裁剪。
JVM 的初心: 就是为了让 Java 程序员能够比较简单的, 感知不到 系统层面的一些内容 还可以说让程序员只关注业务逻辑, 不要关注底层的实现细节 |
JVM 是 java 运行的基础, 也是实现一次编译到处运行的关键, 下面就来说说 JVM 是怎么执行的~
以下是JVM详细的执行流程:
程序在执行之前先要把 java 代码转换为字节码 (class 文件), JVM 首先需要把字节码通过一定的方式类加载器(类加载器 ClassLoad) 把文件加载到内存中 运行中 运行是数据区 (Runtime Data Area), 而字节码文件是 JVM 的一套指令集规范, 并不能直接交给底层操作系统去执行, 云溪需要特定的命令解释器执行引擎 (Execution Engine) 将字节码翻译成底层系统指令再交由 CPU 去执行, 而这个过程中需要顶哦用其他语言的接口 本地库接口 (Native Interface) 来实现整个程序的功能, 这就是这四个只要组成部分的职责和功能
主要是四个部分来执行 java 程序的 :
- 类加载器 (ClassLoader)
- 运行时数据区 (Runtime Data Area)
- 执行引擎 (Execution Engine)
- 本地库接口 (Native Interface)
1. 堆: new 出来的对象 (成员变量)
堆的作用: 程序中创建的所有对象都保存在堆中
堆中分为两个区域:
1. 新生代: 放新建的对象
2. 老生代: 当经过一定 GC 次数之后还存活的对象会放入老生代
2. 栈: 维护方法之间的调用关系 (局部变量)(两个栈放到了一起)
3. 方法区(旧)/元数据区(新) : 放的是类加载(加载的类信息、常量、静态变量、即时编译器编译后的代码等数据)之后的类对象(.class 文件) (静态变量)
4. 程序计数器 : 记录当前程序指定到那个指令了
一个方法中各个地方对应在数据区中的位置
void func() {
Test t = new Test();
}
解释:
new Test 这个对象, 对象的本体是在堆上的
t 本身是一个引用类型, t 是一个局部变量, 此时 t 是在栈上的
void func() : 一个方法在内存中, 是以一些二进制(字节码)的方式来存储的,在方法区中存储
总结:
堆和元数据区, 在一个 JVM 进程中, 只有一份
栈(本地方法栈和虚拟机栈) 和 程序计数器 则是存在多份(每个线程都有一份)
JVM 的线程和操作系统的线程是一对一的关系
每次在 Java 代码中创建的线程, 必然会在系统中有一个对应的线程
把.class 文件, 加载到内存, 得到 类对象 这样的过程
程序要想运行, 就需要把依赖的"指令和数据" 加载到内存中
1. 加载 (Loading) : 找到 .class 文件(双亲委派模型), 并且读文件内容
2. 验证(Verification) : 这一阶段的目的是确保Class文件的字节 流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信 息被当作代码运行后不会危害虚拟机自身的安全。
3. 准备(Preparation) : 给类对象 分配内存空间(未初始化的空间, 内存空间中的数据全是 0 的) (类对象中的静态成员啥的也是 全0 的) 类加载最终 是为了得到类对象
4. 解析(Resolution) : java 虚拟机将常量池内的符号引用替换为直接引用的过程, 也就是针对字符串常量进行初始化
符号引用
在.class 文件中就存在了, 但是他们只是知道彼此之间的相对位置(偏移量), 不知道自己在内存中的实际地址,这时候字符串常量就是 符号引用
直接引用
真正加载到内存中, 就会把字符串常来那个填充到内存中的特定地址上,字符串常量之间的相对位置还是一样的, 但是这些字符串有了自己真正的内存地址, 此时的字符串就是直接引用了
5. 初始化 (Initialzation) : 针对类对象进行初始化, (初始化静态成员, 执行静态代码块, 类要是有父类还需要加载父类) Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程
类加载 这个动作什么时候会触发?
不是 JVM 一启动就会触发, 就是把 .class 都加载了!! 整体是一个 "懒加载" 的策略(懒汉模式) 非必要不加载
什么叫做"必要"
1 创建这个类的实例
2 使用这个类的静态方法/ 静态属性
3 使用子类,会触发父类的加载
类加载中最关键的考点:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载
作用:
就是在第一个步骤中, 找 .class 文件这个过程
JVM 中类加载, 需要用到一组特殊的模块,类加载器
在 JVM 中, 内置了三个类加载器
BootStrap ClassLoader 负责加载 Java 标准库中的类
Extension CllassLoader 负责加载一些非标准的但是 Sun/Oracle 扩展库的类
Application ClassLoader 负责加 载项目中自己写的类 以及 第三方库中的类
1. 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那
么在 B 类进行加载时就不需要再重复加载 C 类了。
2. 安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了
简单来说就是帮助程序员自动释放内存的
C语言中 , malloc 的内存必须手动 free ,否则就容易出现内存泄漏
内存泄漏: 光申请内存, 不释放, 内存逐渐用完了, 导致程序崩溃
申请的时机是明确的 => 使用到了必须得申请
释放的时机是模糊的 => 彻底不使用才能释放
JVM 中的内存有好几个区域, 是释放哪个部分的空间呢?
堆(new 出来的对象)
程序计数器 就是一个单纯存地址的整数, 也是随着线程一起销毁, 方法调用完毕, 方法的局部变量自然随着出栈操作就销毁了元数据区/方法区, 存的类对象, 很少会卸载
GC 也就是以对象 为单位进行释放的(说是释放内存, 其实是释放对象)
1. 找谁是垃圾把垃圾对象的内存释放掉
2. 垃圾回收算法
基本的思想方法, 不代表 JVM 真实的实现方式
JVM 的真正实现方法, 是基于这些思想方法, 但是有做出很多细节上的调整和优化
我们如何找到垃圾:
如果一个对象没有一个对象引用他,此时这个对象一定是无法被使用的,这样的就算是垃圾,也叫做死亡对象
对于垃圾对象的识别是比较保守的
但是又怎么知道一个对象是否有引用指向?
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"
优点: 实现简单, 判定效率高
缺点:
Java没有采取 python PHP 采取了
把对象之间的引用关系, 理解成了一个树型结构, 从一些特殊的起点出发,进行遍历, 只要能遍历访问到的对象, 就是可达,再把不可达的当做成垃圾
可达性分析的关键要点, 需要有起点
1) 栈上的局部变量(每个栈的每个局部变量, 都是起点)
2) 常量池中的引用对象
3) 方法区中, 静态成员引用的对象
上述三点也叫做 : gcroots
优点: 克服了引用计数算法的缺点
缺点:
- 消耗更多的时间,因此某个对象成了垃圾, 也不一定能第一时间发现,因为扫描的过程需要消耗时间的
- 在进行可达性分析的时候, 要顺藤摸瓜, 一旦这个过程中, 当前代码中的对象的引用关系发生了变化, 就麻烦了,因此需要再摸瓜的过程中, 需要让其他的业务线程暂停工作,这个问题称为 STW 问题 但是经过了这么多年的优化, 虽说是不能完全的消除, 已经可以让 STW 的时间尽量缩短了
三种典型的策略:
"标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象
缺点:
复制"算法是为了解决"标记-清理"的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效
缺点:
复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。
针对老年代(存活实现比较长的对象)的特点,提出了一种称之为"标记-整理算法"。标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
实际上 JVM 的实现思路, 是结合了上述几种思想方法
当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法
针对不同的情况, 采用不同的策略,给对象设定了年龄的概念 , 描述了这个对象存在了多久了,如果一个对象刚诞生, 认为是 0 岁,每次经过一轮扫描(可达性分析), 没被标记为垃圾, 这个对象就涨了一岁,通过年龄区分这个对象的存活时间,针对不同的年龄对象采用不同的策略
图文总结分代回收:
针对不同年龄的对象采用不同的回收策略
当垃圾回收扫描到伊甸区之后, 绝大部分对象都会在第一轮 GC 中被干掉
如果老年区发现了垃圾对象, 使用标记整理的方式清楚
特殊情况: 如果对象非常大, 直接送进老年区(因为进行复制算法成本比较高, 而且大的对象也不是很多)
我是一个普通的 Java 对象,我出生在 伊甸 区,在 伊甸 区我还看到和我长的很像的小兄弟,我们在 伊甸 区中玩了挺长时间。有一天伊甸区中的人实在是太多了,我就被迫去了 生存区的 “From” 区(S0 区),自从去生存 区,我就开始漂了,有时候在 Survivor 的 “From” 区,有时候Survivor 的 “To” 区(S1 区),居无定所。直到我 18 岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在老年代里,我生活了很多年(每次GC加一岁)然后被回收了, 最终还是被回收不过甚是精彩