观前提示:本篇博客演示使用的 IDEA 版本为2021.3.3版本,使用的是Java8(又名jdk1.8)
前端使用VSCode(Visual Studio Code1.78.2)
电脑使用的操作系统版本为 Windows 10
目录
1. 什么是 JVM
2. jvm 发展史
2.1 Sun Classic VM
2.2 Exact VM
2.3 HotSpot VM
2.4 JRockit
2.5 J9 JVM
2.6 Taobao JVM(国产研发)
3. JVM 内存区域划分
3.1 堆
3.2 Metaspace (元数据区)
3.3 给一段代码, 问某个变量是在那个区域上?
4. JVM 类加载
4.1 类加载过程
5. 双亲委派模型
5.1 上述类加载器如何配合工作?
5.2 为什么会有上述这种顺序?
5.3 为什么叫双亲委派模型
6. JVM 垃圾回收机制
6.1 GC 实际工作过程
6.1.1 找到垃圾/判断垃圾
6.1.1.1 引用计数(python/php)
6.1.1.2 可达性分析(java)
6.2 JVM 如何清理垃圾
6.2.1 标记清除
6.2.2 复制算法
6.2.3 标记整理
6.2.4 分代回收
JVM(Java Virtual Machine)是指Java虚拟机,它是 Java 语言的核心和关键技术之一。JVM 是一个软件平台,用于在不同计算机体系结构和操作系统上运行 Java 字节码。Java 开发人员使用 Java 编写的程序通过编译器生成字节码,然后 JVM 将字节码解释并转换成可以在特定硬件和操作系统上运行的本地机器代码。这种可移植性是 Java 的一个重要特点,使 Java 程序可以跨平台运行。JVM 还有垃圾回收、内存管理等重要功能,可以帮助 Java 开发者更加高效地创建稳定、可靠的应用程序。
早在1996年 Java1.0 版本的时候,Sun 公司发不了一款名为 Sun Classic vm 的 java 虚拟机,它同时也是世界上第一款商业 java 虚拟机,jdk1.4 时完全被淘汰。
这款虚拟机内部只提供解释器。如果使用 JIT 编译器,就需要进行外挂。但是一旦使用了JIT编译器,JIT就会接管虚拟机的执行系统。解释器就不再工作。解释器和编译器不能配合工作。现在 Hotspot 内置了此虚拟机
为了解决上一个虚拟机问题,jdk1.2时,sun提供了此虚拟机。
Exact 具备现代高性能虚拟机的雏形,包含了一下功能:
1. 热点探测(将热点代码编译为字节码加速程序执行);
2. 编译器与解析器混合工作模式。
只在Solaris平台短暂使用,其他平台上还是 classic vm英雄气短,终被Hotspot虚拟机替换。
HotSpot 历史
1. 最初由一家名为 “Longview Technologies” 的小公司设计;
2. 1997年,此公司被 Sun 收购;2009年,Sun 公司被甲骨文收购。
3. JDK1.3 时,HotSpot VM 成为默认虚拟机
目前 HotSpot 占用绝对的市场地位,称霸武林。
不管是现在仍在广泛使用 JDK6,还是使用比较多的 JDK8 中,默认的虚拟机都是 HotSpot ;Sun/Oracle JDK和OpenJDK 的默认虚拟机。从服务器、桌面到移动端、嵌入式都有应用。名称中的HotSpot指的就是它的热点代码探测技术。它能通过计数器找到最具编译价值的代码,触发即时编译(JIT)或栈替换;通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡。
它可以不太关注程序的启动速度,因此JRockit内部不包含解析器实现,全部代码都靠即时编译器编译后执行;大量的行业基准测试显示,JRockit JVM 是世界上最快的 JVM。
使用 JRockit 产品,客户已经体验到了显著的性能提高(一些超过了70%)和硬件成本的减少(达50%);优势:全面的Java运行时解决方案组合。 JRockit 面向延迟敏感型应用的解决方案 JRockit Real Time 提供以毫秒或微秒级的JVM响应时间,适合财务、军事指挥、电信网络的需要;MissionControl 服务套件,它是一组以极低的开销来监控、管理和分析生产环境中的应用程序的工具;2008,BEA 被 Oracle 收购。Oracle 表达了整合两大优秀虚拟机的工作,大致在 JDK8 中完成。整合的方式是在 HotSpot 的基础上,移植 JRockit 的优秀特性。
全称:IBM Technology for Java Virtual Machine,简称IT4J,内部代号:J9。
市场定位于HotSpot接近,服务器端、桌面应用、嵌入式等多用途JVM,广泛用于IBM的各种Java产品。目前,有影响力的三大商用虚拟机之一,也号称是世界上最快的Java虚拟机(在IBM自己的产品上稳定);2017年左右,IBM发布了开源 J9 VM,命名 OpenJ9,交给 Eclipse 基金会管理,也称为 Eclipse OpenJ9。
由 AliJVM 团队发布。阿里,国内使用Java最强大的公司,覆盖云计算、金融、物流、电商等众多领域,需要解决高并发、高可用、分布式的复合问题。有大量的开源产品。
基于OpenJDK 开发了自己的定制版本AlibabaJDK,简称AJDK。是整个阿里JAVA体系的基石;基于OpenJDK HotSpot JVM发布的国内第一个优化、深度定制且开源的高性能服务器版Java虚拟机,它具有以下特点(了解):
1. 创新的GCIH(GC invisible heap)技术实现了off-heap,即将生命周期较长的Java对象从heap中移到heap之外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收评率和提升GC的回收效率的目的。
2. GCIH中的对象还能够在多个Java虚拟机进程中实现共享。
3. 使用crc32指令实现JVM intrinsic降低JNI的调用开销;
4. PMU hardware的Java profiling tool和诊断协助功能;
5. 针对大数据场景的ZenGC。
taobao JVM应用在阿里产品上性能高,硬件严重依赖intel的cpu,损失了兼容性,但提高了性能,目前已经在淘宝、天猫上线,把Oracle官方JVM版本全部替换了。
JVM 启动的时候,会向操作系统申请一片内存区域, JVM 根据需要, 把整个空间分成几个部分, 每个部分各司其职
Native Method Stacks (本地方法栈): 表示 JVM 内部的 C++ 代码, 就是调用 native 方法(JVM 内部的方法)准备的栈空间
Program Counter Register (程序计数器): 记录当前线程执行到那个指令(很小的一块存一个地址)每个线程都有一份
Java Stacks (虚拟机栈): 给 Java 代码使用的栈
栈帧-1(方法A), 栈帧-2(方法B), 栈帧-3(方法C) ......: 每个线程一个栈帧, jconsole 可以通过查看 Java 进程内部的情况,从而看到所有的线程, 点击线程就能看到该线程调用栈的情况
注意: stack 在数据结构中虽然也是栈的意思, 但他是 "先进后出", 和 JVM 中的栈不是一个东西.
JVM 的栈,是一个特定空间, 对于 JVM 虚拟机栈, 这里存储的是 方法 之间的 调用关系
整个栈空间内部, 可以认为是包含了很多元素(每个元素表示一个方法)把这里的每个元素, 称为是一个"栈帧", 这一个栈帧里,会包含这个方法的 入口地址, 方法的参数是什么, 返回地址是什么, 局部变量
对于 本地方法栈, 存储的是 native 方法之间的调用关系
由于是 函数 调用, 因此也有"先进后出" 的特点, 因此此处的栈,也是先进后出
堆是整个 JVM 空间最大的区域, new 出来的对象都是在堆上面, 类的成员变量也在堆上面,
堆是一个进程只有一份; 栈是每个线程有一份, 一个进程有 N 个
堆是多个线程共用一个; 栈是每个线程都有自己的
kiass 类元信息: 类对象, 常量池, 静态成员 就是存储在这里
Java 7以及之前叫做方法区, Java 8之后改名为 元数据区
原则:
1. 局部变量: 栈
2.普通成员变量: 堆
3. 静态普通成员变量: 方法区/元数据区
类加载就是 .class 文件, 从文件(硬盘)被加载到内存中(元数据区)这样的过程
加载: 把 .class 文件找到(找的过程), 打开文件, 读文件, 把文件内容读到内存中区
验证: 检查 .class 文件格式是否符合需求, .class 文件是一个二进制文件, 这里的格式是有严格说明的, 官方提供了 JVM 虚拟机规范, 规范文档上面详细描述了 .class 的格式
准备: 给类对象分配内存空间(现在元数据区占个位置), 也会使静态成员被设置成 0 值
解析: 初始化字符串常量, 把符号引用转为直接引用
初始化: 调用构造方法, 进行成员初始化, 执行代码块, 静态代码块, 加载父类...
一个类什么时候才被加载呢?
不是 Java 程序一运行就把所有的类加载了, 而是真正用到才加载(懒汉模式)
以下这三种用到了才加载, 一旦加载过, 后续使用就不必重复加载了
1. 构造 类 的实例
2. 调用这个类的 静态方法 / 使用静态属性
3. 加载子类, 就会先加载其父类
其中, 解析里的字符串常量, 需要有一块内存空间, 存这个字符的实际内容, 还得有一个引用, 来保存这个内存空间的起始地址
在类加载之前, 字符串常量, 此时是处在 .class 文件中, 此时这个"引用"记录的并非是字符串常量的真正的地址, 而是他在文件中"偏移量"这个东西(或者是个占位符)类加载之后, 才真正把这个字符串常量给放到内存中, 此时才有"内存地址", 这个引用才能被真正赋值成指定内存地址
举个例子:
学校组织讲座的时候,在进入报告厅之前只知道我的前面是A后面是B,不知道具体坐在那里(符号引用)
此时我只知道自己的相对位置
进入报告厅以后, 大家开始坐下, 当我真正的坐下之后, 我才知道自己的真实的位置(直接引用)
再来看"验证"里面说的官方 JVM 虚拟机规范
Java SE 官方文档
往下拉,找到 Java SE 8
点击 Java SE 8 Edition 的 HTML
找到 4 点击 4.1
就能看到这个
加载: 把 .class 文件找到, 读取文件内容. 而双亲委派模型描述的就是加载, 找 .class 文件 的基本过程
上述三个类, 存在"父子关系"(不是父类子类, 相当于每个 class loader 有一个 parent 属性, 指向自己的父 类加载器)
首先加载一个类的时候,是先从 Application ClassLoader 开始, 但是 Application ClassLoader 会把加载任务交给父类加载器让父类加载器去进行
于是 Extension ClassLoader 要去加载但是也不是真加载而是再委托给自己的父类加载器
Bootstrap ClassLoader 要去加载,也是想委托给自己的父类加载器,结果发现自己的父类加载器是 null
(没有父类加载器或者父类加载器加载完了,没找着类,才由自己进行加载)
此时 Bootstrap ClassLoader 就会搜索自己负责的标准库目录的相关的类,如果找到就加载,如果没找到就继续由子类加载器进行加载
Extension ClassLoader 真正搜索扩展库相关的目录,如果找到就加载,如果没找到就由子类加载器进行加载
Application ClassLoader 真正搜索用户项目相关的目录,如果找到就加载,没找到由子类加载器进行加载(由于当前没有子类了,就只能抛出 "类找不到" 这样的异常)
上述这套顺序其实是出自于JVM 实现代码的逻辑,这段代码大概是类似于"递归"的方式写的。
其实从最上面这里直接开始也不是不行,但是 JVM 代码当前是按照这种类似递归的方式来实现的,就导致了从下到上又从上到下的过程。
这个顺序最主要的目的就是为了保证 Bootstrap 能够先加载 Application 能够后加载,这样就可以避免因为用户创建了一些奇怪的类引起不必要的bug
假设用户在自己的代码中写了个 Java.lang.String 这个类
按照上述加载流程,此时 JVM 加载的还是标准库的类,不会加载到用户自己写的这个类,这样就能保证即使出现上述问题也不会让 JVM 已有代码混乱,最多是用户自己写的类不生效罢了
另一方面,类加载器其实是可以用户自定义的,上述三个类加载器是 JVM 自带的,用户自定义的类加载器也可以加入到上述流程中,就可以和现有的加载配合使用了
双亲委派模型没有双亲, 只有父亲, 因为他是机翻, 他更应该叫做 "单亲委派模型"或者"父亲委派模型"
parent 双亲之一
为什么要有垃圾回收?回收的垃圾指的是什么?回收的垃圾指的是不再使用的内存,而垃圾回收就是把不用的内存帮我们自动释放掉。
在 C 和 C++ 中,动态内存申请是在堆上申请一块内存空间当不用这块内存空间的时候,需要手动进行释放。
如果不手动进行释放这块内存的空间就会持续存在,一直存在到进程结束。
堆上的内存生命周期比较长,不像栈,栈的空间会随着方法执行结束,栈帧销毁而自动释放。堆,则默认不能自动释放。
这就会导致一个非常严重的问题,内存泄露。
如果内存一直占着不用又不释放,就会导致剩余空间越来越少,进一步导致后续的内存申请操作失败,尤其是服务器特别害怕这个,因为服务器是 7*24 小时运营。
为了解决内存泄露,大佬们想了一些办法,GC 是其中最主流的一种方式,
JAVA, Python, PHP,GO,JS 等大部分主流语言都是使用 GC 来解决上述问题的。
那么 C 和 C++ 为什么不使用 GC 呢?或者整一个类似于 GC 的东西?
C++ 要搞肯定也能搞出来但是 GC 必然是有好的一面,也有坏的一面
GC 好处:非常省心,让程序员写代码简单点,不容易出错
GC 坏处:需要消耗额外的系统资源,也有额外的性能开销
Gc的坏处就不符合在 C++ 的初心(追求性能的极致)
另外 GC 还有一个比较关键的问题,STW 问题
如果有时候内存中的垃圾已经很多了,此时触发一次 GC 操作开销可能非常大, 大到可能就把系统资源吃了很多,另一方面 GC 回收垃圾的时候可能会涉及到一些锁操作,导致业务代码无法正常执行,这样的卡顿,极端情况下可能是出现几十毫秒甚至上百毫秒
同样不符合 C++ 的初心
JVM 里面有好多内存区域
而 GC 正是针对 堆 进行释放的, GC 是以 "对象" 为基本单位, 进行回收(不是字节)
GC 回收的是整个对象都不在使用的情况, 而一部分使用, 一部分不使用的对象, 暂时先不回收
(一个对象里面有很多属性, 可能是其中十个属性后面要用,十个属性后面再也不用)
因此, 要回收就回收整个对象, 而不会"回收整个对象". 这样设定, 简单
如何确定这个内存是不是垃圾,关键思路就是有没有“引用“指向他
JAVA中使用对象只有这一条路,通过"引用"来使用,如果一个对象有引用指向,他就可能被使用到。如果一个对象没有引用指向就不会再被使用。
具体如何知道对象是否有引用指向呢?两种典型实现:
1. 引用计数(Python/PHP)
2.可达性分析(Java)
给每个对象分配一个计数器(整数),每次创建一个引用指向该对象计数器就+1,每次该引用被销毁了,计数器就-1。
缺陷:
这个办法简单有效但是java并没有使用
1. 内存空间浪费的多(利用率低)
每个对象都要分配一个计数器,如果按四个字节算,代码中的对象非常少,无所谓,如果对象特别多了,占用的额外空间就会很多,尤其是每个对象都比较小的情况。
一个对象体积1024字节,此时多四个自己无所谓。
一个对象体积是4字节,此时多4个字节相当于体积扩大一倍
2. 存在循环引用的问题
接下来如果 a 和 b 引用销毁,此时1号对象和2号对象引用计数都-1,但是结果都还是1,不是0,但是虽然不是0,不能释放内存,但是实际上这俩对象已经没有办法被访问到了。
Python/PHP,使用引用技术需要搭配其他的机制来避免循环引用。
就一个例子就是通过找到藏宝图来找到宝藏
这两个互相指向对方,除了他俩,谁也找不到他们, 此时这俩就是垃圾, 但是由于引用计数不是0, 还不能释放内存
java中的对象都是通过引用来指向并访问的。经常是一个引用指向一个对象,这个对象里的成员又指向别的对象。
class TreeNode{
int value;
TreeNode left;
TreeNode right;
}
整个 JAVA 中所有的对象就通过类似于上述的关系,通过这种链式/树形结构整体给串起来。
可达性分析就是把所有这些对象被组织的结构视为是树, 就从树根节点出发, 遍历树, 所有能被访问到的对象标记成“可达“(不能被访问到的就是“不可达“)
JVM 自己有一份所有对象的名单,通过上述遍历把"可达"的标记出来了,剩下的"不可达"的就可以作为垃圾进行回收了。
举个栗子:
此时有 root 进行引用,就会有如下的效果(此处的可达性分析就是从 root 出发,尽可能进行遍历, 所有能够被访问到的对象就是可达)
root => a
root.left => b
root.left.left => d
root.left.right => e
root.left.right.left => g
假设此时
root.right.right = null 会导致 f 不可达,此时 f 就是垃圾
root.right = null 会导致 c 不可达, 如果 c 不可达, f 也一定不可达
可达性分析需要进行类似“树遍历“,这个操作相比引用计数来说肯定会慢一些,但是速度慢没关系,上述可达性分析遍历操作并不需要一直执行,只需要每隔一段时间,分析一遍就可以了。
进行可达性分析遍历的起点, 被称为 GCroots
1. 栈上的局部变量
2. 常量池中的对象
3. 静态成员变量
如何清理垃圾?主要是以下三种做法
1. 标记清除
2. 复制算法
3. 标记整理
简单粗暴,内存碎片问题。被释放的空闲空间是零散的,不是连续的 申请内存要求的是连续空间,总的空闲空间可能很大但是每一个具体的空间都很小,可能导致申请大一点的内存的时候就会失败,例如总的空闲空间是 10k 分成 1k 一个,总共十个,此时如果申请 2K 内存就会申请失败。
每次触发复制算法, 都是向另外一侧进行复制, 内存中的数据拷贝过去
缺点:
1. 空间利用率低
2. 如果垃圾少, 有效对象多, 复制成本比较大
优点: 保证了空间利用率, 同时解决了内存碎片的问题
缺点: 效率不高, 如果要搬运的空间比较大, 此时开销也比较大
基于以上三种基本策略, 搞了一个复合策略"分代回收"。
把垃圾回收分成不同的场景,有的场景用这个算法,有的场景用那个算法各展所长。
分代回收是怎么分的?
基于一个经验规律:如果一个东西存在的时间比较长了, 那么大概率还会继续长时间持续存在下去(要消失的话早都要消失了,既然存在肯定还是有用的)
注意:规律不等于“定律“允许例外针对大部分情况是有效的。
上述规律,对于 JAVA 的对象也是有效的。
JAVA 的对象要么就是生命周期特别短的, 要么就是特别长的,根据生命周期的长短分别使用不同的算法,给对象引入一个概念,年龄(单位不是年,而是熬过 GC 的轮次)
这个 GC 的轮次指的就是经历过了这一轮可达性分析的遍历,发现这个对象还不是垃圾,这就是熬过了一轮 GC
年龄越大, 这个对象存在的时间就越久
刚 new 出来,年龄是0的对象,放到伊甸区
熬过一轮 GC 对象就要被放到幸存区了,虽然看起来幸存区很小,伊甸区很大,但是一般都够放
根据上述经验规律,大部分的 JAVA 中的对象都是朝生夕死,生命周期非常短。
从伊甸区到幸存区使用的是复制算法。
幸存区之后也要周期性的接受 GC 的考验,如果变成垃圾就要被释放,如果不是垃圾拷贝到另一个幸存区
(这俩幸存区同一时刻只用一个)在二者之间来回拷贝使用的是复制算法,由于幸存区体积不大,此处的空间浪费也能接受。
如果这个对象已经在两个幸存区中来回拷贝很多次了,这个时候就要进入老年代了。
老年代都是年纪大的对象,生命周期普遍更长,针对老年代也要周期性 GC 扫描,但是频率更低了,如果老年代的对象是垃圾了,使用标记整理的方式进行释放。
本文完,感谢观看,不足之处请在评论区指出 !