Java虚拟机——JVM

1. 引入

几个常见名词,及其包含关系:jdk java 开发工具包 ;jre java 运行的环境 ;jvm java 虚拟机 (解释执行 java 字节码)。 jvm > jre > jdk。

计算机导论中编程语言,可以分成 1)编译型的语言 2)解释型的语言

按照上述这种经典的划分方式,可以认为,java 属于"半编译,半解释"。java 这么设定,最主要的目的,是为了"跨平台"。

C++ 这样的语言是直接编译成了二进制的机器指令。不同的 cpu,上面支持的指令不一样;

而且,生成的可执行程序,在不同的系统上也有不同的格式。windows 可执行程序为 PE 格式,

Linux 可执行程序为 ELF 格式。需要重写编译。

Java 不想重新编译,而是期望能够直接执行。

先通过 javac 把 java 文件 => .class文件 (字节码文件,包含的就是 java 字节码------

java 自己搞的一套 "CPU 指令")

然后在某个具体的系统平台上执行,此时再通过 jvm,把上述的字节码转换成对应的 cpu

能识别的机器指令。

上述过程中,jvm就是一个"翻译官"。

因此,我们编写和发布一个 java 程序,其实就只要发布 .class 文件即可。
jvm 拿到 .class 文件,就知道该如何转换。
windows 上的 jvm 就可以把 .class 转成 windows 上能支持的可执行指令了。
linux 上的 jvm 就可以把 .class 转成 linux 上可以支持的可执行指令了。

不同平台的 jvm 是存在差异的,对上(给 java 层面上提供的内容) 是统一一致的。

.HotSpot VM 是当前主流的 jvm

2. JVM 中的内存区域划分

JVM 其实也是一个进程(任务管理器中看到的 java 进程),进程运行过程中,要从操作系统这里申请一些资源。(内存就是其中的典型资源)这些内存空间,就支撑了后续 java 程序的执行。

jvm 从系统申请了一大块内存. 这一大块内存给 java 程序使用的时候, 又会根据实际的使用用途来换分出不同的空间(这个就是所谓的 "区域划分" ),就像房子有客厅,卧室,洗手间......。如图:

2.1 堆

代码中 new 出来的对象,就都是在堆里。 只有一份,对象中特有的非静态成员变量。也就在堆里。

2.2 栈

分为 本地方法栈 / 虚拟机栈 ,包含方法的调用关系,可能有 N 份。

本地方法栈jvm 内部,通过 C++ 写的代码,调用关系和局部变量。
虚拟机栈 :记录了 java 代码的调用关系, java 代码的局部变量。

(一般不会关注本地方法栈,一般来说谈到栈,默认指的就是 虚拟机栈)

2.3 程序计数器

这个区域比较小的空间,专门用来存储下一条要执行的 java 指令的地址。可能有 N 份

每个线程都有自己的程序计数器和栈(每个线程有自己的执行流)

2.4 元数据区

以前的 java 版本中,也叫做"方法区",从 1.8 开始,改名字。

"元数据"是计算机中的一个常见术语(meta data),往往指的是一些辅助性质的、描述性质的属性。
只有一份

硬盘上不仅仅要有文件的数据库格式,还需要存储一些辅助信息:比如文件的大小、文件的位量、文件的拥有者、文件的修改时间、文件的权限信息......统称为"元数据"

类的信息、方法的信息、一个程序,有哪些类,每个类型都有哪些方法,每个方法里面都要包含哪些指令都会记录在元数据区中。

我们写的 java 代码,if, while, for, 各种逻辑运算......,这些操作最终都会被转换成 java 字节码。(javac 就会完成上述代码 => 字节码)

此时这些字节码在程序运行的时候就会被 jvm 加载到内存中,放到元数据区(方法区)里头。此时,当前程序要如何执行,要做哪些事情,就会按照元数据区里记录的字节码依次执行了。


经典笔试题:

java 复制代码
class Test {
    private int n;
    private static int m;
}

main() {
    Test t = new Test();
}

//上述代码中,t, n, m 各自处于 JVM 内存中的哪个区域?

n :是 Test 的非静态成员变量,处于 堆 上的;

t:是一个局部变量(引用类型),t 这个变量本身是在 栈 上。

m:元数据区

static 修饰的变量, 称为 "类属性",static 修饰的方法, 称为 "类方法";

非 static 的变量, 称为 "实例属性",非 static 的方法, 称为 "实例方法";

上述带有 static 修饰的变量, 就是在类对象中, 也就是在元数据区(方法区)中。

即 Test.class。

JVM 把 .class 文件加载到内存之后, 就会把类的信息使用对象来表示, 此时这样的对象就是类对象。

类对象里就包含了一系列信息:

包括不限于: 类的名称, 类继承自哪个类, 实现了哪些接口。

都有哪些属性, 都叫什么名字, 都是什么类型, 都是什么权限

都有哪些方法, 都叫什么名字, 都是什么参数, 都是什么权限...

.java 文件中涉及到的信息都会在 .class 中有所体现 (注释是不会包含的...)

区分一个变量在哪个内存区域中, 最主要就是看变量的 "形态" (局部变量, 成员变量, 静态成员变量...)

3. JVM 的类加载机制

类加载,指的是, java 进程运行的时候, 需要把 .class 文件从硬盘, 读取到内存, 并进行一系列的校验解析的过程。

类加载大体的过程可以分成 5 个步骤(也有资料上说是 3 个,这个情况就是把 2, 3, 4 合并成一个了)

3.1 加载

把 硬盘 上的 .class 文件,找到并打开文件,读取到文件内容。(认为读到的是二进制的数据)

3.1.1 双亲委派模型

双亲委派模型(加载环节)描述了如何查找.class 文件的策略。

JVM 中进行类加载的操作,是有一个专门的模块,称为 "类加载器" (ClassLoader)

类加载器的作用,给他一个 "全限定类名" (带有包名的类名) eg:java.lang.String, 给定全限定类名之后,找到对应的.class 文件。

JVM 中的类加载器默认是有三个的。(也可以自定义)

  1. BootstrapClassLoader 负责查找标准库的目录

  2. ExtensionClassLoader 负责查找扩展库的目录

    Java 语法规范里面描述了标准库中应该有哪些功能。 实现 JVM 的厂商/组织,也会再标准库的基础上扩充一些额外的功能 (JVM 内置的,不同的厂商扩展可能不太一样)。这块的内容上古时期,用处比较多。随着时代的发展,这块的内容很少会使用了。

  3. ApplicationClassLoader 负责查找当前项目的代码目录以及第三方库的目录

上述的三个类加载器, 存在"父子关系"(不是 面向对象中的父类 、子类 继承关系),而是类似于"二叉树",有一个指针 (引用) parent, 指向自己的"父" 类加载器。

双亲委派模型工作过程:

  1. 从 ApplicationClassLoader 作为入口,先开始工作。

  2. ApplicationClassLoader 不会立即搜索自己负责的目录,会把搜索的任务交给自己的父亲。

  3. 代码就进入到 ExtensionClassLoader ,ExtensionClassLoader 也不会立即搜索自己负责的目录,也要把搜索的任务交给自己的父亲。

  4. 代码就进入到 BootstrapClassLoader ,BootstrapClassLoader 也不想立即搜索自己负责的目录,也要把搜索的任务交给自己的父亲。

  5. BootstrapClassLoader 发现自己没有父亲,才会真正搜索负责的目录(标准库目录)。通过全限定类名,尝试在标准库目录中找到符合要求的 .class 文件。

    如果找到了,接下来就直接进入到打开文件/读文件等流程中。

    如果没找到,回到孩子这一辈的类加载器中,继续尝试加载。

  6. ExtensionClassLoader 收到父亲交回给他的任务之后,自己进行搜索负责目录(扩展库的目录)。

  7. ApplicationClassLoader 收到父亲交回给他的任务之后 ,自己进行搜索负责的目录(当前项目目录/第三方库目录) 。

    如果找到了,接下来进入后续流程。

    如果没找到,也是回到孩子这一辈的类加载器中继续尝试加载,由于默认情况下 ApplicationClassLoader 没有孩子了, 此时说明类加载过程失败了,就会抛出ClassNotFoundException 异常。

上述设定的最主要的目的就是为了确保这几个类加载器之间的优先级,按照上述的顺序,假定在代码中自己定义了一个java.lang.String 这样的类,最终程序的执行效果,自定义的类不会被 jvm 加载。

上述的设定,也可以有效避免你自己写的类、不小心和标准库的类名字重复,导致标准库的类功能失效。

上述这一系列规则,只是 JVM 自带的类加载器,遵守的默认规则。如果自己写类加载器,也可以打破上述规则。比如,自己写类加载器,指定这个加载器就在某个目录中尝试加载。此时如果类加载器的 parent 不去和已有的这些类加载器连到一起,此时就是独立的,不涉及到双亲委派了。

3.2 验证

当前需要确保读到的文件的内容,是合法的 .class 文件(字节码文件)格式。具体的验证依据,在 java 的虚拟机规范中,有明确的格式说明。

u4 四个字节的无符号整数,u2 两个字节的无符号整数。

java 中, int 就是 四个字节,short 就是 两个字节。 但是 C++ 中不是,C++ 中能自己通过 typedef 定义出一些类型。 往往就是类似于 u4 u2 之类的。 通过 4/2 其实就是在描述出这个类型的长度。

magic 也叫做 magic number。魔幻数字,广泛应用于二进制文件格式中。 用来标识当前二进制文件的格式是哪种类型。

3.3 准备

给类对象,申请内存空间。此时申请到的内存空间,里面的默认值,都是全 0 的。

(这个阶段中,类对象里的静态成员变量的值也就相当于是 0 了)

3.4 解析

主要是针对类中的字符串常量进行处理。
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。

java 复制代码
class Test{
	private String s = "hello";
}

上述代码中很明确的知道, s 变量里相当于保存了 "hello" 字符串常量的地址。但是,在文件中

不存在 "地址" 这样的概念。(地址是 "内存" 的地址。这是文件,是硬盘)。虽然没有地址,可以存储一个类似于地址 "偏移量" 这样的概念。

此处文件中填充给 s 的 "hello" 的偏移量就可以认为是 "符号引用"。

接下来,要把 .class 文件加载到内存中,就会先把 "hello" 这个字符串加载到内存中。此时 "hello" 就有地址了。接下来,s 里面的值就可以替换成当前 "hello" 真实的地址(直接引用)。

3.5 初始化

针对类对象完成后续的初始化,还要执行静态代码块的逻辑,还可能会触发父类的加载。

4. JVM 中的垃圾回收算法

这里主要是 垃圾回收机制 (GC)

C 语言:动态内存管理,malloc 申请内存,free 释放内存。此处申请到的内存,生命周期是跟随整个进程的。这一点对于服务器程序非常不友好,服务器每个请求都去 malloc 一块内存,如果 free 释放就会使申请的内存越来越多,后续要想申请内存就没得申请了。即 内存泄露问题。

实际开发中,很容易出现 free 不小心就忘记调用了,或者因为一些情况没有执行到,例如:函数中间存在 if -> return 或者 抛出异常了。

能否让释放内存的操作,由程序自动负责完成?而不是依赖程序猿手工释放呢?

java 就引入了这样的机制 ------ GC,就不需要靠手动来进行释放了。程序会自动判定,某个内存是否会继续使用,如果内存后续不用了,就会自动释放掉。

垃圾回收中的一个很重要的问题: STW (stop the world) 问题。

触发垃圾回收的时候, 很可能会使当前程序的其他业务逻辑被暂停,Java 发展这么多年, GC 这块的技术积累也越来越强大, 有办法把 STW 的时间控制到 1ms 之内。注:一个服务器请求/响应 处理时间, 典型的时间 几毫秒 - 几十毫秒。

垃圾回收,是回收内存。JVM 中的内存有好几块:

  1. 程序计数器 (不需要 GC)
  2. 栈 (不需要 GC)局部变量都是在代码块执行结束之后自动销毁。 (栈自己的特点, 和垃圾回收没啥关系),生命周期都非常明确。
  3. 元数据区/方法区 (一般不需要 GC),一般都是涉及到 "类加载" 很少涉及到 "类卸载"。
  4. 堆 是 GC 主要的战场。

其实这里的垃圾回收,说是回收内存,更准确的说是"回收对象",每次垃圾回收的时候,释放的若干个对象(实际的单位都是对象)。如图:

垃圾回收的两个重要步骤:1) 识别出垃圾。哪些对象是垃圾(不再使用),哪些对象不是垃圾。2) 把标记为垃圾的对象的内存空间进行释放。

4.1 垃圾识别

即判定你这个对象后续是否要继续使用。在 Java 中,使用对象,一定需要通过引用的方式来使用(当然,有一个例外,匿名对象),如果一个对象没有任何引用指向他,就视为是无法被代码中使用,就可以作为垃圾了。

java 复制代码
void func() {
    Test t = new Test() {
    tsasdfasdfa();
}

执行到这个 } 之后,此时局部变量 t 就直接被释放了。此时再进一步,上述 new Test() 对象,也就没有引用指向他了。此时,这个代码就无法访问使用这个对象,这个对象就是垃圾了。

如果代码更复杂一些,这里的判定过程也就更麻烦了

java 复制代码
Test t1 = new Test();
Test t2 = t1;
t3 = t2;
t4 = t3;
......

此时就会有很多引用指向 new Test 同一个对象,(此时有很多引用, 都保存了 Test 对象的地址)。

此时通过任意的引用都能访问 Test 对象。需要确保所有的指向 Test 对象的引用都销毁了, 才能把 Test 对象视为垃圾。

如果代码比较复杂, 上述这些引用的生命周期各不相同的, 此时情况就不好办了。

对于此问题的解决,有两种思路:

  1. 引用计数
    即给每个对象安排一个额外的空间,空间里要保存当前这个对象有几个引用。
    这种思想方法,并没有在 JVM 中使用。但是广泛应用于其他主流语言的垃圾回收机制中。(如:Python, PHP)

此时垃圾回收机制(有专门的扫描线程,去获取到当前每个对象的引用计数的情况),发现对象的引用计数为 0,说明这个对象就可以释放了。(就是垃圾了)

引用计数机制,是一个简单有效的机制,存在两个关键的问题
问题一消耗额外的内存空间

要给每个对象都安排一个计数器(假定计数器按照 2 个字节算) ,如果整个程序中对象数目很多,总的消耗的空间也会非常多。

尤其是如果每个对象体积比较小 (假设每个对象 4 个字节) ,计数器消耗的空间,已经达到对象的空间的一半。

问题二 :引用计数可能会产生**"循环引用的问题"**此时,引用计数就无法正确工作了。

如下代码所示:

java 复制代码
class Test {
    Test t;
}

Test a = new Test();
Test b = new Test();

a.t = b;

b.t = a;

a = null;

b = null;

上述代码出现问题了,两个对象相互引用,此时两个对象, 引用计数都不是 0 ,不能被 GC 回收掉,但是这俩对象又无法使用。

  1. 可达性分析 (JVM 用的是这个)
    本质上是用"时间 换 空间" 相比于引用计数,需要消耗更多的额外的时间,但是总体来说,还是可控的。不会产生类似于"循环引用"这样的问题。

在写代码的过程中,会定义很多的变量。比如,栈上的局部变量/方法区中的静态类型的变量/常量池中引用的对象...

就可以从这些变量作为起点,出发,尝试去进行"遍历",所谓的遍历就是会沿着这些变量中持有的引用类型的成员,再进一步的往下进行访问,所有能被访问到的对象,自然就不是垃圾了。剩下的遍历一圈也访问不到的对象,自然就是垃圾。

如下代码:

java 复制代码
class Node {
    char val;
    Node left;
    Node right;
}

Node buildTree() {

Node a = new Node();
Node b = new Node();
Node c = new Node();
Node d = new Node();
Node e = new Node();
Node f = new Node();
Node g = new Node();

a.left = b;
a.right = c;
b.left = d;
b.right = e;
e.left = g;
c.right = f;

return a;
}

Node(root) = buildTree();

虽然这个代码中,只有一个 root 这样的引用了,但是实际上上述 7 个节点对象都是 "可达的"。JVM 中存在扫描线程, 会不停的尝试对代码中已有的这些变量, 进行遍历,尽可能多的去访问到对象。

JVM 自身知道一共有哪些对象,通过可达性分析的遍历,把可达的对象都标记出来了。剩下的自然就是不可达。

4.2 垃圾空间回收

把标记为垃圾的对象的内存空间进行释放。主要的释放方式,有三种:

  1. 标记-清除
    把标记为垃圾的对象,直接释放掉。(最朴素的做法)一般不会使用这个方案,内存碎片问题,比较致命。

内存碎片问题:上述释放方式,就可能会产生很多小的,离散的空间内存空间。而内存申请,都是一次申请一个连续的内存空间,例如:申请 1M 内存空间,此时,1M 字节,都是连续的。

如果存在很多内存碎片,就可能导致,总的空闲空间,远远超过 1MB,但是并不存在比 1MB 大的连续的空间。此时,去申请内存空间就会失败。

eg:这一次尝申请 1MB,此时总的空闲空间比 1M 大。比如有 1K 个碎片,每个碎片大小是 10K

此时总的空闲空间是 10M,但是 每个碎片都是最大是 10K 没有超过 1M。无法申请。

  1. 复制算法
    复制算法:核心就是不直接释放内存, 而是把不是垃圾的对象, 复制到内存的另一半里。接下来就把左侧空间整体释放掉。

这样做确实能够规避内存碎片问题, 但是也有缺点:

  1. 总的可用内存, 变少了(一半都用来放垃圾了)。买两个煎饼果子, 吃一个丢一个。

  2. 如果每次要复制的对象比较多, 此时复制开销也就很大了。需要是当前这一轮 GC 的过程中, 大部分对象都释放, 少数对象存活, 这个时候适合使用复制。

  3. 标记-整理
    也能解决内存碎片问题。
    类似于 顺序表 删除 (搬运) 中间元素(即垃圾对象)

通过这个过程,也能有效解决内存碎片问题,并且这个过程也不像复制算法一样,需要浪费过多的内存空间。但是,这里的搬运内存开销很大。

因此,JVM 中没有直接使用上述的方案,而是结合上述思想,搞出了一个"综合性"方案,取长补短。

4.3 分代回收

分代回收(依据不同种类的对象,采取不同的方式)

引入概念,对象的年龄。JVM 中有专门的线程负责周期性扫描/释放。一个对象,如果被线程扫描了一次,可达了(不是垃圾),年龄就 + 1(初始年龄相当于是 0)。

JVM 中就会根据对象年龄的差异,把整个堆内存分成两个大的部分:新生代(年龄小的对象) / 老年代(年龄大的对象)。

这个年龄也是需要占的内存的,(每个对象有一个"对象头",在对象头里有一个属性来存储年龄)。

  1. 当代码中 new 出一个新的对象,这个对象就是被创建在伊甸区的。伊甸区中就会有很多的对象。

    一个经验规律: 伊甸区中的对象,大部分是活不过第一轮 GC。

    这些对象都是"朝生夕死",生命周期很短。

  2. 第一轮 GC 扫描完成之后,少数伊甸区中幸存的对象,就会通过复制算法,拷贝到 生存区。

    后续 GC 的扫描线程还会持续进行扫描。不仅要扫描伊甸区,也要扫描生存区的对象。

生存区中的大部分对象也会在扫描中被标记为垃圾。少数存活的,就会继续使用复制算法,拷贝到另外一个生存区中。只要这个对象能够在生存区中继续存活,就会被复制算法继续拷贝到另一半的生存区中。(从 A 到 B 再到 A 到 B)

每次经历一轮 GC 的扫描,对象的年龄都会 + 1

  1. 如果这个对象在生存区中,经过了若干轮 GC 仍然健在,JVM 就会认为,这个对象生命周期大概率很长,就把这个对象从生存区,拷贝到老年代。

  2. 老年代的对象,当然也要被 GC 扫描,但是扫描的频次就会大大降低了。

    老年代的对象,要 GG 早 GG 了,既然没 G 说明生命周期应该是很长的。频繁 GC 扫描意义也不大,白白浪费时间。不如放到老年代,降低扫描频率。

  3. 对象在老年代寿终正寝,此时 JVM 就会按照标记整理的方式,释放内存。

上述分代回收是 JVM GC 中的核心思想。

但是 JVM 实际的 垃圾回收 的实现细节上,还会存在一些变数和优化。具体和是哪个垃圾收集器有关,感兴趣的主要掌握 CMS (老),G1 ( 中 ) 就可以了 (ZGC 青)。