GC

1. 内存分配与回收原则

1.1 对象优先在Eden区进行分配

当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。若无法存入survivor区,则通过分配担保机制把新生代的对象提前转移到老年代中去

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

1.2 大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
大对象直接进入老年代主要是为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

1.3 长期存活的对象进入老年代

大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。
对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

1.4 GC的分类

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

  • 部分收集 (Partial GC):
    新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
    老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
    混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
  • 整堆收集 (Full GC):收集整个 Java 堆和方法区。

2. 如何判断对象可以被回收(死亡对象的判断)

2.1 引用计数法

通过调用对象的次数计数,调用了一次+1,调用了两次+2;如果失去引用了,计数器-1;
弊端:循环引用可能导致计数次数无法归零,进而导致内存泄漏。

2.2 可达性分析算法

先确立一个根对象(GC Root),看有没有对象和根对象直接或者间接的引用,如果有,那就是不能被垃圾回收的对象。
可作为GC Root对象的有:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

2.3 四种引用

2.3.1 强引用

沿着根对象能找到的A对象,那么A对象称为被强引用。仅当强引用与A对象断开时,可被回收。

2.3.2 软引用

SoftRenference<byte[]> ref = new SoftRenference<>(new byte[])
没有被强引用所引用,垃圾回收发生时都有可能被回收。
当发生过一次垃圾回收且内存仍然不够时,会回收被软引用引用的对象
软引用一般适用于一些非必要的场景,比如说网页图片,没有必要一直占着内存空间,等到需要的时候再加载就可以了。

  • 具体案例如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public static void soft() {
    // list --> SoftReference --> byte[]

    List<SoftReference<byte[]>> list = new ArrayList<>();
    for (int i = 0; i < 5; i++) {
    SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
    System.out.println(ref.get());
    list.add(ref);
    System.out.println(list.size());

    }
    System.out.println("循环结束:" + list.size());
    for (SoftReference<byte[]> ref : list) {
    System.out.println(ref.get());
    }
    }
    输出结果:

    [B@330bedb4
    1
    [B@2503dbd3
    2
    [B@4b67cf4d
    3
    [GC (Allocation Failure) [PSYoungGen: 2162K->488K(6144K)] 14450K->13074K(19968K), 0.0019161 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
    内存不足,进行一次垃圾回收。
    [B@7ea987ac
    4
    [GC (Allocation Failure) –[PSYoungGen: 4696K->4696K(6144K)] 17282K->17290K(19968K), 0.0006865 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
    [Full GC (Ergonomics) [PSYoungGen: 4696K->4535K(6144K)] [ParOldGen: 12594K->12545K(13824K)] 17290K->17080K(19968K), [Metaspace: 3373K->3373K(1056768K)], 0.> > 0045075 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
    [GC (Allocation Failure) –[PSYoungGen: 4535K->4535K(6144K)] 17080K->17096K(19968K), 0.0006433 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
    [Full GC (Allocation Failure) [PSYoungGen: 4535K->0K(6144K)] [ParOldGen: 12561K->677K(8704K)] 17096K->677K(14848K), [Metaspace: 3373K->3373K(1056768K)], 0.> > 0059614 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
    进行一次垃圾回收之后内存仍然不足,触发软引用机制,将软引用创建的对象所垃圾回收释放空间。
    [B@12a3a380
    5
    循环结束:5
    null
    null
    null
    null
    [B@12a3a380

2.3.3 引用队列

软弱引用无引用对象时,自动进入引用队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private static final int _4MB = 4 * 1024 * 1024;

public static void main(String[] args) {
List<SoftReference<byte[]>> list = new ArrayList<>();

// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();

for (int i = 0; i < 5; i++) {
// 关联了引用队列, 当软引用所关联的 byte[]被回收时,软引用自己会加入到 queue 中去
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}

// 从队列中获取无用的 软引用对象,并移除
Reference<? extends byte[]> poll = queue.poll();
while( poll != null) {
list.remove(poll);
poll = queue.poll();
}

System.out.println("===========================");
for (SoftReference<byte[]> reference : list) {
System.out.println(reference.get());
}

}

输出:

[B@4aa8f0b4 (最后结果)

2.3.4 弱引用

只要当发生了垃圾回收,被弱引用引用的对象会被回收。
不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
类似于软引用。

2.3.5 虚引用

必须配合引用队列来使用。创建时即关联引用队列。缓存时会留下直接内存,当StringBuffer对象被回收时,他的直接内存没有被回收掉,此时虚引用对象进入引用队列,调用线程清理直接内存。
虚引用主要用来跟踪对象被垃圾回收的活动。起到触发器的作用,当虚引用的对象意识到自己快要被回收了,会做出一些相应的程序。

2.3.6 终结器引用

finallize()将要被垃圾回收时,将终结器引用放入引用队列,由finallizeHandler去由终结器引用的对象去销毁对象
所以说当对象不可达的时候不会被立刻销毁,而要经历两次标记之后才确定要被销毁。
缺点:finallizeHandler优先级很低,且销毁流程漫长,需要两次GC才能销毁完成,易造成内存泄漏 。

3. 三种垃圾收集算法

3.1 标记——清除算法(Mark-Clear)

  • 流程:首先标记出需要垃圾回收的对象,标记完成后,统一回收被标记的对象。也可以相反。
  • 缺点:
    执行效率不稳定,随着对象增长降低效率不断变化;
    内存空间碎片化,需要分配较大内存时无法找到足够连续内存不得不提前进行一次垃圾回收操作。

3.2 标记——复制算法(Mark-Copy)

  • 流程:半区复制。 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,这一块的内存用完了,就将存活着的对象复制到另一块的上面,然后再把已使用的内存空间一次性清理掉。
  • 优点:解决了碎片空间的问题,只需要移动栈顶指针,按顺序分配即可
  • 缺点:内存缩小到原来的一半,造成空间浪费

拓展:APPel式回收
把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用一块Eden和一块Survivor。发生垃圾收集时,将Eden和Survivor仍然存活的对象一次性复制到另一块Survivor中,直接清理掉原来的两块对象。

理论依据:HotSpot默认Eden和Survivor的大小比为8:1,即新生代内存空间占总内存空间的90%,相比于标记复制算法节约了巨大的内存空间,而“朝生夕灭”理论认为,新生代中有98%的对象熬不过第一轮收集,所以内存空间时绰绰有余的。

3.3 标记——整理算法(Mark-Compact)

  • 流程:标记算法和前两者一样,而在回收的时候不同于前者,是先让所有存活的对象往内存空间的一侧进行移动,然后直接清理掉边界之外的内存。
  • 缺点:移动更新对象任务量及其庞大,需要全程暂停用户应用程序才能进行。易形成Stop The World现象。
  • 移动和不移动的利弊比较:
    是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂;从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算;即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要 高得多,这部分的耗时增加,总吞吐量仍然是下降的

4. 垃圾分代回收

(面试题)原因:
因为将对象分代可以根据各个年代的特点选择合适的垃圾分配机制。
比如在新生代,大部分(约98%)的对象都会被回收,那么这时候就可以使用标记——复制算法,只需要复制少量的存活对象,节约内存成本。而在老年代对象存活较多的情况下,也没有足够多的空间为其进行分配担保,所以使用“标记——清除”或者“标记——整理”分类方法。

4.1 分代垃圾回收机制

  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发 minor gc,伊甸园和 from 存活的对象使用 copy 复制到 to 中,存活的对象年龄加 1并且交换 from to。
  • minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit)。
  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,STW的时间更长。
  • 当新生代内存无法容纳对象大小,直接晋升为老年代
  • 线程中出现OOM异常时,他占据的内存资源会全部释放掉,不会影响其他线程的运行

4.2 VM相关参数

5. 垃圾回收器

serial: 串行 单线程,一个方法执行完下个方法才能够执行
parallel : 并行 多线程,可以同时执行垃圾回收, 但其他无法方法运行 STW 相当于所有线程都归垃圾回收所有
concurrent : 并发 多线程,其他方法和垃圾回收可以同时执行,但是只能逐个进行垃圾回收,相当于平等的线程分配给每一个方法

5.1 串行垃圾回收器(Serial/ Serial Old)

单线程。适用于堆内存较少的情况,适合个人电脑
-XX:+UseSerialGC = Serial + SerialOld

新生代用“标记——复制”算法,老年代用“标记——整理”算法

5.2 吞吐量优先(Parallel/ Parallel Old)

多线程,堆内存较大,多核CPU
单位时间内,完成STW次数越少(STW的时间占总用户时间)
关注吞吐量
XX:+UseparalellGC
-XX:ParallelGCThreads:n :允许同时允许的线程数量
-XX:+UseAdaptSizePolicy :自适应调整新生代的大小
-XX:GCTimeRadio:radio : 最多允许垃圾回收时间占总线程时间1/(1+radio),希望堆大,关注的是吞吐量
-XX:MaxGCPauseMillis=is : default是200ms,表示最长垃圾回收时间,希望堆小,与上一个冲突,关注的是响应时间

5.2 响应时间优先(CMS Concurrent Marking Sweep)

多线程,堆内存较大,多核CPU
注重STW时间的长度(单独只考虑STW的时间)

  • 初始标记: 需要STW,但仅标记与GC Root关联的对象,速度快
  • 并发标记: 从GC Root直接关联对象开始遍历整个对象图, 耗时长,但并发运行
  • 重新标记: 需要STW, 修正因并发导致的对象标记变动
  • 并发清除: 清理标记对象
    只有在初始标记和重新标记时候才会STW
    三个缺点:
  • 对CPU占用低,只占用了一部分进行GC, 但拖慢了用户进行的CPU量, 对吞吐量有影响.
  • 并发过程中产生的浮动垃圾难以处理
  • 使用“标记——清除”算法大量空间碎片的产生
    -XX:+UseConcMarkSweepGC
    -XX:ConcGCThreads=thread:运用于垃圾回收的线程,一般是总线程数的1/4
    -XX:CMSInitiatingOccupancyFraction : percent :触发垃圾回收阈值
    剩下内存空间留给其他进行的进程以及浮动垃圾; 阈值过高可能导致并发失败

6. G1

难点,一款很多知识点的垃圾回收器,牵涉到很多新概念

6.1 基础概念的了解

6.1.1 写屏障

可以看成是虚拟机层面对”引用类型字段赋值”这个动作的AOP切面, 在引用对象赋值的时候会形成一个环形,供程序执行额外的操作,而我们解决这种想要在编译场景中赋值的操作,就可以使用写屏障这种在机械码方面操作的手段,简单的来说,写屏障就是负责对在编译阶段中产生改变内容的处理的.

6.1.2 记忆集和卡表

这两者的出现是为了解决对象跨代引用所带来的问题的.跨代引用会导致老年代向新生代的引用难以通过只扫描新生代的对象去识别, 而如果要进行对老年代和新生代进行同时扫描的话, 那么STW的时间会变长, 性能较差.而记忆集是一种抽象的数据结构, 主要是用于记录非搜集区域指向搜集区域的指针集合的抽象数据结构.其在G1垃圾处理器中被安排在新生代中.而卡表是记忆集的一种具体实现.卡表主要运用在老年代, 当有一个老年代对象引用了新生代的时候, 卡表就会在对应的数组元素值标记为1, 表示这个元素为脏.之后通过写屏障在引用对象的行为发生时进行标记, 之后在新生代垃圾回收扫描时,这个对象就会被视为活对象了.

6.2 三色标记算法

6.2.1 基本概念

这是垃圾回收在新生代回收时根据GC Root的遍历所进行标记的一种算法.具体实现为将对象标为黑灰白三种对象, 其划分标准如下:

  • 黑: 已经被垃圾回收器访问过, 其所有引用已经全部被扫描过了, 保证其是安全存活的, 且不会再进行扫描;
  • 灰: 被垃圾回收器访问过, 但是他身上的至少一个引用还没有被垃圾回收器访问, 是扫描引用他的灰色对象之后形成的;
  • 白: 没有被垃圾回收器访问过, 开始的时候所有对象都是白色的, 当垃圾回收扫描完成时, 还是白色的对象说明对象是不可达的.
    扫描完成的标志: 没有灰色对象的存在

6.2.2 遍历过程

1.初始时,全部对象都是白色的
2.GC Roots直接引用的对象变成灰色
3.从灰色集合中获取元素:
3.1 将本对象直接引用的对象标记为灰色
3.2 将本对象标记为黑色
4.重复步骤3,直到灰色的对象集合变为空
5.结束后,仍然被标记为白色的对象就是不可达对象,视为垃圾对象

6.2.3 存在问题

标记算法存在的问题都是基于并发执行下产生的, 因为用户线程在进行的时候, 对象的调用总是会进行, 而同时进行对对象的操作有可能导致引用的错误.

  1. 漏删
    把原本死亡的对象标记为存活, 会发生的情况为因为A到B的引用,B被标灰了,但之后引用就断开了, 此时B没有对象引用他, 但是他的标记是灰色, 不会被回收, 这种我们把它叫做浮动垃圾. 这种解决方案十分简单, 下次扫描的时候, 因为没有对象引用他, 他就会自动被删除的.
  2. 多删
    原本是黑色的对象被标记为白色.具体造成原因为如下两点:
  • 插入了一条或者多条黑色对象对白色对象的引用
  • 删除了所有灰色对象对白色对象的直接或者间接引用
    这种问题很大, 因为本来程序运行所需要的一个对象被销毁了, 会导致程序的异常, 那么垃圾回收器给出了我们两种如下解决方案.

6.2.4 解决方案

  • 增量更新(Increment update)
    破坏的是第一个条件, 他会将这个新插入的引用记录下来, 扫描结束之后会以黑色对象为根重新进行扫描,可以理解为这个技术可以使得产生的新引用的黑色节点变灰.其主要实现为写屏障; 主要用于CMS中
  • 原始快照(SnapShot At The Beginning)
    破坏的主要是第二个条件, 他会在断开连接时将断开连接前的一瞬间记录下来, 再将他放到一个独立的内存区域中, 扫描结束后, 重新以灰色节点为根重新进行扫描.

6.3 G1垃圾回收器的垃圾处理流程

6.3.1 以rigion为分区的回收范围改变

首先我们需要知道,G1相较于CMS包括之前的垃圾回收器做出了一个巨大的改变,就是垃圾回收区域的划分,以前分代收集理论是垃圾回收器的主流理论,而G1将其做了一个更新包装,其面向堆内任何部分来组成回收集(Cset),每一个回收集被叫做region区域,每一个区域中也可扮演为Eden,Survivor空间,每次回收的最小单元是一个region,而对于不同的region区域采取不同的策略,对于垃圾回收产生了更为优秀的结果。G1收集器会自动的去评估每个region区域的回收价值大小,根据-XX:MaxGCPauseMillis参数来确定回收策略,我们叫这种模式为Mixed GC。相比于之前,有两个优点,一个是变得更加灵活了,有回收策略了;另一个是不再需要连续了,内存空间的分配也会更加合理。
那么在我们了解了这种化整为零的内存空间分配策略后,我们需要知道整个G1回收器的垃圾回收流程:

6.3.2 Young GC

程序在运行的时候,随着对象不断创建,许多内存进行Eden区,当Eden区被占满时,会自动触发一次Young GC,此时会引起STW,此时G1涉及的对象只有Eden区和Survivor区,回收的内存进入Cset中进行回收;运用了复制算法,将存活单位复制到新的survivor区域.

  • 扫描根和rset, 此时会有一个小的短暂的STW;
  • 运用写屏障更新rset,此时所有跨代引用对象不会被视为垃圾;
  • 复制算法,实现Eden区和Survivor区的存活对象到新的Survivor区的转移
  • 处理软弱虚引用.

6.3.3 Young GC + Concurrent Marking

  • 首先进行一次young GC, 同时进行一次进行一次初始标记, 标记GC Root对象, 此时会发生STW;
  • 并发标记, 沿着GC Root遍历对象, 同时将所有跨代引用的标记变脏,此时属于并发标记, 不会STW; 同时也会进行SATB, 对漏标的对象进行了处理;
  • 再次标记, 处理原来SATB的对象;
  • 独占清理, 对每一块内存区域进行排序, 为下阶段Mixed GC做准备;
  • 清理本阶段内存.

6.3.4 Mixed GC

此时会根据-XX:MaxGCPauseMillis实现清理策略, 在这个阶段会做三件事:

  • 新生代的垃圾回收: Eden区存活对象到survivor区; Survivor区存活对象到新的survivor区;
  • 晋升: Survivor区达到晋升阈值的对象晋升到老年代
  • 老年代的垃圾回收: 结合-XX:MaxGCPauseMillis和上一阶段的排序, 将部分老年代进行回收;
    这个阶段也说明了Garbage First这个垃圾回收器名字的由来, 就是会在这个环节优先回收占用内存较多的区域.

    -XX:MaxGCPauseMillis: 最大暂停时间过短, 回收速度小于产生速度, 触发单线程的Full GC; 正常情况, 回收速度大于产生速度, Concurrent mark;
    -XX:G1MixedGCLiveThresholdPercent: 回收阈值,默认为65%; 达到之后才会触发回收, 过小意味着存活对象多,复制时间浪费多, STW时间长.

6.4 新增功能

6.4.1 字符串去重

  • 优点:节省大量内存
  • 缺点:略微多占用了 cpu 时间,新生代回收时间略微增加
    -XX:+UseStringDeduplication
    将所有新分配的字符串放入一个队列; 当新生代回收时,G1并发检查是否有字符串重复; 如果它们值一样,让它们引用同一个 char[]

    注意: 与 String.intern() 不一样; String.intern() 关注的是字符串对象; 而字符串去重关注的是 char[]; 在 JVM 内部,使用了不同的字符串表

6.4.2 类卸载

所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类
-XX:+ClassUnloadingWithConcurrentMark 默认启用

6.4.3 巨型对象管理

一个对象大于 region 的一半时,称之为巨型对象
G1 不会对巨型对象进行拷贝
回收时被优先考虑
G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为0 的巨型对象就可以在新生代垃圾回收时处理掉

7. ZGC收集器

是一款低延迟垃圾收集器, 希望在对吞吐量影响不大的前提下, 把垃圾收集的停顿时间缩短在10ms以内。
其内存布局也是和G1一样划分为若干Region区,但其具有动态的容量大小和动态创建和销毁的特征,可以分为如下三种容量:

  • 大: 不固定可以动态变换,但是一定为2MB的整数倍,不会被重分配。
  • 中: 容量固定为32MB, 用来存储256KB ~ 4MB的对象
  • 小: 容量固定为2MB, 用来存储0 ~ 256KB的对象

7.1 ZGC的垃圾收集流程

7.1.1 并发标记(Concurrent Marking)

只会在开始标记GC Root的时候有小小的停顿,剩下的标记活动都是并发运行的。和G1需要STW进行三色算法不同,ZGC的标记位置是在指针上的,所以可以省略去遍历对象图的流程

7.1.2 并发预备重分配(Concurrent Perpare for Relocation)

通过并发标记得出的结论查询到底有哪些垃圾是需要回收的,然后将需要回收的Region区组成重分配集(Relocation Set)。因为ZGC不区分新生代老年代的特点,所以该阶段的扫描是针对全堆的,但同时相较于G1也省去了记忆集的维护成本。

ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,而不是说回收的行为针对这个集合里面的region进行。

7.1.3 并发重分配(Concurrent Relocation)

是ZGC垃圾回收的核心过程。他将重分配集中的存活对象转移到新的Region中,并在重分配集中建立一个转发表(Forward Table),用来标记重分配区中旧的Region对象到新的Region对象的转发关系。当用户访问了重分配集中的对象,那么就会被预置的内存屏障所截获,根据转发表的记录转发到新的对象上,并将引用更改到新的对象上,使其指向新的对象。这种行为被称为指针的“自愈”行为。
一旦region的存活对象都被复制完毕,那么这个region就可以进行下一轮的内存分配。但是转发表却得等到所有重分配集中的region对象复制完毕才可删除。

7.1.4 并发重映射(Concurrent Remap)

重映射指的是修正整个堆中指向重分配集中的旧对象的所有引用。可以合并到下一次的并发标记的流程进行,节约一次遍历对象图的操作。

7.2 ZGC解决并发算法问题的关键——染色指针

将少量标识信息存储在指针上,实现可达性分析,相较于G1的遍历对象图节约了许多时间的成本。
三个优点:

  1. 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能被释放和重用。指针的自愈行为使得它拥有即时性,而复制算法则是需要STW进行1:1的复制后才能够实现重用。
  2. 染色指针可以大幅度减少在垃圾收集过程中内存屏障的使用数量,内存屏障的功能之一就是记录对象引用的变动情况,而染色指针可以接替这一工作。
  3. 可以作为一种可拓展的存储结构用来记录,重定位过程的相关数据,日后可以进一步提升性能。日后标志位可用的提升可以使得其更有扩展性。