引导
垃圾收集只要完成三件事,判断哪些垃圾要回收,什么时候回收,怎么回收?
1. 哪些垃圾要回收
首先,java栈,本地方法栈,PCR的生命周期都和线程一样,栈中的帧栈随着方法的进入和退出在做入出栈的操作,这三个区域内存分配和回收都很明确,无需考虑。所以我们主要是要回收java堆和方法区里的垃圾。(一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。)
1.1 哪些对象回收
接着再细化怎么判断java堆里的哪些对象需要回收。有两种方法。
1.1.1 引用计数法
给对象添加一个引用计数器,每有一个地方引用就+1,当引用失效时就 - 1。
简单高效,但无法解决对象之间的循环调用。
1.1.2 可达性分析算法
通过一系列称为GC Roots的跟对象为始节点集,根据引用关系向下搜索,搜索的路径称为“引用链”。 如果从GC Roots到一个对象不可达时,说明此对象不可用了。
GC Roots包括:
- 栈帧中局部变量表中引用的对象,比如各个线程被调用的方法堆中使用的参数,局部变量,临时变量。
- 方法区中类静态属性引用的对象,比如java类的引用类型静态变量(
public static List<String> myList = new ArrayList<>();
)。 - 方法区中常量引用的对象(
public static final String CONSTANT_STRING = "Hello";
) - Native方法区引用的对象。(
public native void nativeMethod();
) - java虚拟机内部的引用,如基本数据类型对应的Class对象,异常对象。
(Class<?> clazz = String.class;
,throw new Exception("Example");
) - *被同步锁持有的对象。(
synchronized (this){}
)
1.2 引用的概念
1.1中提到很多引用,下面讲讲Java中的引用概念,分为四种引用。
- 强引用
指程序代码中普遍的引用赋值,比如Object ob = new Object()
, 永远不会被垃圾回收器回收的对象。 - 软引用
被软引用关联着的对象,在系统发生内存溢出前,会列入回收范围进行第二次回收,如这次回收完了,内存还不够,才会抛出内存异常。 - 弱引用
被弱引用关联着的对象,只能生存到下一次垃圾收集发生为止。也就是当垃圾收集器开始工作后,所有只被弱引用关联的对象会被回收掉。 比较与软引用,软引用在垃圾收集器工作后,不一定全被回收掉。 - 虚引用
无法通过虚引用取得对象实例。为一个对象设置虚引用的唯一目是为了能在这个对象被回收时收到一个系统通知。
2. 怎么回收(垃圾收集算法)
2.1 分代收集理论
当前垃圾收集器都遵循“分代收集”理论设计的。这是一套根据程序员经验的设计,建立在三个假说之上。
- 弱分代假说: 绝大多数对象都是朝生夕灭的。
- 强分代假说: 经历过越多次垃圾收集过程的对象越难以消亡。
所以, 设计者把Java堆分成新生代和老年代,在新生代中每次垃圾收集都会有大批对象死去,而每次回收后存货的少量对象会逐步晋升到老年代。
这时,会出现一些问题,如果我现在只想进行一次局限于新生代的垃圾收集(Minor GC),但新生代的对象很可能被老年代引用,为了找出该区域的存活对象,除了固定GC Roots之外,还要再遍历整个老年代来确保可达性结果的正确性,性能消耗很大。为了解决,增加了第三条假说,跨代引用假说。
3. 跨代引用假说:存在互相引用的两个对象,应该倾向于同时生存或同时消亡。
依据于这条假说,就不应为了少量的跨代引用扫描整个老年代。只需在新生代建立一个全局的数据结构(记忆集),这个结构把老年代分成若干小块,标记处哪一块内存会用到跨代引用。
上文中提到过Minor GC,这是部分收集的一种,目标是不是完整收集真个Java堆。
- Minor GC:只收集新生代产生的垃圾。
- Major GC:只收集老生代产生的垃圾。
- Mixed GC:收集整个新生代和部分老年代垃圾。
2.2 几种垃圾收集算法
垃圾收集器都遵循“分代收集”理论,了解了垃圾收集的一些特性,下面了解下具体的垃圾收集算法。
2.2.1 标记清除(Mark-Sweep)
缺点:有大量垃圾要被回收时,效率低。产生碎片化内存,后果是如果有一个很大的对象要分配内存,无法找到连续的内存空间,就会触发另一次的垃圾收集动作。
2.2.1 标记复制
简称“复制算法”。解决了标记-清除算法面对大量可回收对象时执行效率低的问题。
每次只是用一半,当这一半用完时,就把这一半还存活的对象复制到另一半,再清理这一半。
![[uTools_1687407061635.png]]
缺点:空间浪费。内存间复制会产生一些开销。
目前,大多JVM会采用复制算法回收新生代,针对具备“朝生夕灭”特点的对象,提出了⼀种更优化的半区复制分代策略,现在称为 “Appel式回收” 。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。
Appel式回收的具体做法是把新生代分为⼀块较大的Eden空间和两块较小的Survivor空
间,每次分配内存只使用Eden和其中⼀块Survivor。发生垃圾搜集时,将Eden和
Survivor中仍然存活的对象⼀次性复制到另外⼀块Survivor空间上,然后直接清理掉Eden
和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上⼀个Survivor的10%),只有⼀个Survivor空间,即10%的新生代是会被“浪费”的。当Survivor空间不足以容纳⼀次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年
代)进行分配担保(Handle Promotion)。如果另外⼀块Survivor空间没有足够空间存放上
一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。
2.2.1 标记整理(Mark-Compact)
复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。所以老年代采用标记整理,标记要清楚的对象,让所有存活的对象都向内存空间⼀端移动,然后直接清理掉边界以外的内存。
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是⼀种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”(STW)。
但不考虑移动和整理的话,碎片空间只能依赖更复杂的内存分配器和内存访问器解决(分区空闲分配链表,硬盘分区表),直接会影响程序吞吐量。
移动整理则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。
2.3 HotSpot算法实现细节
2.3.1 根节点枚举
使用一组称为OopMap的数据结构来达到这个目的。⼀旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置(Safepoint)记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正⼀个不漏地从方法区等GC Roots开始查找。
2.3.2 安全点 Safepoint
在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举。实际上HotSpot也的
确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信
息,这些位置被称为安全点。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。
2.3.3 记忆集与卡表
记忆集是⼀种用于记录从非收集区域(老年代)指向收集区域(新生代)的指针集合的抽象数据结构。 如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某⼀块非收集区域是否存在有指向了收集区域的指针就可以了(下面卡精度有具体解释),并不需要了解这些跨代指针的全部细节。
卡精度: 每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常
用的⼀种记忆集实现形式。卡表最简单的形式可以只是⼀个字节数组,而HotSpot虚拟机
确实也是这样做的。⼀个卡页的内存中通常包含不止⼀个对象,只要卡页内有⼀个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中⼀并扫描。
2.3.4 写屏障
是通过写屏障技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生⼀个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使⽤到写屏障,但直至G1收集器出现之前,其他收集器都只⽤到了写后屏障。
2.4 经典垃圾收集器
2.4.1 Serial收集器
单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束(STW)。
客户端模式下的默认新生代收集器,简单高效,内存受限的情况下,额外内存消耗最少。
2.4.2 Serial Old收集器
Serial的老年代版本,供客户端模式下使用。服务端模式下,也可能有两种用途:JDK5以前与Parallel Scavenge搭配或者作为CMS失败时的备选方案。
Parallel Scavenge本身有PS MarkSweep进行老年代收集,但这个与Serial Old的实现几乎一样。
2.4.3 ParNew收集器
Serial(新生代)的多线程并行版本。同时是JDK5使用CMS后,默认的新生代收集器。
除了Serial外,只有他能与CMS配合使用。
2.4.4 Parallel Scavenge收集器
多线程收集器,和ParNew很像。
不一样的是CMS等关注点是尽可能缩短垃圾收集时用户线程的停顿时间。而它的目标是达到一个可控制的吞吐量。
![[uTools_1687432245005.png]]
MaxGCPauseMillis控制最大停顿时间,GCTimeRatio直接控制吞吐量。
停顿时间短(CMS) 就更适合要用户交互,要保证服务响应时间的程序。
而高吞吐量可以更高效率利用处理器资源,适合后台运算而不需要太多交互的分析任务。
2.4.5 Parallel Old收集器
Parallel Scavenge的老年代版本,支持多线程并发。在之前新生代选择Parallel Scavenge,老年代除了Serial Old(PS MarkSweep)没有选择。由于老年代Serial Old在服务端应用的拖累,Parallel Scavenge也未必能获取吞吐量最大化的效果。
直到Parallel Old出现,在注重吞吐量优先的收集器选择上有了搭配组合。
![[uTools_1687433146308.png]]
2.4.6 CMS收集器
CMS(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器。 现在很多Java应用集中在互联网网站或B/S系统的服务端,会很关注服务的响应速度,就很适合CMS。
四个步骤是基于标记——清除算法实现(步骤1, 3也是要STW的):
- 初始标记:只标记GC Roots能直接关联到的对象,速度很快。
- 并发标记:从GC Roots直接关联到的对象开始遍历整个对象图的过程,可与用户线程并发。
- 重新标记:因为并发标记时用户线程还在运作,所以这次标记是标记产生变化的那一部分对象记录。
- 并发清理:删除掉标记阶段判定为死亡的对象,由于不需要移动存活对象,也可与用户线程并发。
缺点:
- 处理器核心少于4时,对用户程序的影响可能会很大,因为并发阶段会导致程序变慢,降低总吞吐量。
- 并发标记和并发清理是,用户线程还在运行,产生了浮动垃圾,只能等下次垃圾收集。
- 垃圾回收阶段,用户线程还要运行,所以要保留足够空间给用户线程并发。
- 基于标记清除算法,会产生大量碎片空间。
2.4.7 Garbage First(G1)收集器
一、简介:
面向服务端应用的垃圾收集器。有“停顿时间模型”,也就是能够支持指定在一个长度为M毫秒的时间片段,消耗在垃圾收集的时间大概率不超过N秒。是一个在不同场景下关注吞吐量和延迟之间的最佳平衡的垃圾收集器。
二、如何实现停顿时间模型 △△△△
在之前,垃圾收集的范围要么是整个新生代或者整个老年代或者整个java堆。G1可以面向堆内存任何部分回收(Mixed GC)。
为了实现Mixed GC,G1开创了Region的堆内存布局。虽然也是遵循分代收集的理论,但不再坚持固定大小固定数量的分代区域划分,而是把连续的Java堆分成大小相等的独立区域,每个Region可根据需要作为Eden区,Survivor区,老年代空间。
还有一个特殊的区,Humongous区域,专门存放大对象(大于半个Region的对象)。每个Region大小可根据-XX: G1HeapRegionSize调整,取值范围1M~32M,2的N次幂。
Region是单次回收的最小的垃圾收集单元,具体思路是让G1跟踪各个Region里的垃圾堆积的价值大小,价值就是回收后能获得的空间和回收所需要的时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间。优先处理价值最大的Region。
三、G1在实现时遇到的问题
-
Region里存在跨Region引用对象怎么解决?
使用记忆集避免全堆作为GC Roots扫描,但G1的记忆集稍微复杂些,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内 。所以G1的记忆集在存储结构的本质上是⼀种哈希表, Key是别的Region的起始地址,Value是⼀个集合,里面存储的元素是卡表的索引号。 -
并发标记阶段怎么保证收集线程与用户线程互不干扰运行?
CMS用的增量更新。G1用原始快照(SATB)方法保证用户线程改变对象引用关系时,不打破原来的对象图结构。此外,G1的每个Region还有两个名为TAMS的指针,把Region中的一部分空间划分出来用于并发回收过程的新对象分配。并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。
四、怎么建立可靠的停顿时间模型
G1收集器的停顿时间模型是以衰减均值为基础设计的。收集时会记录每个Region回收耗时,成本,平均值,置信度等。所以Region的统计状态越新越能决定其回收的价值。
五、G1收集过程 △△△
在这里插入图片描述
- 初始标记:只标记GC Roots能直接关联到的对象,然后修改TAMS指针以便并发标记时用户产生的新对象能正确分配。耗时短,而且借用Minor GC的时候同步完成,没有额外停顿。
- 并发标记:从GC Roots直接关联到的对象开始递归遍历整个对象图的过程,可与用户线程并发。之后还要重新处理SATB(原始快照)记录下的并发时有引用改变的对象。
- 最终标记:对用户线程做一个短暂的暂停,处理并发阶段后仍遗留的少量SATB记录
- 筛选回收:更新Region统计数据,对各个Region的回收价值和成本排序。然后根据用户设定的期望停顿时间制定回收计划。(选择任意多个Region,然后把要回收的Region的存活对象复制到空的Region,再清理掉旧的Region。)涉及移动存活对象,必须暂停用户线程。????
六、与CMS的比较(优缺点)
与CMS 的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,五论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片。
G1也不尽优。G1和CMS都使用卡表来处理跨代指针,但G1的记忆集更多,每个Region都有一个,可能这会占到整个堆容量的20%。
2.4.8 垃圾收集器的选择
衡量垃圾收集器的三项最重要的指标是: 内存占用(Footprint)、吞吐量(Throughput)和延
迟(Latency)。
三个因素
- 关注点不同。如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点; 如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点; 而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。
- 运行的基础设施,硬件,分配多少内存,选择系统。
- JDK发行商,版本。
3.实战:内存分配和回收策略
3.1 对象优先在Eden分配:
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟
机将发起⼀次Minor GC。
3.2 大对象直接进入老年代:
HotSpot虚拟机提供了-XX: PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是*避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。
3.3 长期存活的对象进入老年代:
虚拟机给每个对象定义了⼀个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第⼀次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。 对象在Survivor区中每熬过⼀次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
3.4 动态对象年龄判断
为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须
达到- XX: MaxTenuringThreshold才能晋升⽼年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的⼀半,年龄大于或等于该年龄的对象就可以直接进入老年代,⽆须等到-XX: MaxTenuringThreshold中要求的年龄。
3.5 空间分配担保
(可看)在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这⼀次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX: HandlePromotionFailure参数的设置值是否允许担保失败; 如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的; 如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行⼀次Full GC。
JDK 6 Update 24之后的规则变为==只要老年代的连续空间大于新生代对象总大小或者历次
晋升的平均大小,就会进行Minor GC,否则将进行Full GC。(不再使用-XX: HandlePromotionFailure参数了)