JVM垃圾回收
1、垃圾回收概述
垃圾收集的三个经典问题
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
1.1、什么是垃圾
“An Object is considered garbage when it can no longer be reached from any pointer in the running program”
垃圾是指在运行的程序中没有任何指针指向的对象,占据了无效的内存空间
1.2、为什么需要垃圾回收
如果不及时对内存垃圾进行回收清理,那么这些垃圾所占用的空间会一直保持到程序结束,浪费内存的同时还有可能会导致内存溢出
内存泄露:见下
全面:
- 如果不进行GC,内存迟早会被消耗完
- 除了释放没用的对象,GC也可以清除内存中的记录碎片,碎片整理将所占用的对内存移动到堆的一端,以便JVM将整理出的内存分配给新的对象
- 没有GC无法保证程序的正常运行,经常造成的STW的GC又跟不上实际的需求,所以才会对GC进行优化
1.3、早期垃圾回收机制
早期C和C++中,用new
申请内存,用delete
释放内存
这种方式很灵活,但是会给开发人员带来负担,如果一处空间忘记回收,会造成内存泄漏,垃圾对象永远无法被清除,直到OOM造成程序崩溃
除了Java,Python、Ruby、C#等语言也有GC的功能,GC已经是现代语言的标配了
1.4、Java垃圾回收机制
自动内存管理降低程序内存泄漏和溢出的风险,使程序员更关注于业务的开发
但是自动管理内存会弱化开发人员在程序出现内存问题时定位问题和解决问题的能力
2、垃圾回收相关算法
垃圾标记阶段:哪些是垃圾(判断对象是否存活)?(引用计数算法、可达性分析算法)
- 在堆中存放着几乎所有的Java对象实例,在执行GC回收之前,首先需要区分出内存中哪些是存活对象,那些是已死亡对象(当一个对象已经不被任何存活的对象所引用)。只有被标记为已经死亡的对象才会被回收,这个阶段称之为标记阶段
垃圾清除阶段:怎么处理垃圾?(标记-清除算法、复制算法、标记-压缩算法)
- 当成功区分出内存存活对象和死亡对象的时候,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存,这个阶段被称为垃圾清除阶段
2.1、标记阶段:引用计数算法
算法:
- 对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况, 对于一个对象A,只要有一个对象引用了A,则A对应的计数器加1;当引用失效时,计数器减1。只要对象A的引用计数器值为0,即表示A不可能再被使用,可以进行回收
**优点:**实现简单,垃圾对象容易辨识,判定效率较高,回收没有延迟性
缺点:
- 需要计数器以及对计数器的操作,增加了额外的空间开销和时间开销
- 无法处理循环引用的问题
JVM中没有使用该算法,Python解决了循环引用问题进而使用了该算法:
- 手动解除:在合适的时机接触引用关系
- 使用弱引用weakref
2.2、标记阶段:可达性分析算法
也叫根搜索算法、追踪性垃圾搜集,Java、C#中使用了该算法,相比较引用计数算法,可达性分析算法解决了循环引用的问题
算法:
- 以根对象集合(GC Roots)为起始点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接活间接连接着,搜索所走过的路径称为引用链
- 如果目标对象没有任何引用链相连,则是不可达的,意味着该对象已经死亡,被标记为垃圾对象
- 可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象
GC Roots主要包括:
- 虚拟机栈、本地方法栈中的引用对象
- 方法区中类静态属性引用的变量
- 常量引用的对象
- 所有被同步锁synchronized的对象
- JVM内部的引用(Class、OOM、系统类加载器)、反应JVM内部情况的JMXBean、JVMTi中注册的会调、本地代码缓存等
除了上述之外还有可能把临时对象加入GC Roots、比如分代收集和局部回收
注意点:
如果要使用该算法,那么分析工作必须在能保证一致性的快照中进行,这也是导致STW的原因,即使是号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的
2.3、对象的finalization机制
Java语言提供了对象终止机制允许开发人员提供对象被销毁之前的自定义处理逻辑
当垃圾回收器发现没有引用指向该对象的时候,即垃圾回收对象之前,总会先调用该对象的finalize()
方法
finalize
方法允许子类重写,用于在对象被回收时进行资源释放和清理的工作,比如关闭套接字、数据库连接等
**注意:**不要主动调用一个对象的finalize()
方法,理由是:
- 调用
finalize()
可能使对象复活 finalize()
执行时间是没有保障的,完全由GC线程决定,极端情况下如果不发生GC,则finalize()
方法不会执行- 糟糕的
finalize()
会影响性能
由于finalize()
的存在,JVM中的对象一般分为三种状态,
一个无法触及的对象也有可能在某一个条件下复活,那么对它的回收就是不合理的,为此定义了三种状态
- 可触及的:从根节点开始可以到达这个对象
- 可复活的:对象的所有引用都被释放,但是对象有可能在
finalize()
中复活 - 不可触及的:对象的
finalize()
被调用,并且没有复活
只有在对象处于不可触及的状态时才可以被回收,由此我们说:判断一个对象objA是否要回收,最多要经历两次标记
- 如果objA到GC Roots没有引用链,则进行第一次标记
- 判断该对象是否应该执行
finalize()
方法:- 如果对象没有重写,或者重写后已经被调了,虚拟机视为“没有必要执行”
- 如果objA重写了
finalize()
,并且还未执行,那么objA会被插入到一个F-Queue
队列中,由一个虚拟机自动创建的低优先级的Finalizer
线程触发其finalize()
方法 finalize()
是一个对象最后一次逃脱死亡的机会,稍后GC会对F-Queue
队列中对象进行二次标记,如果objA在这个方法中和引用链上任意一个对象建立了关系,那么objA会被移出"即将回收"的集合进入可触及状态,否则进入不可触及状态finalize()
方法对于一个对象来说只能被调用一次,当finalize()拯救了的对象再次出现没有引用链,会直接进入不可触及状态
2.4、清除阶段:标记-清除算法(Mark-Sweep)
**背景:**非常基础、常见的,1960年被 J.McCarthy等人提出并且应用到Lisp
语言
执行过程:
当堆中的有效内存空间被耗尽,就会停止整个程序(Stop The World),然后进行两项工作,第一项是标记、第二项是清除
- 标记:Collector从引用根节点开始遍历,标记所有被引用的对象, 一般是在对象的Header中记录为可达对象
- 清除:Collector从对内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有被标记为可达对象,则将其回收
缺点:
- 效率不算高
- 在进行GC时需要STW,用户体验差
- 这种方式产生的内存空间不是连续的,会产生内存碎片
**注意:**什么是清除?
这里的清除并不是讲空间置为空,而是将可以回收的地址保存在空闲的地址列表里,有新对象要加载的时候可以直接使用这些空间
2.5、清除阶段:标记-复制算法
背景:为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表论文,“使用双存储区的Lisp语言垃圾收集器CA LISP Garbage Collector Algorithm Using Serial Secondory Storage”,这种算法被人们称为复制算法
核心思想:
将活着的内存空间分成两块儿,每次只使用其中的一块儿,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块儿中,之后清除正在使用的内存块儿,交换两个内存的角色,最后完成垃圾回收
优点:
- 没有清除的过程,实现简单运行高效
- 复制过去能保证空间的连续性,解决了内存碎片问题
缺点:
- 需要额外的内存空间
- 对于G1这种分拆成大量的region的GC,复制而不是移动,意味着GC需要维护region之间的对象引用关系,不管是内存占用还是时间消耗都不小
2.6、清除阶段:标记-压缩算法(Mark-Compact)
背景:复制算法基于高效性建立在存活对象少,垃圾对象多的情况下,这种情况在新生代经常发生,但是在老年代更常见的情况是大部分对象都处于存活状态,如果用复制算法时间成本和空间成本高,。标记清除算法可以用在老年代中,但是该算法会产生内存碎片,因此基于此,我们需要用其他的算法——标记压缩算法,也叫标记-整理算法,在现在很多垃圾搜集器中都用的是这个算法或者是改进版本
执行过程:
- 和标记清除算法一样,从根节点标记所有被引用的对象
- 将所有活着的对象压缩到内存的一端,按顺序存放,之后清理掉外边所有空间
标记压缩算法最终效果等同于标记清除算法执行完成后,在进行一次内存整理。标记-压缩算法和标记-清除算法区别在于,清除算法是一种非移动式的回收算法,压缩算法是移动式的
优点:
- 消除了标记-清除算法的缺点,不会产生内存碎片
- 消除了标记-复制算法的缺点,没有巨大的内存代价
缺点:
- 从移动效率来说,标记整理算法要低于复制算法
- 移动对象的过程中,需要调整被引用的对象的指针位置
- 移动过程中需要全程暂停用户进程STW
2.7、小结
三种垃圾收集的标记算法比较
标记-清除算法 | 标记-压缩算法 | 标记-复制算法 | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(有碎片) | 少(无碎片) | 通常需要存活对象的2倍(无碎片) |
移动对象 | 否 | 是 | 是 |
2.8、分代搜集算法
如上所示的三种算法,没有一种算法可以完全替代另外一种,它们都具有各自独特的优势
分代搜集算法是基于这样一个事实,不同对象的生命周期是不一样的,因此不同生命周期的对象可以采用不同的搜集方式,以便提高回收效率,一般是把Java堆分成新生代和老年代,这样可以根据各个年代的特点使用不同的垃圾搜集算法,以提高垃圾回收的效率
目前所有的垃圾回收器都采用的分代搜集算法:
-
年轻代:区域较小,对象生命周期短,存活率低,回收频繁
复制算法效率最高,效率只和当前存活对象有关,因此很适用于年轻代的回收
-
老年代:区域较大,对象生命周期长,存活率高,回收不频繁
一般是用标记-清除算法和标记-整理算法混合实现的
- Mark阶段开销和存活对象的数量成正比
- Sweep阶段开销和所管理的区域大小成正比
- Compact阶段开销和存活对象的数据成正比
2.9、增量搜集算法、分区算法
2.9.1、增量搜集算法(Incremental Collecting)
STW:Stop The World,应用程序的所有线程都会停下来,暂停一切正常工作,等待垃圾回收完成,如果这个时间过长,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾搜集算法的研究直接导致了增量搜集算法的诞生
基本思想:如果一次性将所有垃圾回收,需要造成长时间的STW,那么就可以让垃圾搜集线程和应用线程交替执行,每次垃圾搜集线程只搜集一小块儿区域的内存空间,接着切换到应用线程。如此反复执行,直到垃圾搜集完成。总体来说,这种算法基础仍然是传统的标记-清除算法和复制算法。增量搜集算法通过对线程之间的冲突妥善处理,允许垃圾搜集以分阶段的方式完成标记、清理或复制工作。
**缺点:**由于线程切换造成的上下文切换的消耗,会使得垃圾回收的总体成本较高,造成系统吞吐量降低
2.9.2、分区算法
一般来说,在相同条件下,堆空间越大,一次GC花费的时间越长,有关GC停顿也越长。
基本思想: 为了更好的控制GC产生的停顿时间,将一块儿大的内存区域划分成多个小块儿(region),根据目标的停顿时间,合理地回收若干个小区间,从而减少一次GC所产生的停顿。分代算法将按照对象的生命周期划分成两个部分,分区算法将整个堆划分成连续的不同小区间。每个小区间都独立使用,独立回收。可以控制一次回收多少个小空间
3、垃圾回收相关概念
3.1、System.gc()
的理解
默认情况下,通过调用System.gc()
或者Runtime.getRuntime().gc()
的调用,会显示触发Full GC
,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存
注意:System.gc()
调用附带一个免责声明,无法保证堆垃圾收集器的调用。无法保证马上执行GC,但是如果加上``System.runFinalization(),则强调使用引用的对象的
finalize()`方法
一般我们不会主动调用System.gc()
来决定JVM的GC行为
3.2、内存溢出与内存泄露
3.2.1、内存溢出(OutOfMemory)
内存溢出:是引发程序崩溃的罪魁祸首之一,常发生在堆中,Javadoc对OOM的描述为:没有空闲内存,并且垃圾收集器也无法提供更多内存
- 由于GC的发展,除非应用程序占用的内存增长速度非常快,造成垃圾回收的速度跟不上内存消耗的速度,否则不太容易出现OOM的情况
- 大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就来一次独占的
Full GC
,这时候会回收大量的内存供程序使用 - 在OOM之前,一定会触发一次Full GC的,在GC后仍然没空间才会报OOM
3.2.2、内存泄漏(Memory Leak)
也叫做"存储渗漏",严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况才叫内存泄漏,比如说:
-
单例模式
单例模式的生命周期和应用程序是一样长的, 如果单例程序中持有对外部对象的引用,那么这个外部对象是不能被回收的
-
一些提供close的资源未关闭导致内存泄漏
数据库连接、网络连接、IO连接必须手动close,否则不会被回收
-
ThreadLocal内存泄漏:
实际情况下,很多时候一些疏忽导致的对象的生命周期变得很长甚至OOM,也可以叫做广义上的内存泄漏,比如说局部变量变为实例变量又变为类变量
尽管内存泄漏不会立即引起程序崩溃,但是内存泄露的发生会逐步蚕食内存,直到耗尽OOM ,ML --> OOM
3.3、Stop The World
简称STW,指的是在GC事件中,会产生应用程序的停顿,停顿产生时整个应用程序线程都会被暂停,没有任何响应,这个停顿被称为STW
比如说,可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿,本质上是GC过程中产生,Why?
- 分析工作必须在一个能保证一致性快照中进行,一致性指的是整个分析期间整个执行系统看起来像被冻结在某个时间点上,如果出现分析过程中引用关系还在不断变化,则分析结果的准确性无法保证
STW跟垃圾回收器无关,因为所有的垃圾回收器都有这个事件
3.4、垃圾回收的并行与并发
操作系统中,在一个时间段中有多个程序处于运行状态中,且这几个程序都是在同一个处理器上运行,称为并发,将时间段改为时刻则称为并行
在垃圾回收中,并行与串行解释如下:
- 并行:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态(ParNew、Parallel、Scavenge、Parallel Old)
- 串行:相较于并行的概念,单线程执行。如果内存不够程序暂停,启动垃圾回收器进行垃圾回收。回收完再启动程序的线程
在垃圾回收中,并行与并发解释如下:
-
并发:指用户线程和垃圾收集线程同时执行(不一定并行,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行
,如CMS、G1
3.5、安全点与安全区域
3.5.1、安全点
程序执行时并非在所有的地方都能停顿下来GC,只有在特定的位置才能停顿下来GC,这些位置称为安全点(Safepoint)
安全点的选择比较重要,如果太少可能导致GC等待时间过长,如果太频繁可能导致运行时的性能问题,这个选择给予的标准一般是“是否具有让程序长时间执行的特征”,大多会在方法调用、循环跳转、异常跳转中
另一个需要考虑的问题是,如何在垃圾收集发生的时候,让所有的线程都跑到最近的安全点,然后停顿下来,我们这里主要有两种方案:
- 抢先式中断:在GC发生时,系统首先把所有用户线程全部中断,如果发现有用户线程的地方不再安全点上,就恢复这条线程继续执行,直到跑到安全点上
- 主动式中断:当垃圾收集需要中断线程时,仅仅简单设置一个标志位,各个线程执行过程中会时不时主动轮询这个标志位,一旦发现中断标志位为真,就主动安全挂起。轮训标志的地方和安全点是重合的,Hotspot采用了这种方式,将轮询指令精简到只有一条汇编指令的程度。
3.5.2、安全区域
安全区域的存在是基于这样一个事实:在程序处于Sleep
或者Blocked
状态的时候,线程无法响应虚拟机的中断请求,不能走到安全点去挂起自己,由此我们引入了安全区域(Safe Region)
安全区域是指的是,确保一段代码片段中引用关系不会发生变化,在这个区域内任意地方开始GC都是ok的,我们可以将这个区域看作拉伸的安全点
当用户线程走到安全区域,会标示自己进入了,这样当JVM想要发起垃圾收集的时候就不用管这个线程了,在线程走出安全区域的时候会判断有没有还在GC,如果还在就等会儿,否则就出去
3.6、几种引用
之所以要给引用划分程度,是因为在早期的Java中,引用的概念是这样的:如果reference类型的数据中存储的数值代表的是另外一块内存的其实地址,就成该reference数据是代表某块内存、某个对象的引用。我们不能说这种定义有问题,只是对于现在复制的对象回收机制来说,有点太简单了——对象的状态只有被引用和不被引用两种状态。如果我们想要定义一种引用级别,在对象回收的时候可以保留下来,但是在内存不够的时候,也可以回收出去,显然这种定义无法满足这个需求
于是我们定义了4种引用级别,依次递减:强引用、软引用、弱引用,这几种引用的对象都是可触及的
3.6.1、强引用(Strong Reference)
最常见的引用方式:在程序中普遍存在的引用赋值,强引用是造成内存泄漏的主要原因之一
Object obj = new Object();
无论任何情况下,只要强引用关系还在,垃圾收集器就永远不会回收被引用的对象
3.6.2、软引用(Soft Reference)
系统将要发生OOM(或者内存不够)前,才会把这些对象放入回收范围之内进行二次回收的引用方式
也就是说,如果内存够,这些被引用的对象是不会被回收的 “内存不足才回收” 做缓存用
3.6.3、弱引用(Weak Reference)
只被弱引用关联的对象只能生存到下一次垃圾收集之前,当垃圾收集器工作时,无论内存是否够都会回收被弱引用引用的对象
"发现即回收"
3.6.4、虚引用(Phantom Reference)
也叫幽灵引用或者幻影引用,是所有引用类型中最弱的一个
一个对象是否有虚引用完全不会对其生存时间造成影响,也无法通过虚引用获取对象实例,为一个对象设置虚引用的唯一目的是在这个对象被垃圾收集器回收之前收到一个系统通知,以便追踪垃圾回收过程。
虚引用必须喝引用队列一起使用,在创建一个虚引用的时候必须提供一个引用队列作为参数,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后将这个虚引用加入引用队列,以通知应用程序对象的回收情况
3.6.5、终结器引用(Final Reference)
用于实现对象的finalize()
方法,无需手动编码其内部配合引用队列使用
在GC时,终结器引用入队,由Finalizer
线程通过终结器引用找到被引用的对象并调用它的finalize()
方法,第二次GC时才能回收被引用对象
4、垃圾回收器
垃圾回收器是上面垃圾回收算法和垃圾回收相关概念的实现
4.1、垃圾回收器分类与性能指标
GC没有在规范中有过多规定,可以由不同厂商、不同版本的JVM来实现
- 按照GC的线程数分,分为串行GC和并行GC
- 按照工作模式分,分为并发式垃圾回收器和独占式垃圾回收器
- 按照碎片处理方式分,分为压缩式垃圾回收器和非压缩式垃圾回收器
- 按照工作的内存空间分,又分为年轻代垃圾回收器和老年代垃圾回收器
评估垃圾回收器的性能指标
- 吞吐量:运行用户代码的时间占总运行时间的比例
- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
- 内存占用:Java堆区所占内存大小
- 收集频率:相对于应用程序的执行,收集操作发生的频率
- 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例
- 快速:一个对象从诞生到被回收所经历的时间
前三点共同构成一个"不可能三角",三者总体表现会随着技术进步而愈来越好,一款优秀的GC最多同时满足两项
这三个里面暂停时间的重要性日益凸显。因为硬件发展,内存占用多可以容忍,硬件性能的提升也有助于降低GC运行时对应用程序的影响,提高了吞吐量。而内存扩大对延迟反而带来负面效果,总体来说主要看中两点:吞吐量、暂停时间,这两者性能时矛盾的,所以我们现在设计GC的标准是:在最大保证吞吐量的情况下降低暂停时间
吞吐量
吞吐量 = 运行用户代码的时间 / (运行用户代码的时间 + 垃圾收集时间)
这种情况下,应用程序能容忍较高的暂停时间,因此高吞吐量的应用程序有更高的时间基准,快速响应是不必考虑的。
吞吐量优先,意味着在单位时间内,STW的时间最短
暂停时间(pause time)
指的是一段时间内应用程序暂停,让GC线程执行的状态,暂停时间优先意味着尽可能让单词STW时间最短
4.2、不同的垃圾回收器概述
-
串行回收器:Serial、Serial Old
-
并行回收器:ParNew、Parallel Scavenge、Parallel Old
-
并发回收器:CMS、G1
-
新的GC:ZGC、Shenandoah GC
Young Gen: [Serial GC]、[Parallel Scavenge GC]、[ParNew GC]
--------------------------------[G1]------------------------
Old Gen: [Serial Old GC]、[Parallel Old GC]、[CMS GC]
4.3、Serial回收器:串行回收
最基本、历史最悠久的GC,JDK1.3之前回收新生代的唯一选择,目前在HotSpot中Cilent模式下仍被默认使用(+Serial Old)
采用复制算法、串行回收、和Stop The World机制的方式执行内存回收,简单高效
除了年轻代,Serial收集器还有执行老年代垃圾收集的Serial Old收集器,Serial Old收集器也采用了串行回收和STW,只不过内存回收算法采用的是标记-压缩算法,
Serial Old在Sever模式下有两个用途:
- 与新生代的Parallel Scavenge配合使用
- 作为老年代的CMS收集器的后备垃圾收集方案
4.4、ParNew回收器:并行回收
ParNew GC可以理解为Seria回收器的多线程版本,其中Par是Parallel缩写,New指的是新生代
ParNew收集器除了采用并行回收之外和Serial几乎没有区别,同样也采用复制算法、STW机制
ParNew是很多JVM在Server默认使用的GC
在新生代使用ParNew后,老年代的回收可以选用CMS或者Serial Old,但是在JDK9及以后只能配合使用CMS,在JDK14后CMS被移除
4.5、Parallel回收器:吞吐量优先
Parallel Scavenge GC也采用了复制算法、并行回收、STW,也是一款年轻代收集器
在有了ParNew之后为什么要有这一款呢?
- 和ParNew不同, Parallel Scavenge GC的目标是达到一个可控制的吞吐量,也被称为吞吐量优先的GC
- 自适应调节策略也是Parallel Scavenge和ParNew一个重要区别
Parallel Scavenge和Serial一样有一个Parallel Old用来处理老年代的垃圾,在JDK1.6出现替代了原来使用的Serial Old,Parallel Old采 用了标记压缩算法,但同样也是机遇并行回收和STW机制,JDK8中默认是用这个组合
高吞吐量可以高效利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务,如批量处理、订单处理
4.6、CMS回收器:低延迟
CMS:Concurrent Mark Sweep, 这款GC是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作,采用标记-清除算法,同时也会STW
CMS关注点是尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短就越适合和用户交互的程序,如在B/S系统上服务器
CMS无法配合Parallel Scavenge配合,在JDK1.5只能选择和ParNew或者Serial中的一个使用,CMS在JDK9被过时,JDK14被remove
CMS工作原理:
-
初始标记(CMS initia mark)
程序进入STW,然后标记出GC Roots能够直接关联到的对象,一旦标记完成之后就会恢复之前被暂停的所有线程,速度较快
-
并发标记(CMS concurrent mark)
和用户线程同时执行,从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长
-
重新标记(CMS remark)
程序STW,为了修正并发标记阶段,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段花费时间通常会比初始标记时间稍长,这个时候往往有多条标记线程执行
-
并发清除(CMS concurrent sweep)
和用户进程同时执行,此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间
注意:
- 因为出现STW的初始标记和重新标记两个阶段花费的时间较短,所以整体上看程序STW的时间较短
- 因为存在用户线程和GC标记线程并发执行,所以在这个过程中要保证用户线程有足够的内存可用,因此CMS GC不能想其他GC一样等到老年代几乎快填满了再GC,而是当堆内存达到某一个阈值就要开始回收,如果CMS运行期间预留的内存无法满足程序需要,就会出现
Concurrent Mode Failure
,这时候JVM会临时采用Serial Old GC来重新进行老年代GC,这样停顿时间会变长 - CMS采用的是标记-清除算法,这意味着每次执行完内存回收后不可避免产生内存碎片,CMS在为新对象分配空间时将无法使用指针碰撞(Bump the Pointer),只可以使用空闲列表(Free List)。之所以不能用标记-压缩算法,是因为在进行最后一个并发清除阶段时,因为存在用户线程的执行,所以不能改变对象的内存位置,势必会影响到用户线程的引用执行
总结:
CMS优点:并发收集、延迟低
CMS缺点:
- 会产生内存碎片,跟采用的标记-清除算法有关,详见上
- CMS在并发阶段对CPU资源敏感吞吐量降低(虽然可以一起执行,但是势必会占据CPU导致用户线程执行稍慢)
- 无法处理浮动垃圾(在并发标记和并发清除阶段产生的垃圾)
- CMS 当堆内存达到某一个阈值就要开始回收,详见上
如果你想要最小化地使用内存和并行开销——Serial GC
如果你想最大化应用程序吞吐量——Parallel GC
如果你想最小化GC的中断或停顿时间——CMS GC
4.7、G1回收器:区域化分代式
4.7.1、先导知识
Remembered Set(RSet):记忆集
一个Region不是独立的,一个Region中的对象可能被其他任意Region中的对象引用,判断对象是否存活,是否要扫描整个Java堆?或者说,回收新生代难道也不得不扫描老年代(如果old区有引用到Eden区)?
肯定不可以,由此我们引入RSet来避免全局扫描
- 每个Region都有一个记忆集
- 每次引用类型数据写操作的时候,都会产生一个**写屏障(Write Barrier)**暂时中断操作(AOP)
- 然后去检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region
- 如果来自不同的Region,通过CardTable把相关引用信息记录到引用指向对象的Region对应的RSet中
- 当进行垃圾搜集时,在GC Roots的枚举范围内加入RSet,就可以保证不进行全局扫描也不会遗漏
Dirty Card Queue(脏卡队列)
-
卡表是RSet的实现,脏卡队列指的是:对于应用程序的赋值语句:
// 比如说ObjectA是老年代的对象,objectB是Eden中的对象 ObjectA.field = objectB;
JVM会在之前和之后执行特殊的操作在DCQ中入队了一个保存了对象引用信息的card,在YGC的时候,G1会堆DCQ中所有的card进行处理,从而更新RSet,保证RSet实时准确反映引用关系
为什么不在这行代码执行时就更新RSet呢?为了性能需要,因为RSet处理需要同步,开销大,使用队列性能好
4.7.2、Garbage First
G1 GC是一款面向服务端应用的GC,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时兼具高吞吐量的特征,在JDK9中称为默认GC,号称"全功能的GC"
问题1:既然已经有了几个强大的GC,为什么还要发布G1 GC呢?
- 业务越来越庞大、复杂、用户越来越多,经常造成STW的GC跟不上实际业务需求
- 内存越来越大,处理器核心越来越多,有优化的硬件基础
- 官方由此想要研制一款在延迟可控的情况下尽可能高的吞吐量的"全功能收集器"。
问题2:为什么叫G1(Garbage First)?
- 因为G1是一个并行回收器,它把对内存分割为很多不相关的物理上不连续区域(Region),使用不同的Region代表Eden、S0、S1
- G1有计划地在整个Java堆中进行全区域的GC,G1跟踪各个Region里面垃圾堆积的价值大小(回收所获得的空间大小以及回收所需呀时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
- 这种方式的侧重点在于回收垃圾最大量的Region,所以我们起名"垃圾优先"(Garbage First)
G1 GC的GC主要包含三个环节:
-
YGC
当Eden区用尽开始年轻代的回收过程,G1年轻代阶段是一个并行的独占(STW)式GC,暂停所有用户线程,启动多线程执行年轻代回收,然后从年轻代区间移动存活对象到S区或者老年区间,也有可能是两个区间都会被涉及
YGC时,首先G1 STW,G1常见回收集(Collection Set),回收集指的是需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden和S区的所有内存分段
- 扫描根:即GC Roots,static变量指向的对象,正在执行方法的局部变量,根引用连同RSet记录的外部引用作为扫描存活对象的入口
- 更新RSet:处理dirty card queue中的card,更新RSet,使得RSet可以准确反映老年代对所在内存分段中对象的引用
- 处理RSet:识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的
- 复制对象:Eden区中存活的对象会被**复制(复制算法)**S区中的内存分段,S区内存分段中存活的对象如果年龄没达到阈值则加一,达到阈值的被分配到老年代,如果S区空间不够,Eden空间的部分数据会直接晋升到老年代
- 处理引用:处理Soft、Weak、Phantom、Final、JNI Weak等引用,最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片
-
老年代并发标记过程
当堆内存使用到达一定值(45%)时,开始老年代并发标记过程
- 初始化标记阶段():像CMS第一个环节,标记从根节点直接可达的对象,这个阶段STW,并且会触发一次YGC
- 根区域扫描(Root Region Scaning):G1扫描S区直接可达的老年代区域对象,并标记被引用的对象,这一过程必须在YGC之前完成
- 并发标记(Concurrent Marking):在整个堆中进行并发标记, 此过程可能被YGC中断,在并发标记阶段若发现区域对象都是垃圾那这个区域会被立即回收
- 再次标记(Remark):应用程序需要持续进行,需要修正上一次标记的结果。是STW的,G1中采用了比CMS中更快的初始快照算法(SATB,snapshot-at-the-beginning)
- 独占清理(cleanup,STW):计算各个区域内存活对象和GC的回收比例,并进行排序,识别可以混合回收的区域,为下文做铺垫,这个过程是STW的
- 并发清理阶段:识别并清理完全空闲的区域
-
混合回收
标记完成后开始,G1从老年代移动存活对象到空闲区间,这些空闲区间也成了老年代的一部分。老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代回收,一次只需要扫描/回收一小部分老年代Region即可,同时这个老年代Region是和年轻代一起被回收的
当越来越多的对象晋升到老年代old region时,为了避免内存被耗尽,虚拟机会触发一个混合的垃圾回收器Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region还会回收一部分Old Region,是一部分老年代而不是全部老年代。可以选择哪些Old Region进行搜集,从而可以对垃圾回收的耗时时间进行控制
-
(如果需要,单线程、独占式、高强度的Full GC还是继续存在的)Full GC
G1的初衷是为了避免Full GC,但是如果上述方式不能正常工作,G1会立即停止应用程序的执行(STW),使用单线程的内存回收算法进行GC,性能会非常差,应用程序的停顿时间会很长,一般来说导致Full GC有两个原因:
- Evacuation的过程没有足够的to-space来存放晋升对象
- 并发处理过程完成之前空间耗尽
G1 GC的特点(优势):
-
并行与并发
并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力,此时用户线程STW
并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此一般来说不会在整个回收阶段发生完全阻塞应用程序的情况
-
分代搜集
- G1仍然属于分代型的GC,它会区分年轻代和老年代,年轻代依然有Eden和S区,但从堆的结构来看,它不要求整个Eden区、年轻代或者老年代的内存是连续的,也不坚持固定大小和固定数量
- 将堆空间分为若干个区域,这些区域中包含了逻辑上的年轻代和老年代
- 和之前的GC不同,它同时兼顾年轻代和老年代,对比其他回收器或者工作在新生代或者工作在老年代
-
空间整合
- CMS是标记-清除算法、会出现内存碎片,会在若干次GC后进行一次内存整理
- G1将内存划分为一个个的region,内存的回收是以Region为基本单位的,Region之间使用的是复制算法,但整体上是标记-压缩算法。两种算法都可以避免内存碎片。
-
可预测的停顿时间模型(软实时:soft real-time)
这是G1相对于CMS的另一大优势,G1除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M ms的时间段上消耗垃圾收集上的时间不超过N ms
- 由于Region的存在,G1可以只选取部分区域进行内存回收,缩小了回收的范围,因此对于全局停顿的情况发生的到了控制
- G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先队列,每次根据允许的收集时间优先回收价值最大的Reigon。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率
G1的缺点:
- 在用户程序运行过程中,G1无论是为了**垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)**都比CMS高
G1常见调优:1、开启G1 GC ; 2、设置堆的最大内存; 3、设置最大停顿时间
G1三种GC 模式: 1、YoungGC; 2、Mixed GC; 3、Full GC
G1优化建议:
- YGC大小:避免使用-Xmn或-XX:NewRatio显示设置大小,固定年轻代的大小会覆盖暂停时间目标
- 暂停时间目标不要太苛刻,G1吞吐量目标是90%应用程序时间和10%GC时间,目标时间太苛刻表示你愿意承受更多次数的GC,这直接回应用代吞吐量
4.8、垃圾回收器总结
GC | 分类 | 作用位置 | 使用算法 | 特点 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单CPU Client模式 |
Serial Old | 串行 | 老年代 | 标记-压缩算法 | 响应速度优先 | 单CPU Client模式 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多CPU Server模式,配合CMS |
Parallel | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 后台运算不需要太多交互场景 |
Parallel Old | 并行 | 老年代 | 标记-压缩算法 | 吞吐量优先 | 后台运算不需要太多交互场景 |
CMS | 并发 | 老年代 | 标记-清除算法 | 响应速度优先 | 互联网 B/S服务 |
G1 | 并发并行 | 都有 | 标记压缩算法、复制算法 | 响应速度优先 | 面向服务端应用 |
GC发展路径:Serial(串行) => Parallel(并行) => CMS(并发) => G1(Region) => ZGC
如何选择GC?
- 优先调整堆的大小让JVM自适应完成
- 如果内存小于100MB,使用串行GC
- 如果是单核、单机程序并且没有停顿时间要求使用串行GC
- 如果是多CPU,需要高吞吐量,运行一定时间停顿,选择并行或者JVM自己选择
- 如果是多CPU,追求低停顿时间,需要快速响应,使用并发GC
- 官方推荐G1,目前互联网项目一般都是G1
4.9、GC日志分析
内存分配与回收参数列表
-XX:+PrintGC :输出GC日志
-XX:+PrintGCDetails :输出GC详细日志
-XX:+PrintGCTimeStamps :输出GC的时间戳(基准时间)
-XX:+PrintGCDateStamps :输出GC的时间戳(日期形式,如2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC :在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log :日志文件的输出路径
4.10、垃圾回收器的新发展
Shenandoah GC(Open JDK),不是Oracle领导的,RedHat开发,只有在Oracle JDK中有该GC,主打低延迟,暂停时间和堆大小无关
ZGC:尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以将GC停顿时间限制在10ms以下