JAVA垃圾收集

哪些内存需要回收

Java内存运行时区域

Java内存运行时区域大致分为几个部分。

  • 方法区
  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

其中程序计数器,虚拟机栈,本地方法栈三个区域是线程私有区域,随线程而生,随线程而灭,在这几个区域不用考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟着回收了。
而Java堆和方法区这两个区域则有着不确定性,这部分内存回收是动态的,垃圾收集器所关注的正是这部分的内存。

判断对象已死

在堆中存放中JAVA中几乎所有对象实例,回收垃圾时,需要判断对象是否可以被回收。

引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器加一,当引用失效时,计数器减一。任何时刻,引用计数器为零的对象时不被使用的。
虽然引用计数器占用了一些额外内存,但它的原理简单且效率高。但是,会存在循坏引用的问题,Java领域主流的虚拟机都没有采用这种方法。

可达性分析

Java领域主流的虚拟机都是通过可达性分析来判断对象是否存活的。这个算法的基本思路就是通过一些称为GC Roots的对象作为起始节点,根据引用关系向下搜索,搜索过程所走过的路径称为引用链。如果某个对象没有和任何一个GC Roots有引用链关联,那么这个对象是不被使用的。
固定GC roots的对象包括以下几种:

  • 在虚拟机栈中引用的对象,比如各个线程中的方法堆栈中的参数,局部变量,临时变量等。
  • 在方法区中静态属性引用的对象。如引用类型静态变量
  • 在方法区中常量引用的变量,如字符串常量的引用。
  • 在本地方法栈中引用的变量
  • Java虚拟机内部的引用,如一些基本类型的class对象,一些常驻的异常对象。
  • 所有被同步锁持有的对象。
  • 反映Java虚拟机内部情况的JMXBean,JVM TI中注册的回调、本地代码缓存等。

除此之外,根据垃圾收集器以及回收内存区域的不同,还可以有其他对象‘临时性’加入。比如局部回收时,需要将其他区域的一些对象引用加入进去。

引用

无论是通过引用计数法判断引用数量还是可达性分析法判断引用链是否可达,都离不开引用。在JDK1.2以前,引用的定义很纯粹:如果一个reference类型里面存储的是另一个对象的内存地址,那么称这个reference是该对象的一个引用。
在JDK1.2以后,将引用分为了四个类型:

  • 强引用:最传统的引用,是指代码中普遍存在的引用赋值,只要强引用关系存在,对象就不会被回收。
  • 软引用:用来描述有用但非必需的对象,只拥有软引用的对象会在发生内存溢出时进行二次回收。用SoftReference表示一个软引用。
  • 弱引用:也是用来描述那些非必须的对象,只拥有弱引用的对象只能存活到下一次垃圾回收。无论内存是否足够,都会回收掉该对象。用weakReference表示一个弱引用。
  • 虚引用:又称为幽灵引用,一个对象持有虚引用不会对其的生命周期造成任何影响,也无法通过虚引用获取实例对象,其唯一的作用是在对象被收集前进行一系列通知。用PhantomReference来表示一个虚引用。

自我救赎

通过引用判断不被使用的对象也不是一定会被回收,在第一次被回收时,还有一次自我救赎的机会。也就是对象的finalize方法。
当获取到要回收的对象时,会判断该对象是否重写finalize方法,将重写了该方法并且该方法没被调用过的对象加入F-Queue队列中,之后由一条虚拟机自动创建的、优先级低的线程finalizer来触发该方法,但不一定会等待方法执行结束。原因是如果finalize方法如果执行缓慢或者发生死循环,会导致F-Queue队列中其他方法的执行,甚至影响到整个垃圾回收。
然后会将F-Queue中的对象进行一次小规模的再标记,如果对象在finalize方法中改变了自己的引用关系,使得该对象存在于引用链中,那么该对象就不会被回收。
finalize方法有些c++析构函数的影子,可能是兼容C++程序员的产物,有些地方说该方法能被用于资源释放,但其不一定执行成功的特性使得其完全不如try finally语法来释放资源。

方法区的垃圾回收

虚拟机规范并没有规定要对方法区进行垃圾回收,并且也有一些收集器没有实现或者没有完整实现方法区的垃圾收集,比如ZGC。相比于新生代70%-99%的回收率,方法区的垃圾回收的性价比也很低。
方法区的垃圾回收主要包含两个部分,废弃的常量与不在使用的类型。

  • 废弃的常量:其与堆中对象的收集比较相似,当没有任何地方引用到常量池中的常量时,该常量就会被回收。比如字面量,类、方法、字段的符号引用。
  • 不再使用的类型:判断类型不再使用的条件比较苛刻,必须同时满足以下三个条件。
  1. 该类的所有对象实例已被回收。
  2. 该类的类加载器已被回收。这个条件除非是一些精心设计的替换类加载器的场景,比如JSP的重加载等,否则很难达成。
  3. 该类对应的Class对象没有在任何对方被引用。

什么时候回收

对象分配策略

对象一般来说在堆上分配,有一种特殊情况,在即时编译的逃逸分析过程中,当对象不能逃逸出方法时,可能会进行标量替换,让对象直接在栈上分配。堆中的分配策略如下:

  • 优先在Eden区分配,当Eden区没有足够的空间时,会发起一次minor GC。
  • 大对象直接进入老年代,大对象指的是需要大量连续空间的对象,最典型的是长字符串或者大数组。
  • 长期存活的对象将进入老年代,虚拟机给每个对象定义了一个年龄,存储在对象头中。当对象每经过一次minor GC后存活,该年龄就+1。由于存储年龄只用了4个bit,所以对象的年龄最大为15,默认也是15.
  • 动态对象年龄判定,如果survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,那么年龄大于或者等于该年龄的对象就可以直接进入老年代。
  • 空间分配担保,在发生Minor GC之前 , 虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间 , 如果这个条件成立 , 那这 一 次Minor GC可以确保是安全的 。 如果不成立 , 则虚拟机会先查看 -XX: HandlePromotionFailure参数的设置值是否允许担保失(Handle Promotion Failure) ; 如果允许 , 那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小 , 如果大于 , 将尝试进行 一 次Minor GC , 尽管这次Minor GC是有风险的; 如果小于 , 或者 -XX:HandlePromotionFailure设置不允许冒险 , 那这时就要改为进行 一 次Full GC 。

GC时机

通常在两种情况下触发GC,程序调用System.gc方法时,等于程序发了要GC的通知,但不一定会发起GC。二是由系统自身来决定触发时机。
Minor GC触发条件:Eden区内存不够时。
Full GC触发条件:调用System.gc时。老年代空间不足时。方法区空间不足时。空间分配担保失败时。空间分配担保时,通过Minor GC进入老年代的平均大小大于老年代可用内存时。

如何回收

分代收集理论

  • 弱分代假说:绝大多数对象都是朝生夕灭的。
  • 强分代假说:熬过越多垃圾收集过程的对象越难被回收。
  • 跨代引用假说:跨代引用相对于同代引用只占极少数。

根据两条分代假说,奠定了分代收集的理论基础。收集器应该将JAVA堆划分为不同的区域,任何根据年龄分配到不同的区域存储。可以将那绝大多数朝生夕灭的对象放入一个区域,将其他难以消亡的对象放入一个区域。也就是新生代与老年代,可以分别以不同的频率来进行两个区域的垃圾回收。
当进行新生代的垃圾回收时,新生代的对象是有可能被老年代的对象所引用的。根据跨代引用假说,我们不必扫描整个老年代来判断其是否有引用新生代的对象,我们只需要在新生代上设立一个全局的数据结构来存储有引用新生代的老年代对象-记忆集(Rememberd Set),这个结构吧老年代分为若干小块,标识出老年代哪块内存存在跨代引用,此后发生Minor GC时,只有包含了跨代引用的内存里的对象才会加入到GC Roots中。
这种方法虽然要在改变引用关系时维护,会增加一些运行时开销,但总体还是比较划算的。

收集类型

  • 部分收集(Partial GC):指目标不是完整收集整个堆的垃圾收集。包括以下几种:
  1. 新生代收集(Minor GC):目标只是新生代的垃圾收集。
  2. 老年代收集(Major GC/Old GC):目标只是老年代的垃圾收集,目前只有CMS收集器有此行为。
  3. 混合收集(Mixed GC):目标是收集整个新生代与部分老年代的垃圾收集。目前只有G1收集器有此行为。
  • 整堆收集(Full GC):收集整个堆与方法区的垃圾收集。

垃圾收集算法

根据垃圾标记的不同,垃圾收集算法也可以分为两种:引用垃圾收集,追踪式垃圾收集。由于主流虚拟机都是追踪式垃圾收集,这里只讨论此种收集算法。

1.标记-清除算法

分为两个步骤,标记,清除。先标记出需要回收的对象,然后清除掉标记的对象,也可以反过来,标记存活的对象,回收未被标记的对象。
该算法是最基本的回收算法,后面的算法都是基于该算法的缺点改进而来。
该算法有两个缺点,一是执行效率不稳定,面对大量需要被回收的对象,就必须进行大量的标记和清除的动作。这个使得该算法不便用于新生代收集。二是清除之后会产生大量的内存碎片。
基于低延迟的CMS算法是采用的标记-清除算法,但在特定情况下,会进行一次标记-整理算法。

2.复制算法

全称标记-复制算法,为了解决标记-清除算法效率问题,它可将内存分为大小相同的两块,每次只使用其中一块,收集时标记存活的对象,然后复制到另一块内存中。
根据统计,98%的对象是朝生夕灭的,所以可以不必按照1:1的比例分配内存。
1989年提出了一种更优化的半区复制分代策略-Appel式回收。把新生代分为一块较大的Eden区,两块较小的Survivor区,每次分配空间只使用Eden区和其中一块Survivor区,发生垃圾收集时,将存活的对象放到另一块Survivor区(当Survivor区放不下时,需要依赖其他内存区域进行分配担保,一般是老年代),然后直接清理掉Eden区和已经用过的Survivor区。Hotspot虚拟机默认的Eden与Survivor比例是8:1。

3.标记-整理算法

由于复制算法需要额外的空间并且需要分配担保,且在对象存活率较高时,效率会被降低,故而老年代不适合用此算法。
为了解决标记-清除算法的内存碎片问题,提出了标记-整理算法。第一步与标记-清除算法一样,但后续步骤是让所有存活对象向一端移动,然后清理掉边界以外的内存。
移动存活对象需要更新所有引用这些存活对象的地方,这是一个很大的开销。并且这个对象移动操作必须Stop The World(暂停所有用户线程)。
但如果不考虑移动对象的话,就必须在分配对象时采取额外的措施来解决内存碎片产生的问题,譬如通过分区空闲分配列表。
基于以上,是否移动对象都存在弊端,移动对象时垃圾收集时会更复杂,不移动时,内存分配时更复杂。
所以关注总吞吐量的Parallel Scavenge收集器采用标记-整理算法,虽然收集时花销较大,但整体吞吐量还是更高的。
关注低延迟的CMS采用标记-清除算法。

算法实现细节

根节点枚举

可达性分析筛选不被使用的对象时,需要枚举根节点,这个过程是需要暂停所有用户线程的。固定作为GC Roots的节点主要在全局引用与执行上下文,以现在Java应用的大小,光是方法区的大小就常常有成百上千兆,其中的类、常量就更是数不胜数,若要逐个检查以这里为起源的引用,肯定要消耗不少时间。
由于目前主流Java虚拟机采用的是准确式垃圾收集,所以当用户线程停顿时,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应该能直接得到哪些地方存放着对象引用。在Hotspot中,采用的OopMap的数据结构来实现的。一旦类加载完成,虚拟机就会将对象内什么偏移量上是什么类型的数据给计算出来。在即时编译过程中,也会在特定的位置记录下栈中和寄存器中哪些位置是引用。这样收集器在扫描时就可以直接得知这些消息了,并不需要真正一个不漏的从方法区等GC Roots开始查找。

全局安全点

在OopMap的协助下,可以快速准确地完成根节点枚举,但还有一个问题,引用关系是会不断变化的,也就是OopMap的内容可能会不断变化,如果为每一条语句生成对应的OopMap,那么需要大量的额外空间。
实际上,Hotspot并没有为每条语句生成OopMap,只是在特定的位置记录了这些消息,这些位置被称为安全点。
有了这个设定,用户程序并不是在代码任何位置都能停顿下来开始垃圾收集,而是强制必须执行到安全点后才能暂停。
因此安全点的选定不能太少让垃圾收集器等待过长,也不能太多以至于增加内存开销。安全点的选定基本是以‘是否能让程序长时间执行’的标准进行的,因为每条指令的执行时间都非常短暂,程序不太可能因为指令流太长而长时间执行,长时间执行指的是指令系列的复用,比如方法调用,循坏跳转等。
接下来要考虑是问题是,如何在垃圾收集发生时让所有线程跑到最近的安全点。一般采用两种方案:

  • 抢先式中断:系统把所有线程全部中断,如果发现有线程不在安全点,就恢复这条线程,过一会儿再重新中断,直到跑到安全点。
  • 主动式中断:垃圾收集器要收集时设置一个标识,每个线程会不断轮询这个标识,当发现要进行垃圾收集时,自己会在最近的安全点主动中断挂起。
安全区域

安全点似乎已经完美解决如何停顿用户线程,但还有一种情况,当线程处于Sleep或者Blocked状态时,线程就无法自己走到安全点起中断挂起自己,虚拟机也不可能长时间等待线程被重新唤醒。对于这种情况,就必须引入安全区域来解决。
安全区域是指某一段代码片段中,引用关系不会发生变化。
当用户线程执行到安全区域里面时,会标识自己已经进入安全区域,那么这段时间里虚拟机发起垃圾收集时就不必管这些线程。当线程要离开安全区域时,会检查虚拟机是否已经完成Stop The World,如果已经完成,那就当没事发生,如果没有完成,那么它就一直等待,直到收到可以离开的信号为止。

记忆集与卡表

对于部分区域的垃圾收集,比如minorGC,存在有跨代引用,所以在枚举GC Roots时,应当把这些引用也加入其中。但随之而来的一个问题是,如何判断哪些引用是跨代引用。
这里引入一个概念:记忆集-一种记录从非收集区域指向收集区域的指针集合的数据结构。
我们能想到最简单的实现,用一个对象集合来表示记忆集,但引用是可能频繁变动的,这种方式无论在空间占用还是维护成本都十分高昂。
由于记忆集的作用只是判断某一块非收集区域是否存在指向收集区域的指针,所以可以选择粗旷的粒度来节省记忆集的存储和维护成本。一般分为三个记录精度:

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

其中卡精度指的是用一种称为卡表的数据结构,也是目前最常用的一种记忆集实现方式,甚至很多地方直接将其与记忆集混为一谈。
HotSpot虚拟机默认的卡表标记逻辑如下:

CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE每个元素都对应着其标识的内存区域中的一块特定大小的内存块,这个内存块被称为卡页。一般为2的n次幂,如上为2的9次幂,即512字节。
一个卡页的内存中通常不止一个对象,主要卡页中有一个对象有字段含有跨代引用,那么将对应卡表的数组元素的标识设为1,称这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,会将标识为1的卡页内存块一起加入GC Roots的扫描。

写屏障

如何维护卡表状态就是下一个要解决的问题了,在HotSpot虚拟机中,通过写屏障来维护卡表信息,但注意要将这里的写屏障和解决并发乱序执行的 内存屏障 区分开来。这里的写屏障可以看作是虚拟机对‘引用类型字段赋值’的AOP切面,在赋值时会有一个Arroud通知,赋值前的动作叫做写前屏障,赋值后的叫做写后屏障,但直至G1收集器出现前,其他收集器只用到了写后屏障。写后屏障更新卡表信息简化逻辑如下:

void oop_field_store(oop* field, oop new_value){
//引用字段赋值操作
*field = new_value;

//写后屏障,完成卡表更新操作
post_write_barrier(field,new_value);
}

这样每次只要对引用进行更新,就会有额外的开销,不过对于Minor GC时扫描整个老年代比,还是低得多的。
除了写屏障的开销外,还存在伪共享的问题,伪共享指的是多个线程同时更新独立的变量,但这些变量在同一缓存行时,就会彼此影响(写回,无效化,同步)而导致性能降低。一种简单的解决方法是,先检查卡表标记,只有当卡表元素为脏时,才会将其改成0。

并发的可达性分析

在oopMap的加持下,让本就数量有限的GC Roots扫描起来更加快速,且不会由堆的增大而降低效率。但是在分析引用链的过程中,其时间还是会随堆的增大而增加,这时如果还是stop-the-world的话,那将变得难以忍受。
想解决或者降低用户线程的停顿 , 就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历? 为了能解释清楚这个问题 , 我们引入三色标记(Tri-color Marking)作为工具来辅助推导 , 把遍历对象图过程中遇到的对象 , 按照“ 是否访问过”这个条件标记成以下三种颜色:

  • 白色: 表示对象尚未被垃圾收集器访问过 。 显然在可达性分析刚刚开始的阶段 , 所有的对象都是白色的 , 若在分析结束的阶段 , 仍然是白色的对象 , 即代表不可达。
  • 黑色: 表示对象已经被垃圾收集器访问过 , 且这个对象的所有引用都已经扫描过 。 黑色的对象代表已经扫描过 , 它是安全存活的 , 如果有其他对象引用指向了黑色对象 , 无须重新扫描 一遍 。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象 。
  • 灰色: 表示对象已经被垃圾收集器访问过 , 但这个对象上至少存在一个引用还没有被扫描过 。

可以想象可达性分析的扫描过程,就是对象图上一股灰色波纹从黑向白的推导过程。最开始只有gc roots的对象是黑色的,GC Roots所引用的对象为灰色。现在就可以分析一下该过程与用户线程并发可能导致的问题。

  • 误放:已经被扫描过的黑色对象,被重新指向了引用链不可达的白色对象,或者被删除了到黑色对象的引用。导致该对象本是应该被回收,但是由于已经被标记成为了黑色对象而没被回收的。这种情况会产生‘浮动垃圾’,但是可以在下一次垃圾收集时回收,可以接受。
  • 错杀:一个在引用链上白色对象,被删除了灰色对象或者白色对象对其的引用,并重新建立黑色对象对其的引用;或者黑色对象指向一个新建的对象。由于黑色对象已经被扫描,刚刚加入引用链的对象就不会出现扫描成为黑色对象,从而导致不是垃圾的对象被回收。这种情况时不能被接受的。

第二种情况就需要进行特殊处理,解决办法大致分为两种:

  • 增量更新:每当有黑色对象改变引用关系,那么就将其标记,等扫描完成后将其作为根再次扫描一遍。也可以当作将其变为了灰色对象。
  • 原始快照:当删除灰色或者白色对象的引用时,将其记录下来,后续再以记录下来的原先的引用关系再进行一次扫描。这种方式会产生一部分浮动垃圾。

CMS收集器使用的是增量更新,G1使用的是原始快照。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值