参考资料:《深入理解Java虚拟机》第三版
文章目录
- 一,运行时数据区域(基础重中之重)
- 二,垃圾收集器与内存分配策略
- 1)对象已死
- 2)再谈引用
- 3)对象回收
- 4)内存分代收集理论(起源)
- 5)垃圾收集算法
- 6)HotSpot的算法细节实现
- 6.1 根节点枚举(收集基准)
- 6.2 安全点(什么时刻可以发起收集任务)
- 6.3 安全区域(什么时间段可以进行收集)
- 6.4 记忆集与卡表(新生代老年代的跨代引用问题怎么解决)
- 6.5 写屏障(记忆集中的卡表怎么维护)
- 7)并发的可达性分析(低停顿时间GC的基础)
- 8)经典垃圾收集器
- 8.1 Serial(STW单线程收集器)
- 8.2 ParNew(多线程并行版本Serial)
- 8.3 Parallel Scavenge(ZGC的思想/前身)
- 8.4 Serial Old
- 8.5 Parallel Old
- 8.6 CMS(重要)
- 8.7 Garbage First(重要)
- 9)低延迟垃圾收集器
- 9.1 Shenandoah
- 9.2 Z_Garbage_Collector
- 10)虚拟机及垃圾收集器日志(了解)
- 10.1 实际案例说明:
一,运行时数据区域(基础重中之重)
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有着各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁的。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:
- 程序计数器
程序计数器是一块较小的内存空间,可将它看作为是当前线程所执行的字节码的行号指示器。它工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,他是程序控制流的指示器,分支、循环、跳转、异常处线程恢复等基础功能都需要依赖这个计数器来完成(字节码指定的跳转与上下文切换)。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
- Java虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表存放了编译器可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)
、对象引用(reference类型,他并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)
和returnAddress类型(指向了一条字节码指令的地址)
。
- 本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需求自由实现,Hot-Spot虚拟机就直接讲本地方法栈和虚拟机栈合二为一了。
- Java堆
对于Java应用程序来说,Java堆是虚拟机所管理的内存中最大的一块,它也是被所有线程都共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界中几乎所有对象实例都在这里分配内存。
Java堆既可以被实现成固定大小的,也能设置为可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的,可通过-Xmx
和-Xms
来设定。
- 方法区
方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名“非堆(Non-heap)”,目的是与Java堆区分开来。
说到方法区,不得不提一下“永久代”这个概念,尤其是在JDK 8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的Hotspot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而己,这样使得Hotspot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。
但是对于其他虚拟机实现,譬如BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受 《Java虚拟机规范》管束,并不要求统一。但现在回头米看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题),而且有极少数方法(例如String:interno)会因永久代的原因而导致不同虚拟机下有不同的表现。当Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java Mission Contro!管理工具,移植到HotSpot虛拟机时,但因为两者对方法区实现的差异而面临诸多困难。
考虑到HotSpot末来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存 (Native Memory)来实现方法区的计划了口,到了JDK7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间 (Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
- 方法区—运行时常量池
运行时常量池是方法区中的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
运行时常量池对于Class文件常量池的另外一个重要特性是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类中的intern()方法。
- 直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但这部分内存也被频繁地使用,而且也可能会导致OutOfMemoryError异常。
Jdk4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的对象作为这块内存的引用进行操作。这样可以避免在Java堆和Native堆中来回复制数据,显著提高性能。
- 对象的创建
二,垃圾收集器与内存分配策略
带着三个问题看垃圾收集器:
1)哪些内存需要回收?
2)什么时候执行回收计划?
3)如何回收?
回望Java虚拟机的发展,我们已经了解到虚拟机中的程序计数器、虚拟机栈、本地方法栈这三个区域随线程而生、随线程而灭。栈中的栈帧随着方法的进入和退出有条不紊地执行着出栈和入栈的操作。每个栈帧中分配多少内存基本上是在类结构确定下来时就已经已知了,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的也正是这部分内存。
1)对象已死
在Java堆中存放着Java世界的几乎所有对象实例,GC在对堆进行回收之前,第一件事就是要确定这些对象之间哪些还“存活”,哪些已经“死去”了。
- 引用计数器算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一。任何时刻计数器为0时该对象就是不可能再被使用的。
客观来说该方法虽然占用了一些额外的内存空间来计数,但它的原理简单,判定效率高,大多数情况下它都是个不错的选择。同时它也有自己的缺陷,看下面的伪代码:
/*
MyClass有字段instance,进行赋值令classA.instance = classB,classB.instance = classA。除此之外这两个对象再无任何的引用,实际上这两个对象已经不可能再被访问,但它们因为互相引用着对方,导致它们的引用计数都不为0,也自然无法回收它们。
*/
MyClass classA = new MyClass();
MyClass classB = new MyClass();
classA.instance = classB;
classB.instance = classA;
classA = null;
classB = null;
// 假设此时发生GC,A与B对象能否被回收?可使用-XX:+PrintGCDetails查看
System.gc();
// 此处我使用的是G1收集器,可以看到虚拟机并没有因为这两个对象互相引用就放弃它们,这也说明了Java虚拟机并不是通过引用计数算法来判断对象是否存活的
- 可达性分析算法
当前主流的商用程序语言的内存管理子系统,都是通过可达性分析算法来判断对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索走过的路径就称之为“引用链”,如果某个对象到GC Roots间没有任何引用链相连的话,则证明该对象是不可能再被使用的。
在Java的计数体系中,固定可作为GC Roots的对象包括以下几类:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量;
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用;
- 在本地方法栈中JNI(即常说的Native方法)引用的对象;
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器;
- 所有被同步锁(synchronized关键字)持有的对象;
- 反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
目前最新的几款垃圾收集器(G1/Shenandoah/ZGC/PGC/C4)
都具备了局部回收的特征,为了避免GC Roots包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。
2)再谈引用
无论是通过计数器算法还是可达性分析算法,判断对象是否存活都离不开引用关系。在jdk2之前,Java中的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据时代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看起来有点过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态。
在jdk2之后,Java堆引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,引用强度也随之递减:
- 强引用:最传统的“引用”定义,是指在程序代码之中普遍存在的引用赋值,类似于
Object o = new Object()
这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象; - 软引用:用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列入回收范围之中进行第二次垃圾回收,如果这次回收还是没有足够的内存才会抛出OOM。jdk2之后提供了
SoftReference类
来实现软引用; - 弱引用:也是用来描述非必须的对象,但是它的强度比软引用更弱一点,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。**当垃圾收集器开始工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。**jdk2之后提供了
WeakReference类
来实现弱引用; - 虚引用:也被称之为“幽灵引用”或“幻影引用”,它是最弱的一种引用关系。**一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。**jdk2之后提供了
PhantomReference类
来实现虚引用。
3)对象回收
要真正宣告一个对象的死亡,至少需要经历两次标记过程:如果可达性分析算法没有发现与之关联的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是否有必要执行finalize()方法
,假设对象没有覆盖该方法或者已经被调用过了,那就被视为没有必要执行。
如果该对象被判定为需要执行该方法,name它会被放置在一个名为F-Queue
的队列之中,并在稍后由一条由虚拟机自动建立、低调度优先级的Finalizer线程去执行它们的finalize方法,要注意这个等待执行的过程是非阻塞的。稍后,收集器将对F-Queue
中的对象进行第二次小规模的标记,
// 看一段代码,理解一下对象回收的过程
public class Jvm {
public static Jvm SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i'm still alive!");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
Jvm.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new Jvm();
// 对象第一次尝试拯救自己
SAVE_HOOK = null;
System.gc();
Thread.sleep(500); // finalize() 方法优先级很低,等待一会
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i'm dead");
}
// 对象第二次尝试拯救自己,但是这次却失败了
SAVE_HOOK = null;
System.gc();
Thread.sleep(500); // finalize() 方法优先级很低,等待一会
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i'm dead");
}
}
}
4)内存分代收集理论(起源)
当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集的理论,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的;
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡;
这两个分代假说共同奠定了多款常用的垃圾收集器一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象根据其年龄分配到不同的区域之中存储。Java堆划分出不同的区域之后,垃圾收集器才能每次只回收其中某一个或者某些部分的区域,因此才有了Minor GC
、Major GC
、Full GC
这样的回收类型划分,因而发展出了标记-复制算法
、标记-清除算法
、标记-整理算法
等针对不同区域的垃圾收集算法。
假如现在要进行一次Minor GC(新生代区域收集)
,但新生代中的对象可能被老年代所引用,为了找出该区域中的存活对象,不仅要在固定的GC Roots之外还要遍历新生代或者老年代,这样的开销是不能接受的。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
根据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门去记录每个对象是否存在以及存在哪些跨代引用,**只需要在新生代上建立一个全局的数据结构(该结构被称为“记忆集” Remembered Set),**这个结构会把老年代划分为若干个小块,标识着老年代的哪一块内存存在着跨代引用。此后当发生Minor GC
时,只有包含了跨代引用的小块内存中的对象才会被加入到GC Roots进行扫描。
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集器,其中又可以细分为以下几类:
① 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集;
② 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器有单独收集老年代的功能;
③ 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器有这种功能;
④ 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
5)垃圾收集算法
前面已经介绍过对象回收的方法与分代回收的理论了,但真正要堆堆内存进行回收内存时,具体的操作是什么样的呢?下面根据各算法出现的时间线进行介绍:
- 标记—清除算法
它是最基础的算法,后续的收集算法大多都是以该算法为基础,对其缺点进行改进而得到的:第一个是执行效率不稳定,如果进行大量标记和清除的动作,执行效率都随着对象数量的增长而降低;第二个是内存空间碎片化问题。
- 标记—复制算法(半区复制算法)
为了解决内存碎片化与执行效率低下的问题,1969年某位科学家提出了一种新的垃圾收集算法,他将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这块的内存使用完之后,就将还存活着的对象复制到另外一块上面,然后再将刚才已使用的一般内存一次全都给清理掉。这样实现简单,运行高效,不过其缺陷就是将内存缩小了一半。
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集,因此并不需要按照1:1的比例来划分新生代的内存空间。
1989年,某位针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为Appel式回收。HotSpot虚拟机的serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。Apple式回收的具体做法是把新生代分为一块较大的Eden区间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。当发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已使用过的那块Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1:1,也就是说每次新生代中可用内存空间是整个新生代空间容量的90%,当然这样做的底气是老年代为它进行担保。
- 标记—整理算法
复制算法在对象存活率较高时效率会大打折扣,并且还需要额外的内存空间来为它进行担保,所以在老年代中一般不能直接选用这种算法,因此一种结合前两种算法优点的算法出现了。
该算法与标记清除算法的本质差异在于后者是一种非移动式的回收算法,而前者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
如果移动存活对象,尤其是在老年代这种每次回收都有着大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,业界常称为STW:Stop The World。
6)HotSpot的算法细节实现
作为Java8中的默认虚拟机,很有必要了解其实现细节,这对后续理解其他虚拟机很有帮助。
使用jdk工具查看字节码文件命令,命令行打开class文件的目录然后使用命令
javap -c xxx.class
6.1 根节点枚举(收集基准)
至今为止,所有垃圾收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此根节点枚举也存在着STW
的问题。现在可达性分析算法耗时最长的查找引用链过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器,根节点枚举也是必须要停顿的。
由于目前主流的Java虚拟机使用的都是“准确式垃圾收集”,所以当用户线程停顿下来之后,其实并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机应当由方法可以直接得到哪些地方存放着对象引用。在HotSpot的解决方案中,是使用一组称为OopMap
的数据结构来达到这个目的。一旦类加载的动作完成之后,HotSpot就会把对象内什么偏移量上是什么类型的数据结构计算出来。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。
6.2 安全点(什么时刻可以发起收集任务)
在OopMap
的协助下,HotSpot可以快速准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致OopMap
内容变化的指令非常多,如果为每条指令都生成对应的OopMap
那将会需要大量的额外存储空间,我们也无法接受。
实际上HotSpot并没有为每条指令都生成OopMap
,前面提到只是在“特定的位置”记录了这些信息,这些位置被称之为安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能停顿下来开始垃圾收集,而是强制要求必须到达安全点之后才能够暂停。
6.3 安全区域(什么时间段可以进行收集)
使用安全点的设计似乎已经完美解决了如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况并不一样。**安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。**但是如果程序未执行的时候呢?也就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全点的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(safe Region)来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化。因此在这个区域中的任何地方开始垃圾收集都是安全的,也可以将安全区域看作是一段安全点的集合。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,他会检查虚拟机是否已经完成了根节点枚举的工作(或者是GC过程中需要暂停用户线程的阶段),如果已经完成了,那线程就当做什么事都没有发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
6.4 记忆集与卡表(新生代老年代的跨代引用问题怎么解决)
讲解分代收集理论时,为了解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用来避免把整个老年代加入GC Roots的扫描范围。事实上并不只是新生代、老年代之间才会有跨代引用的问题,所有涉及部分区域收集Partial GC
行为的垃圾收集器,典型的如G1、ZGC和Shenandoah
收集器,都会面临相同的问题,因此我们有必要进一步来理清记忆集的原理和实现方式。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。类似下面的伪代码:
Class RememberedSet {
Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
这种记录全部含跨代引用对象的实现方法,无论是空间占用上还是维护成本方面都是十分昂贵的。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针即可,并不需要了解这些跨代指针的全部细节。
设计者在实现记忆集的时候,可以选择更为粗狂的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择的记录精度:
- 字长精度:每个记录精确到一个机器字长(也就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含包含跨代指针;
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针;
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
其中,第三种“卡精度”所指的是一种称为“卡表(Card Table)”的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式,一些资料中甚至把它和记忆集混为一谈。前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是其只定义了记忆集的行为意图,并没有定义其行为的具体实现。
卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等,可以将其理解为Java语言中的HashMap与Map的关系。
卡表最简单的形式可以是一个字节数组,HotSpot虚拟机中确实也是这样做的,字节数组
CARD_TABLE
的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称为“卡页(Card Page)”。一个卡页的内存中通常包含不止一个对象,只要卡页中有一个或多个对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称这个元素变脏,没有则标识为0。当垃圾收集发生时,只需要筛选出卡表中变脏的元素,就能够直接得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
6.5 写屏障(记忆集中的卡表怎么维护)
前面我们已经了解到可以使用记忆集来缩减GC Roots扫描范围的问题,但怎么维护卡表呢,例如它们何时变脏、谁来把它们变脏改变状态呢?
显而易见,**卡表元素变脏一定是当有其他分代区域中的对象引用本区域对象时,其对应的卡表元素就应该变脏了,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。**但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表数据呢?
在HotSpot虚拟机中,是通过写屏障(Write Barrier)
技术来维护卡表状态的。可以将其看作是在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环型通知,供程序来执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫做写前屏障(Pre-Write Barrier),在赋值后的部分则叫做写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1的出现之前,其他收集器都只用到了写后屏障,下面是一段更新卡表状态的伪代码:
void oop field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*filed = new_value;
// 写后屏障,在这里完成卡表状态的更新
post_write_barrier(field, new_value);
}
应用写屏障之后,虚拟机就会为其所有的赋值操作生成相对应的指令,一旦收集器在写屏障中增加了更新卡表的操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
7)并发的可达性分析(低停顿时间GC的基础)
前面提到过当前主流编程语言的垃圾收集器基本上都是依靠
可达性分析算法
来判定对象是否是存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能进行分析,这就意味着要想进行可达性分析就必须全程冻结用户线程的运行(也就是STW)。在进行可达性分析之前,首先要做的就是“根节点枚举”这个步骤,GC Roots在整个Java堆中毕竟还是极少数,只占了一小部分。且在各种优化技巧(如OopMap)的辅助下,它的STW停顿时间已经是非常短暂且相对而言较为固定的(不会随着堆容量而增长)了。可是从GC Roots再继续往下遍历对象图,寻找引用链时这一步骤的停顿时间就必然与Java堆容量直接成比例关系了:堆越大,存储的对象越多,对接结构图与引用链也越复杂,要标记更多对象而产生的停顿时间自然就更长。
之所以在可达性分析算法的过程中要依赖一个能保障一致性的快照才能进行,是因为如果是并发执行,那么在标记过程中可能引用链发生改变而导致有的对象原本不可达变成可达的(容忍度低),或者可达的对象变成不可达了(容忍度高),这样会导致“对象消失问题”。
某位学者在1994年在理论上证明了当且仅当以下两个条件同时满足时,会产生对象消失问题:
- 赋值器插入了一条或多条从可达对象到不可达对象的新引用;
- 赋值器删除了全部从可达对象到非可达对象的直接或间接引用。
因此,想要解决并发扫描时出现的对象消失问题,只需要破坏这两个条件中的任意一个即可。由此也诞生出来了两种解决方案:增量更新和原始快照(类似于Redis中的主从数据同步解决方案)。
- 增量更新
其破坏的就是第一个条件,当可达对象新增指向非可达对象的引用关系时,就将这个新插入的引用记录记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的可达对象作为Roots,重新扫描一次。可以简化理解为,可达对象一旦插入了指向非可达对象之后,它就变成了待可达对象
。
- 原始快照
其破坏的就是第二个条件,当待可达对象
要删除指向非可达对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中待可达对象
为根,重新扫描一次。可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障来实现的。在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如CMS就是基于增量更新来做并发标记的
(目前项目中使用的就是jdk8+CMS收集器)
,G1、Shenandoah则是用原始快照来实现。到此为止,HotSpot虚拟机是如何发起内存回收、如何加速其过程,以及可靠性是怎么保证的都已介绍完毕,但是寻你急是如何具体地进行内存回收动作还未介绍。因为内存回收如何进行是由虚拟机所采用的哪一款垃圾收集器所决定的,而通常虚拟机中往往有多重垃圾收集器,下面介绍一些HotSpot虚拟机中出现的垃圾收集器。
8)经典垃圾收集器
如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。《Java虚拟机规范》中对垃圾收集器应该如何实现并没有做出任何规定,因此不同的厂商、不同版本的虚拟机所包含的垃圾收集器可能会有很大差别,不同的虚拟机一般也会提供各种参数供用户根据自己的应用特点和要求组合出各个内存分代所使用的收集器。
各款经典垃圾收集器之间的关系如下图所示:
图中收集器所处的区域,表示了它属于新生代收集器或是老年代收集器,后续会重点分析CMS(工作使用)和G1这两款相对复杂而又广泛使用的收集器。
要明确,直到目前还没有最好的垃圾收集器,更不存在万能的收集器,所以我们选择的只是对具体的应用来说最合适的收集器。我们要学习收集器的思想和实现方式,而不是过度相互比较。
8.1 Serial(STW单线程收集器)
Serial收集器是最基础、历史最悠久的垃圾收集器,曾经(jdk1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。该收集器如名所示,是一个单线程工作的收集器,但它的单线程意义并不仅仅是说明他只会使用一个处理器或一条收集线程去完成垃圾收集过程,**更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。**下图展示了它的运行过程:
从jdk3开始,一直到现在最新的jdk,HotSpot虚拟机开发团队对消除或降低用户线程因垃圾收集而导致停顿的努力一直持续进行着,从Serial收集器到Parallel收集器再到Concurrent Mark Sweep(CMS)和Garbage First(G1)收集器,最终至现在最新的前沿成果Shenandoah和ZGC等,我们看到了越来越优秀的垃圾收集器在出现,用户线程停顿的时间也在不断的持续缩短,但是仍然没有办法彻底根除。
从这,我们也能看出未来Java虚拟机的发展和优化的方向,事实上Serial它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器。
8.2 ParNew(多线程并行版本Serial)
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如:-xx:SurvivorRatio/-xx:PretenureSizeThreshold/-xx:HandlePromotionFailure
等)、收集算法、STW、对象分配规则、回收策略等都和Serial收集器完全一致。下图展示了它的运行过程:
ParNew收集器除了支持多线程并行收集之外,其外与Serial相比之下并没有太多的创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是jdk7之前的遗留系统中首选的新生代收集器。
根据经典垃圾收集器关系图谱中可以看出来除了Serial收集器之外,目前只有它能与CMS收集器配合工作。
在jdk5发布时,HotSpot退出了一款在强交互应用中几乎可称为具有划时代意义的垃圾收集器——CMS收集器。**这款收集器是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。**需要注意的是在jdk5中使用CMS来收集老年代时,新生代只能选择Serial或ParNew收集器中的一个。ParNew更是在激活CMS后
(使用-XX:UseConcMarkSweepGc选项)
的默认新生代收集器,当然也可以使用-XX:+/-UseParNewGC
选项来强制指定或禁用它。
可以说CMS的出现极大巩固了ParNew的地位,但随着垃圾收集器的不断迭代,作为CMS的继承者和替代者G1出现(首次出现是在jdk9中,jdk11时就是默认的收集器了),G1是一个面向全堆的收集器,不需要其他新生代/老年代收集器的协助了。也就是从jdk9开始,ParNew+CMS收集器组成就不再是官方推荐的服务端模式下的收集器解决方案了。
官方希望G1能够代替它们,为此还取消了ParNew+Serial Old
以及Serial+CMS
这两组收集器组合的支持,还直接取消了-XX:+UseParNewGC
参数,这就意味着ParNew和CMS从此只能互相配合使用,再也没有其他收集器能够与之配合。
可以说,从此ParNew合并入CMS了,成为了它专门处理新生代部分的指定伙伴。
ParNew一定会比Serial的效率/效果更好吗?
我们知道ParNew是在Serial的基础上对其做了升级,支持多线程并行收集。但是在ParNew在单核心的处理器环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能保证超越Serial收集器。当然,随着可以被使用的处理器核心数量的增加,ParNew对于垃圾收集时系统资源的高效利用还是很有好处的,可以通过
-XX:ParallelGCThreads
参数来限制垃圾收集器的线程数。
8.3 Parallel Scavenge(ZGC的思想/前身)
Parallel Scavenge收集器也是一款新生代收集器,他同样是基于标记—复制算法实现的收集器,同时也是能够并行收集的多线程收集器…Parallel Scavenge的诸多特性从表面看与ParNew非常相似,但它的关注点与其他收集器不同。CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是指处理器用于运行用户代码的时间与处理器总消耗时间的比值。
T
h
r
o
u
g
h
p
u
t
=
运行用户代码时间
运行用户代码时间
+
运行垃圾收集时间
Throughput = \frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间}
Throughput=运行用户代码时间+运行垃圾收集时间运行用户代码时间
如果虚拟机完成某个任务,用户代码加上垃圾收集总共花了100分钟,其中垃圾收集花了1分钟,那么其吞吐量就是 99/100=99%。
停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
Parallel Scavenge收集器提供了两个参数用于精准的控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMills
参数以及直接设置吞吐量大小的-XX:GCTimeRatio
参数,由于Parallel Scavenge与吞吐量关系密切,其也经常被称作为吞吐量优先收集器
。
-XX:MaxGCPauseMills
:设置一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过这个值也不是越小越好,垃圾收集停顿时间缩短是以缩短吞吐量和新生代空间为代价换取的:系统把新生代调小一点,收集300MB新生代肯定会比收集500MB快,但这也直接导致垃圾收集发生得更加频繁了,原来10s收集一次,每次停顿100ms,现在则是5s收集一次,每次停顿70ms,停顿时间的确在下降但是吞吐量也下降了。-XX:GCTimeRatio
:设置一个大于0小于100的整数,也就是垃圾收集时间占总时间的比例,相当于吞吐量的倒数。譬如设置为19则允许的最大垃圾收集时间占总时间的5%(即1/(1+19)),该值默认是99,表示允许最大1%的垃圾收集时间。-XX:+UseAdaptiveSizePolicy
:这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)
、Enen与Survivor区的比例(-XX:SurvivorRatio)
、晋升老年代对象大小(-XX:PretenureSizeThreshold)
等细节参数了,**虚拟机会根据当前系统的运行情况来收集西能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量。**这种调节方式称为垃圾收集器的自适应调节策略(GC Ergonomics)。
8.4 Serial Old
Serial Old是Serial收集器的老年代版本,他同样是一个单线程收集器,使用标记—整理算法。下图展示了它的运行过程:
8.5 Parallel Old
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记—整理算法实现。下图展示了它的运行过程:
8.6 CMS(重要)
CMS(Concurrent Mark Sweep)
收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者是基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统的停顿时间尽可能的短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。
其收集过程是基于标记—清除算法来实现的,它的运作过程相对于前面集中收集器来说更为复杂一点,整个过程分为四个步骤,包括:
- 初始标记(CMS initial mark)
- 需要STW,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
- 并发标记(CMS concurrent mark)
- 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与GC线程一起并发运行。
- 重新标记(CMS remark)
- 需要STW,为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段长一些,但远比并发标记阶段停顿时间短
(还记得之前的三色标记法么,就是此处使用)
。
- 需要STW,为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段长一些,但远比并发标记阶段停顿时间短
- 并发清除(CMS concurrent sweep)
- 清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发进行的。
由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。下图可以看到CMS的运行过程中并发和需要停顿的阶段:
CMS是一款很优秀的收集器,其优点就如其名:并发收集、低停顿,CM是HotSpot虚拟机追求低停顿的第一次成功尝试,但其还有几个明显的缺点,后续的G1与ZGC也可以分析一下:
① CMS对处理器资源很敏感,在并发阶段虽然可以和用户线程共同进行,但却会因为占用了一部分线程(或者说是处理器的计算能力),从而导致应用程序响应变慢,降低总吞吐量。CMS默认启动的回收线程数量是(处理器核心数量+3)/4,也就是说如果处理器核心数在四个及以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并会随着处理器核心数量的增加而下降。反正当处理器核心数量不足四个时,CMS对用户程序的影响就可能会变得很大。
② CMS无法处理浮动垃圾(Floating Garvage),在并发标记和并发清理阶段,用户线程仍然是继续运行的,程序在运行自然就会伴随着有新的垃圾对象不断产生,但这一部分垃圾在此次收集中并不能处理,只能等到下一次收集任务,这部分垃圾就被称之为“浮动垃圾”。因为CMS与用户线程是并发进行的,所以它不能等到老年代几乎快满了再进行收集,在jdk5中,默认的阈值是68%,如果在实际应用中老年代增长并不快可以使用
-XX:CMSInitiatingOccu-pancyFraction
的值来提供CMS的触发百分比,降低内存回收频率,获得更好的性能。jdk6中默认的阈值就已经改为92%了,不过阈值太高会容易导致大量并发失败产生,性能反而降低,应当在实际的生产环境中根据实际的应用情况来权衡。③ 前面提到,CMS是基于标记—清除算法实现的收集器,这意味着收集结束时会有大量的空间碎片产生,将会给大对象分配带来很大麻烦,甚至会频繁地提前触发FullGC的情况,对比CMS提供了两个参数可以解决:
-XX:+UseCMS-CompactAtFullCollection
(默认开启,该参数从jdk9开始废弃),用于在CMS不得不进行FullGC时开启内存碎片的合并整理过程;-XX:+CMSFullGCsBefore-Compaction
(该参数从jdk9开始废弃),用于要求CMS收集器在执行过n次不整理空间的FullGC之后,下次进入FullGC前会先进行碎片整理(默认0,即每次进行FullGC时都进行碎片整理)。
8.7 Garbage First(重要)
G1收集器是垃圾收集器技术发展史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。jdk9发布之日。G1宣布成为服务端模式下的默认垃圾收集器,CMS沦为声明为不推荐使用的下场。
作为CMS的替代者和继承人,设计者们希望能够建立一款“停顿时间模型(Pause Prediction Model)”的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段中,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java(RTSJ)的中软实时垃圾收集器特征了。
首先G1收集器在思想上做出了极大的改变,在G1收集器出现之前的所有其他收集器,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,**它可以面向堆内存任何部分来组成回收集(Collection Set,CSet)**进行回收,衡量标准不再是它属于哪个分代范围了,而是堆内存中哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1的
Mined GC模式
。
G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。虽然G1也是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有着非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据其需要,来扮演新生代的Eden区、Survivor区或者是老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新车间的对象还是已经存活了一段时间、熬过多次手机的旧对象都能获得很好的收集效果。
其次Region中还有一类特殊的Humongous区域,专门用来存储大对象的,G1默认认为只要超过了一个Region容量一半的对象即可判定为大对象。而每个Region的大小可以通过参数-XX:G1HeapRegionSize
来设置,取值范围为1MB~32MB(应为2^n),而对于超过了整个Region容量的超级大对象,它们将会被存放在N个连续的Humongous Region中,具体结构如下图所示:
虽然我们看到G1中仍保留着新生代与老年代的概念,但新生代与老年代不再是固定的了,它们都是一系列区域(非必须连续)的动态集合。G1收集器之所以能够建立可预测的停顿时间模型,与他将Region作为单词回收的最小单元密不可分,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。
更具体的解决方案是让G1跟踪每个Region中的垃圾“价值”大小,价值即是回收所获空间大小以及回收所需时间的经验值,然后再后台维护一个优先级列表,这样每次可以根据用户设定的允许手机停顿时间(可以使用
-XX:MaxGCPauseMills
指定,默认200ms),优先处理回收价值最大的Region,保证了G1在有限的时间内得到最高效率的收集效果,这也是Garbage First的名字由来。
G1将堆内存化整为零
的思路不难理解,但其背后的实现细节可远远没有想象得那么简单,G1至少有但不限于以下关键问题待解决:
G1是基于Region来回收的,但夸Region的引用对象如何解决呢?
前面已经提到过可以使用记忆集来避免全堆都成为GC Roots的扫描对象,但在G1上记忆集的应用其实要复杂得多,它的**每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己这一块Region的指针,并标记这些指针分别在哪些卡页的范围之内。**G1记忆集在存储结构上本质上是一种哈希表,key是别的Region的起始地址,value则是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是我指向谁,这种结构还多记录了谁指向我)比原来的卡表实现起来更为复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。
在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
这里首先要解决的是用户线程改变对象引用关系时,必须要保证其不能打破原本的对象图结构,如果对CMS还有印象的话就会知道其使用增量更新算法来解决,而G1则是通过原始快照(SATB)算法来解决的。此外,垃圾收集对用户线程的影响还体现在回收过程新创建对象的内存分配上,程序要继续运行就肯定会有新对象持续被创建。G1为每个Region设计了两个名为TAMS(Top at Mark Start)
的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们都是存活的,不会将其纳入回收范围。
都知道G1可以建立起可靠的停顿预测模型,它是怎么做到的呢?
我们可以通过-XX:MaxGCPauseMills
参数来制定预期停顿时间,G1收集器的停顿预测模型是以衰减均值为理论基础来实现的。在垃圾收集的过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤所花费的预期成本,并分析得出平均值、标准偏差、置信度等等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易收到新数据的影响,平均值代表着整体的平均状态,但衰减平均值则更准确地代表着“最近的”平均状态。换句话说,Region的统计状态越新则越能决定其回收的价值。然后通过这些信息就可以预测现在开始回收的话,由哪些Region组成的回收集既能不超过预期时间,又能最大化回收收益。
如果我们不去计算用户线程运行过程中的动作(如使用写屏障来维护记忆集的操作),G1收集器的运行过程,我们大致可以将其划分为以下四个步骤(和CMS的思想、实现方式有很多类似的地方):
- 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段与用户现场并发运行时,能正确地在可用的Region中分配新的对象。这个阶段需要停顿线程,但耗时很短。
- 并发标记(Concurrent Marking):从GC Roots开始,对堆中对象进行可达性分析,递归地扫描整个堆里面的对象图,找出要回收的对象,这阶段耗时较长,但可与用户线程并发执行。当对象图扫描完成以后,还需要重新处理SATB记录下的在并发过程期间有引用变动的对象。
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后扔遗留下来的最后那少量的SATB记录;
- 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,以便根据用户所期望的停顿时间来制定回收计划,自由选择任意多个Region组成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。
从G1的运行过程不难看出,G1在收集的过程中除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之G1并不是纯粹的在追求低延迟,**官方对它的期望是在延迟可控的情况下获得尽可能高的吞吐量。**下图可以看到G1的运行过程中并发和需要停顿的阶段:
Oracle官方透露出,回收阶段其实本也有想过设计成与用户程序一起并发执行,但难度比较大,并且G1每次回收也只是回收一部分Region,停顿时间是用户可以控制的,所以不用那么迫切的去实现,选择将此功能放到了G1之后的低延迟垃圾收集器ZGC。
毫无疑问,可以由用户指定期望的停顿时间是G1收集器非常强大的功能,设置不同的期望停顿时间,可以使G1在不同的应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过这里设置的期望时间要符合实际业务情况,它默认是200ms,简而言之这是一把双刃剑。
9)低延迟垃圾收集器
目前Java的LTS版本已经发布到17了,从9到17的默认收集器都是G1,回顾垃圾收集器的发展历史,从Serial发展到CMS再到G1,经历了逾二十多年,在不断迭代的历史长河中,他们已经十分优秀。不过距离完美还是很遥远。衡量一个收集器的三项最重要指标分别是:内存占用(Footprint)
、吞吐量(Throughput)
和延迟(Latency)
,想要同时达到这三者几乎是不可能的,一款优秀的收集器通常最多可以同时满足其中两项。
类似于微服务中的CAP原则,最多只能同时满足其中两项。
C:一致性
A:可用性
P:分区容错性
下面整体的看一下几款垃圾收集器的工作流程图:
最后两款收集器Shenandoah+ZGC
,几乎整个工作过程全都是并发的,只有初始标记、最终标记这些阶段会有短暂的停顿,但这部分停顿的时间基本上是固定的,与堆容量、堆中对象的数量并没有正比例关系。并且它们都可以在任意可管理的堆容量下,实现垃圾收集的停顿都不超过10ms,目前这两款仍处于实现状态,被官方称为“低延迟垃圾收集器”。
9.1 Shenandoah
最初Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat就把Shenandoah贡献给了OpenJDK,并推动它称为OpenJDK12的正式特征之一,这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在10ms内的垃圾收集器。所以Shenandoah是一款只有openjdk才会包含而OracleJDK里反而不存在的收集器。
虽然Shenandoah和G1很相似,也是使用基于Region的堆内存布局,同样有着用于存放大对象的Humongous Region,默认的回收策略也是同样优先处理回收价值最大的Region……但在管理堆内存方面,它与G1至少存在着三个明显的不同之处:
- 支持并发的整理算法,G1的回收阶段是可以多线程并行的,但是却不能和用户线程并发执行;
- Shenandoah(目前)默认是不使用分代收集的,换言之不会有专门的新生代Region或者老年代Region的存在,在内部并没有实现分代。对此并不是说分代对Shenandoah没有价值,而是出与性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。
- Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵(Connection Matrix)”的全局数据结构来记录跨Region的引用关系,从而降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。
连接矩阵(Connection Matrix)可以简单理解为一张二维表格,如果Region N有对象指向Region M,那么就在表格的N行M列中打上一个标记,在回收时只需要通过这张表格就能得出哪些Region之间产生了跨代引用,不必像G1那样在每个Region中都维护一个记忆集了。连接矩阵示意图如下所示:
Shenandoah的工作过程可以大致分为以下九个阶段:
- 初始标记(Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是STW的,但停顿时间与堆大小无关,之和GC Roots相关;
- 并发标记(Concurrent Marking):与G1一样,遍历对象图,标记处全部可达性分析可达的对象,这个阶段是与用户线程一起并发的,所花费的时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度;
- 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,并将这些Region组成为一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿时间;
- 并发清理(Concurrent Cleanup):这个阶段用于哪些整个区域连一个存活对象都没有找到的Region(Immediate Garbage Region);
- 并发回收(Concurrent Evacuation):并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面存活的对象先复制一份到其他未被使用的Region之中。其通过读屏障和被称为“Brooks Pointers”的转发指针来解决与用户线程并发执行带来的问题。并发回收阶段运行的时间长短取决于回收集的大小;
- 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为“引用更新”;
- 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可;
- 最终引用更新(Final Update Reference):解决了堆中的引用问题之后,还要修正GC Roots中的引用关系,这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关;
- 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已经再无存活对象了,它们都变为Immediate Garbage Region了,最后再调用一次并发清理过程来回收掉这些Region的内存空间即可。
下面是Shenandoah收集器的工作原理图解:
Shenandoah收集器支持并行整理的核心概念—Brooks Pointer。Brooks是一个人的名字,他首先提出了使用转发指针来实现对象移动和用户程序并发的一种方案。具体可以去阅读论文《Trading Data Space for Reduced Time and Code Space in Real-Time Garbage Collection on Stock Hardware》。
9.2 Z_Garbage_Collector
ZGC和Shenandoah目标是高度相似的,都是希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。但是其二者的实现思路又存在显著差异。
首先ZGC的内存布局,同Shenandoah、G1一样,它也采用了基于Region的堆内存布局,但它们不同之处在于ZGC的Region(在一些官方资料中也会称其为Page或ZPage)
具有动态性—可以动态创建和销毁,以及动态的区域容量大小。在x64的硬件平台下,ZGC的Region可以具有如下图所示的大、中、小三种容量:
- 小型Region(Small Region):容量固定为2MB,用于存放
0~256KB
的小对象; - 中型Region(Medium Region):容量固定位32MB,用于存放
256KB~4MB
的中对象; - 大型Region(Large Region):容量不固定,可以随需求动态变化,但必须是2MB的整数倍,用来存放
4MB~+∞
的大对象。同时每个大型的Region中只会存放一个大对象,但是虽然名字叫做“大型Region”,但它的实际容量可能小于中小型的Region,最小可低至4MB。大型Region在ZGC的实现中是不会被重新分配的,因为复制一个大对象的代价十分高昂。
接下来是ZGC的核心技术—并发整理算法的实现。Shenandoah使用转发指针+读屏障来实现并发整理,ZGC内部虽然同样也使用了读屏障,但和Shenandoah的解决方案却是完全不同的。
ZGC收集器有一个标志性的设计是它采用的
染色指针技术(Colored Pointer,其他类似的技术中可能将它称为Tag Pointer或者Version Pointer)
。但其中的实现方式太过复杂,且jdk11与jdk17目前默认都是G1收集器,这里就不展开介绍了,大概类似下图这样:
下面了解一下ZGC收集器是如何工作的,ZGC的运行过程大致可划分为以下四个大阶段,全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,而这些小阶段就譬如初始化GC Roots直接关联对象的Mark Start,与之前G1和Shenandoah的Initial Mark阶段差不多:
- 并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。
与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位
。 - 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程中要清理哪些Region,将这些Region组成
重分配集(Relocation Set)
,这与G1收集器的回收集(Collection Set)不一样。ZGC将堆内存划分为Region布局的目的并不是像G1那样做收益优先的增量回收,相反ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中各个Region维护记忆集的维护成本。
因此ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。 - 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个阶段过程中要把重分配集
(Relocation Set)
中存活的对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。还记得前面的染色指针
吗,ZGC仅从引用上就能得知到一个对象是否处于重分配集之中。如果用户线程此时并发访问了谓语重分配集中的对象,那么这次访问将会被预置的内存屏障所截获,然后根据Region上的转发表将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象(这个类似于Redis主从复制数据时的响应式更新(有缓存key击中了未同步的数据就会先返回数据然后立马将这个key先同步))
。ZGC称这个行为是指针的自愈
能力。 - 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必须要“迫切”完成的任务,因为前面说过,即使是旧引用,他也是有自愈能力的,最多不过是第一次击中时会多一次旧引用转发和修正至新应用的过程。
ZGC的核心思想就是并发重映射,也就是将旧引用更新为新引用的过程。重映射清理这些旧引用的主要目的是为了不变慢(以及清理结束后可以释放转发表这样的附带收益,所以说这并不是很“迫切”的工作),
ZGC巧妙的把并发重映射阶段所需要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,
反正它们都是要遍历所有对象的,这样合并之后就节省了一次遍历对象图的开销。一旦所有的旧指针都被修正之后,原来记录新旧对象指针关系的转发表也就自然可以释放了。
相比于G1、Shenandoah等先进的垃圾收集器,ZGC在实现细节上做了一些不同的权衡选择,譬如G1需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。G1中每个Region都要维护一份记忆集,光是记忆集就要占用大量的内存空间,写屏障也对正常程序运行造成额外的负担,但这都是权衡选择的结果。
对于前面G1存在的缺陷,ZGC就完全没有使用记忆集、分代,像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因为完全没有使用到写屏障,所以给用户线程带来的运行负担也自然要小得多。可世上没有双刃剑,在减少内存消耗的同时,它所能承受的对象分配速率不会太高。
到此为止,所有常见的垃圾收集器均已介绍完毕,通过这些垃圾收集器的迭代更新历史,我们也能看到Java做出的努力和收集器的发展目标,真是令人感慨万千。
10)虚拟机及垃圾收集器日志(了解)
在工作中,阅读分析虚拟机和垃圾收集器的日志是处理Java虚拟机内存问题的必备技能,垃圾收集器日志是一系列认为设定的规则,直到jdk9,HotSpot才把所有功能日志都集中在了-Xlog
参数中,这个参数的能力也相应被极大的扩展:
-Xlog[:[selector][:[output][:[decorators][:output-options]]]]
命令行中最关键的参数是选择器selector
,它由标签(Tag)和日志级别(Level)共同组成,标签可理解是虚拟机中某个功能模块的名字,它告诉日志框架用户希望得到虚拟机的哪些功能日志输出。垃圾收集器的标签名为“gc”,HotSpot中还有许多其他功能的日志,全部功能标签包括:
add, age, alloc, annotation, aot, argument, attach, barrier, biasedlocking, blocks, bot, breakpoint, bytecode
日志的级别从低到高,共有Trace、Debug、Info、Warning、Error、Off六种级别,默认是Info。此外,还能通过修饰器(Decorator)来要求每行日志输出都附加上额外的内容,支持附加在日志行上的信息包括:
- time:当前日期时间
- uptime:虚拟机启动到现在的时间,单位秒
- timemillis:当前时间的毫秒数,相当于
System。currentTimeMills()
- uptimemills:虚拟机启动到现在的毫秒数
- timenanos:当前时间的纳秒数,相当于
System.nanoTime()
- uptimenanos:虚拟机启动到现在经过的纳秒数
- pid:进程ID
- tid:线程ID
- level:日志级别
- tags:日志输出的标签机
# 如果不指定,默认值是uptime、level、tags三个,此时的日志输出类似如下
[3.080s][info][gc,cpu] GC(5) User=0.03s Sys=0.00s Real=0.01s
10.1 实际案例说明:
-
查看GC基本信息,资料上说jdk9之间使用
-XX:+PrintGC
,jdk9之后使用-Xlog:gc:
,但我的jdk17亲测两个都可以:[0.007s][info][gc] Using G1 Connected to the target VM, address: '127.0.0.1:56180', transport: 'socket' [0.088s][info][gc] GC(0) Pause Full (System.gc()) 4M->2M(28M) 2.011ms finalize method executed! yes, i'm still alive! [0.594s][info][gc] GC(1) Pause Full (System.gc()) 2M->2M(20M) 1.711ms no, i'm dead Disconnected from the target VM, address: '127.0.0.1:56180', transport: 'socket'
-
查看GC详细信息,jdk9之间使用
-XX:+PrintGCDetails
,jdk9之后使用-Xlog:gc*
-
查看GC前后的堆、方法区可用容量变化,jdk9之间使用
-XX:+PrintHeapAtGC
,jdk9之后使用-Xlog:gc+heap=debug:
-
查看GC过程中用户线程并发时间以及停顿的时间,jdk9之前使用
-XX:+PrintGCApplicationConcurrentTime
以及-XX:+printGCApplicationStoppedTime
,jdk9之后使用-Xlog:safepoint:
-
查看收集器的Ergonomics机制(虚拟机自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息,jdk9之间使用
-XX:+PrintAdaptive-SizePolicy
,jdk9之后使用-Xlog:gc+ergo*=trace:
更多虚拟机参数推荐查看这篇博客,非常详细:
https://www.cnblogs.com/xiaojiesir/p/15636100.html