一、什么是分代回收
我们会把堆内存中的对象间隔一段时间做一次GC(即垃圾回收),但是堆内存很大一块,内存布局分为新生代和老年代、其对象的特点不一样,所以回收的策略也应该各不相同
对于“刚出生”的新对象,很多时候生命周期并不长,有的只是临时用一下,需要短期被GC的概率很高,多次GC周期后都留存下来的对象就是很难被回收的对象。根据这两类对象的特点,我们就分为新生代和老年代,并且采取不同的回收算法。
二、分代回收算法有哪些
复制算法
原始的复制算法(Copying)是这样的:
- 1、将内存按容量划分为大小相等的两块,每次只使用其中的一块。
- 2、当其中一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
带来的好处是:
- 1、实现简单,运行高效,
- 2、每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可
存在的弊端是:
- 1、内存的使用率缩小为原来的一半。
- 2、内存移动是必须实打实的移动(复制),所以对应的引用(直接指针)需要调整。
适用场景:
复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。
但是像 hotspot 这样的虚拟机大都对原生的复制算法进行了改进,因为它对内存空间的利用率不高,而且专门研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,所以改进后的复制回收策略叫做: Appel 式回收。
- 1、将新生代划分为一块较大的 Eden 区和两块较小的 Survivor 空间(你可以叫做 From 或者 To ) , HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1
- 2、每次使用 Eden 和其中一块 Survivor ,当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。
在这样的算法下
- 1、每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被 “浪费”
- 2、当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(老年代)进行分配担保( Handle Promotion )。
标记-清除算法
标记-清除(Mark-Sweep)算法分为“标记”和“清除”两个阶段:
- 1、首先扫描所有对象,标记出需要回收的对象,
- 2、在标记完成后扫描并回收所有被标记的对象,故需要两次扫描
该算法特点如下:
- 1、回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低,所以该算法不适合新生代。
- 2、它的主要问题是在标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。
- 3、标记清除算法适用于老年代。
标记-整理算法
算法逻辑如下:
- 1、首先标记出所有需要回收的对象,
- 2、在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,
- 3、然后直接清理掉端边界以外的内存。
该算法特点:
- 1、标记整理需要扫描两遍
- 2、标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新(直接指针需要调整)。
- 3、标记整理算法不会产生内存碎片,但是效率偏低。
- 4、标记整理算法适用于老年代。
因此,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。
三、什么是跨代引用,如何解决?
什么是跨代引用
分代回收也并不是简单划分一下内存区域这么简单,因为对象不是孤立的,对象之间存在跨代引用,譬如:现在要在新生代进行回收,但新生代的对象极有可能被老年代对象所引用,那为了找到这些可能存活的对象,不得不在既定的 GC Roots 之外,再遍历整个老年代对象确保可达性分析结果的正确性。反过来回收老年代也是一样。这样无疑带来了性能负担。
如何解决跨代引用
跨代引用相对于同代引用来说仅仅占少数,正是因为只占少数,所以不应该为了为了这些少量的跨代引用而区扫描整个老年代,也不能浪费空间让每个对象都记录它是否存在跨代引用。
所以为了解决这个问题只需要在新生代建立一个全局的数据结构叫做:记忆集( Remembered Set ),这个结构把老年代划分成若干小块内存,并标识哪块内存存在跨代引用,后续新生代发生 gc 时,只有包含了跨代引用的小内存区域才会被加入到 GC Roots 进行扫描;
当然这种方法需要在对象改变引用关系的时候维护记忆集中数据的正确性。这种做法相比垃圾收集时扫描整个老年代来说仍然是划算的
四、记忆集Remmber Set有了解过吗?卡表是什么
前面讲到,为了解决跨代引用带来的问题,垃圾收集器在新生代建立了一个叫做:记忆集的数据结构存储老年代哪些区域存在跨代引用,以便在根节点扫描时将这些老年代区域加入 GC Roots 的扫描范围,这样避免将整个老年代都加入 GC Roots 的扫描范围。
当然跨代引用的问题并非只在回收新生代才有,回收老年代也是一样的,所以需要更进一步理解记忆集的原理和实现方式。
记忆集定义:是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
记忆集的实现:最常见的实现方式是通过卡表( Card Table )的方式去实现,卡表最简单的形式是一个字节数组( hotspot ),如下:
CARD_TABLE[this address >> 9 ] = 0
- 1、字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作:卡页( Card Page ),卡页大小一般是2的N次幂, hotspot 中是2的9次幂(地址右移9位),即512字节。
- 2、如果卡表标识的起始地址是:0x0000,那数组的0,1,2号元素,分别对应的地址范围是:0x0000~ox01ff,0x0200~0x03ff,0x0400~0x05ff
- 3、一个卡页的内存中通常包含不止一个对象,只要卡页内存中有一个或多个对象的字段存在跨代引用指针,那就将卡表对应字节数组元素的值标识位1,称之为 Ditry ,没有则标识位0,垃圾收集器工作时只要筛查 CARD_TABLE 中为1的元素,就能轻易找到哪些卡页内存块中包含跨代引用,就把这些内存块加入到 GC Roots 的扫描范围内。