第十九节: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 文件中的 来初始化对象,为相关字段赋值。
3.2 对象的内存布局
在 HotSpot 中,对象在堆内存中的存储布局可以划分为以下三个部分:
1. 对象头 (Header)
对象头包括两部分信息:
- Mark Word:对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,官方统称为 Mark Word,我们曾在 synchronized 的四种锁状态讲过。
- 类型指针:对象指向它类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。需要说明的是,并非所有的虚拟机都必须要在对象数据上保留类型指针,这取决于对象的访问定位方式。
2. 实例数据 (Instance Data)
即我们在代码中定义的各种类型的字段,无论是从父类继承而来,还是子类中定义的都需要记录。
回复