两种判断方式 JVM通常只用第二种
在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了。
主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。发生循环引用的对象的引用计数永远不会为0,结果这些对象就永远不会被释放。
从GC Roots 为起点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots 没有任何引用链相连时,则证明此对象是不可用的。不可达对象。
Java 中,GC Roots 是指:
例如
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 {
// 类的静态属性
// ...
}
Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
这样子设计的原因主要是为了描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
MyClass obj = new MyClass(); 只有当obj = null 的时候 MyClass对象才会被回收
只要强引用存在,垃圾收集器永远不会回收被引用的对象,只有当引用被设为null的时候,对象才会被回收。但是,如果我们错误地保持了强引用,比如:赋值给了 static 变量,那么对象在很长一段时间内不会被回收,会产生内存泄漏
SoftReference
软引用是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。
软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
WeakReference
弱引用的强度比软引用更弱一些。当 JVM 进行垃圾回收时,无论内存是否充足,都会回收只被弱引用关联的对象。
引申出两个基于弱引用的垃圾回收监控
WeakHashMap&ReferenceQueue
WeakHashMap 是 Java 集合框架中的一种特殊的 Map 实现,它使用弱引用(WeakReference)来实现对键的引用。ReferenceQueue 是 Java 中的一个队列,用于监控对象的引用是否被垃圾收集器回收。这两者常常结合使用,特别是在需要监控弱引用键是否被回收时。
WeakHashMap 的特点是,当键不再被其他强引用引用时,它们可以被垃圾收集器回收,这样相应的键值对就从 WeakHashMap 中移除了。这对于一些缓存场景很有用,因为如果某个对象不再被其他地方引用,那么就可以释放相关的缓存数据。
javaCopy code
import java.util.WeakHashMap;
public class WeakHashMapExample {
public static void main(String[] args) {
WeakHashMap 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 时,意味着该引用指向的对象已经被垃圾收集器回收。结合 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 referenceQueue = new ReferenceQueue<>();
WeakHashMap weakHashMap = new WeakHashMap<>();
Key key1 = new Key("1");
Key key2 = new Key("2");
WeakReference weakReference1 = new WeakReference<>(key1, referenceQueue);
WeakReference 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 中移除。
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
PhantomReference
标记-清除算法在概念上是最简单最基础的垃圾处理算法。
该方法简单快速,但是缺点也很明显,一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法改进了标记-清除算法的效率问题。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
缺点也是明显的,可用内存缩小到了原先的一半。
在Java的内存管理中,内存分配担保(Allocation Failure)是一种垃圾收集器的工作机制,它确保在进行新生代(Young Generation)的垃圾回收时,能够留出足够的空间给新分配的对象。
内存分配担保的基本思想是,当程序试图在新生代分配一个对象时,首先会检查新生代的可用空间是否足够。如果足够,就直接分配;如果不足,就触发一次垃圾回收。垃圾回收的目标是清理出足够的空间,然后再尝试分配对象。这就是所谓的内存分配担保。
具体的步骤如下:
这种机制的目的是确保分配对象时有足够的空间可用,同时避免频繁地进行垃圾回收。
内存分配担保通常发生在新生代,而老年代的空间不足时,由于老年代使用的是标记-清除或标记-整理算法,触发一次垃圾回收可能会导致较大的停顿时间,因此在老年代并不会采用类似的担保机制。
JVM 的堆空间分成2个区域:年轻代、老年代
年轻代又进一步细分成3个区域:Eden、Survivor From、Survivor To
如下图所示:
GC过程
复制算法主要用于回收新生代的对象,但是这个算法并不适用于老年代。因为老年代的对象存活率都较高(毕竟大多数都是经历了一次次GC千辛万苦熬过来的,身子骨很硬朗 )
根据老年代的特点,提出了另外一种标记-整理(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
首先要认识到的一个重要方面是,对于大多数JVM,需要两种不同的GC算法,一种用于清理新生代,另一种用于清理老年代。
这些连线表示在JDK 1.8中 使用的垃圾收集器的组合关系
常用组合
Serial 工作在新生代,使用“复制”算法,Serial Old 工作在老年代,使用“标志-整理”算法。
“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束(STW阶段)。 Stop The World
串行收集器有着优于其他收集器的地方,那就是简单而高效。对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的,单线程没有线程交互开销。
(这里实际上也是一个时间换空间的概念)
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,成为它专门处理新生代的组成部分。
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) |
Parallel Old是Parallel Scavenge收集器的老年代版本,多线程,基于“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供的。
由于如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外别无选择(Parallel Scavenge无法与CMS收集器配合工作),Parallel Old收集器的出现就是为了解决这个问题。Parallel Scavenge和Parallel Old收集器的组合更适用于注重吞吐量以及CPU资源敏感的场合。
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,有八位,约定好每一位的含义就可区分哪个是引用新生代,哪个是并发标记阶段修改过的)
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作为老年代的一部分来进行看待。
如果发现由于大对象分配导致频繁的并发回收,需要把大对象变为普通的对象,建议增大Region Size。
G1(Garbage-First)是一个服务器风格的垃圾收集器,针对的是具有大内存的多处理器机。
它试图在实现高吞吐量的同时,以较高的概率满足垃圾收集(GC)暂停时间目标。
Garbage-First(垃圾优先)表示优先处理那些垃圾较多的内存块。即:根据堆中各个区域(Region)的垃圾回收价值在后台维护一个优先级列表,每次在允许的收集时间内优先回收价值最大的区域,从而避免在整个堆中进行全区域垃圾回收。
其中:
G1将对象从堆的一个或多个区域复制到堆的单个区域,并在进程中压缩和释放内存。这种转移在多处理器上并行执行,以减少暂停时间并提高吞吐量。因此,对于每次垃圾收集,G1都会持续地减少碎片。
G1的第一个重点是为运行需要大堆且GC延迟有限的应用程序的用户提供解决方案。这意味着堆大小约为6GB或更大,并且稳定且可预测的暂停时间低于0.5秒(在Java19中推荐10G或者更大的堆内存)。
(如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了-种失败保护机制,即强力回收。)
G1除开维护Rset的流程:
在分配一般对象时,当所有eden region使用达到最大阈值并且无法申请足够内存时,会触发一次Minor GC。每次Minor GC会回收所有Eden以及Survivor区,并且将存活对象复制到Old区以及另一部分的Survivor区。
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 + 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)的长期替代品,它们的主要区别:
参考链接:https://juejin.cn/post/6844904040346681352
garbage-collection-algorithms-implementations
什么?面试官问我G1垃圾收集器? - 掘金