认识JVM

✏️作者:银河罐头
系列专栏:JavaEE

“种一棵树最好的时间是十年前,其次是现在”

目录

  • JVM 内存区域划分
    • 程序计数器
    • 元数据区
  • JVM 类加载机制
    • 加载
    • 验证
    • 准备
    • 解析
    • 初始化
    • 双亲委派模型
  • JVM 垃圾回收机制
    • GC 实际工作过程
      • 找到垃圾
      • 清理垃圾

推荐一本书:《深入理解 Java 虚拟机》。

JVM(Java Virtual Machine)内存区域划分,JVM 类加载机制,JVM 垃圾回收机制。

HotSpot VM : 最主流的 JVM,Oracle 官方 jdk 和开源的 openjdk ,都是使用这个 JVM。占据绝大部分市场份额。

JVM 内存区域划分

举个栗子:买房子,假设买了一套 100 平米的房子,区域划分,主卧,次卧,客厅,阳台,厨房,卫生间,浴室…

JVM 启动的时候,也会申请到一块很大的内存区域,JVM 是一个应用程序,要从操作系统申请内存。JVM 就会根据内存需要,把空间划分成几个部分,每个部分有各自的功能和作用。

认识JVM_第1张图片

此处所说的 “栈”,是 JVM 中的一块特定空间。

对于 JVM 虚拟机栈,这里存储的是 方法之间的调用关系。

整个虚拟机栈空间内部,可以认为是包含很多个元素,每个元素是一个 “栈帧”,每一个栈帧,包含方法的入口地址,方法的参数,返回地址,局部变量…

对于本地方法栈,这里存储的是 native 方法之间的调用关系。

认识JVM_第2张图片

由于函数调用,也是有"后进先出"的特点,此处这里的 “栈”,也是"后进先出"的。

数据结构中的"栈",是一个通用的,更广泛的概念,而此处的"栈"特指 JVM 上的一块内存空间。

调用一个方法,就会创建栈帧,方法执行结束了,栈帧就销毁了。

栈空间有上限, JVM 启动的时候是可以设置参数的,其中有一个参数就可以设置占空间的大小。

这里的栈不是只有一个。每个线程有一个,jconsole, 查看 Java 进程内部情况,就可以看到所有的线程,点击线程就可以看到该线程调用栈的情况。

程序计数器

记录当前线程执行到哪个指令了(很小的一块,存一个地址), 每个线程有一份。

整个 JVM 空间中最大的区域。

new 出来的对象,都在堆上,类的成员变量也是在堆上。

堆,是一个进程有一份。

栈,是一个线程有一份。一个进程,有 N 份。

每个 Java 虚拟机(JVM)就是一个进程。

元数据区

Java8 之前叫做方法区。

类对象,常量池,静态成员,都在这里。

一个进程,一块元数据区。

针对内存区域划分:

考点:给你一段代码,问你某个变量在哪个区域上?

局部变量:栈

普通成员变量:堆

静态成员变量:元数据区/方法区

JVM 类加载机制

类加载,就是 .class 从文件(硬盘)被加载到内存(元数据区)中这样的过程。

.java 通过 javac , 得到 .class 文件

认识JVM_第3张图片

加载

加载:把 .class 文件找到,打开文件,读文件,把文件读到内存中。

注意这只是类加载机制的一小步,最终加载完成是要得到类对象。

验证

检查下 .class 文件格式是否正确。(不正确就加载失败)

.class 是一个二进制文件,这里的格式有严格说明,官方提供了 JVM 虚拟机规范,文档上详细描述了 .class 文件的格式。

Java 官方文档:https://docs.oracle.com/javase/specs/index.html

认识JVM_第4张图片

认识JVM_第5张图片

准备

给类对象分配内存空间(在元数据区占个位置),静态变量被初始化为0

解析

初始化字符串常量,把符号引用转为直接引用。

字符串常量,得有一块内存空间,存字符的实际内容。还要有一个引用,来保存这个内存空间的地址。

在类加载之前,字符串常量此时是处在 .class 文件中的,此时这个"引用"记录的并非是 字符串真正的地址,而是它在文件中的"偏移量"(或占位符)。类加载完成之后,字符串常量被加载到内存中,此时才有"内存地址",这个引用才能真正被赋值为"内存地址"。

初始化

针对类对象里的内容进行初始化,执行静态代码块,加载父类…

认识JVM_第6张图片

一个类,啥时候会被加载呢?

不是 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 ,是以"对象"为单位进行回收的。

认识JVM_第7张图片

GC 实际工作过程

找到垃圾

关键思路,看这个对象有没有"引用"指向它。

如果一个对象有引用指向他,就有可能会被用到;如果没有引用指向它,就不会再被用到了。

  • 具体如何知道,一个对象是否有引用指向它呢?

两种典型实现:

1.引用计数[不是 Java 的做法,python/php]

给每个对象分配了一个计数器(整数)

每次创建一个引用指向该对象,计数器 + 1;每次引用销毁,计数器 - 1

认识JVM_第8张图片

这个方法简单有效,但 Java 没有使用,原因 :1.内存空间浪费的多,每个对象都要分配一个计数器,如果代码里的对象非常多,占用的额外空间就会很多,尤其是每个对象非常小的情况下。2.存在循环引用的问题。

认识JVM_第9张图片

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.标记清除

认识JVM_第10张图片

2.复制算法

解决了内存碎片问题。

认识JVM_第11张图片

每次触发算法, 都是向另外一侧进行复制。

缺点:1.空间利用率低 2.如果垃圾少,有效对象多,复制成本就比较大。

3.标记整理

解决复制算法的缺点。

标记整理,就是类似于顺序表删除中间元素,会有元素搬运的操作。保证了空间利用率,也解决了内存碎片的问题。

这种做法效率也不高,如果要搬运的空间比较大,开销也会比较大。

上述做法并不完美。基于上述这些基本策略,搞了一个复合策略"分代回收"。

把垃圾回收分成不同的场景,有的场景用这个算法,有的场景用那个算法,扬长避短。

Java 中的对象,要么就是生命周期特别长,要么就是特别短。根据生命周期的长短,分别使用不同的算法。给对象引入一个概念,年龄(熬过 GC 的轮次)。年龄越大,这个对象存在的时间就越久。

经过这一轮可达性分析的遍历,发现这个对象还不是垃圾,这就是"熬过一轮 GC"。

认识JVM_第12张图片

新生代:刚 new 出来的,年龄是 0 的对象,放到伊甸区。熬过一轮 GC ,就要放到幸存区。

Java 对象一般都是朝生夕死,生命周期非常短。所以幸存区够放。

伊甸区 -> 幸存区 通过"复制算法"。

到幸存区之后,也要周期性的接受 GC 的考验。如果变成垃圾,就被释放掉;如果不是垃圾就被放到另一个幸存区。这 2 个幸存区同一时刻只用一个。在这两者之间来回拷贝(复制算法)。

如果这个对象在幸存区已经来回拷贝很多次了,这个时候就要进入老年代了。

老年代都是年龄大的对象,生命周期更长,针对老年代,也要周期性的进行 GC 扫描,但是频率更低了。

如果老年代的对象是垃圾了,就使用标记整理的方式进行释放。

上述介绍的是 GC 典型的垃圾回收算法。

如何确定垃圾+如何清理垃圾 这些介绍的都是策略,

实际 JVM 实现的时候会有一定差异,JVM 有很多 垃圾回收实现,称为"垃圾回收器"。

垃圾回收器的具体实现,会围绕上述算法思想展开,会有一些变化。

不同的 垃圾回收器的侧重点不同,有的追求扫的快,有的追求扫的好,有的追求对用户的打扰少(STW 尽量短)

垃圾回收器举例:CMS, G1, ZGC

你可能感兴趣的:(JavaEE初阶,jvm,java,开发语言)