垃圾回收
约 9203 字大约 31 分钟
2025-11-17
垃圾回收算法
Java中的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java自动内存管理最核心的功能就是堆内存中对象的分配与回收。
分代收集理论
Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度来看,现代的垃圾收集器都是采用了分代收集器的算法。
分代收集算法不是一种特殊的算法,它是一种垃圾收集的思想。在大多数的情况下,Java的大量对象的存活时间是有很大的差异的,例如在Spring中的一些Bean实例,它存活的时间会很长,有些Bean甚至在整个JVM进程的生命周期内都是存活的。当然,也有一些对象是存活时间很短的,就比如我们方法中的一些局部变量,基本上方法所代表的栈帧出栈后对应的局部变量就会被回收掉了。
所以分代收集算法就是根据对象存活的时间不同,将内存的区域进行划分用于存储不同存活时间的对象,通常而言会将堆内存分为年轻代和老年代。
年轻代
在年轻代中存储着大量的存活时间很短的对象,一般而言(除了经过逃逸分析后再栈上分配内存空间的对象)绝大多数的对象都是直接在堆上分配内存的。对于新创建的对象都会在年轻代为它分配内存,但是对于一个大对象以及特殊情况下会直接在老年代为它分配内存。
在年轻代中,通常会将它分为三个区域:Eden区域、Survivor Form区域和Survivor To区域,它们的比例是:8:1:1

可以使用-XX:SurvivorRatio=N可以调整新生代中Eden与单个Survivor的比例,含义是Eden : Survivor = N : 1。使用这个参数的时候有两个要点:
- 在JDK8中默认使用Parallel作为新生代垃圾收集器,存在自适应大小策略,会动态调整Eden和Survivor区域。导致即使设置了SurvivorRatio参数也不生效的问题。如果需要不开启自适应大小策略,可以通过
-XX:-UseAdaptiveSizePolicy参数关闭自适应大小策略; - 在JDK11中会使用G1垃圾收集器,则SurvivorRation参数是不生效的,因为G1采取的Region分区策略,不能用该参数设置固定的比例;
年轻代的垃圾收集方式
当对象创建时它会被放在Eden区域,随着对象的不断创建,Eden区域会逐渐被填满。当Eden区域无法容纳新创建的对象时,它会触发一次Minor GC(年轻代的垃圾收集)。
在Minior GC中,会清理掉年轻代中的垃圾对象,然后将存活的对象搬到其中一个Survivor区域中。随着对象的不断创建,Minor GC再次发生,此时会同时清理Eden区域和Survivor区域(S0)中的垃圾对象,然后将存活下来的对象复制到另外一个Survivor区域(S1)中,如此循环往复。
大对象直接在老年代中分配内存空间
当每次创建对象的时候,都会尝试在Eden区域为这个对象分配对应的内存空间。在JVM垃圾回收机制中,大对象通常不会在新生代的Eden区进行分配,而是直接进入老年代,这是为了避免大对象在新生代频繁复制,从而提高内存分配效率并降低GC开销。
关于大对象的定义是由-XX:PretenureSizeThreshold参数来配置,它的作用是设置一个与之,大于该值的对象将直接在老年代分配,而不会现在新生代Eden分配。例如:-XX:PretenureSizeThreshold = 1M表示大于1M的对象就是大对象。
此参数仅对Serial和ParNew垃圾收集器有效,对Parallel和G1等现代收集器无效或不推荐使用
Survivor区域放不下的时候直接在老年代中分配内存空间
在Minor GC的时候,如果Eden区的对象存活,他会被复制到Survivor区域。但是,如果对象太大了,连Survivor区域都放不下,那么该对象会直接晋升到老年代,而不经过Survivor。
动态年龄判断
JVM会根据每个年龄的对象的总大小,动态判断是否将某些对象提前晋升到老年代。如果某个年龄的对象所占空间总和,超过了Survivor区域一定比例(默认是一半),那么大于等于该年龄对象直接进入老年代,这也可能是导致一些大对象较早晋升
可以通过-XX:TargetSurvivorRatio可以指定Survivor区域大小的限制。
老年代空间分配担保机制
在年轻代每次Minor GC之前都会计算老年代剩余可用空间,如果这个空间小于年轻代里所有对象大小之和(包括垃圾对象),就会判断-XX:HandlePromotionFailure参数是否设置了。
- 如果有这个参数,就看老年代的可用内存大小,是否大于之前每一次Minor GC后进入老年代对象的平均大小;
- 如果上一步的结果是小于或者没有设置这个参数,那么就会触发Full GC,对老年代和年轻代一起回收一次垃圾;
- 如果回收完还是没有足够的空间存放新的对象就会发生OOM;
当然,如果Minor GC之后剩余存活的对象中需要挪到老年代的对象大小之和还是大于老年代的可用空间,那么也会触发Full GC,Full GC完成之后老年代仍然没有足够的空间来容纳新生代中晋升上来的对象,就会发生OOM。
老年代
一般而言老年代中存放的都是从年轻代晋升上来的对象和一些大对象,它们的存活时间都很长。根据JVM的默认参数在对象的分代年龄达到15的时候就会从年轻代晋升代老年代,可以通过-XX:MaxTenuringThresholdl参数控制对象经过多少次GC后晋升代老年代。
当随着年轻代中的对象一次一次的熬过Minor GC,对象的年龄不断的递增,也会有月老越多的对象晋升成为老年代中的对象。老年代中的对象的数量也会开始快速的递增,当老年代中的对象不足以存放从年轻代晋升上来的对象时,就会触发Full GC。在完成Full CG之后,仍然无法容纳从年轻代上晋升上来的所有对象,就会触发OOM。
一般而言年轻代和老年之间的比例为:1:2,但是也可以通过-XX:NewRatio参数来调整年轻代与老年代之间的比例关系。
垃圾收集的算法
垃圾收集算法针对不同的分代下对象的特点产生出来的一些算法,包含:标记-复制、标记-清除和标记-整理算法。
标记-复制算法
标记-复制算法也被成为复制算法,是一种用于垃圾回收中的内存回收策略,主要用于年轻代的垃圾回收,尤其是Survivor区域和Eden区域之间对象的转移。
它是一种以空间换时间效率为特点的算法,核心思想是:将存活的对象从一个内存区域复制到另一个内存区域,然后一次性清理掉整个旧区域,从而避免内存碎片、提高回收效率。

标记-复制算法会将内存分为两个区域,每次只会使用其中一个区域。当被使用的区域内存空间不足时,就会触发GC,GC的时候会清理掉垃圾对象,然后将存活的对象复制到空的区域中。这样就导致了标记-复制算法中每次可用的内存空间只有50%,对于内存空间的利用率较低。
标记-清除算法
标记-清除算法分为两个步骤:标记与清除。标记阶段要做的事情就是标记出存活的对象,清除节点就是回收所有没有被标记的对象。标记-清除算法比较简单,也很好实现,但是会存在一个问题:会产生大量不连续的内存碎片。

产生大量不连续的内存碎片,会导致后续分配内存空给对象的时候及其容易不满足对象大小的需求,从而导致GC的次数更加的频繁。
标记-整理算法
标记-整理算法是对标记-清除算法的一种改进,它也一样会分为两个步骤:标记和整理。标记阶段与标记-清除算法是一样的,都是将垃圾对象和可用对象进行区分。整理阶段,标记-整理算法除了清除垃圾对象,还会将存活对象复制到连续的内存空间中。

标记-整理算法主要被用在老年代,它解决了标记-清除算法中的大量内存碎片的问题。但是,它需要移动对象会带来额外的开销,移动对象就涉及到:
- 对象数据的拷贝
- 对所有引用该对对象的指针进行更新
这些操作都比较消耗CPU的资源,并且也会导致STW(Stop the world)的时间变长,尤其是在老年代对象较多时,标记、移动、更新引用都需要遍历大量的对象,导致GC的STW时间增加,对于应用的响应时间影响较大。
常用的垃圾收集器
根据垃圾收集针对的内存区域进行划分,可以划分为年轻代垃圾收集器和老年代垃圾收集:
- 年轻代垃圾收集器:Serial垃圾收起、ParNwe垃圾收集器和Parallel垃圾收集器;
- 老年代垃圾收集器:Serial Old垃圾收集器,Parallel Old垃圾收集器和CMS垃圾收集器;
- 跨代垃圾收集器:G1垃圾收集器和ZGC垃圾收集器;

Serial垃圾收集器和Serial Old垃圾收集器
垃圾收集的算法:新生代采用标记-复制算法,老年代采用标记-整理算法。
Serial是最基本的垃圾收集器,它是一个单线程的垃圾收集器。它的单线程的意义不仅仅意味着它只会用一个线程去完成垃圾收集工作,更重要的是它在进行垃圾收集的过程中必须暂停其它所有的工作线程,也是常说的STW(Stop The World)直到它收集结束。

虽然STW带来的用户体验很差,但是也是不可避免的,所有后续所有垃圾收集器的都是在尽可能的降低STW的时间。
Serial垃圾收集器的优点就是简单高效,没有线程之间的交互和上下文之间的切换,所以可以获得很高的单线程垃圾收集的效率。
Serial Old垃圾收集是Serial的老年代版本,它也是一个单线程的垃圾收集器,它的作用有两点:
- 在JDK 1.5之前搭配Parallel垃圾收集器使用
- 作为CMS的后备垃圾收集器
Parallel垃圾收集器和Parallel Old垃圾收集器
垃圾收集的算法:新生代采用标记-复制算法,老年代采用标记-整理算法。
Parallel是Serial的多线程版本,除了使用多线程来进行垃圾收集外,其余的行为(控制参数、收集算法、回收策略等)都和Serial收集器类似。默认的收集线程的数量与CPU的核心数相同,当然也可以使用-XX:ParallelGCThreads参数来控制,但是一般不推荐修改。

Parallel垃圾收集是关注吞吐量的,而后续介绍的CMS垃圾收集器使关注延迟的。这点其实可以通过它们的设计思想就能区分开来,对于Parallel垃圾收集器而言,它的目标是尽最快的速度完成垃圾收集来降低STW的时间,这样GC线程的持续时间就断,CPU就有更多的资源分配给用户线程,所以能够提高用户线程的吞吐量。
对于CMS垃圾收集器而言,它致力于让用户线程获得最短的STW时间,所以就会在各个节点让GC线程与用户线程并发。因此就会导致GC线程的持续时间增多,所有CPU分配给用户线程的资源就减少了,可以并发的用户线程的数量就减少了,但是它让用户线程获得了最少的等待时间,也就是以低延迟为目标的一款垃圾收集器。
ParNew垃圾收集器
垃圾收集算法:标记-复制算法;
ParNew垃圾收集器跟Parallel垃圾收集器使非常相似的,它是在Parallel的基础进行修改让它能够搭配CMS来使用。因为Parallel无法搭配CMS来使用,所以就通过Parallel来进行修改兼容CMS垃圾收集器。

CMS垃圾收集器
CMS垃圾收集器的全称是Concurrent Mark Sweep,意思是并发标记清除垃圾收集器。CMS收集器使一种获得最短停顿时间为目标的垃圾收集器,它是HotSpot真正意义上的第一款并发的垃圾收集器,它第一次实现了GC线程和用户线程(基本上)同时工作。

CMS垃圾收集器一共分为四个节点:
- 初始标记(STW):暂停所有的其他线程,并记录下GC Root可以直接引用的对象,该阶段的速度很快,因为扫描的对象很少;
- 并发标记:从GC Root的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要暂停其它线程,用户线程可以与GC线程一起运行。因为用户程序继续运行,所以这个阶段会导致已经标记过的对象状态发生改变。
- 重新标记(STW):重新标记就是为了修正并发标记阶段中因为用户线程继续运行而导致标记产生变动的那一部分对象的标记。
- 并发清理:GC线程开始清理未被标记的对象。因为这个阶段用户线程继续运行,所以老年代会有新增对象(晋升或者大对象直接分配),这部分的对象会被标记,而不会被清理掉。
其实通过对上述流程的描述,在CMS中最重要的如何高效的标记出垃圾对象,同时还要在GC线程与用户线程并发的场景下保证线程安全的问题。在CMS中采用的三色标记法。
三色标记法
三色标记法是垃圾回收中用于标记存活对象的一种逻辑模型,它是很多现代垃圾收集器(CMS、G1和ZGC等)在并发标记阶段的核心技术之一。三色标记法将对被标记的对象分为三种颜色:
- 白色:表示对象尚未被垃圾回收器扫描过,默认所有对象初始都是白色;
- 灰色:表示本身已经被发现,但其内部引用的其他对象还没有被扫描到,意思是中间状态;
- 黑色:表示对象已经被垃圾收集器访问过了,并且它的所有引用对象都已经被扫描过了。黑色对象被认为是存活对象,且它的引用关系已经处理完毕。
三色标记的法的步骤
- 初始化所有的对象都是白色。
- 从GC Roots触发,这些直接引用对象被标记以为灰色。
- 从灰色对象开始遍历,检查灰色对象引用的对象,将这些被引用的对象也标记为灰色;
- 当对象的所有直接引用都被扫描完了,将当前对象标记为黑色;
- 重复步骤3直到没有灰色对象为止;
最终所有的对象都会是黑色或者白色,所有可达对象都标记为黑色,所有不可达对象都被标记以为白色。
并发标记阶段的问题

来看下上图的这种场景,假如A的直接引用到B,B的直接引用包含C和D。当垃圾收集器正在扫描对象B的时候就有可能出现:
- 用户线程修改了对象B的直接引用,比如上图中的删除了指向对象C的引用的,但是因为对象C已经被扫描过了被标记为了黑色,所以就产生了多标的问题。
- 用户线程修改了对象A的直接引用,比如上图中新增了执行对象D的引用,但是因为对象A已经被扫描过了,就可能导致对象D无法被扫描到,也就无法被标记,所以就产生漏标的问题。
还是看上面这幅图,其实对于多标的而言,他不会造成恶劣的影响,最坏的结果的就是这次垃圾收集过程中无法将对象C进行回收,会产生一些浮动垃圾而已。这些浮动垃圾并不需要额外的去处理,大不了就在下一次垃圾收集的过程中清理掉他们。
但是对于漏标的问题就十分的严重,因为对象D没有被标记所以在后续的并发清理阶段该对象就可能被垃圾收集直接清理掉了。那么当应用程序需要使用这个引用的时候,就会出现NPE从而引发用户线程的异常问题。
对于漏标的解决策略
漏标会给应用程序带来巨大的危害,所以针对漏标的问题是必须要有解决方案。对于漏标的问题,也有两种解决方案:增量更新和原始快照。
增量更新就是当黑色对象插入了新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束后,再将这些记录过引用关系的中的黑色对象为根,重新扫描一次。这可以简化理解为:黑色对象一旦新插入了指向白色对象的引用之后,它就变回了灰色对象了。
原始快照就是当灰色对象删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,并发扫描结束后再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,将扫描的白色对象直接标记为黑色。
其实增量更新和原始快照就是两种思想的体现:
- 对于增量更新而言,当黑色对象有新增指向白色对象的引用时,就记录下这个引用,因为确定这个白色对象是被黑色对象引用的,所以后续可以顺着黑色对象再扫描一遍;
- 对于原始快照而言,当灰色对象有删除指向白色对象的引用时,就记录下这个引用,因为不确定被删除这个白色对象是否会被其它的黑色对象引用,索性就直接标记为黑色不清理了;
无论是增量更新还是原始快照,它们都是基于写屏障来实现的。那么什么是写屏障呢?可以简单理解为AOP思想,就是在对象的引用发生变化的时候,JVM会自动插入一段额外的逻辑(写屏障代码),来执行相应的记录、标记或保护动作。
在CMS垃圾收集器中解决并发标记阶段的漏标问题采取的是增量更新的方式。
G1垃圾收集器
G1垃圾收集器是们目前垃圾回收技术最前沿的成果之一。G1与CMS垃圾收集器一样,是一款关注最小时延的垃圾收集器,与CMS不同的地方在于G1垃圾收集器不再是只针对老年代的垃圾收集器,而是一款可以同时回收老年代和年轻代的垃圾收集器。
G1垃圾收集器摒弃了传统的分代模型,它的分代特性是通过区域划分来实现的。G1垃圾收集器会将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。一般每个Region的大小等于堆大小除以2048,例如堆大小为4096M,那么每一个Region就是2M。支持通过-XX:G1HeapRegionSize参数来修改手动指定Region的大小,但是一般不推荐修改。
G1的内存区域划分
G1将内存划分为多个大小相等的区域,这些区域根据他们的存储的对象的特点被分为了四种类型:
- Old区域:存储老年代对象
- Eden区域:存储年轻代对象
- Survivor区域:存储GC后年轻代中存活下来的对象;
- Humongous区域:存储大对象的区域;

从内存区域上的划分来看,G1特地为大对象开辟出了一个内存空间,不会到大对象直接进入老年,这样的话可以减少老年代GC的压力。在G1中对于大对象的判断就是对象大小超过了Region区域的一半则认为是大对象,比如按照上面的计算方式每个Region的大小为2M,那么对象大小超过了1M就会被认为是大对象。大对象会直接进入Humongous区域存储的,而且如果一个对象很大,可能需要横跨多个Region来存放(需要连续的Region来存放)。
Humongous区域专门存放短期巨型对象,不用直接进入老年代,可以节约老年代的空间,避免因为老年代空间不够而带来的GC开销。
G1中的区域是不固定的,当GC在之后将其中一个Survivor区域清空后,此时如果有对象晋升到老年代,它是有可能被放入到这个空的内存区域中,此时这块内存区域就变成了Survivor区域了。所以它的内存区域是动态变化的,但是它大致会遵循整体的内存比例:
- 初始年轻代对堆内存的占比是5%,可以通过
-XX:G1NewSizePrecent参数来指定; - 随着对象的不断增加,年轻代的占比会不断地上升,但是最多不会超过60%,可以通过
-XX:G1MaxNewSizePercent调整; - 年轻代中的Eden区Survivor区域之间的比例仍然是:
8:1:1
G1的垃圾回收模式
在G1垃圾回收中,主要采用了两种垃圾回收模式:Young GC和Mixed GC
- Young GC(年轻代回收):主要针对年轻代区域的垃圾回收,包括Eden区和Survivor区。当所有的Eden区使用率达到最大阈值(默认是60%)或者G1计算出来的回收时间接近用户设定的最大暂停时间,会触发一次Young GC。触发Young GC后,会回收Eden区和Survivor区,将存活对象复制移动到另外的Survivor区域或者晋升到老年代区域;
- Mixed GC(混合回收):Mixed GC是G1垃圾收集器独有的,也称混合回收,针对年轻代和部分老年代区域的垃圾回收。当老年代的占有率达到阈值(默认是45%)或年轻代被分配大对象时,会触发一次Mixed GC,回收所有的年轻代和部分老年代区域,控制最大暂停时间。
当Mixed GC后仍然没有足够的内存空间来存放对象时,就会触发Full GC,直接暂停用户线程,调用Serial Old垃圾收集器,采用标记-整理算法单线程执行垃圾回收,耗时较长。
在这一点上G1和CMS的表现是相同的,都是利用Serial Old垃圾收集器来作为兜底策略。在CMS中:
- 当CMS在执行并发清理的过程中,老年代空间不足容纳晋升的对象或大对象时,会触发一次Full GC,此时会使用Serial Old垃圾收集器来回收整个堆内存;
- 当年轻代的对象需要晋升到老年代,但老年代空间不足且CMS无法及时回收足够空间时,也会触发Full GC,转而使用Serial Old垃圾收集器
此外,如果通过JVM参数显式设置了-XX:UseCmsCompactAtFullCollectiuon(默认开启),在Full GC后还会使用Serial Old进行内存碎片的整理。
G1垃圾收集器的流程
与CMS垃圾收集器类似,G1的垃圾收集器也分为四个阶段:
- 初始标记(STW):暂停其它所有的线程,标记GC Roots的直接引用对象;
- 并发标记:与CMS的并发标记类似,利用三色标记法,从GC Roots的直接引用对象开始遍历整个对象图;
- 重新标记(STW):与CMS的并发标记类似,主要是为了解决在并发标记阶段用户线程修改的对象引用,主要是解决漏标的问题;
- 筛选回收(STW):首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划;

与CMS的最低时延设计思想不同,G1在提供最低时延的同时希望给用户提供一个可控的时延,可以用-XX:MaxGCPauseMillis参数来指定。与CMS不同的点在于对于最后的清理节点CMS是支持与用户线程并发的,但是G1底层的算法较为复杂,在最后的筛选回收阶段仍然是需要STW的。
G1收集器在后台为维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字 Garbage-First的由来),比如一个Region花200ms就能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限的情况下,G1会优先选择后面的这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方法,保证了G1垃圾收集器在有限时间内尽可能高的收集效率。
漏标的解决方法
在上面介绍了在三色标记方法中会出现漏标的问题,然后在CMS的垃圾收集器中它采取的增量更新的解决方案,而在G1中采取的原始快照的模式来解决。至于G1与CMS对于漏标问题的解决方案不同,我的理解是它们针对的内存区域不同:
两款收集器选择了不同的三色标记修正策略,本质是为了在各自的运行时架构与停顿目标下,用更低成本保证标记正确性。CMS面向老年代的并发标记-清除,使用增量更新配合写屏障,把黑色对象执行白色对象这类事件记录下来,最终在重新标记阶段以这些记录为根重新扫描,代价可控且实现简单。
G1是分区化,以停顿时间可预测为目标的收集器,使用原始快照配合写屏障,在灰色对象删除对白色对象的引用记录下来,并把并发标记期间新分配的对象用TAMS指针区间标记为隐藏式存活,最终标记只需处理少量队列,避免大范围根重扫,从而更稳定地满足停顿目标。
G1的TAMS机制
在并发标记阶段,G1会与应用程序线程并发执行,即一边标记存活对象,一边让应用继续分配对象。那么就会有一个问题:。
为了解决这个问题,G1引入了TAMS(Top At Mark Start)指针机制,具体包括两个指针:
- prevTAMS:表示在本次并发标记开始时刻,该Region的堆顶(即对象分配指针)位置,它是标记开始时,该Region中已分配对象的最高点;
- nextTAMS:表示在并发标记的过程中,对象分配指针的当前位置,也就是最新分配对象的顶部
应用程序在标记期间继续分配对象,这个指针会不断向上移动
关键点在于:在并发标记开始之后、结束之前,所有在[prevTAMS,nextTAMS]这个区间内新分配的对象,都给G1默认为是存活的,哪怕它们还没有被标记器显式访问过。
跨代引用的问题
因为现在的垃圾收集器都是基于分代收集的理论来实现的,所以对于跨代引用问题时无法避免的。所谓的跨代引用就是指,老年代中的对象引用了年轻代中的对象或者年轻代中的对象引用了老年代中的对象。
老年代引用年轻代的对象
在进行Minor GC的时候,如果只是从GC Roots出发来找到对应的引用,就容易忽略掉这些被老年代引用的年轻代对象。如果这些对象被误认为是垃圾对象被回收掉了,那么就会导致老年代对象的对应引用指向了null,就会导致应用程序出现异常。
那么要解决这些问题,就必须要知道哪些年轻代对象被老年代对象引用了。如果去遍历整个老年代中的对象来找到这些被老年代对象引用的年轻代对象时,就会导致Minor GC的效率很低。
其一是因为老年代的空间一般会比年轻代的空间更大,扫描的效率低;其二就是因为老年代的对象扫描到了,一般也不会成为垃圾对象(因为老年代对象都是熬过了多次Minor GC),所以扫描整个老年代对象的方法效率低收益也不高。
年轻代指向老年代的对象
在进行Major GC的时候,因为年轻代的对象也是GC Roots,所以根据可达性分析对于这种年轻代引用的老年代对象就会在可达性分析的对象图中,所以这个老年代对象也不会被认为是垃圾对象而被回收掉。
所以跨代引用带来的问题就是要解决Minor GC的时候,避免被老年代引用的年轻代对象被误认为是垃圾回收掉了。
跨代引用的解决防范
在Minor GC中的时候,对于被老年代引用的年轻代对象,如果去遍历整个堆来找到这些引用关系就会导致Minor GC的效率很低。所以JVM采用了一种高效且巧妙的解决方案:**记忆集(Remember Set)与卡表(Card Table)**技术,避免全量扫描老年代。
记忆集(Remember Set)
记忆集是一种数据结构,用于记录从非收集区域指向收集区域的引用,也就是从老年代指向年轻代的引用。在Minor GC的时候,收集器并不需要扫描整个老年代来查找哪些对象引用了年轻代对象,而是只查看记忆集中记录的那些引用。结合GC Roots和记忆集的作用,就能精确地找到所有存活的年轻代对象,从而避免扫描整个老年代。
记忆集的作用就是缩小扫描范围,提升垃圾收集的效率。不同的垃圾收集器对记忆集的实现方式略有不同,但核心思想是一致的:记录跨代引用的来源。
卡表(Card Table)
卡表是记忆集的一种粒度较粗的实现,它通过以内存区域为单位来记录该区域内是否存在跨代引用的指针。卡表的数据结构是一个字节数组:
CARD_TABLE [this address >> 9] = 0;字节数组CARD_TABLE的每一个元素都对应着其表示的内存区域中一块特定大小的内存块,这个内存块被称之为“卡页”。一般来说,卡页大小都是2的N次幂的字节数,HotSpot虚拟机中卡页的大小是29=512字节。

一个卡页内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表数组元素的值标识为1,称为这个元素变脏,没有则标识为0。
这里其实也是我们在日常开发的过程中可以借鉴的一种思想。当多个线程都需要对同一个资源的状态进行更新,可以通过增加一个状态判断来避免多次的写入操作。
在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并进行扫描。
卡表的实现也是依赖与写屏障,虚拟机会为所有的复制操作生成相应的指令,一旦垃圾收集在写屏障中增加了更新卡表的操作,就会在引用变化的时候去更新卡表。
利用写屏障来更新卡表的时候就出导致伪共享的问题,也就是在多线程进行卡表更新的时候,就因为更新卡表时正好写入同一个缓存行而影响性能。
伪共享是处理并发底层细节时一种经常需要考虑的问题,现代的CPU的高速缓存系统都是以缓存行为单位存储的。当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响而导致性能降低,这就是伪共享问题
JVM为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时,才将其标记为变脏。
多数的传统收集器(如ParNew + CMS)使用写后屏障无条件置脏;而G1使用写前屏障配合SATB机制,在引用变更前记录,减少并发标记阶段对卡表直接写冲突压力,从机制上环节热点区域伪共享问题。