1. 哪些内存需要回收?
2. 什么时候回收?
两种判断方式 JVM通常只用第二种
1. 引用计数法
在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了。
主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。发生循环引用的对象的引用计数永远不会为0,结果这些对象就永远不会被释放。
2. 可达性分析算法
从GC Roots 为起点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots 没有任何引用链相连时,则证明此对象是不可用的。不可达对象。
Java 中,GC Roots 是指:
- Java 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中引用的对象
- 方法区中常量引用的对象
例如
public class ConstantExample {
public static final String MY_CONSTANT = "Hello, World!";
public static void main(String[] args) {
// MY_CONSTANT是方法区中的常量引用的对象
String greeting = MY_CONSTANT;
System.out.println(greeting);
}
}
- 方法区中类静态属性引用的对象
例如
public class StaticFieldExample {
public static MyClass myObject = new MyClass();
public static void main(String[] args) {
// myObject是方法区中类静态属性引用的对象
MyClass anotherObject = myObject;
// 其他操作...
}
}
class MyClass {
// 类的静态属性
// ...
}
3.引用类型
Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
这样子设计的原因主要是为了描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
强引用
MyClass obj = new MyClass(); 只有当obj = null 的时候 MyClass对象才会被回收
只要强引用存在,垃圾收集器永远不会回收被引用的对象,只有当引用被设为null的时候,对象才会被回收。但是,如果我们错误地保持了强引用,比如:赋值给了 static 变量,那么对象在很长一段时间内不会被回收,会产生内存泄漏
软引用
SoftReference<MyClass> softReference = new SoftReference<>(new MyClass());
软引用是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。
软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
弱引用
WeakReference<MyClass> weakReference = new WeakReference<>(new MyClass());
弱引用的强度比软引用更弱一些。当 JVM 进行垃圾回收时,无论内存是否充足,都会回收只被弱引用关联的对象。
引申出两个基于弱引用的垃圾回收监控
WeakHashMap&ReferenceQueue
WeakHashMap 是 Java 集合框架中的一种特殊的 Map 实现,它使用弱引用(WeakReference)来实现对键的引用。ReferenceQueue 是 Java 中的一个队列,用于监控对象的引用是否被垃圾收集器回收。这两者常常结合使用,特别是在需要监控弱引用键是否被回收时。
WeakHashMap:
WeakHashMap 的特点是,当键不再被其他强引用引用时,它们可以被垃圾收集器回收,这样相应的键值对就从 WeakHashMap 中移除了。这对于一些缓存场景很有用,因为如果某个对象不再被其他地方引用,那么就可以释放相关的缓存数据。
javaCopy code
import java.util.WeakHashMap;
public class WeakHashMapExample {
public static void main(String[] args) {
WeakHashMap<Key, String> weakHashMap = new WeakHashMap<>();
Key key1 = new Key("1");
Key key2 = new Key("2");
weakHashMap.put(key1, "Value1");
weakHashMap.put(key2, "Value2");
System.out.println("Before nullifying keys: " + weakHashMap);
key1 = null; // This may allow key1 to be garbage collected
System.gc(); // Explicitly request garbage collection
System.out.println("After nullifying keys: " + weakHashMap);
}
}
class Key {
private String id;
public Key(String id) {
this.id = id;
}
@Override
public String toString() {
return "Key{" + id + "}";
}
}
在这个例子中,当 key1 被设为 null 后,我们调用了 System.gc() 来显式触发垃圾回收。因为 WeakHashMap 使用的是弱引用,所以在垃圾回收时,key1 所对应的键值对就会被移除。
ReferenceQueue
ReferenceQueue 是一个用于监控引用对象是否被垃圾收集的队列。当一个对象的引用被放入 ReferenceQueue 时,意味着该引用指向的对象已经被垃圾收集器回收。结合 WeakHashMap 使用时,我们可以在 ReferenceQueue 中获取到被回收的键的引用。
WeakReference是一个类,允许你对一个对象创建弱引用,并且使用监听队列ReferenceQueue,一旦这个这个对象不被强引用,在进行GC的时候,就会把对象放进ReferenceQueue,程序可以通过轮询的方式对这些放入队列的对象进行操作回收。
javaCopy code
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.util.WeakHashMap;
public class ReferenceQueueExample {
public static void main(String[] args) {
ReferenceQueue<Key> referenceQueue = new ReferenceQueue<>();
WeakHashMap<Key, String> weakHashMap = new WeakHashMap<>();
Key key1 = new Key("1");
Key key2 = new Key("2");
WeakReference<Key> weakReference1 = new WeakReference<>(key1, referenceQueue);
WeakReference<Key> weakReference2 = new WeakReference<>(key2, referenceQueue);
weakHashMap.put(weakReference1.get(), "Value1");
weakHashMap.put(weakReference2.get(), "Value2");
key1 = null; // This may allow key1 to be garbage collected
key2 = null; // This may allow key2 to be garbage collected
System.gc(); // Explicitly request garbage collection
// Poll the ReferenceQueue to check if any references have been collected
Reference<? extends Key> collectedReference;
while ((collectedReference = referenceQueue.poll()) != null) {
System.out.println("Collected Key: " + collectedReference);
}
System.out.println("WeakHashMap after nullifying keys: " + weakHashMap);
}
}
在这个例子中,我们创建了 WeakReference 对象,并将其与 ReferenceQueue 关联。当 key1 和 key2 被设为 null 后,我们调用 System.gc() 来显式触发垃圾回收。通过轮询 ReferenceQueue,我们可以检查是否有引用被回收。在这个例子中,被回收的引用对应的键值对会从 WeakHashMap 中移除。
幻象引用/虚引用(Phantom References)
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
PhantomReference<MyClass> phantomReference = new PhantomReference<>(new MyClass(), new ReferenceQueue<>());
3. 如何回收?
GC算法
- 标记-清除(Mark-Sweep)算法
- 复制(Copying)算法
- 标记-整理(Mark-Compact)算法
1. 标记-清除(Mark-Sweep)算法
标记-清除算法在概念上是最简单最基础的垃圾处理算法。
该方法简单快速,但是缺点也很明显,一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2. 复制(Copying)算法
复制算法改进了标记-清除算法的效率问题。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
缺点也是明显的,可用内存缩小到了原先的一半。
在Java的内存管理中,内存分配担保(Allocation Failure)是一种垃圾收集器的工作机制,它确保在进行新生代(Young Generation)的垃圾回收时,能够留出足够的空间给新分配的对象。
内存分配担保的基本思想是,当程序试图在新生代分配一个对象时,首先会检查新生代的可用空间是否足够。如果足够,就直接分配;如果不足,就触发一次垃圾回收。垃圾回收的目标是清理出足够的空间,然后再尝试分配对象。这就是所谓的内存分配担保。
具体的步骤如下:
- 程序试图分配对象: 当程序需要在新生代分配一个对象时,先检查新生代的可用空间是否足够。
- 空间足够: 如果新生代的可用空间足够,直接分配对象。
- 空间不足: 如果新生代的可用空间不足,触发一次新生代的垃圾回收(通常使用复制算法)。垃圾回收会将存活的对象复制到另一块区域,同时清理掉不再使用的对象。
- 重新检查空间: 垃圾回收后,再次检查新生代的可用空间。
- 再次尝试分配: 如果现在新生代的可用空间足够,就直接分配对象;否则,可能需要进行一些更大范围的内存操作,例如将对象移动到老年代。
这种机制的目的是确保分配对象时有足够的空间可用,同时避免频繁地进行垃圾回收。
内存分配担保通常发生在新生代,而老年代的空间不足时,由于老年代使用的是标记-清除或标记-整理算法,触发一次垃圾回收可能会导致较大的停顿时间,因此在老年代并不会采用类似的担保机制。
JVM 的堆空间分成2个区域:年轻代、老年代
年轻代又进一步细分成3个区域:Eden、Survivor From、Survivor To
如下图所示:
- 默认情况下,年轻代与老年代比例为1:2。可以通过参数-XX:NewRatio修改,NewRatio默认值是2。如果NewRatio修改成3,那么年轻代与老年代比例就是1:3
- 默认情况下Eden、From、To的比例是8:1:1。可以通过参数-XX:SurvivorRatio修改,SurvivorRatio默认值是8,如果SurvivorRatio修改成4,那么其比例就是4:1:1
GC过程
- 所有的年轻代首先会在Eden区进行分配,当Eden区满了之后会进行第1次Minor GC
- 第1次GC之后仍然存活的对象,会复制到Survivor From区,同时对象年龄+1(此时年龄=1),然后清理其之前占用的内存
- 第2次会对Eden+From同时进行GC,之后仍然存活的对象会复制到Survivor To区,年龄+1,同时清理之前占用的内存(此时From区会变成空)
- 第3次GC之后,From区会存放存活的对象,而To区被清空
- 当Survivor区域对象的年龄达到-XX:MaxTenuringThreshold设定的值(默认15),会将此对象移到老年代,同时清空他们在年轻代占用的内存空间
- 每次minorGC会判断老年代空间是否够用,当老年代空间不够用了,会发生Full GC(回收整个堆内存)
- 总结:From和To区总是互相复制,每次GC之后总有其中一个区域会被清空
- 当某些大对象需要分配一块较大的连续空间时,会直接进入老年代,而不会经过以上步骤
3.标记-整理算法
复制算法主要用于回收新生代的对象,但是这个算法并不适用于老年代。因为老年代的对象存活率都较高(毕竟大多数都是经历了一次次GC千辛万苦熬过来的,身子骨很硬朗 )
根据老年代的特点,提出了另外一种标记-整理(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
分代收集算法
- 新生代:复制算法
- 老年代:标记-清除算法、标记-整理算法
垃圾收集器
首先要认识到的一个重要方面是,对于大多数JVM,需要两种不同的GC算法,一种用于清理新生代,另一种用于清理老年代。
这些连线表示在JDK 1.8中 使用的垃圾收集器的组合关系
常用组合
1.串行收集器Serial Serial Old
Serial 工作在新生代,使用“复制”算法,Serial Old 工作在老年代,使用“标志-整理”算法。
“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束(STW阶段)。 Stop The World
串行收集器有着优于其他收集器的地方,那就是简单而高效。对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的,单线程没有线程交互开销。
(这里实际上也是一个时间换空间的概念)
2.并行收集器
a. ParNew 收集器
ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。
但是从G1 出来之后呢,ParNew的地位就变得微妙起来,自JDK 9开始,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。官方希望它能完全被G1所取代,甚至还取消了『ParNew + Serial Old』 以及『Serial + CMS』这两组收集器组合的支持(其实原本也很少人这样使用),并直接取消了-XX:+UseParNewGC参数,这意味着ParNew 和CMS 从此只能互相搭配使用,再也没有其他收集器能够和它们配合了。可以理解为从此以后,ParNew 合并入CMS,成为它专门处理新生代的组成部分。
b. Parallel Scavenge收集器
Parallel Scavenge收集器与ParNew收集器类似,也是使用复制算法的并行的多线程新生代收集器。但Parallel Scavenge收集器关注可控制的吞吐量(Throughput)
注:吞吐量是指CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /( 运行用户代码时间 + 垃圾收集时间 )
Parallel Scavenge收集器提供了几个参数用于精确控制吞吐量和停顿时间:
参数 | 作用 |
--XX: MaxGCPauseMillis | 最大垃圾收集停顿时间,是一个大于0的毫秒数,收集器将回收时间尽量控制在这个设定值之内;但需要注意的是在同样的情况下,回收时间与回收次数是成反比的,回收时间越小,相应的回收次数就会增多。所以这个值并不是越小越好。 |
-XX: GCTimeRatio | 吞吐量大小,是一个(0, 100)之间的整数,表示垃圾收集时间占总时间的比率。 |
XX: +UseAdaptiveSizePolicy | 这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics) |
c. Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,多线程,基于“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供的。
由于如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外别无选择(Parallel Scavenge无法与CMS收集器配合工作),Parallel Old收集器的出现就是为了解决这个问题。Parallel Scavenge和Parallel Old收集器的组合更适用于注重吞吐量以及CPU资源敏感的场合。
3.Concurrent Mark and Sweep (CMS)
CMS(Concurrent Mark Sweep,并发标记清除) 收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。
老年代
从名字就可以知道,CMS是基于“标记-清除”算法实现的。它的工作过程相对于上面几种收集器来说,就会复杂一点。整个过程分为以下四步:
1)初始标记 (CMS initial mark):主要是标记 GC Root 开始的下级(注:仅下一级)对象,这个过程会 STW,但是跟 GC Root 直接关联的下级对象不会很多,因此这个过程其实很快。
2)并发标记 (CMS concurrent mark):根据上一步的结果,继续向下标识所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞,没有 STW。
3)重新标记(CMS remark):顾名思义,就是要再标记一次。为啥还要再标记一次?因为第 2 步并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。
4)并发清除(CMS concurrent sweep):
初始标记为什么是串行的?
初始化标记阶段是串行的,这是JDK7的行为。JDK8以后默认是并行的,可以通过参数
-XX:+CMSParallelInitialMarkEnabled控制
并发清除不阻塞其他线程,所以在过程中产生的新垃圾对象会在下一次GC中清除。
在处理老年代垃圾回收时,需要考虑老年代对象引用的年轻代的对象是否是可达对象
可达对象GC Roots 栈引用、静态变量、常量、锁对象、class对象 如果老年代对象引用了新生代对象,也属于gc roots
所以在并发标记时还需要进行 并发预处理 和 可终止的预处理
如果Eden空间数据量和使用率比较低的时候,可以进行预处理,或者在minorGC之后,再进入remark重新标记
新生代
进行minorGC的时候,如果有老年代引用了新生代,那么这个也属于gc roots,也就是说还需要去收集有哪些新生代被老年代引用。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的数据结构
具体实现 卡表
在hotspot虚拟机中,卡表是一个字节数组,数组的每一项对应着内存中的某一块连续地址的区域,如果该区域中有引用指向了待回收区域的对象,卡表数组对应的元素将被置为1,没有则置为0;
(1) 卡表是使用一个字节数组实现:CARD_TABLE[],每个元素对应着其标识的内存区域一块特定大小的内存块,称为"卡页"。hotSpot使用的卡页是2^9大小,即512字节
(2) 一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0。GC时,只要筛选本收集区的卡表中变脏的元素加入GC Roots里。
卡表的使用图例
并发标记的时候,A对象发生了所在的引用发生了变化,所以A对象所在的块被标记为脏卡
继续往下到了重新标记阶段,修改对象的引用,同时清除脏卡标记。
卡表其他作用:
老年代识别新生代的时候
对应的card table被标识为相应的值(card table中是一个byte,有八位,约定好每一位的含义就可区分哪个是引用新生代,哪个是并发标记阶段修改过的)
4.G1 Garbage First
JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器。
鉴于 CMS 的一些不足之外,比如: 老年代内存碎片化,STW 时间虽然已经改善了很多,但是仍然有提升空间。G1 就横空出世了,它对于堆区的内存划思路很新颖,有点算法中分治法“分而治之”的味道。
G1 将连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中。
Humongous,简称 H 区,是专用于存放超大对象的区域,通常 >= 1/2 Region Size,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。
- 1)没有被引用的巨型对象会在标记清理阶段或者Full GC时被释放掉。
- 2)Young GC和Mixed GC阶段都会对巨型对象进行回收。
- 3)巨型对象永远不会移动,即使在Full GC中。
- 4)每一个Region中都只有一个巨型对象,其他剩余空间无法被利用,会产生内存碎片。
- 6)在分配巨型对象之前会先检查是否超过InitiatingHeapOccupancyPercent和The Marking Threshold,超过则启动全局并发标记(Global Concurrent Marking),目的是提早回收,防止Evacuation Failures(转移失败)和Full GC。
如果发现由于大对象分配导致频繁的并发回收,需要把大对象变为普通的对象,建议增大Region Size。
G1(Garbage-First)是一个服务器风格的垃圾收集器,针对的是具有大内存的多处理器机。
它试图在实现高吞吐量的同时,以较高的概率满足垃圾收集(GC)暂停时间目标。
Garbage-First(垃圾优先)表示优先处理那些垃圾较多的内存块。即:根据堆中各个区域(Region)的垃圾回收价值在后台维护一个优先级列表,每次在允许的收集时间内优先回收价值最大的区域,从而避免在整个堆中进行全区域垃圾回收。
其中:
- 回收价值:回收所获得的空间大小以及回收所需时间的经验值。
G1将对象从堆的一个或多个区域复制到堆的单个区域,并在进程中压缩和释放内存。这种转移在多处理器上并行执行,以减少暂停时间并提高吞吐量。因此,对于每次垃圾收集,G1都会持续地减少碎片。
G1的第一个重点是为运行需要大堆且GC延迟有限的应用程序的用户提供解决方案。这意味着堆大小约为6GB或更大,并且稳定且可预测的暂停时间低于0.5秒(在Java19中推荐10G或者更大的堆内存)。
- 年轻代GC (Minor GC)
- 老年代并发标记过程 (Concurrent Marking)
- 混合回收(Mixed GC)
(如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了-种失败保护机制,即强力回收。)
G1除开维护Rset的流程:
- 初始标记(Initial Marking):Stop The World,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记。
- 并发标记(Concurrent Marking):使用一条标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢。
- 最终标记(Final Marking):Stop The World,使用多条标记线程并发执行。
- 筛选回收(Live Data Counting and Evacuation):回收废弃对象,此时也要 Stop The World,并使用多条筛选回收线程并发执行。(还会更新Region的统计数据,对各个Region的回收价值和成本进行排序)
Minor GC
在分配一般对象时,当所有eden region使用达到最大阈值并且无法申请足够内存时,会触发一次Minor GC。每次Minor GC会回收所有Eden以及Survivor区,并且将存活对象复制到Old区以及另一部分的Survivor区。
Mixed GC
- 初始标记阶段(Initial Marking): 这是一个短暂的STW阶段,用于标记直接引用的对象。(STW)
- 并发标记阶段(Concurrent Marking): 在此阶段,G1收集器并发地对整个堆进行标记,标记存活对象。这个过程与应用程序的执行同时进行。
- 重新标记阶段(Remark): 在并发标记结束后,可能会有一次短暂的STW重新标记阶段,用于处理在并发标记期间发生的引用变化。(STW)
- Mixed GC阶段: G1收集器会尝试一次性地回收部分年轻代和一部分老年代的垃圾(一次YGC+部分老年代GC)。这个阶段是并发的,但可能会包含一小段STW的暂停(STW)
- 清理阶段(Cleanup): 在Mixed GC阶段完成后,G1收集器继续并发地清理未使用的内存,释放空间。(STW)
G1 一般来说是没有FGC的概念的。因为它本身不提供FGC的功能。
如果 Mixed GC 仍然效果不理想,跟不上新对象分配内存的需求,会使用 Serial Old GC 进行 Full GC强制收集整个 Heap。
G1从整体来看采用标记-整理算法,从局部来看采用复制算法。
G1在解决 跨代引用 的问题的存储 叫 Rset
RememberedSet(简称RS或RSet)就是用来解决这个问题的,RSet会记录这种跨代引用的关系。在进行标记时,除了从GC ROOTS开始遍历,还会从RSet遍历,确保标记该区域所有存活的对象(其实不光是G1,其他的分代回收器里也有,比如CMS)
如下图所示,G1中利用一个RSet来记录这个跨区域引用的关系,每个区域都有一个RSet,用来记录这个跨区引用,这样在进行标记的时候,将RSet也作为ROOTS进行遍历即可
每个Region都会有,它记录着「其他Region引用了当前Region的对象关系」
所以在对象晋升的时候,将晋升对象记录下来,这个存储跨区引用关系的容器称之为RSet,在G1中通过Card Table来实现。
注意,这里说Card Table实现RSet,并不是说Card Table是RSet背后的数据结构,只是RSet中存储的是Card Table数据。
RSet 负责记录从年轻代到老年代的引用信息,即哪些老年代的区域中包含指向年轻代对象的引用。这个信息对于并发标记和部分收集过程非常关键,因为它帮助垃圾回收器快速定位可能包含存活对象的老年代区域,而无需全局扫描整个老年代。
Cset
CSet 是 G1 收集器中的一个重要的概念,指的是当前需要被回收的内存块的集合,也称为 "Collection Set"。G1 垃圾回收器在执行 Mixed GC(混合垃圾回收)时,会选择一部分年轻代区域和一部分老年代区域来组成 CSet。
具体来说,CSet 是 G1 在一次垃圾回收周期中选定的待回收的区域的集合,它包括了既有年轻代的部分,也有老年代的部分。这些区域被标记为候选区域,G1 将在 Mixed GC 阶段对它们进行回收。
G1 收集器通过动态地选择 CSet 的组成部分,根据堆的状况和回收的需求来调整。选择哪些区域包括到 CSet 中的决策是在 G1 的一些策略和启发式算法的指导下完成的。G1 垃圾回收器的设计目标之一就是根据实际需求进行智能的区域选择,以优化垃圾回收的效率和停顿时间。
收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。
候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。
SATB(Snapshot At the Begging)
SATB,也可以称为对象快照技术,在GC之前对整个堆进行一次对象索引关系,形成位图,相当于堆的逻辑快照。在并并发回收过程中,通过增量的方式维护这个对象位图。
- SATB解决了什么问题?
主要是为了解决并发标记过程中,出现的漏标,误标等问题。 - 什么是漏标,误标。
在并发标记过程中,APP线程跟GC线程同时进行,GC线程扫描的时候发现一个对象位垃圾对象,不对他进行标记,而APP线程马上就会操作索引指向这个对象。那么在垃圾回收的时候这样漏标的对象被回收就会产生灾难性的后果。也有的情况就是,在GC标记完一个对象不需要回收之后,APP线程之后就会把所有指向这个对象的索引完全去除,那么这就是一个垃圾对象,然而在回收过程之中并没有回收,造成了浮动垃圾,这种情况就是误标。 - 漏标、误标的解决方案。
解决漏标误标,就必须了解两个名词。第一个名词:三色标记法,第二个名词:writer barrier(写栅栏)
首先解释第一个名词:三色标记算法
首先我们知道无论是 g1还是CMS垃圾标记算法都叫做根可达(root searching),首先搜索比如 线程栈上的对象、静态变量、常量池中的对象以及jni指针,这个部分往往发生是G1的初始标记阶段,会进行STW。然后就进入了并发标记阶段。首先我们定义:扫描过当前对象以及其子索引对象的为不可回收的对象位黑色对象,有黑色父对象索引指向的,并且未扫描其子索引的对象为灰色对象,需要回收的对象:为白色对象。
第二个名词:写栅栏:write barrier
当上图中 B->C 改变成 A->C 的索引,垃圾回收器是如何感知的?就是通过writer barrier技术,其实就相当于一个钩子程序,但执行索引改变的时候,触发一下write barrier,然后write barrier根据相应的需求增加一条索引更改的日志。每个App现场都会有一个LTB(local thread buffer)当一个LTB缓冲区写满之后,就新起一个缓冲区,把原来的缓冲区写入全局缓冲中,又相应的垃圾回收线程去更新SATB 的对象快照图。
SATB + RSet 解决了什么问题?
上面说了三色标记算法,为了解决漏标问题提出了一个writeBarrier的解决方案。但是还是有一种情况的漏标是writeBarrier解决不了的。就是在并发的情况下,当一个线程扫描对象A,对象A有索引:A->B,A->C,其中线程T1,扫描完B在扫描C的状态中,此时有个线程T2把索引B改动,改成A->D,把A设置为灰色,此时T1把C扫描完了,把A设置为黑色。这时我们就发现黑色对象A,下面就会有一个白色对象D未扫描。那么这样的漏标如何解决?
SATB位图构建过程中,所有有索引改动的对象,如上面所说的D跟B,就放入一个队列中。当Remark阶段,扫描这个队列里面的所有对象,重新标记。但是重新标记,按照道理来说,我们又需要扫描整个堆,但是我们其实只想回收某一个Region,又去扫描整个堆效率上来说肯定是不行的。这个时候,我们就可以去扫描Region中的RSet,如果RSet 没有记录其他Region对这个对象的索引,自己内部也没有,那么这个对象就是一个可回收的垃圾对象。
CMS中通过incremental update解决了部分漏标问题,但是像这样并发情况的下的漏标是不能解决的。所以为了解决可能存在的漏标问题,也是通过WriterBarrier,将A这样有改变过索引的对象放入一个堆栈中,在AbortPreClean、Remark阶段重新扫描一次这些对象。
G1与CMS对比
G1被计划作为并发标记扫描收集器(CMS)的长期替代品,它们的主要区别:
- 空间压缩:G1采用复制-整理算法,在压缩空间方面有优势,可以避免产生内存空间碎片;而CMS采用标记-清除算法,会产生较多的空间碎片。
- 暂停时间的可控性:G1使用暂停预测模型来满足用户定义的暂停时间目标,并根据指定的暂停时间目标选择要收集的区域数;而CMS无法设置目标暂停时间,暂停时间不可控。
- 内存模型方面:G1采用物理分区(Region),逻辑分代,Eden、Survivor、Old区不在是连续的一整块内存,而是由不连续的内存区域(Region)组成;而CMS中Eden、Survivor、Old区是连续的一整块内存。
- G1既可以收集年轻代,也可以收集老年代;而CMS只能收集老年代。
参考链接:https://juejin.cn/post/6844904040346681352