1. 引入
在上一篇《引入篇》了解了JVM中,究竟什么样的对象被认为垃圾。本文主要是解答学习垃圾回收机制的第二个核心问题:JVM
如何进行垃圾回收。
1.1 分代收集(Generational Collection
)
分代收集理论名为理论,实质是一套符合大多数程序运行实际情况的经验法则,建立在以下三个分代假说之上:
- 弱分代假说(
Weak Generational Hypothesis
):绝大数对象都是朝生夕灭; - 强分代假说(
Strong Generational Hypothesis
):熬过多次垃圾回收过程的对象就越难以消亡;
以上两条理论奠定了多款垃圾回收器的设计原则:回收器应该将
Java
堆划分出不同区域,然后将回收对象根据其年龄(年龄:对象熬过垃圾回收过程的次数)分配到不同的区域当中存储。
-
跨代引用假说(
Intergenerational Reference Hypothesis
):跨代引用相对于同代引用来说仅占极少数。存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。-
跨代引用:新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用。
-
跨代引用存在问题:以新生代
GC
为例,GC
时,为了找到新生代中的存活对象,不得不遍历整个老年代;反之亦然。这种方案存在极大的性能浪费。 -
解决方案:引入记忆集(用来记录跨代引入的表)。以新生代
GC
为例,GC
时,根据GC ROOT
和记忆集,可以判断新生代区对象是否存活,不必遍历老年代。
-
1.2 分区收集
运行时整个堆空间划分成连续的不同小区间,每一个小区间都能独立使用,独立回收。
这种回收策略带来的好处:可以控制一次回收多少个区间,可以较好的控制GC
时间。
2. 堆区域的划分
2.1 为什么要对Java堆进行区域划分?
对堆进行区域划分是为了提高垃圾回收的效率。
这种划分只是根据垃圾回收机制来进行划分,并非Java
虚拟机规范本身制定。
根据上面提到的分代理论,不同对象存活时间有长有短,如果混合在一起,必然会造成频繁的垃圾回收,这样效率很慢。
因此,在对Java
堆划分出不同的区域之后,垃圾回收器能够针对不同的区域采用不同的方法进行回收。
2.2 Java堆区域是如何划分的?
Java
堆分为新生代(Young Generation
)和老年代(Old Generation
)两个区域。
- 新生代:存放存活时间短的对象,此区域发生频繁
GC
,每次GC
后存活的少量对象对象,将会逐步晋升到老年代中存放。Eden
空间From Survivor
空间To Survivor
空间
Q: 新生代划分的三个区域 Eden:from:to 的比例为何是 8:1:1 ?
总结来说,是为了减少内存空间的浪费,提高内存空间利用率。
新生代区域划分为一块较大的
Eden
和两块较小的Survivor
, 新生代中的对象98%是朝生夕死的,每次分配内存只使用Eden
和其中一块Survivor
,发生GC
时,将Eden
和Survivor
中仍然存活的对象一次性复制到另外一块Survivor
空间上,然后直接清理掉Eden
和已经使用过的那块Survivor
空间。按照8:1:1的比例分区,内存的空间利用率达到了90%,只有10%的空间是浪费掉了。
- 老年代:存放应用程序中生命周期长的对象。
3. 如何给对象分配内存?
3.1 内存分配原则
-
对象优先在Eden区分配:大多数情况下,对象在新生代
Eden
区中分配。当Eden
区没有足够空间进行分配时,虚拟机将发起一次Minor GC
; -
大对象直接进入老年代,为了避免为大对象分配内存时的复制操作而降低效率。
大对象:需要大量连续内存空间的
Java
对象,如:很长的字符串,元素数量很庞大的数组。
- 长期存活对象进入老年代:每一个对象都有一个对象年龄,对象在新生代中每经过一次垃圾回收,对象年龄增长1,当对象年龄超过某个阈值(默认为15)时,该对象会进入老年代。
Q: 如何理解年龄?
JVM
给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden
区中诞生,如果经过第一次Minor GC
后仍然存活且能被Survivor
容纳,这个对象就会被移动到Survivor
空间中,并且将其对象年龄设为1岁。对象在Survivor
区中每熬过一次Minor GC
,年龄就增加一岁,当其年龄增加到一定程度,就会晋升到老年代中。
-
动态对象年龄判断:为了能更好地适应不同程序的内存状况,并不是永远要求对象的年龄必须达到某个值才能晋升老年代,如果在
Survivor
空间中相同年龄所有对象大小的总和大于Survivor
空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。 -
空间分配担保:假如在
Minor GC
之后,新生代仍然有大量对象存活,就需要老年代进行分配担保,把Survivor
无法容纳的对象直接送入老年代。
3.2 堆内存分配流程
4.GC
类型
4.1 部分收集(Partial GC
)
收集目标:不是完整回收整个Java堆的垃圾,其中又分为: 新生代收集,老年代收集,混合收集。
4.1.1 新生代收集(Minor GC/Young GC
)
- 收集目标:新生代的垃圾;
- 触发时机:新创建的对象优先在新生代
Eden
区分配,如果Eden
区没有足够的空间,就会触发Minor GC
来清理新生代。
4.1.2 老年代收集(Major GC/Old GC
)
- 收集目标:老年代的垃圾。目前只有
CMS
收集器有单独收集老年代的行为。
4.1.3 混合收集(Mixed GC
)
- 收集目标:收集整个新生代及部分老年代的垃圾收集,目前只有
G1
收集器有这种行为。
4.2 整堆收集(Full GC
)
- 收集目标:收集整个Java堆和方法区的垃圾。
- 触发时机:
Minor GC
前检查老年代:发现老年代可用的连续内存空间 < 新生代历次Young GC后升入老年代的对象总和的平均大小,说明本次Minor GC
后可能升入老年代的对象大小,可能超过了老年代当前可用内存空间,那就会触发Full GC
;Minor GC
后老年代空间不足:Minor GC
后一批对象会进入老年代,此时老年代无足够的内存空间就会触发Full GC
;- 老年代空间不足:老年代内存使用率过高,达到一定比例,触发
Full GC
; - 方法区空间不足:如果方法区由永久代实现,永久代空间不足触发
Full GC
; - 空间分配担保失败:新生代的
To
区放不下从Eden
和From
拷贝过来对象,或者新生代对象 GC 年龄到达阈值需要晋升这两种情况,老年代如果放不下的话都会触发 System.gc()
等命令触发。
5. 垃圾回收算法
5.1 标记-清除(Mark-Sweep
)算法
5.1.1 基本思想
算法分为标记和清除两个阶段:
- 标记阶段:对象是否属于垃圾的判定过程,在此过程中标记出所有需要回收的对象或者存活的对象;
- 清除阶段: 对所有被认定为是垃圾的对象进行统一回收
5.1.2 缺点
- 效率问题:标记和清除这两个过程效率都不高;
- 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得触发另一次
GC
动作。
5.2 复制(Copying
)算法
5.2.1 基本思想
将JVM
中原有堆内存分成两块,每次只使用其中的一块。发送GC
时,就将还存活着的对象复制到另外一块上,然后再把已使用过的内存空间一次清理掉。清理完成后,交换两块内存的角色。
5.2.2 优点
- 回收后不需要考虑内存碎片的复杂情况;
- 内存分配时直接使用简单高效的指针碰撞方式,移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
5.2.3 缺点
- 将可用内存缩小为了原来的一半,浪费内存;
- 复制时对象移动的开销。
现代商用
JVM
大多采用复制算法用于回收 新生代 空间。
5.3 标记-整理(Mark-Compact
)算法
5.3.1 基本思路
同样也是分为两个阶段:标记阶段与整理阶段。标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法, 而后者是移动式的
5.3.2 优点
- 解决了标记-清除算法的内存碎片;
- 解决了复制算法的空间浪费。
5.3.3 缺点
收集效率不高,因为需要标记对象,移动存活对象,所以耗费的时间资源开销大。
标记-整理算法主要用于回收老年代区域。
5.4 基础GC算法总结
以上三种GC
算法则是JVM
的基础GC
算法,综合来看:
- 收集速度:复制算法 > 标记-清除算法 > 标记-整理算法
- 内存整齐度:复制算法 = 标记-整理算法 > 标记-清除算法
- 内存利用率:标记-整理算法 > 标记-清除算法 > 复制算法
6. STW
/安全点/安全区域
6.1 STW
6.1.1 什么是STW
?
STW
:Stop The World,指的是GC
时会停下所有的用户线程,从而导致程序出现全局停顿的无响应情况。
发生STW
后,所有的代码会停止运行,不过native
代码可继续执行。
一般发生STW都是由于GC
引起,但在某几种少数情况,也会导致STW
出现,如:线程Dump
,死锁检查,堆日志Dump
等。
6.1.2 GC
时为什么需要STW
?
总结为以下两点原因:
-
避免浮动垃圾产生
设想如下情形:刚刚标记完成一块区域中的对象,但转眼用户线程又在该区域中产生了新的垃圾。
这种情况下
GC
机制很难去准确判断哪些是垃圾,会给GC
线程造成很大的负担,GC
算法的实现难度也会增加。
浮动垃圾:用户线程运行过程中产生了新的垃圾,新的垃圾在此次
GC
中无法清除,只能等到下次清理,这些垃圾叫做浮动垃圾。
-
确保内存一致性
在
GC
发生时,可达性分析标记垃圾的期间,JVM
不可以出现分析过程中对象引用关系还在不断变化的情况。这样分析结果的准确性无法得到保证。
6.1.3 STW
带来的问题
-
客户端长时间无响应问题
STW
发生时,会停止所有用户线程,所以对于客户端发送过来的网络请求并没有线程处理,则客户端会一直处于无响应状态。 -
HA系统中的主从切换脑裂问题
同时如果系统做了主备、主从、多活等
HA
方案,如果主机触发GC
发生STW
,造成主机长时间停顿,而备机会监测到主机没有工作,于是备机开始尝试将流量切换到自身来处理,从备机变为了主机。但旧主不工作只是暂时的,因为
GC
的原因导致暂停一段时间,而当GC
完成后,旧主会依旧开始工作,最终造成了整个HA
系统中出现了双主情况,形成了脑裂问题。
HA:High Availability Cluster,简称HA Cluster,是指以减少服务中断时间为目的的服务器集群技术。
脑裂问题:通俗来讲就是出现了两个大脑,不知道听谁的。
-
上游系统宕机问题
如果系统是以多机器部署的工程项目,那么如果当某个工程所在的机器发送
GC
出现STW
时,那么上游系统过来的请求则不会处理,如果STW
时间一长,最终很有可能导致上游机器扛不住流量而出现宕机。
6.2 安全点(Safepoint
)
6.2.1 什么是安全点?
代码中的一些特定位置,当线程运行到这些位置不会导致引用关系的变化,这样JVM
可以安全的进行一些操作。
这些特定的位置主要在:
- 循环的末尾(非
counted
循环) - 方法临返回前 / 调用方法的
call
指令后 - 可能抛异常的位置
用通俗的比喻,假如老王去拉车,车上东西很重,老王累的汗流浃背,但是老王不能在上坡或者下坡休息,只能在平地上停下来擦擦汗,喝口水。此处的“平地” 就是安全点。
(图源三分恶)
6.2.2 为什么要有安全点?
在GC
时需要暂停用户线程,用户程序执行时并不是在代码指令流的任意位置都能停下来,我们要确保暂停的点不会导致引用关系的变化,也就是必须执行到安全点才能停止。
6.2.3 GC时如何让所有线程停到最近的安全点?
-
抢先式中断(
Preemptive Suspension
):不需要线程的执行代码主动去配合,在GC
时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。【几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC
事件】 -
主动式中断(
Voluntary Suspension
):当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象
6.3 安全区域(Safe Region
)
6.3.1 什么是安全区域?
一段代码片段中,引用关系不会发生变化。在这个区域内任意地方GC
都是安全的。这一段代码片段就是安全区域。
6.3.2 为什么要有安全区域?
当用户线程处于Sleep
或者Blocked
状态时,线程无法进入安全点,针对上述情况,必须引入安全区域。
用户线程执行到安全区域中的代码时,首先会标识自己已经进入安全区域,这段时间如果JVM
发起GC
,就不用管这个线程了。
当线程要离开安全区域时,线程要检查JVM
是否已经完成了GC Roots
或者其他需要STW
的阶段:
- 如果已经完成,线程可以当作无事发生,继续执行;
- 否则线程需要一直等待,直到收到可以离开安全区域的信号为止。
参考资料
- 《深入理解Java虚拟机(第三版)》
- juejin.cn/column/7057…
- juejin.cn/post/704693…
- juejin.cn/post/704693…
- juejin.cn/column/7057…