本篇将会简单介绍GC相关的基础知识,大致介绍所有的GC,着重讲解其中的CMS
C/C++ and Java不同的垃圾回收机制
C/C++
- C/C++的垃圾回收需要程序员自己回收通过调用free/delete来手动释放内存
- C/C++的垃圾回收效率高
- C/C++的垃圾回收极其容易出现问题(忘记回收或者多次回收),编程难度高
Java
- Java的垃圾回收不需要程序员管理,JVM自动回收
- Java的垃圾回收效率比起C/C++明显低
- Java的垃圾回收因为全部交由JVM实现,所以对于程序员来说,编程难度低
有关GC的基础概念
垃圾定义
- 垃圾的定义就是没有引用指向的对象就可以被称为垃圾
垃圾寻找的方法
Reference count:维护指向对象的引用的数量,有一个引用指向这个对象引用计数就+1,当对象的引用计数归零的时候就可以被回收了
但是referenceCount无法解决循环引用问题
我们可以看到,这三个对象循环引用,每一个对象的referenceCount都不为0,但是没有外部的引用指向这个整体,也就无法取到这个整体中包含的对象,于是我们有了新的一种方法
root searching:根可达算法,首先找到根对象,从根对象开始,所有和根对象相连的对象都不是垃圾。以下都是JVM中的根对象
- JVM Stacks中的引用对象(也就是局部变量表中的引用)
- 方法区中的类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(Native方法)引用的对象。
- load进来的Clazz对象
常见的垃圾回收算法
标记清除算法(Mark-Sweep)
标记清除算法非常简单,我们首先通过根可达算法来标记所有可达的对象,这些对象都是不可回收的对象,这就是标记阶段(mark);接下来我们再次遍历所有的对象,如果某个对象没有被标记,那么这个对象就一定不是不可回收算法,直接将其回收
优点:
算法简单,易于实现
在存活对象较多的时候效率较高,适合使用
缺点:
需要扫描两遍(标记一遍,清除一遍)
容易产生碎片(某些对象被回收之后其周围都没有被回收的对象,这就成为了一个碎片,这个碎片可能会因为空间较小,无法放下后面添加的对象)
拷贝算法(copying)
拷贝算法的实现就是将可用的内存分成大小相等的两份,每一次只使用其中的一块。当其中的一块使用完毕需要启动垃圾回收的时候就将这一块儿内存中存活的对象全部依次存放到另外空闲的一半内存中去,再把移动完毕后的空间直接全部回收
优点:
不会产生碎片化问题
在存活对象较少的时候效率相对较高,适合使用
缺点:
改变一个对象的物理地址需要改动的地方太多,除了要移动对象以外,还需要改动引用指向的物理地址
一次性相当于浪费了一半的空间,相当亏本
标记压缩算法(Mark-compact)
标记压缩算法的实现就是在进行垃圾清除的时候,会把存活对象全部移动到内存的最前面,将整片空间整理成为
优点:
不会产生碎片
不会浪费一半的内存
缺点
需要扫描两次(标记和移动)
需要移动对象,移动对象的代价较高
堆内存逻辑分区(不适用于不分代的垃圾回收器)
堆内存的逻辑分区和使用的垃圾收集器息息相关,JVM使用了很久的分代模型,这里我们要提一些特例
ZGC、Epsilon、Shenandosh是不分代GC;G1是逻辑上分代,物理上不分代(物理不分代的意思是并没有专门划分出来的区域来分别装新生代和老年代);其他的GC全部都是逻辑和物理上都分代
在新生代,使用的垃圾回收算法一般是copying算法;在老年代使用的垃圾回收算法一般是mari-sweep或者mark-compact
一个对象的分配逻辑
当一个对象被new出来的时候,首先会尝试在操作数栈上分配,分配失败进行下一步
当一个对象是线程私有的对象,并且这个对象可以被栈装下,进行栈上分配
new出来的对象无法逃逸,看下面的示例,这里面的User对象就无法逃逸,很困死在m()这一段代码里面
public void m(){ User u = new User(); u.getName(); }
对象栈上分配失败,分配到eden区
指针碰撞
我们可以看到,JVM在eden区域分配对象空间的时候是通过指针来分配的,指针左边都是被分配过的,右边都是没有被分配过的
如果我现在处于多线程状态,几个线程同时创建对象,就有可能出现分配对象的指针发生碰撞情况
TLAB
TLAB全程Thread Local Allocation Buffer,意思是每一个线程在创建的时候都默认给它在eden区域创建一个区域,专门用于这个线程存放对象,这样就可以解决指针碰撞的问题;TLAB的默认大小是eden空间的1%
我们可以发现TLAB虽然解决了指针碰撞问题,但是却也有其他问题,例如TLAB会加剧碎片化
当这个存在于eden区的对象经历一次垃圾回收之后,如果没有被回收,将会进入到一个survivor区域;当这个对象又一次经历垃圾回收之后就会进入另一个survivor区域;就这样反复横跳,直到年龄达到一定条件然后进入老年代(终身区)
不同区域触发GC的不同称呼
- 在年轻代触发的GC叫做Minor GC/Young GC,简称YGC
- 当eden的空间耗尽或达到设定的阈值触发YGC
- 在所有的空间进行(老年代和年轻代一起)的GC叫做Major GC/Full GC,检查FGC
- 当老年代的空间耗尽或达到设定的阈值会触发FGC,年轻代和老年代一起回收
对象进入老年代的时机
- 超过参数 XX:MaxTenuringThreshold 指定的次数;我们以前在提到对象头的时候,对象的头部就有4位是作为这个对象的age,四位最大就是15
- Parallel Scavenge:15
- CMS:6
- G1 15
- 动态年龄:当进行一次垃圾回收后,eden区和一个survivor区中所有存活的对象全部进入到另一个survivor区的并且这个占有的空间超过一半的时候就会启动动态年龄判断,当某个年龄开始的对象开始达到并且超过50%的时候就会将大于等于这个年龄的所有对象全部转移到eden。例如:age为1的对象占了survivor空间的25%,age为2的占了10%,age为3的占了20%,那么从age=3开始就超过了50%,那么所有age>=3的对象就会全部被转移到老年代
垃圾回收器简单介绍
垃圾回收器简单认识
我们主要会介绍7种垃圾回收器
从这个垃圾回收器的分代我们就可以知道,不同的垃圾回收器在不同的区域工作,serial、parallel scavenge、parNew都是在新生代使用的GC;serial old、parallel old、cms都是在老年代使用的GC;也就是说,不同年龄代的GC就可以搭配使用,比如serial GC除了可以和Serial old搭配以外,也是可以和cms搭配使用,以下是我们搭配使用的图
垃圾回收器历史
- 1999年随JDK1.3.1一起来的是串行方式的SerialGC,它是第一款GC。ParNew垃圾收集器是Serial收集器的多线程版本
- 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC(CMS GC)跟随JDK1.4.2一起发布·
- Parallel GC(适用于新生代,对应老年代为 Parallel Old GC)在JDK6之后成为HotSpot默认GC。
- 2012年,在JDK1.7u4版本中,G1(G First)可用。
- 2017年,JDK9中G1变成默认的垃圾收集器,以替代CMS。
- 2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
- 2018年9月,JDK11发布。引入Epsilon 垃圾回收器,又被称为 “No-Op(无操作)“ 回收器。同时,引入ZGC:可伸缩的低延迟垃圾回收器(Experimental测试版)
- 2019年3月,JDK12发布。增强G1,自动返回未用堆内存给操作系统。同时,引入Shenandoah GC:低停顿时间的GC(Experimental)。·
- 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给操作系统。
- 2020年3月,JDK14发布。删除CMS垃圾回收器。扩展ZGC在 MAC OS 和 Windows 上的应用
Serial GC & Serial Old
英文描述:a stop-the-world,copying collector which uses a single GC thread
中文翻译:SerialGC是一个存在stw,使用单线程的GC
serial GC运行逻辑:
当触发GC时,serial GC会让所有的工作线程暂停,然后serial GC就开始工作,当垃圾清理完毕后所有的工作线程继续执行
serial 细节
serial是初代的GC
当serial让所有的工作线程停止工作时,所有的工作线程并不是立即停止,而是达到了safe point才会停止,因为有的线程可能正在执行一个同步方法,要等他执行完毕释放同步资源后再停止
从所有的工作线程停止到GC完成垃圾回收,所有工作线程继续工作的时间段叫做stop-the-world,简称stw
Serial Old就是在老年代工作的Serial GC
Parallel Scavenge & Parallel Old
英文描述:a stop-the-world,copying collector which uses a multiple GC threads
中文翻译:Parallel是一个存在stw,使用多线程的GC
Parallel Sacvenge运行逻辑
运行逻辑和Serial差不多,区别就在于Parallel Scavenge使用的是多线程
Parallel Scavenge 细节
Parallel Scavenge是第二代的GC
如果jdk8没有对JVM的GC设置的话,那么默认就是使用的Paralle Scavenge + Parallel Old,简称PS+PO
ParNew & CMS
英文描述:
a stop-the-world,copying collector which uses a single GC thread.
it differs from “Parallel Scavenge” in that it has enhancements that make it usable with CMS.
for example “ParNew” does the synchronization needed so that it can run during the concurrent phase of CMS
中文描述:ParNew是一个有stw,并且使用多线程的GC,它和Parallel Scavenge的区别在于ParNew做了一些增强,让它更加适合于CMS,这是为了CMS适配设计的。例如,ParNew可以在CMS的某个特定阶段运行
CMS简单介绍
CMS是一个里程碑式的GC,它开启了并发(在工作线程运行的时候,同时可以使用CMS)回收时代,但是CMS毛病非常多,以至于它不是任何JDK版本的默认GC并且它还在JDK14的时候被删除了
CMS运行逻辑(七个阶段)
初始标记:进行初始标记阶段,该阶段将会把根对象标记出来,并且标记出由新生代对象指向的老年代对象,该阶段会产生stw,但是因为数量较少所以耗时较少,下图中绿色的对象就是在初始标记阶段被标记的对象
并发标记:进入并发标记阶段,该阶段做的事情是从初始被标记的对象开始找到所有的存活对象,因为标记的过程中同时在进行工作线程运行,所以标记完毕后还会进行重新标记。并发标记是最消耗时间的步骤,可以占到总时间的80%以上,下图中是并发标记运行后的结果
预清理阶段:在并发标记阶段不一定可以将所有老年代的存活对象都标记出来,我们可以看到,在这里,有个3号对象,在经过线程的操控后把引用指向了6号节点,那么这个3号节点会被标记为dirty
可终止的预处理阶段:这个阶段尝试着去承担下一个阶段Final Remark阶段足够多的工作。这个阶段持续的时间依赖好多的因素,由于这个阶段是重复的做相同的事情直到发生abort的条件(比如:重复的次数、多少量的工作、持续的时间等等)之一才会停止。 ps:此阶段最大持续时间为5秒,之所以可以持续5秒,另外一个原因也是为了期待这5秒内能够发生一次ygc,清理年轻带的引用,是的下个阶段的重新标记阶段,扫描年轻带指向老年代的引用的时间减少;
重新标记阶段:该阶段是第二个stw,在这个阶段会将在并发标记阶段没有标记的存活对象进行重新标记,为了防止出现问题,我们需要停止其他所有的工作线程然后进行标记,这里我们可以直接检查在预清理阶段标记的dirty节点,这样可以极大节约时间
并发清理阶段:该阶段就会对没有标记的那些对象进行清理,但是注意,这个阶段不是stw的,所以有可能会产生新的垃圾,这些垃圾就被叫做浮动垃圾,只能等到下一次启动GC的时候清理
并发重置阶段:这个阶段并发执行,重新设置CMS算法内部的数据结构,准备下一个CMS生命周期的使用
CMS的缺点
CMS发明的原因
- CMS被发明出来的原因是因为人们已经无法忍受stw了,在以前JVM的内存只有几百M的时候,使用serial可以解决,人们也不会觉得stw有多么严重,随着JVM内存的不断扩大,serial的效率完全无法满足人们的需求,并且在stw阶段会让人崩溃,于是就发明了并发垃圾清理器,也就是CMS
CMS最大缺点
- 从上面的介绍我们可以清楚的了解,CMS被发明的初衷是为了解决STW过长的问题,但是CMS却也有可能出现非常严重的STW问题,这是因为CMS会和Serial搭配使用
- CMS在进行垃圾回收的时候使用的是Mark-sweep算法,这个算法的效率不错,但是这个算法最大的缺点就是会产生大量的碎片,一旦老年代存在大量的碎片的时候,老年代有可能无法再次往其中添加对象了,那么这个时候就会开始触发CMS的最终手段——使用serial进行mark-compact清理。
- 我们可以感受到,在拥有巨大内存空间的时候,如果发生了调用serial使用mark-compact进行垃圾清理的情况下我们将会面临一个恐怖的stw间隔,有可能会出现几个小时的stw时间,这个时间里系统就卡死了,我们无法进行任何操作,这是一个企业无法忍受的
- 一个想要解决stw时间过长的垃圾回收器却有可能出现恐怖的stw,这是cms的一个巨大缺陷!
CMS问题的解决方式
- 我们可以调整一个参数来解决CMS参数调优的问题:CMSInitiatingOccupancyFraction
- 通过这个参数我们就可以调整CMS触发的阈值,我们将这个值降低,这样就可以给浮动垃圾的产生预留空间,并且较低的触发阈值来多次触发CMS就可以避免垃圾积累过多后产生的一次性大量的垃圾清理导致出现serial清理的情况
经典案例
一个公司为了解决系统反应慢的问题准备给公司的服务器升级硬件,其中着重扩大了内存空间。可是这样的作法不但没有解决系统反应卡顿的问题,反而变得更加严重,这是为什么?
出现这种情况,极有可能就是CMS触发的阈值太高,导致CMS触发的时候老年代已经装满了,清理完毕后变得更加碎片化,这就导致CMS会调用serial来进行内存整理,在提升了内存的情况下,serial需要整理的内存空间也会变得非常大,这就会导致stw时间变得更加长,用户用起来自然会觉得非常卡顿