✏️作者:银河罐头
系列专栏:JavaEE
“种一棵树最好的时间是十年前,其次是现在”
JVM(Java Virtual Machine)内存区域划分,JVM 类加载机制,JVM 垃圾回收机制。
HotSpot VM : 最主流的 JVM,Oracle 官方 jdk 和开源的 openjdk ,都是使用这个 JVM。占据绝大部分市场份额。
举个栗子:买房子,假设买了一套 100 平米的房子,区域划分,主卧,次卧,客厅,阳台,厨房,卫生间,浴室…
JVM 启动的时候,也会申请到一块很大的内存区域,JVM 是一个应用程序,要从操作系统申请内存。JVM 就会根据内存需要,把空间划分成几个部分,每个部分有各自的功能和作用。
此处所说的 “栈”,是 JVM 中的一块特定空间。
对于 JVM 虚拟机栈,这里存储的是 方法之间的调用关系。
整个虚拟机栈空间内部,可以认为是包含很多个元素,每个元素是一个 “栈帧”,每一个栈帧,包含方法的入口地址,方法的参数,返回地址,局部变量…
对于本地方法栈,这里存储的是 native 方法之间的调用关系。
由于函数调用,也是有"后进先出"的特点,此处这里的 “栈”,也是"后进先出"的。
数据结构中的"栈",是一个通用的,更广泛的概念,而此处的"栈"特指 JVM 上的一块内存空间。
调用一个方法,就会创建栈帧,方法执行结束了,栈帧就销毁了。
栈空间有上限, JVM 启动的时候是可以设置参数的,其中有一个参数就可以设置占空间的大小。
这里的栈不是只有一个。每个线程有一个,jconsole, 查看 Java 进程内部情况,就可以看到所有的线程,点击线程就可以看到该线程调用栈的情况。
记录当前线程执行到哪个指令了(很小的一块,存一个地址), 每个线程有一份。
整个 JVM 空间中最大的区域。
new 出来的对象,都在堆上,类的成员变量也是在堆上。
堆,是一个进程有一份。
栈,是一个线程有一份。一个进程,有 N 份。
每个 Java 虚拟机(JVM)就是一个进程。
Java8 之前叫做方法区。
类对象,常量池,静态成员,都在这里。
一个进程,一块元数据区。
针对内存区域划分:
考点:给你一段代码,问你某个变量在哪个区域上?
局部变量:栈
普通成员变量:堆
静态成员变量:元数据区/方法区
类加载,就是 .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 文件,解析每个字节是干啥的(验证,准备,解析,初始化)
垃圾回收机制 GC
啥是垃圾,就是不再使用的内存。
垃圾回收,就是把不用的内存帮我们自动释放了。
GC可以避免内存泄漏问题。
GC 好处:省心,使写代码变得简单,不容易出错。
GC 坏处:需要消耗额外的系统资源,也有额外的性能开销。GC 还有一个 STW(stop the world)问题。
STW:1.如果内存里的垃圾已经很多了,触发一次 GC,开销可能非常大,把系统资源消耗非常多。2.GC可能会涉及到一些锁操作,导致业务代码无法正常执行。这样的卡顿,极端情况下可能是几十ms甚至是上百ms.
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 尽量短)
垃圾回收器举例:CMS, G1, ZGC