第十九节:JVM 核心知识点总结
一、基本概念
1.1 OpenJDK
自 1996 年 JDK 1.0
发布以来,Sun 公司在大版本上发行了 JDK 1.1
、JDK 1.2
、JDK 1.3
、JDK 1.4
、JDK 5
,JDK 6
,这些版本的 JDK 都可以统称为 SunJDK 。
之后在 2006 年的 JavaOne 大会上,Sun 公司宣布将 Java 开源,在随后的一年多里,它陆续将 JDK 的各个部分在 GPL v2(GNU General Public License,version 2)协议下开源,并建立了 OpenJDK 组织来对这些代码进行独立的管理,这就是 OpenJDK 的来源,此时的 OpenJDK 拥有当时 sunJDK 7 的几乎全部代码。
1.2 OracleJDK
在 JDK 7 的开发期间,由于各种原因的影响,Sun 公司市值一路下跌,已无力推进 JDK 7 的开发,于是 JDK 7 的发布一直被推迟。
之后在 2009 年 Sun 公司被 Oracle 公司收购,为解决 JDK 7 长期跳票的问题,Oracle 将 JDK 7 中大部分未能完成的项目推迟到 JDK 8 ,并于 2011 年发布了JDK 7,在这之后由 Oracle 公司正常发行的 JDK 版本就由 SunJDK 改称为 Oracle JDK。
在 2017 年 JDK 9 发布后,Oracle 公司宣布:以后 JDK 将会在每年的 3 月和 9 月各发布一个大版本,即半年发行一个大版本,目的是为了避免众多功能被捆绑到一个 JDK 版本上而引发的无法交付的风险。
在 JDK 11 发布后,Oracle 同步调整了 JDK 的商业授权,宣布从 JDK 11 起,将以前的商业特性全部开源给 OpenJDK ,这样 OpenJDK 11 和 OracleJDK 11 的代码和功能,在本质上就完全相同了。
同时还宣布以后会发行两个版本的 JDK :
- 一个是在 GPLv2 + CE 协议下由 Oracle 开源的 OpenJDK;
- 一个是在 OTN 协议下正常发行的 OracleJDK。
两者共享大部分源码,在功能上几乎一致。唯一的区别是 Oracle OpenJDK 可以在开发、测试或者生产环境中使用,但只有半年的更新支持;而 OracleJDK 对个人免费,但在生产环境中商用收费,可以有三年时间的更新支持。
目前最新的长期支持的 JDK 是 JDK 21(LTS),详情可以参考朋友 why 技术的帖子。
1.3 HotSpot VM
它是 Sun/Oracle JDK 和 OpenJDK 中默认的虚拟机,也是目前使用最为广泛的虚拟机。
最初由 Longview Technologies 公司设计发明,该公司在 1997 年被 Sun 公司收购,随后 Sun 公司在 2006 年开源 SunJDK 时也将 HotSpot 虚拟机一并进行了开源。
Oracle 收购 Sun 以后,建立了 HotRockit 项目,并将其收购的另外一家公司(BEA)的 JRockit 虚拟机中的优秀特性集成到 HotSpot 中。
HotSpot 在这个过程里移除掉永久代,并吸收了 JRockit 的 Java Mission Control 监控工具等功能。
到 JDK 8 发行时,采用的就是集两者之长的 HotSpot VM。
我们可以在自己的电脑上使用 java -version
来获得 JDK 的信息:
二、Java 内存区域
Java 内存区域我们之前讲过,这里再盘一盘。
2.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。
字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要该计数器来完成。
每个线程都拥有一个独立的程序计数器,各个线程之间的计数器互不影响,独立存储。
2.2 虚拟机栈
虚拟机栈(Java Virtual Machine Stack)也是线程私有,它描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
方法从调用到结束就对应着一个栈帧从入栈到出栈的过程。在《Java 虚拟机规范》中,对该内存区域规定了两类异常:
- 如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出
StackOverflowError
异常; - 如果 Java 虚拟机栈的容量允许动态扩展,当栈扩展时如果无法申请到足够的内存会抛出
OutOfMemoryError
异常。
2.3 本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈类似,其区别在于:Java 虚拟机栈是为虚拟机执行 Java 方法(也就是字节码)服务的,而本地方法栈则是为 JVM 使用到的本地(Native)方法服务。
2.4 堆
堆(Java Heap)是虚拟机所管理的最大一块内存空间,它被所有线程所共享,用于存放对象实例。
Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为是连续的。Java 堆可以被实现成固定大小的,也可以是可扩展的。
当前大多数主流的虚拟机都是按照可扩展来实现的,即可以通过最大值参数 -Xmx
和最小值参数 -Xms
进行设定。
如果 Java 堆中没有足够的内存来完成对象实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError
异常。
2.5 方法区
方法区(Method Area)也是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、JIT 编译后的代码缓存等数据。
方法区也被称为 “非堆”,目的是与 Java 堆进行区分。《Java 虚拟机规范》规定,如果方法区无法满足新的内存分配需求时,将会抛出 OutOfMemoryError
异常。
JDK 8 以后的方法区实现已经不再是永久代(Permanent Generation)了,而是使用元空间(Metaspace)来实现。
运行时常量池(Runtime Constant Pool)也是方法区的一部分,用于存放常量池表(Constant Pool Table),常量池表中存放了编译期生成的各种符号字面量和符号引用。
JDK 8 以后的运行时常量池在元空间中。
三、对象
3.1 对象的创建
当我们在代码中使用 new
关键字创建一个对象时,其在 JVM 中需要经过以下步骤:
1. 类加载过程
当虚拟机遇到一条字节码指令 new
时,首先将去检查这个指令的参数是否能在常量池中定位到一个符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就必须先执行相应的类加载过程。
2. 分配内存
在类加载检查通过后,虚拟机需要给新生对象分配内存空间。根据 Java 堆是否规整,可以有以下两种分配方案:
①、指针碰撞:假设 Java 堆中内存是绝对规整的,所有使用的内存放在一边,所有未被使用的内存放在另外一边,中间以指针作为分界点指示器。
此时内存分配只是将指针向空闲方向偏移出对象大小的空间即可,这种方式被称为指针碰撞。
②、空闲列表:如果 Java 堆不是规整的,此时虚拟机需要维护一个列表,记录哪些内存块是可用的,哪些是不可用的。在进行内存分配时,只需要从该列表中选取出一块足够的内存空间划分给对象实例即可。
注:Java 堆是否规整取决于其采用的垃圾收集器是否带有空间压缩整理能力,前面讲过了。
除了分配方式外,由于对象创建在虚拟机中是一个非常频繁的行为,此时需要保证在并发环境下的线程安全:如果一个线程给对象 A 分配了内存空间,但指针还没来得及修改,此时就可能出现另外一个线程使用原来的指针来给对象 B 分配内存空间的情况。
想要解决这个问题有两个方案:
①、方式一:采用同步锁定,或采用 CAS 配上失败重试的方式来保证更新操作的原子性。
②、方式二:为每个线程在 Java 堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
线程在进行内存分配时优先使用本地缓冲,当本地缓冲使用完成后,再向 Java 堆申请分配,此时 Java 堆采用同步锁定的方式来保证分配行为的线程安全。
3. 对象头设置
将对象有关的元数据信息、对象的哈希码、分代年龄等信息存储到对象头中。
可以和 JIT 那节的内容关联起来。
4. 对象初始化
调用对象的构造方法,即 Class 文件中的 <init>()
来初始化对象,为相关字段赋值。
3.2 对象的内存布局
在 HotSpot 中,对象在堆内存中的存储布局可以划分为以下三个部分:
1. 对象头 (Header)
对象头包括两部分信息:
- Mark Word:对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,官方统称为 Mark Word,我们曾在 synchronized 的四种锁状态讲过。
- 类型指针:对象指向它类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。需要说明的是,并非所有的虚拟机都必须要在对象数据上保留类型指针,这取决于对象的访问定位方式。
2. 实例数据 (Instance Data)
即我们在代码中定义的各种类型的字段,无论是从父类继承而来,还是子类中定义的都需要记录。
3. 对齐填充 (Padding)
主要起占位符的作用。HotSpot 要求对象起始地址必须是 8 字节的整倍数,即间接要求了任何对象的大小都必须是 8 字节的整倍数。对象头部分在设计上就是 8 字节的整倍数,如果对象的实例数据不是 8 字节的整倍数,则由对齐填充进行补全。
3.3 对象的访问定位
对象创建后,Java 程序就可以通过栈上的 reference
(也就是引用)来操作堆上的具体对象。
《Java 虚拟机规范》规定 reference
是一个指向对象的引用,但并未规定其具体实现方式。主流的方式方式有以下两种:
- 句柄访问:Java 堆将划分出一块内存来作为句柄池,
reference
中存储的是对象的句柄地址,而句柄则包含了对象实例数据和类型数据的地址信息。 - 指针访问:
reference
中存储的直接就是对象地址,而对象的类型数据则由上文介绍的对象头中的类型指针来指定。
通过句柄访问对象:
通过直接指针访问对象:
句柄访问的优点在于对象移动时(垃圾收集时移动对象是非常普遍的行为)只需要改变句柄中实例数据的指针,而 reference
本身并不需要修改;
指针访问则反之,由于其 reference
中存储的直接就是对象地址,所以当对象移动时, reference
需要被修改。但针对只需要访问对象本身的场景,指针访问则可以减少一次定位开销。由于对象访问是一项非常频繁的操作,所以这类减少的效果会非常显著,基于这个原因,HotSpot 主要使用的是指针访问的方式。
四、垃圾收集机制
在 JVM 内存模型中,程序计数器、虚拟机栈、本地方法栈这 3 个区域都是线程私有的,会随着线程的结束而销毁,因此在这 3 个区域当中,无需过多考虑垃圾回收问题。垃圾回收问题主要发生在 Java 堆上。
在 Java 堆上,垃圾回收的主要内容是死亡的对象(不可能再被任何途径使用的对象)。
判断对象是否死亡有以下两种方法:
4.1 引用计数法
在对象中添加一个引用计数器,对象每次被引用时,该计数器加一;当引用失效时,计数器的值减一;只要计数器的值为零,则代表对应的对象不可能再被使用。该方法的缺点在于无法避免相互引用的问题:
objA.instance = objB
objB.instance = objA
objA = null;
objB = null;
System.gc();
如上所示,此时两个对象已经不能再被访问,但其互相持有对对方的引用,如果采用引用计数法,则两个对象都无法被回收。
4.2 可达性分析
但上面的代码在大多数虚拟机中都能被正确的回收,因为大多数主流的虚拟机都是采用的可达性分析方法来判断对象是否死亡。
可达性分析是通过一系列被称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots
间没有任何引用链相连,这代表 GC Roots
到该对象不可达, 此时证明该对象不可能再被使用。
在 Java 语言中,固定可作为 GC Roots
的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
- 在方法区(元空间)中类静态变量引用的对象,譬如 Java 类中引用类型的静态变量;
- 在方法区(元空间)中常量引用的对象,譬如字符串常量池(String Table)里的引用;
- 在本地方法栈中的 JNI(Native 方法)引用的对象;
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(如 NullPointException,OutOfMemoryError 等)及系统类加载器;
- 所有被同步锁(synchronized 关键字)持有的对象;
除了这些固定的 GC Roots
集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域的不同,还可能会有其他对象 “临时性” 地加入,共同构成完整的 GC Roots
集合。
4.3 对象引用
可达性分析是基于引用链进行判断的,在 JDK 1.2 之后,Java 将引用关系分为以下四类:
强引用 (Strongly Reference)
最传统的引用,如 Object obj = new Object()
。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
回复