作为八股文的常青树,或许你认为 JVM
在日常开发中并无涉及,但司空见惯的机制背后正是 JVM
所造就的,了解它的机制特性或许能在意想不到的时候给你的一个惊喜。
这篇文章就让我们共同来揭开
JVM
的神秘面纱
一、结构介绍
在开始之前让我们先了解一下 JVM
的组成结构,可以粗略的分为以下五个部分,参考下图:
1. 程序计数器
线程私有,用于标记字节码执行的位置,简单而言就是标记多线程切换时每个线程执行的进度。
程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖计数器来完成,各线程之间计数器互不影响,独立存储。
2. 虚拟机栈
线程私有,生命周期同线程,描述 Java
方法执行的线程内存模型,每个方法被执行时 JVM
都会同步创建一个 栈帧
用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
3. 本地方法栈
线程私有,本地方法栈类似虚拟机栈,但是虚拟机栈存储的 Java
中定义的方法,而本地方法栈存储的是 C++
的 native
方法。
4. Java堆
线程共享,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,Java
里几乎所有的对象实例都在这里分配内存,其可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
Java
堆既可以被实现成固定大小的,也可以是可扩展的(通过参数 -Xms
与 -Xmx
设置最大与最小值),不过当前主流的 JVM
都是按照可扩展来实现的,如果在 Java
堆中没有内存完成实例分配,并且堆也无法再扩展时, JVM
将会抛出 OutOfMemoryError
异常。
(1) 直接内存
与 Java
堆内存相对应的另一概念为 堆外内存
,也称为直接内存,其不受 Java
堆内存的限制,通过 -XX:MaxDirectMemorySize
参数限制,默认与 Java
堆最大值一致,虽然其并不属于虚拟机运行时数据区的组成部分,但仍是 Java
中非常重要的部分。
常见的如 Unsafe
类中 allocateMemory()
方法申请的内存即为直接内存,需要注意执行 Minor GC
时并不会回收直接内存的无效对象,只有在触发 Full GC
时才会回收,因此需要格外注意内存释放防止 OOM
问题。
5. 方法区
线程共享,存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据,这一部分空间通常也称为 永久代
,其配置信息如下:
- 通过
-XX:PermSize
和-XX:MaxPermSize
可设置方法区的初始大小和最大值。 - 超过方法区最大值将会抛出
java.lang.OutOfMemoryError: PermGen space
异常。
Class
文件中除了有类的版本、字段、方法、接口等描述信息外,在编译期间还会生成各种字面量与符号引用,而这部分内容将在类加载后将存放到方法区的运行时常量池中。
(1) 元空间
在 JDK 8
中 JVM
移除了方法区中的持久代,取而代之为元空间 (Meta space)
,其特性与配置如下:
- 通过
-XX:MetaspaceSize=N
与-XX:MaxMetaspaceSize=N
设置初始值与最大值,默认无最大限制。 - 当到达
-XX:MetaspaceSize
所指定的阈值后会开始进行清理该区域。 - 本地内存耗尽将抛出
java.lang.OutOfMemoryError: Metadata space
异常。
在 JDK 8
之前方法区中数据存储于 JVM
的堆内存中,因此其内存空间局限于 JVM
堆的内存空间,而元空间改为使用本地内存 (Native memory)
,即物理内存,意味着只要本地内存足够就不会出现 OOM
错误。
二、对象布局
1. 对象头
Java
的对象头中定义了两个重要字段:运行时数据 (Mark Word)
与类型指针 (Klass)
,若为数组对象则还会额外包含一个字段存储数组大小。
(1) Mark Word
Mark Word
字段存储了如哈希码 (HashCode)
、GC
分代年龄、锁状态标志、线程持有的锁、偏向线程 ID
、偏向时间戳等信息,官方称它为 Mark Word
。
Mark Word
占用大小为 32bit
,其中 25bit
用于存储对象哈希码,4bit
用于存储对象分代年龄,2bit
用于存储锁标志位,1bit
固定为 0
,这部分的数据在 32
位和 64
位的虚拟机(未开启压缩指针)中分别为 32bit
和 64bit
。
(2) Klass
Klass
字段存储了指向对象所属类的元数据的指针,也称为类的元数据指针(通过这个指针来确定该对象是哪个类的实例),允许 Java
虚拟机在运行时获取对象的类信息,并进行相应的操作。
在 HotSpot
虚拟机中的 Java
对象头中 Klass
字段是指对象的元数据信息,用于管理对象的信息,存储指向该对象类的元数据指针。对象的元数据指针大小同载体的机器指针大小,如 32
位虚拟机指针大小即为 32bit
,64
位虚拟机对应大小为 64bit
,可通过指针压缩实现 64bit
对象的指针压缩。
(3) 数组长度
当对象为 Java
数组时,则对象头必须多一块空间存储数组长度,若为普通对象则可省略。
2. 实例数据
实例数据部分是对象真正存储的有效信息,即存储在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
3. 对齐填充
这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
4. 初始对象
在为对象分配完内存之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了 TLAB
的话,这一项工作也可以提前至 TLAB
分配时顺便进行。这步操作保证了对象的实例字段在 Java
代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
Java
虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(真正调用 hashCode()
方法时才计算)、对象的 GC
分代年龄等信息。
5. 指针压缩
在上面的对象布局中提到了为了在 64
位虚拟机中实现更高效的存储因此引入了指针压缩,通过指针压缩可实现在 64
位虚拟机上同 32
位虚拟机一样仅占用 32bit
,从而节省了一半空间。
在开始之前简单介绍一下两类指针类型:
原始指针
与元数据指针
,其作用如下:
- 原始指针:作用于对象,传统意义上的指针,指向对象在内存中的存储地址。
- 元数据指针:作用于对象,即对象头的
Klass
字段,标识该对象所属的对象实例等信息。
在 JDK 6
之后默认开启原始指针的压缩(压缩后大小同 32bit
),在 JDK 8
中引入了 UseCompressedClassPointer
用于激活 Java
对象的指针压缩,指针存储空间称为 Compressed class space
。
指针压缩中涉及的配置参数如下,若程序加载引用对象过多并未能通过 GC
释放可能会抛出:java.lang.OutOfMemoryError: Compressed class space
异常。
参数 | 描述 |
---|---|
-XX:+UseCompressedOops | 激活原始指针压缩,默认开启。 |
-XX:+UseCompressedClassPointer | 激活元数据指针压缩,默认开启。 |
-XX:CompressedClassSpaceSize=1G | 指定对象元数据指针压缩空间大小,默认为 1G,避免超过 3G。 |
在运行的 Java
进程中通过 jmap
命令即可查看进程的堆内存信息,从下述中则可看到相应信息,同时注意到 MaxMetaspaceSize
值十分巨大,可以理解为其大小默认无限制。
参数 | 描述 |
---|---|
MetaspaceSize | 对象元空间大小。 |
MaxMetaspaceSize | 对象元空间最大值。 |
CompressedClassSpaceSize | 元数据指针压缩空间大小。 |
三、对象管理
1. 对象创建
当 JVM
加载至一条 new
指令时将会为对象分配相应内存空间,常见的分配方式有以下两类。
(1) 指针碰撞
假设 Java
堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞 (Bump The Pointer)
。
(2) 空闲列表
如果 Java
堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表 (Free List)
。
2. 分配方式
在为 Java
对象分配内存时存在指针碰撞和空闲列表两种方式,而具体的划分实现上存在下述两种方式。
(1) 同步处理
在为对象分配内存空间时采用 CAS
配置,通过失败重试的方式保证更新操作的原子性。
(2) 本地线程分配缓冲
将内存分配的动作按照线程划分在不同的空间之中,即每个线程在 Java
堆中预先分配一小块内存,称为本地线程分配缓冲 (Thread Local Allocation Buffer,TLAB)
,哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定本地线程分配缓冲。
3. 引用算法
在执行垃圾回收通俗的讲就是判断哪些对象已经没用应该进行资源释放,而判断一个对象是否可用存在两种算法实现:引用计数法
与 可达性分析法
。
(1) 引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时计数器值就加一,当引用失效时计数器值就减一,任何时刻计数器为零的对象就是不可能再被使用的。
但是引用计数法存在一个问题即当对象之间相互循环引用时将无法及时回收。
(2) 可达性分析法
通过一系列称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链” (Reference Chain)
,如果某个对象到 GC Roots
间没有任何引用链相连,或者用图论的话来说就是从 GC Roots
到这个对象不可达时,则证明此对象是不可能再被使用的。
可作为
GC Roots
的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如
Java
类的引用类型静态变量。- 在方法区中常量引用的对象,譬如字符串常量池
(String Table)
里的引用。- 在本地方法栈中
JNI
(即通常所说的Native
方法)引用的对象。JVM
内部的引用,如基本数据类型对应的Class
对象,一些常驻的异常对象(比如NullPointExcepiton
、OutOfMemoryError
)等,还有系统类加载器。- 所有被同步锁
synchronized
关键字持有的对象。- 反映
JVM
内部情况的JMXBean
、JVMTI
中注册的回调、本地代码缓存等。
4. 引用类型
在 Java
中一个对象的引用情况分为三类:强引用 (Strong Reference)
、软引用 (Soft Reference)
和弱应用 (Weak Reference)
。
(1) 强引用
强引用是日常使用中最常见的引用方式,当通过 new
关键字或基础类型赋值时即创建一个强引用。
如下示例中即创建了两个强引用对象,在执行 GC
垃圾回收时强引用对象资源不会被系统回收。
int i = 1;
Object obj = new Object();
(2) 软引用
通过 SoftReference
泛型类即可创建一个软引用对象,如下示例中将强引用对象 prime
转化为软引用对象。
软引用对象在不存在任何实例或引用时即可被 GC
识别,但其所占内存空间不会被立即回收,只有在 JVM
内存空间不足时才会被真正 GC
回收。
Integer prime = 1;
SoftReference<Integer> soft = new SoftReference<Integer>(prime);
// 赋空,此时可被 GC 识别但只在 JVM 内存不足时执行 GC
prime = null;
(3) 弱引用
通过 WeakReference
可创建一个弱应用对象,如下示例中将强引用对象 prime
转化为弱引用对象。
当一个对象被声明为弱引用时,在下一次 GC
生命周期即会被系统回收。
Integer prime = 1;
WeakReference<Integer> soft = new WeakReference<Integer>(prime);
// 赋空,下一次 GC 即被回收
prime = null;
四、安全点
1. OopMap
迄今为止,所有收集器在根节点枚举 (GC Roots
) 这一步骤时都是必须暂停用户线程的,从而防止在执行标记的过程中产生新的引用,而虽然可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行。
目前主流 Java
虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的,在 HotSpot
的解决方案里,是使用一组称为 OopMap
的数据结构来达到这个目的。
一旦类加载动作完成的时候,HotSpot
就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中也会在特定的位置记录下栈里和寄存器里哪些位置是引用,这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots
开始查找,从而省去大量时间。
2. 安全点
HotSpot
没有为每条指令都生成 OopMap
,如果为每一条指令都生成对应的 OopMap
,那将会需要大量的额外存储空间,因此仅在 特定的位置
记录了这些信息,这些位置被称为安全点(Safepoint
),通过合理的设置安全点,即可将 GC
的停顿时间控制在一个合理的区间。
因此,用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。
至此,针对于对象的引用链路扫描即有了一个清晰的轮廓,其基本流程如下:
- 当触发
GC
进程时用户线程需要到达安全点后并挂起。- 从安全点处根据之前提到的可作于
GC Roots
的对象寻找根节点(期间停止所有用户线程)。- 由
GC Roots
根节点信息配合OopMap
识别出其相应的引用链(可以与用户线程并发执行)。
3. 设定条件
安全点的选定既不能太少以至于让收集器等待时间过长(等待用户线程到达安全点),也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以 “是否具有让程序长时间执行的特征
” 为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行。
如上图中当触发 GC
时 方式1
中执行器需要等待用户线程达到 安全点B
耗时显然等待时间长于 方式2
,等待时间的延长从而增加了 GC
的耗时可能导致程序出现卡顿,同时在等待期间可能会产生新的垃圾对象导致 GC
效率降低,而在 方式3
由于安全点过多虽然能降低等待时间,但可能会增加运行时的内存负荷。
长时间执行
的最明显特征就是指令序列的复用,常见的具有产生安全点的功能的指令如下:
- 循环跳转;
- 异常跳转;
- 执行
native
方法;- 执行方法调用(方法的临返回之前放置);
HotSpot
虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用 int
类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的,这种循环被称为可数循环(Counted Loop
),在退出循环之后才会放置安全点。相对应地,使用 long
或者范围更大的数据类型作为索引值的循环就被称为不可数循环(Uncounted Loop
),将会被放置安全点。
如下示例中的第一个循环即为可数循环(Counted Loop
),第二个与第三个为不可数循环(Uncounted Loop
),当程序的循环次数过多时或单词循环耗时过久时,可将循环遍历类型设置为 long
或在循环内部通过 Thread.sleep(0)
显式的设置安全点,因为 sleep()
方法调用了 native
方法实现。
需要注意的一点的是设置安全点并不是代表一定会触发 GC
,而是当执行 GC
时用户线程可从当前安全点作为 GC Roots
开始扫描对象引用关系。因此,当执行耗时的可数循环(Counted Loop
)时若没有手动设置安全点但此刻却触发了 GC
进程,将会导致 GC
停顿时间过久,这显然不是我们愿意看到的结果。
// Counted Loop
for (int i = 0; i < 1000000000; i++) {
// do something
}
// Uncounted Loop
for (long i = 0; i < 1000000000; i++) {
// do something
}
// Uncounted Loop
for (int i = 0; i < 1000000000; i++) {
// do something
if(i % 1000 == 0) {
try {
// sleep() will add Safeponit
// Because it execute the native method
Thread.sleep(0);
} catch (InterruptedException e) {
}
}
}
4. 执行方案
针对如何在垃圾收集发生时让所有线程都跑到最近的安全点然后停顿下来,有两种方案可供选择:抢先式中断(Preemptive Suspension
)和主动式中断(Voluntary Suspension
)。
当下较为流行并被广泛使用的为主动式中断,二者相对应的定义如下:
(Ⅰ) 抢先式中断
抢先式中断 (Preemptive Suspension)
不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
但现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC
事件。
(Ⅱ) 主动式中断
主动式中断 (Voluntary Suspension)
即当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java
堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
5. 安全区域
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。
典型的场景便是用户线程处于 Sleep
状态或者 Blocked
状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
参考链接:
- Guide to WeakHashMap in Java
- 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》