✏️作者:银河罐头
📋系列专栏:JavaEE
🌲“种一棵树最好的时间是十年前,其次是现在”
推荐一本书:《深入理解 Java 虚拟机》。
JVM(Java Virtual Machine)内存区域划分,JVM 类加载机制,JVM 垃圾回收机制。
HotSpot VM : 最主流的 JVM,Oracle 官方 jdk 和开源的 openjdk ,都是使用这个 JVM。占据绝大部分市场份额。
JVM 内存区域划分
举个栗子:买房子,假设买了一套 100 平米的房子,区域划分,主卧,次卧,客厅,阳台,厨房,卫生间,浴室…
JVM 启动的时候,也会申请到一块很大的内存区域,JVM 是一个应用程序,要从操作系统申请内存。JVM 就会根据内存需要,把空间划分成几个部分,每个部分有各自的功能和作用。
栈
此处所说的 “栈”,是 JVM 中的一块特定空间。
对于 JVM 虚拟机栈,这里存储的是 方法之间的调用关系。
整个虚拟机栈空间内部,可以认为是包含很多个元素,每个元素是一个 “栈帧”,每一个栈帧,包含方法的入口地址,方法的参数,返回地址,局部变量…
对于本地方法栈,这里存储的是 native 方法之间的调用关系。
由于函数调用,也是有"后进先出"的特点,此处这里的 “栈”,也是"后进先出"的。
数据结构中的"栈",是一个通用的,更广泛的概念,而此处的"栈"特指 JVM 上的一块内存空间。
调用一个方法,就会创建栈帧,方法执行结束了,栈帧就销毁了。
栈空间有上限, JVM 启动的时候是可以设置参数的,其中有一个参数就可以设置占空间的大小。
这里的栈不是只有一个。每个线程有一个,jconsole, 查看 Java 进程内部情况,就可以看到所有的线程,点击线程就可以看到该线程调用栈的情况。
程序计数器
记录当前线程执行到哪个指令了(很小的一块,存一个地址), 每个线程有一份。
堆
整个 JVM 空间中最大的区域。
new 出来的对象,都在堆上,类的成员变量也是在堆上。
堆,是一个进程有一份。
栈,是一个线程有一份。一个进程,有 N 份。
每个 Java 虚拟机(JVM)就是一个进程。
元数据区
Java8 之前叫做方法区。
类对象,常量池,静态成员,都在这里。
一个进程,一块元数据区。
针对内存区域划分:
考点:给你一段代码,问你某个变量在哪个区域上?
局部变量:栈
普通成员变量:堆
静态成员变量:元数据区/方法区
JVM 类加载机制
类加载,就是 .class 从文件(硬盘)被加载到内存(元数据区)中这样的过程。
.java 通过 javac , 得到 .class 文件
加载
加载:把 .class 文件找到,打开文件,读文件,把文件读到内存中。
注意这只是类加载机制的一小步,最终加载完成是要得到类对象。
验证
检查下 .class 文件格式是否正确。(不正确就加载失败)
.class 是一个二进制文件,这里的格式有严格说明,官方提供了 JVM 虚拟机规范,文档上详细描述了 .class 文件的格式。
Java 官方文档:https://docs.oracle.com/javase/specs/index.html
准备
给类对象分配内存空间(在元数据区占个位置),静态变量被初始化为0
解析
初始化字符串常量,把符号引用转为直接引用。
字符串常量,得有一块内存空间,存字符的实际内容。还要有一个引用,来保存这个内存空间的地址。
在类加载之前,字符串常量此时是处在 .class 文件中的,此时这个"引用"记录的并非是 字符串真正的地址,而是它在文件中的"偏移量"(或占位符)。类加载完成之后,字符串常量被加载到内存中,此时才有"内存地址",这个引用才能真正被赋值为"内存地址"。
初始化
针对类对象里的内容进行初始化,执行静态代码块,加载父类…
一个类,啥时候会被加载呢?
不是 Java 程序一运行就把所有的类都加载了,真正要用到才加载,不用就不加载(“懒汉模式”)。
什么情况才算是"用到"?
1.构造类的实例
2.调用类的静态方法/使用静态属性
3.加载子类,就会先加载其父类
一旦加载过了,就不用再重复加载了。
双亲委派模型
加载阶段,要找到 .class 文件,具体去哪里找?双亲委派模型描述的是这个问题。
JVM 默认提供了 3 个类加载器:
1.BootstrapClassLoader : 负责加载标准库中的类( Java 要求提供哪些类) ,不管是哪一种 JVM 的实现都会提供这些一样的类。
2.ExtensionClassLoader : 负责加载 JVM 扩展库中的类(规范之外,由 实现 JVM 的厂商/组织提供的额外的功能)
3.ApplicationClassLoader : 负责加载用户提供的第三方库/用户项目代码中的类
上述三个类加载器,存在"父子关系"
BootstrapClassLoader <- ExtensionClassLoader <- ApplicationClassLoader
每个 ClassLoader 都有一个 parent 属性,指向自己的父 类加载器。
上述类加载器如何配合工作?
首先加载一个类,是从 ApplicationClassLoader 开始,
但是 ApplicationClassLoader 会把加载任务交给父亲 ExtensionClassLoader 去执行;
于是 ExtensionClassLoader 要去加载了,但是也不是真加载,而是再委托给自己的父亲;
BootstrapClassLoader 要去加载了,也是想委托给自己的父亲,结果发现自己的父亲是 null,
没有父亲/父亲加载完了,才由自己进行加载。
此时 BootstrapClassLoader 就会搜索自己负责的 标准库目录相关的类,如果找到就加载,没找到就继续由子类加载器加载;
ExtensionClassLoader 真正搜索 扩展库相关的目录,如果找到就加载,没找到就继续由子类加载器加载;
ApplicationClassLoader 真正搜索用户项目相关的目录,如果找到就加载,没找到就继续由子类加载器加载(目前没有子类了,只能抛出"类找不到"这样的异常)。
这个顺序就是为了保证 BootstrapClassLoader 能够先加载,ApplicationClassLoader 后加载。
1.这就可以避免因为用户创建了一些奇怪的类而引起的 bug.
比如,如果用户写了个 java.lang.String 这个类,按照现在这个加载流程,就会先加载标准库的类,而不会加载用户写的这个类。
这样就能保证,即使出现上述问题,也不会让 jvm 已有代码出现混乱,顶多就是用户自己写的类不生效。
另一方面,类加载器是可以用户自定义的,上述 3 个类加载器是 JVM 自带的。
2.用户自定义的类加载器,可以加入到上述流程中,和现有的类加载器配合使用。
类加载,主要是围绕 3 个面试题展开的:
1.类加载的流程
2.类加载的时机
3.双亲委派模型
站在 JVM 的角度,上述 3 个东西都不是类加载的核心,真正的核心应该是 解析.class 文件,解析每个字节是干啥的(验证,准备,解析,初始化)
JVM 垃圾回收机制
垃圾回收机制 GC
啥是垃圾,就是不再使用的内存。
垃圾回收,就是把不用的内存帮我们自动释放了。
GC可以避免内存泄漏问题。
GC 好处:省心,使写代码变得简单,不容易出错。
GC 坏处:需要消耗额外的系统资源,也有额外的性能开销。GC 还有一个 STW(stop the world)问题。
STW:1.如果内存里的垃圾已经很多了,触发一次 GC,开销可能非常大,把系统资源消耗非常多。2.GC可能会涉及到一些锁操作,导致业务代码无法正常执行。这样的卡顿,极端情况下可能是几十ms甚至是上百ms.
GC 主要是针对 堆 进行释放的。
GC ,是以"对象"为单位进行回收的。
GC 实际工作过程
找到垃圾
关键思路,看这个对象有没有"引用"指向它。
如果一个对象有引用指向他,就有可能会被用到;如果没有引用指向它,就不会再被用到了。
- 具体如何知道,一个对象是否有引用指向它呢?
两种典型实现:
1.引用计数[不是 Java 的做法,python/php]
给每个对象分配了一个计数器(整数)
每次创建一个引用指向该对象,计数器 + 1;每次引用销毁,计数器 - 1
这个方法简单有效,但 Java 没有使用,原因 :1.内存空间浪费的多,每个对象都要分配一个计数器,如果代码里的对象非常多,占用的额外空间就会很多,尤其是每个对象非常小的情况下。2.存在循环引用的问题。
Python/PHP 使用引用计数,需要搭配其他的机制,来避免循环引用的问题。
2.可达性分析[ Java 的做法]
Java 里的对象,都是通过引用来指向并访问的,经常有一个引用指向一个对象,这个对象的成员又指向另一个对象。
class TreeNode{
int value;
TreeNode left;
TreeNode right;
}
TreeNode root = new TreeNode();
root.left =
整个 Java 里所有的对象,就通过链式/树形结构,整体给串起来。
这些对象被组织的结构视为树。可达性分析,就是从树根节点出发,遍历树,所有能被访问到的对象,标记为"可达",不能访问到的,就是"不可达",GC把"不可达"的作为垃圾回收了。
可达性分析,需要进行类似于"树遍历"这样的操作,相比于引用计数来说,肯定是要慢一些的。但是速度慢,没关系,可达性分析遍历操作,并不需要一直执行,隔一段时间分析一遍就可以了。
进行可达性分析,遍历的起点,称为 GCroots.
GCroots,有以下几种:
1.栈上的局部变量
2.常量池中的对象
3.静态成员变量
一个代码中有很多个这样的起点,把每个起点都往下遍历一遍,就完成了一次扫描过程。
清理垃圾
主要是 3 种基本做法:
1.标记清除
2.复制算法
解决了内存碎片问题。
每次触发算法, 都是向另外一侧进行复制。
缺点:1.空间利用率低 2.如果垃圾少,有效对象多,复制成本就比较大。
3.标记整理
解决复制算法的缺点。
标记整理,就是类似于顺序表删除中间元素,会有元素搬运的操作。保证了空间利用率,也解决了内存碎片的问题。
这种做法效率也不高,如果要搬运的空间比较大,开销也会比较大。
上述做法并不完美。基于上述这些基本策略,搞了一个复合策略"分代回收"。
把垃圾回收分成不同的场景,有的场景用这个算法,有的场景用那个算法,扬长避短。
Java 中的对象,要么就是生命周期特别长,要么就是特别短。根据生命周期的长短,分别使用不同的算法。给对象引入一个概念,年龄(熬过 GC 的轮次)。年龄越大,这个对象存在的时间就越久。
经过这一轮可达性分析的遍历,发现这个对象还不是垃圾,这就是"熬过一轮 GC"。
新生代:刚 new 出来的,年龄是 0 的对象,放到伊甸区。熬过一轮 GC ,就要放到幸存区。
Java 对象一般都是朝生夕死,生命周期非常短。所以幸存区够放。
伊甸区 -> 幸存区 通过"复制算法"。
到幸存区之后,也要周期性的接受 GC 的考验。如果变成垃圾,就被释放掉;如果不是垃圾就被放到另一个幸存区。这 2 个幸存区同一时刻只用一个。在这两者之间来回拷贝(复制算法)。
如果这个对象在幸存区已经来回拷贝很多次了,这个时候就要进入老年代了。
老年代都是年龄大的对象,生命周期更长,针对老年代,也要周期性的进行 GC 扫描,但是频率更低了。
如果老年代的对象是垃圾了,就使用标记整理的方式进行释放。
上述介绍的是 GC 典型的垃圾回收算法。
如何确定垃圾+如何清理垃圾 这些介绍的都是策略,
实际 JVM 实现的时候会有一定差异,JVM 有很多 垃圾回收实现,称为"垃圾回收器"。
垃圾回收器的具体实现,会围绕上述算法思想展开,会有一些变化。
不同的 垃圾回收器的侧重点不同,有的追求扫的快,有的追求扫的好,有的追求对用户的打扰少(STW 尽量短)文章来源:https://www.toymoban.com/news/detail-413499.html
垃圾回收器举例:CMS, G1, ZGC文章来源地址https://www.toymoban.com/news/detail-413499.html
到了这里,关于认识JVM的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!