垃圾回收算法与收集器

102 阅读23分钟

1.为什么要GC

Garbage Collection垃圾搜集

GC是为了解决各种内存溢出,内存泄露问题。 内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java提供的GC功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的。

如果想要请求垃圾收集,可以调用下面的方法之一:System.gc() 或Runtime.getRuntime().gc()

第一个实际调用第二个

public static void gc() {

    Runtime.getRuntime().gc();

}

内存溢出 out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;

内存泄露 memory leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光

常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。

偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。

一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,所以内存泄漏只会发生一次。

隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。

2. 如何判断分配的内存可以被GC

对于程序计数器(唯一没有OOM),虚拟机栈,本地方法,这些内存是伴随线程分配,回收的,并且由于其基于栈结构,和分配内存大小基本确定,不用过多考虑GC问题。

  1. 引用计数法(主流jvm并未采用)

对象中添加计数器,添加引用加一,应用失效减一。无法解决对象之间相互循环引用问题。

  1. 可达性分析算法

通过一些被称为(GC Roots)的根对象作为起点集,从这些节点通过引用关系向下搜索,搜索走过的路径被称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。

在Java中,可作为GC Root的对象包括以下几种:

· 虚拟机栈( 栈帧中的本地变量表)中引用的对象

· 方法区中类静态属性引用的对象,如引用类型静态变量

· 方法区中常量引用的对象,如字符串常量池里的引用

· 本地方法栈中JNI(即一般说的Native方法)引用的对象

·JVM内部的引用,如基本数据类型对应的class对象,一些异常对象,系统类加载器。

·所有被同步锁(synchronized)持有的对象

·反映JVM内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存。

除了这些固定的,还可以根据情况“临时”的加入其它对象,构成完整的GC Roots集合。

  1. 死亡判定与finalize()方法

对于可达性分析算法判定不可达的对象并不是立马回收,其最多会经历两次标记过程。

判定不可达后会第一次标记,此后会筛选此对象有没有必要执行的finalize()方法。如果对象没有覆盖finalize()方法,或者finalize()方法已经被JVM调用过(即只会被系统自动调用一次),则“没必要执行”。

如果有,则把这些对象放到一个F-Queue的队列里,并稍后由一条JVM自动创建的,低调度优先级的Finalizer线程去执行他们的finalize()方法。执行仅仅是触发方法开始,并不一定等他结束,以免缓慢的或者死循环的导致整个队列处于等待,甚至内存回收子系统崩溃。

稍后收集器会对F-Queue中对象进行二次标记,如果还是不可达的则基本被回收。

  1. 引用分类

JDK 1.2版本以前

在reference类型的数据中存储另一块内存的起始地址,就称reference数据代表某个内存或者对象的引用,这样就只有“被引用”和“未被引用”两种状态,无法处理复杂情况。

JDK 1.2版本以后

  • 强引用(Strong Reference):指引用赋值,即类似“Object obj = new Object()”,任何情况下,存在强引用,就不会回收被引用的对象。
  • 软引用(Soft Reference):描述还有用,但非必要的对象。只被软引用关联的对象,会在系统将要发生内存溢出前,将这些对象列进行回收范围内进行二次回收。
  • 弱引用(Weak Reference):描述比软引用强度更弱的非必要对象,其能活到下一次垃圾收集发生。当垃圾收集器开始工作后,都会回收只被弱引用关联的对象。
  • 虚引用(Phantom Reference):一个对象是否有虚引用不对生存时间构成影响,也无法通过虚引用获得实例,仅仅是为了对象被回收时收到一个系统通知
  1. 回收方法区

方法区主要回收:废弃的常量和不再使用的类型。

常量的回收与堆中的对象类似。如常量池中有“java”,但系统中又没有一个字符串对象是java,且其他地方也没有引用,则有必要的话可以清理掉它。

但类型的卸载更加苛刻:

  • 该类所有的实例都已经被GC,也就是JVM中不存在该Class及其派生子类的任何实例。

  • 加载该类的ClassLoader已经被GC。

  • 该类的Java.lang.Class 对象没有在任何地方被引用,如不能在任何地方通过反射访问该类的方法

3.垃圾收集算法

  1. 分代收集理论

当前大多数商业虚拟机的垃圾收集器都遵循了“分代收集”的理论设计:

弱分代假说:绝大多数对象都是朝生夕灭的

强分代假说:熬过越多次垃圾收集的对象就越难以消灭。

两个假说奠定了多个常用收集器的设计原则:将堆划分不同的区域,根据其年龄(次数)分配到不同的区域。 把朝生夕灭的对象放到一起,回收时标记存活的。对于难以消灭的放到一起,以较低的频率回收这个区域。

通常会把堆至少分为新生代和老年代两个区域,但分代收集存在对象的跨代引用的难题,即:

新生代的对象可能被老年代引用,那么可达性分析时不得不在固定的GC Roots上,再遍历整个老年代所有对象,因此引入了第三法则:

跨代引用假说:跨代引用相对于同代引用仅占极少数。

为了解决这个困难,只用在新生代建立一个全局的数据结构(记忆集),标识出老年代(分为若干小块)的哪一块内存存在跨代引用,在Minor GC时只加入包含跨代引用的小块内存。

名词:

部分收集(Partial GC):目标不是完整收集整个堆,可以分为

新生代收集(Minor GC/Young GC)

老年代收集(Major GC/Old GC):目前只有CMS有单独收集老年代的行为。

混合收集(Mixed GC):整个新生代及部分老年代。目前仅G1支持。

整堆收集(Full GC):收集整个堆和方法区。

2.标记-清除算法

首先标记出回收(存活)的对象,完成后统一回收(保留)被标记的对象。

优点:

  1. 可与用户线程并发

缺点:

  1. 效率不稳定:大量对象要被清除时,有大量的标记和清除动作。效率和被清除对象的数量成反比。
  2. 内存碎片化:清除后会产生大量不连续的内存碎片,可能会导致分配大对象时找不到足够连续内存不得不提前触发另一次垃圾收集。

3.标记-复制算法

将内存分为大小相同的两块,每次只使用其中的一块,当这一块的内存使用完了,就将还存活的对象复制到另一块上,再将已使用过的一次性清理掉

优点:少数对象存活时,复制开销低,无空间碎片问题。

缺点:多数对象存活时,开销大。内存缩小一半。

由于新生代中大多数对象都熬不过第一轮收集,因此可以不用1:1来划分空间。提出了Appel式回收:将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次只使用Eden和一个Survivor,当GC时,将二者中还存活的对象都复制到另一块Survivor空间,然后直接清理掉Eden和用过的Survivor。

分配担保:当一次Minor GC后Survivor存放不下存活下来的对象,就要依赖其他内存区域(大多是老年代)。

  1. 标记-整理算法

由于复制算法要么浪费50%的空间,要么有分配担保,并不适合老年代的情况。

先标记出存活的对象,然后让存活的对象向内存空间的一端移动,接着直接清理掉边界以外的内存。

清除和整理算法的区别在于是否移动存活的对象,而这是优缺点并存的。

移动对象:老年代这种每次回收都有大量对象存活的区域,移动和更新引用是极为负重的操作。并且移动还要暂停用户应用程序“Stop The World”。

不移动:空间碎片问题只能依赖更复杂的内存分配器和内存访问器来解决。加重了内存访问的负担,影响了程序的吞吐量。

因此像关注吞吐量的Parallel Old收集器基于整理算法,关注低延迟的CMS基于清除算法,但其碎片过多时,会整理一次。

收集器算法的选择

Serial复制
ParNew复制
Parallel Scavenge复制
Serial Old整理
Parallel Old整理
CMS清除
G1局部复制,整体整理

4.垃圾收集器

1.Serial

单线程(指STW)工作收集器,简单高效(单线程相比),额外内存消耗最小。

2.ParNew

Serial的多线程并行版本,除了同时使用多条线程进行GC,其余基本和Serial一致。

3.Parallel Scavenge

一款基于标记-复制算法的新生代收集器,支持多线程收集。与其他收集器不同的是其更关注达到一个可控制的吞吐量(处理器用于运行用户代码的时间与总时间的比值)。可通过参数设置最大停顿时间和吞吐量大小。并且其支持自适应调整(新生代的大小比例等各种参数)。

4.Serial Old

Serial的老年代版本,单线程,标记-整理算法。

5.Parallel Old

Parallel Scavenger的老年代版本,支持多线程收集,基于标记-整理算法。

6.CMS(Concurrent Mark Sweer)

以获得最短回收停顿时间为目标的收集器。基于标记-清除算法

  1. 初始标记(STW):仅仅标记GC Roots能直接关联的对象,速度很快。
  2. 并发标记:从直接关联对象遍历整个对象图,耗时较长,但可以和用户线程并发运行。
  3. 重新标记(STW):修正并发标记期间,产生变动的对象的标记记录(增量更新法)。
  4. 并发清除:由于采用清除算法,可以并发运行。

缺点:

  1. 对处理器资源敏感:由于并发设计,会占用一部分处理器计算能力导致应用程序变慢,降低吞吐量。
  2. 无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败导致一个STW的Full GC产生:由于并发的标记和清除,程序还会产生当次无法清理的垃圾,因此CMS要在老年代中预留空间。可以通过参数设置阈值。如果产生失败,则启用Serial Old进行收集。
  3. 空间碎片:参数控制(默认开启),CMS不得不进行Full GC时开启内存碎片整合过程,无法并发。另外可以通过参数控制在多少次不进行内存整理的Full GC后,下次进入会先进行碎片整理。

7.Garbage First

G1是一款主要面向服务端应用的垃圾收集器。它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,Mixed GC模式。

G1仍遵循分代收集理论设计的,但堆内存的布局与其他收集器不同:不再固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。

Region中有Humongous区域,专门存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为把Humongous Region作为老年代的一部分来进行看待。

G1收集器每次收集的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseM illis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。

实现存在的困难:

  1. 跨Region引用:每个Region都维护有自己的记忆集,这些记忆集记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。存储结构的本质上是一种哈希表。由于Region数量多,因此G1收集器有着更高的内存占用负担。
  2. 并发标记:采用原始快照。
  3. 新创建对象的内存分配上:为每一个Region划分设计了两个名为TAMS(Top at Mark Start)的指针,新分配的对象地址都必须在这两个指针位置以上。G1收集器默认在这个地址以上的对象是存活的,不纳入回收范围。与CMS中的失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也会导致Full GC而产生长时间“Stop The World”。
  4. 怎样建立起可靠的停顿预测模型:G1收集器的停顿预测模型是以衰减均值为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

G1收集器大致可划分为以下四个步骤: 仅并发标记是并发的

  • 初始标记(Initial M arking):只标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。可借用进行Minor GC的时候同步完成的。
  • 并发标记(Concurrent M arking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆

里的对象图。当对象图扫描完成以后,重新处理SATB记录下的在并发时有引用变动的对象。

  • 最终标记(Final M arking):处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划, 可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。

什么情况下应该考虑使用G1

  • 实时数据占用超过一半的堆空间
  • 对象分配或者晋升的速度变化大
  • 希望消除长时间的GC停顿(超过0.5-1秒)

8.CMS与G1对比

CMSG1
记忆集数量1每个Region都有
写屏障写后屏障维护卡表更复杂写后屏障维护卡表,写前屏障记录并发时指针变化以实现原始快照
写屏障实现同步操作类似消息队列,将写前写后放到队列里,然后异步处理
并发标记增量更新原始快照
第三步修正引用变化停顿时间
回收算法清除整体整理,局部复制
空间碎片
清除时是否STW
是否严格分代
是否组合使用
清理对象老年代
相比内存占用
实现方式目标最小的停顿时间为目标建立可预测的停顿时间模型
大对象处理直接老年代单个或者连续Humongous

5.HotSpot细节实现

  1. 根节点枚举

固定可作为GC Roots的节点主要在全局性引用(常量,类静态属性)和执行上下文(局部变量表)中,虽然目标明确,其查找仍然费时,并且目前的收集器这一步都会STW。

HotSpot通过OopMap的数据结构来直接得到哪里存放着对象的引用。

  1. 安全点

由于能导致引用关系变化的指令非常多,如果都生成对应的OopMap会占用大量额外的空间,因此只在“特定位置”记录信息,便称为安全点(Safepoint)。

所以垃圾收集时代码指令流暂停便只能在安全点的位置。对于安全点位置的选择,一般在“具有让程序长时间执行的特征”处,如方法调用,循环跳转等指令序列复用的位置。

如何让垃圾收集时线程都到安全点的位置有两种方法:

抢断式中断(几乎没有应用):垃圾收集时,直接中断所有的用户线程,如果有不在安全点上的线程则恢复执行,直到跑到安全点。

主动式中断:通过设置标志位,线程执行过程会不停的主动轮询标志位。垃圾收集时改变标志位,轮询发现为真时主动在安全点挂起。轮询标志的地方和安全点重合,另外加上所有创建对象和其他需要在堆上分配内存的地方。这是为了检查是否即将要发生垃圾收集,避免没有内存分配新对象。

  1. 安全区

对于像Sleep状态的线程显然不可能主动跑到安全点挂起。因此引入安全区:确保在某一段代码片段中,引用关系不会发生变化,可以任意地方开始垃圾收集。

用户线程会标识自己进入安全区,当要离开时,会先检查JVM是否完成了需要暂停用户线程的操作,没有则等待。

  1. 记忆集与卡表

记忆集是一种记录从非收集区域指向收集区域的指针集合的抽象数据结构。

记录精度:

  • 字长精度:每个记录精确到一个机器字长,该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集。

HotSpot虚拟机卡表的形式是一个字节数组

CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代

指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。

  1. 写屏障

在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。

写屏障在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。

void oop_field_store(oop* field, oop new_value) {

// 引用字段赋值操作

*field = new_value;

// 写后屏障,在这里完成卡表状态更新

post_write_barrier(field, new_value);

}

伪共享:现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低。

假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓

存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。

一种解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,可参数控制。

if (CARD_TABLE [this address >> 9] != 0)

CARD_TABLE [this address >> 9] = 0;
  1. 并发的可达性分析

关于为什么必须在一个能保障一致性的快照上才能进行对象图的遍历,先引入三色标记

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是

白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代

表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对

象不可能直接(不经过灰色对象)指向某个白色对象。

  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

当且仅当以下两个条件同时满足时,会产生“对象消失”的问题

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

由此分别产生了两种解决方案:

增量更新(Incremental Update):破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照(Snapshot At The Beginning,SATB):破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。