前言
此文是我阅读了《深入理解Java虚拟机》
和一些其它博客后对于Java运行时数据区的结构组成的一个总结,它阐述了Java 虚拟机的运行时数据区各组成,以及如何通过垃圾回收机制保证内存的可用。
由于本人能力的问题,在书写时难免会有错误或纰漏,希望发现的读者可以指出来,以让我进步。
运行时数据区
JVM 的运行时数据区以俯瞰的角度来看其实并不复杂,主要分为方法区、本地方法栈,虚拟机方法栈、堆、程序计数器
,它们的组成如下所示:
对于方法区和堆,它们是属于线程共享的,
为了提高性能,在堆上会划分出一个小区域在存储单个线程的数据,称之为 TLAB——Thread Local Allocation Buffer。该内容后面会提到。
对于虚拟机方法栈(虚拟机方法即非本地方法)、本地方法栈、程序计数器,它们是线程独有的。
TLAB的大小是固定的,如果创建的对象大于当前线程TLAB的剩余大小,那么就会放入堆中。在一些情况下会造成空间的浪费。为了解决这个问题,虚拟机设置了最大浪费空间。当剩余的空间小于最大浪费空间,那该TLAB属于的线程在重新向Eden区申请一个TLAB空间。进行对象创建,还是空间不够,那你这个对象太大了,那么就直接去Eden堆创建,当剩余空间大于最大浪费空间,那么这个大对象也是去Eden堆创建。
接下来讲简要介绍一下运行时数据区的各组成部分。
方法区
方法区存放的是类的相关信息(可以称为类元信息
)、静态变量和常量等数据。因为这些数据大多是经常会被使用的,可以看作是永久存在
,所以在以前一些虚拟机实现比如HotSpot
中将方法区也称为永久代
。
永久代
的称呼早于方法区
,因为以前的永久代是位于 JVM 内存模型的,如果里面数据过多会触发OOM
错误,但是现在的方法区是被移到操作系统内存,也就是说它现在使用的不是虚拟机内存,而是直接和操作系统内存挂钩。方法区大小可以指定,也可以采用扩展方式。
虽然现在的方法区相当于以前的永久代,但是实际上这里还是会存在垃圾收集的,主要的回收是针对常量池和类的卸载。
对于常量的回收要求很简单,只要不存在对它的引用就会被回收。但是对于类信息则是比较严格,需要满足下面三个条件才会被允许回收:
- 这个类的所有实例都被回收(包括子类)
- 加载这个类的
ClassLoader
被回收 - 这个类的
java.lang.Class
对象没有被引用
常量池
常量都是存放在常量池里的,Java 中的常量池主要包括三种:
-
字符串常量池
在 HotSpot 中字符串常量池的实现是一个名为
StringTable
的哈希表,这个表被所有类所共享。 -
class常量池
该常量池顾名思义就是存放编译器通过 class 文件生成的字面量和符号引用。
- 字面量:字符串、基本类型的值、
final
常量 - 符号引用:类和方法的全限定名、字段名称和描述符、方法名称和描述符
- 字面量:字符串、基本类型的值、
-
运行时常量池
运行时常量池存在于内存中,在类加载到内存中后,JVM 就会将 class 常量池中的内存放到运行时常量池中,同时把符号引用替换为直接引用,在解析的过程中会去查询 StringTable 表,来保证运行时常量池和字符串常量池中的所引用字符串时一致的。
符号引用和直接引用的区别?
符号引用是因为在编译时,Java 类并不知道所引用的类的实际地址,所以只能通过符号引用来代替。比如:
org.example.B
引用了org.example.A
,但是B
并不知道A
的实际内存地址,所以只能用org.example.A
来代替。直接引用是能明确所指向的实际内存地址的值,它可以是直接指向目标的指针、相对偏移量、一个能间接定位到目标的句柄。当转换为直接引用,那么引用的目标必定以及加载进了内存。
jdk1.8
中字符串常量池和运行时常量池在逻辑上是在方法区里的,但在物理上是存放在堆内存里的,也就是说,物理上Java 的内存模型如下所示:
方法栈
方法栈包括本地方法栈和虚拟机方法栈,本地方法栈就是直接用C++
等底层代码对操作系统进行操作的方法,虚拟机方法通俗地来说就是用Java写的或者是用虚拟机执行的方法。
方法栈是一个后入先出的栈结构,每一个栈都是由若干个栈帧构成的,栈帧可以看作一个方法,每个栈帧里面包含了局部变量表、操作数栈、动态链接、方法出口等。
局部变量表
栈帧中局部变量表的单位是槽(solt)
,Java 并没有规定一个solt的大小是多少,而只是说明了:一个solt可以存放boolean、byte、char、int、float、reference、returnAddress
类型的数据。因为1个字节在操作系统中占8位,int为4字节,所以我们一般可以说solt的大小为32位。
为了节省存储空间,solt是可以复用的,但是在某些情况下会影响到GC的收集。比如来自《深入理解Java虚拟机》
一书的例子:
public static void main(String[] args){
{
byte[] p = new byte[64 * 1024 * 1024];
}
// 加上这一行 p 才会被回收
int a = 0;
System.gc();
}
加上
int a = 0
这一行后,原本的p所在的slot被替换为a,作为GC Root的局部变量表没有保持对它的关联,所以就会被回收。这也是为什么有一些代码会给不需要使用的变量赋值为
null
,之所以能够加快回收就是这个原因。但是也不要过多依赖赋值为null,因为在JIT
编译后,会消除原本赋值为null的操作,并且即使去掉了int a = 0
这一行,经过优化后的代码也会回收掉变量p。
方法中如果有
this
会怎么样?对于实例方法,局部变量表中第0位默认是用于传递方法所属的对象的引用,在方法中对应的就是
this
的使用,其余参数按照参数列表进行排列。
操作数栈
操作数栈就是一个栈类型,在方法执行过程中,会从局部变量表或对象实例字段中复制数据到操作数栈,根据计算指定对出栈数据进行操作。
关于操作数栈的简单实战可以查看这篇文章:Class文件解析实战
为了减少内存空间使用,一部分虚拟机会将一个栈帧的部分操作数栈和另一个栈帧的局部变量表重叠,这样在进行方法调用时可以共用一份数据,减少了参数复制的内容。
动态链接
每个栈帧包含了一个指向运行时常量池中该栈帧所属方法的引用。
栈帧中的动态链接是为了Class类的符号引用和直接引用在运行时进行解析和连接提供的支持。
在类加载阶段,符号引用转为直接引用为静态解析。
在运行阶段,符号引用转为直接引用为动态连接。
方法返回地址
在方法正常执行或异常抛出后需要跳转到调用该方法的地址(即方法返回地址),在返回时需要携带一些额外信息,用于帮助恢复上层方法的执行状态。
在方法正常返回时,方法返回地址一般是调用者的PC寄存器的值。
在方法异常退出时,返回地址使通过异常处理表来获得的,栈帧中一般不包含这部分信息。
堆
Java 堆是内存管理的重灾区
,因为在程序运行过程中,会不断地创建销毁对象,而堆中存放的是对象的实例,因此会涉及到堆的频繁操作。如何让堆中的内存能够存放程序中创建的对象实例,如果高效地管理堆中的数据,这些是垃圾收集机制和垃圾收集器的关注点。
分代划分
为了很好地管理堆中的数据,虚拟机对堆中存活时间不同的数据进行了划分,并在逻辑上为它们分配了对应的内存位置,具体如下图所示:
堆内存被分为
Young
(图中绿色区域)和0ld
,年轻代被分为Eden
和Survisor
区,Eden
区存放的是刚创建的新对象,经过一定次数的垃圾回收后,会被放入到Survisor
区,在Survisor
区经过一定垃圾回收后被会放入到Old
区。为什么
Survisor
区又会被分为from
和to
两个部分?此做法是为了给垃圾收集机制中的
标记--整理算法
做准备的,具体的后面会说到。
内存分配策略
可达性分析
对于对象是否存活,即是否有指向它的引用,目前主要的有两种算法:
- 引用计数
- 可达性分析
对于引用计数,就是如果有某个对象被引用了,那么该对象的计数就会+1,取消引用则-1,如果计数为0,则表示没有指向该对象的引用了。这种方法很好理解和实现,但是在实际生产中有着一个问题:
无法解决循环引用问题,即对象A引用对象B,对象B引用对象A,那么就会形成循环依赖,对象就无法被回收。
对于可达性分析,它会形成一个多路树的结构,根节点称为GC Root
。从根节点开始向下面节点搜索,走过的路径称为引用链
,如果一个对象到GC Root
没有引用链的话,那就说明没有指向该对象的引用了。
在Java中,可以作为GC Root
的对象有以下几种:
- 虚拟机栈中本地变量表里引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用的对象
引用分级
对于一些不重要,或者说是鸡肋的对象,如果经过垃圾回收之后,内存空间还是比较紧张,那么可以考虑抛弃这些对象。对于这种想法,Java 对以前的引用进行了扩展,相对以前的要么引用,要么无引用
,它提供了强引用,软引用,弱引用,虚引用
的概念和对应的API。
强引用:强引用就是通常的直接引用,比如Object o = new Object()
。
软引用:在系统将要发生OOM
之前,它会回收掉弱引用对象,如果还是会发生OOM
,那么才会抛出错误。Java提供了SoftReference
来实现软引用。
弱引用:在垃圾收集器工作时,无论弱引用对象是否被引用,都会被回收掉。Java提供了WeakReference
来实现弱引用。
虚引用:如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾收集器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动,它必须要和引用队列 (ReferenceQueue
)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。当虚引用所引用的对象已经执行完finalize函数的时候,就会把对象加到queue
里面。可以通过判断queue
里面是不是有对象来判断你的对象是不是要被回收了。
垃圾收集算法
标记——清除
标记——清除
算法的工作流程和它的名字一样,首先通过可达性分析标记出所有需要回收
的对象,然后统一进行清除。
标记——清除算法的实现很简单,那么对应的效率就不会很高,因为对于标记和清除都要遍历所有的对象,而且需要清除的对象大部分不是在连续的内存,所以会产生大量的不连续的内存碎片。太多的内存碎片会使存储大对象时无法找到足够的连续内存来进行存储,不得不再次触发一次垃圾收集过程。
标记——复制
针对标记——清除算法会产生大量的空间碎片的问题,又推出了标记——复制
算法。标记——复制的工作流程为:将内存划分为两部分,通过可达性分析,标记需要回收的对象,然后把存活对象复制到另一部分,接着把原来部分的垃圾进行回收。这样就很好地解决了空间碎片地问题。
还记得前文的堆中的from
区和to
区吗?它们就是为标记——复制算法而划分的。不过因为大多数对象的生命周期都很短,所以并不需要将区域一半一半进行划分。将Young
区的总体大小定义为10,那么Eden
区的大小为8,而from
和to
区的比例为1:1
。
因为无法保证每次都只有小于或等于
10%
的垃圾需要回收,当Survisor
空间不够时,需要依赖其它内存(老年代)进行分配担保。
分配担保机制:当在新生代无法分配内存的时候,把新生代的对象转移到老生代,然后把新对象放入腾空的新生代。
标记——整理
对于标记——复制算法来说,如果存活的对象很多,那么算法的效率就会降低,此外还可能会让额外的空间进行分配担保。所以说标记——复制算法只适合于新生代。那么对于老年代来说(包含老对象和大对象),可以将复制的步骤变化为将对象移动到另一端。虽然这样的效率没有复制算法那么高,但是可以避免分配担保的出现。
垃圾收集器
现在Java的垃圾收集器基本上为G1 GC
,还有Java14出的用于大型应用的可回收TB级别的ZGC
。所以后面就主要记录这两个。
Serial
收集器是一个串行化的收集器,使用标记——复制算法,从可达性分析到垃圾回收都保证了并发安全。但是在整个过程中其它任务是阻塞的,也即是STW
时间。虽然这样看Seriel
不太好,但是对于单CPU来说,不需要切换线程上下文,效率比一般的多线程要好。
PerNew
收集器是Serial
收集器的多线程形式,在垃圾回收过程中开启多个线程进行该操作。Serial
收集器和PerNew
收集器都能和CMS
回收器进行工作。
Parallel Scavenge
收集器是一个新生代收集器,使用复制算法和并行机制,大部分功能和PerNew
收集器差不多。其中最主要区别在于PerNew
注重的是降低垃圾回收的停顿时间,而Parallel Scavenge
则是力求达到一个可空的吞吐。(吞吐量=用户代码的运行时间/(用户代码的运行时间+垃圾回收的时间))。高的吞吐量可以提高CPU的使用效率。
Serial Old
收集器是一个串行化,使用标记——整理算法,用于老年代垃圾回收的收集器。此外在Server
模式下,会作为CMS
收集器的后背方案,在并发收集发生Concurrent Mode Failure
时使用。
Parallel Old
收集器,是Parallel Scavenge
收集器的老年代,使用多线程和标记——整理算法。
G1 GC
G1 GC是一个并发标记、并发清除的垃圾收集器,是从CMS
收集器演变来的。为了更好地了解G1,先来简单介绍一下CMS。
CMS(Concurrent Nark Sweep)收集器基于标记——清除算法。回收过程主要分为以下四个部分:
- 初始标记(STW):标记GC Root能直接连接到地对象
- 并发标记
- 重新标记(STW):修正并发标记期间因用户程序继续运行而导致原本标记产生变动,该时间远比并发标记时间短。
- 并发清除
虽然CMS使用并发标记和并发清除极大地降低了垃圾回收地停顿时间,但是由于清除的多线程机制,可能会导致出现浮动垃圾
。而且标记——清除算法会导致空间碎片的出现。
浮动垃圾是指出现在出现在重新标记之后的垃圾,这时候对它们的回收就只能等到下一次垃圾收集了。
G1的出现解决了空间碎片的问题,而且相对于CMS,G1还可以预测停顿时间。
在G1中,不再像以前那样划分出连续的内存作为新生代和老年代的空间,而是在逻辑上延续了原来的概念,但是在物理层面则是划分为一个个小块、分散的
Region
。G1会记录每个Region的垃圾的价值,同时在后台维护一个优列表。在进行回收时根据允许的回收时间,优先回收价值最大的Region,这也是G1收集器名称的由来(Garbage-First)。因为G1是通过选择垃圾价值来进行回收,而不是整块内存回收,那么就可以预测自己的回收时间。
每个Region中的对象可能会有其它Region对象的引用,对于这种情况,G1在每个Region中维护一个
Remembered Set
表,表里记录的是引用的不同Region的对象的地址。这样就可以保证在不进行全堆扫描的情况下进行垃圾回收。
G1收集器的过程主要分为以下操作:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
ZGC
虽然广泛使用的G1降低了停顿时间、解决了空间碎片的问题,但是对于低延迟高可用的服务系统来说,时不时的STW真的是让人很困扰,因为即使每一次GC的停顿时间短,但是从流式的请求和响应来看还是会对服务的可用性造成很大的影响。
ZGC的主要特点有两个:
- 停顿时间更短,不管多大的堆都能保持在10ms以下。
- 支持TB级别的垃圾清理。
我们先来看一下G1和ZGC的垃圾回收周期图:
初始标记和再标记要STW是不用说的。
在清理阶段要清出存活对象区域和没有存活对象区域,这一过程是STW的。
复制算法中的转移阶段也是要STW的,因为要分配新的内存和复制对象,其中需要复制对象的多少和复杂程度决定着STW的多少。
所以说G1一轮回收需要四次STW
我们可以看到ZGC处理了STW下的垃圾转移过程,只需要三次STW,主要使用的技术是着色指针和读屏障。
在对象转移的时候对象地址没有更新怎么办?
应用线程访问对象将触发
读屏障
,如果发现对象被移动了,那么读屏障
会把读出来的指针更新到对象的新地址上。虚拟机时是如何判断对象发生了转移?
ZGC会在对象信息中通过4个bit来表示GC过程中每个对象的状态,分别为:Finalizable、Remapped、Marked1、Marked0。通过读取状态来了解对象的变化。
关于ZGC的文章推荐可以看看这篇[新一代垃圾回收器ZGC的探索与实践