一、如何判断对象可以回收
1.1 引用计数算法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器加一,当引用失效时,计数器减一,当该对象的引用计数器为 0 时,我们认为该对象就不能被使用了。
效率高,但是无法解决循环引用的问题(即 A 引用 B,B 引用 A)。
1.2 可达性分析算法
以一个 GC Roots 为根节点,从这个节点往下搜索,搜索走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链时,就证明此对象是不可用的。
可作为 GC Roots 的对象有:虚拟机栈中引用的对象、本地方法栈中引用的对象、方法区中类的静态属性引用的对象、方法区中常量引用的对象。
1.3 四种引用
1.3.1 强引用
被 GC ROOT 引用的对象,比如 Object o = new Object(),只要强引用还在,JVM 就不会回收该对象。当强引用消失了,JVM 才会回收该对象。
1.3.2 软引用
还有用但非必须的对象,在 JVM 发生内存溢出之前,会对这些对象进行回收。
还可以配合引用队列一起来使用,即当软引用的对象被回收之后,那么软引用将被放到引用队列里面,因为软引用也是本身也是个对象,他们自身也要占用一部分内存,如果想对它占用的内存做进一步的释放那么就需要引用队列来找到它俩,然后做进一步的操作。
1.3.3 弱引用
不管 JVM 内存是否存在溢出的情况,只要扫描到它都会被回收。
还可以配合引用队列一起来使用,即当虚引用的对象被回收之后,那么虚引用将被放到引用队列里面,因为虚引用也是本身也是个对象,他们自身也要占用一部分内存,如果想对它占用的内存做进一步的释放那么就需要引用队列来找到它俩,然后做进一步的操作。
1.3.4 虚引用
必须配合引用队列来使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存。
1.3.5 终结器引用
必须配合引用队列来使用,无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize方法,第二次 GC 时才能回收被引用对象。
1.3.6 强引用示例
首先设置堆内存大小为 20 M,我们下面的程序循环 5 次,每次是 4M 的内存,应该会超出 jvm 内存,会报错,如下所示:
强引用是不会被回收的,内存直接就溢出了。
public class Test {
// 设置堆内存大小 -Xmx20m
public static final int _4MB = 4*1024*1024;
public static void main(String[] args) throws IOException {
List<byte[]> list = new ArrayList<>();
for(int i=0;i<5;i++) {
list.add(new byte[_4MB]);
}
}
}
1.3.7 软引用示例
首先设置堆内存大小为 20 M,List 不直接引用 byte 数组,而是在他们之间加了一个 SoftReference 软引用的对象,软引用的对象再间接的引用 byte 数组,代码如下所示:
public class Test {
// 设置堆内存大小 -Xmx20m
public static final int _4MB = 4*1024*1024;
public static void main(String[] args) throws IOException {
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());
}
}
}
可以看到程序是正常结束了,在第一次 for 循环中调用 get 方法是可以正常输出的,但是等循环结束了,我们再去循环一遍发现,数组的前 4 个元素都变成了 null,只剩下了最后一个,添加 GC 日志再次执行一遍,看下日志的输出:
-Xmx20m -XX:+PrintGCDetails -verbose:gc
从日志可以看到,从第四次开始首先触发了 minor gc ,发现没回收多少内存,然后又触发了 full gc,以此类推。
1.3.8 软引用配合引用队列
在 1.3.2 的时候我们说过,软引用还可以搭配引用队列一起使用,来清理没有用的软引用,比如下面打印的那四个 null ,其实就是没有用但还占用内存的软引用对象本身,可以通过引用队列来清除它,如下所示:
import java.io.IOException;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;
public class Test {
// 设置堆内存大小 -Xmx20m
public static final int _4MB = 4*1024*1024;
public static void main(String[] args) throws IOException {
List<SoftReference<byte[]>> list = new ArrayList<>();
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for (int i = 0; i < 5; i++) {
// 关联软引用参数和引用队列
// 当软引用关联的 byte 数组被回收时,那么软引用自身的这个对象就会被加入到这个引用队列当中去
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB],queue);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
// 通过 queue 获取无用的软引用对象,通过 queue.poll() 方法来获取,这个方法是获取最先放入队列的那个元素
Reference<? extends byte[]> poll = queue.poll();
// 只要是在这个 poll 里面的都是要回收的软引用对象
while(poll != null) {
// 在 list 里面移除掉无用的软引用对象
list.remove(poll);
// 继续在队列里获取下一个无用的对象
poll = queue.poll();
}
System.out.println("循环结束:" + list.size());
for (SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}
}
}
1.3.9 弱引用示例
首先设置堆内存大小为 20 M,List 不直接引用 byte 数组,而是在他们之间加了一个 WeakReference 软引用的对象,软引用的对象再间接的引用 byte 数组,代码如下所示:
public class Test{
private static final int _4MB = 4 * 1024 * 1024;
public static void main(String[] args) {
List<WeakReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB]);
list.add(ref);
for (WeakReference<byte[]> w : list) {
System.out.print(w.get()+" ");
}
System.out.println();
}
System.out.println("循环结束:" + list.size());
}
}
二、垃圾回收算法
2.1 标记清除算法 Mark Sweep
首先标记处所有需要回收的对象,然后统一回收所有标记的对象,速度快,但是会造成内存碎片。
把可用的内存占用的起始和结束的地址记录到空闲的地址列表里面就可以了,下次再分配内存的时候,就到空闲列表中去找,看看有没有一块内存可以存放这个对象。
2.2 标记整理算法 Mark Compact
把存活的对象往一端压缩(替换位置),然后清理掉可回收的对象,速度慢,但是没有内存碎片。
2.3 复制算法 Copying
将内存按容量划分为大小相等的两块,每次只使用其中的一块,当一块用完,就把还存活的对象复制到另外一块上,再把上一块内存清理干净,大多数的 JVM 都采用这种算法回收新生代。
将内存分为一块较大的 Eden 区和两块较小的 Survivor 区,每次使用 Eden 和其中一块 Survivor,HotSpot 默认的 Eden 和 Survivor 大小比例是 8:1,即每次只浪费10%
三、分代垃圾回收
3.1 分代回收
JVM 采用分代的垃圾回收算法,即把内存分为新生代和老年代,新生代采取复制算法,老年代采用标记压缩或标记算法。特点如下:
1、对象首先分配在 Eden 区域。
2、新生代空间不足时,触发 minor gc,Eden 和 from 存活的对象使用 copy 复制到 to 中,存活的对象年龄加 1 并且交换 from to。
3、minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
4、当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit)。
5、当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,STW 的时间更长。
3.2 JVM 相关参数
含义 | 参数 | |
堆初始大小 | -Xms | |
堆最大大小 | -Xmx 或 -XX:MaxHeapSize=size | |
新生代大小 | -Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size ) | |
幸存区比例(动态) | -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy | |
幸存区比例 | -XX:SurvivorRatio=ratio | |
晋升阈值 | -XX:MaxTenuringThreshold=threshold | |
晋升详情 | -XX:+PrintTenuringDistribution | |
GC详情 | -XX:+PrintGCDetails -verbose:gc | |
FullGC 前 MinorGC | -XX:+ScavengeBeforeFullGC |
3.3 GC 测试
首先运行一个空的 main 方法,看下内存的使用情况,记得需要配置 jvm 的相关参数,如下所示:
public class Test3 {
// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
// -Xms20M -Xmx20M:初始和最大的堆内存为 20M
// -Xmn10M:新生代设置内存为 10M
// -XX:+UseSerialGC:使用 SerialGC 垃圾回收器,因为 jdk1.8 下默认的垃圾回收器不是它
// -verbose:gc -XX:-ScavengeBeforeFullGC:打印 gc 的详情
public static void main(String[] args) {
}
}
Heap
# 新生代的相关信息,总共有9M多,我们明明设置了 10M,因为幸存区 to 一直要空着,是不能用的
# used 1147K 表示新生代使用了1M多
def new generation total 9216K, used 1147K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
# 分给 eden 区 8M 多,为什么用了 14%,是因为 JVM 要加载一些类
eden space 8192K, 14% used [0x00000000fec00000, 0x00000000fed1ef20, 0x00000000ff400000)
# 分给 from 1M 多
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
# 分给 to 1M 多
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
# 老年代的相关信息,还没有被使用
tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
# 元空间的相关信息
Metaspace used 2767K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 299K, capacity 386K, committed 512K, reserved 1048576K
3.4 大对象直接进入老年代
// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public class Test3 {
private static final int _8MB = 8 * 1024 * 1024;
// 放入一个 8M 的对象,他已经超过了我们 eden 区的总容量
// 直接就进入了老年代
// 不会触发垃圾回收
public static void main(String[] args) {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
}
}
# 没有触发 gc
Heap
# 新生代的内存容量没有发生变化
def new generation total 9216K, used 1147K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 14% used [0x00000000fec00000, 0x00000000fed1ef20, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
# 直接占用了老年代的内存空间
tenured generation total 10240K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 80% used [0x00000000ff600000, 0x00000000ffe00010, 0x00000000ffe00200, 0x0000000100000000)
Metaspace used 2768K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 299K, capacity 386K, committed 512K, reserved 1048576K
如果放入两个 8M 的对象会发生什么?直接就内存溢出了,因为新生代和老年代都放不下了。
// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public class Test3 {
private static final int _8MB = 8 * 1024 * 1024;
public static void main(String[] args) {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
}
}
如果子线程发生内存溢出,会不会影响我的主线程正常执行呢?答案是不会的,如下所示,程序目前还是没有结束。
// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public class Test3 {
private static final int _8MB = 8 * 1024 * 1024;
public static void main(String[] args) throws InterruptedException {
new Thread(() ->{
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
list.add(new byte[_8MB]);
}).start();
System.out.println("sleep");
Thread.sleep(10000000L);
}
}
四、垃圾回收器
4.1 串行
单线程运行,适合堆内存较小的个人电脑,cpu 核数多了也没用,因为就有一个线程。开启串行垃圾收集器语句如下所示。
# Serial 新生代,复制算法
# SerialOld 老年代,标记压缩算法
-XX:+UseSerialGC = Serial + SerialOld
垃圾回收线程在运行的时候,其他的业务线程都需要在一个安全点等待,等待垃圾回收完成之后才可以继续运行。只有一个垃圾回收线程在工作。
4.2 吞吐量优先
多线程运行,适合堆内存较大,需要多核 cpu 支持,让单位时间内,STW 的时间最短。垃圾回收时间占比最低,这样就称吞吐量高。开启并行垃圾收集器语句如下所示。
# jdk1.8 默认的并行的垃圾收集器
# UseParallelGC 新生代复制算法的垃圾收集器
# UseParallelOldGC 老年代使用标记整理的算法
# 这两个参数,配置一个就可以了,另外一个就会自动生效
-XX:+UseParallelGC ~ -XX:+UseParallelOldGC
假设现在的 CPU 是 4 核的,现在 jvm 内存不足了,需要进行垃圾回收,此时用户线程就会跑到一个安全点进行等待,垃圾回收器会开启 4 个线程进行垃圾回收,开启的线程的个数默认跟你 cpu 的核数有关,等到垃圾回收完成之后,再恢复用户线程继续执行。
垃圾回收的线程个数可以通过参数进行配置,如下
# 垃圾收集器开启垃圾收集线程的个数
-XX:ParallelGCThreads=n
# 自适应调整的策略,调整新生代的大小,就有可能不是 8:1:1 了
-XX:+UseAdaptiveSizePolicy
# ParallelGC 比较智能,可以根据你的一个设定目标来尝试去调整堆的大小,来达到你下面的两个目标
# 目标1:调整吞吐量,即垃圾回收的时间和总时间的一个占比,公式为 1/(1+ratio),ratio的默认值为99
# 意思就是垃圾回收的时间不能超过总时间的百分之一。假设工作了100分钟,就只能有1分钟进行垃圾回收,
# 如果达不到这个值,那么 ParallelGC 就会尝试去调整堆的大小,来达到这个目标,一般是增大堆,
# 增大之后垃圾回收就不频繁了。ratio 如果设置成 19 还是比较合理的。
-XX:GCTimeRatio=ratio
# 目标2:调整最大暂停毫秒数,默认为 200 ms,其实目标1和目标2是冲突的,因为为了实现目标2会将堆变小
# 应该根据实际情况合理的配置目标1和目标2
-XX:MaxGCPauseMillis=ms
4.3 响应时间优先
多线程运行,适合堆内存较大,需要多核 cpu 支持,尽可能让单次 STW 的时间最短。
# jdk 1.8
# 基于标记清除算法的老年代垃圾回收器,并且是并发的
# 和他配合的是 UseParNewGC 新生代复制算法的垃圾回收器
# 当发生并发失败时,它会采取一个补救的措施,让老年代的 CMS 收集器退化成单线程的 SerialOld 收集器
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
# 由于采用的时标记清除的算法,有可能会产生过多的内存碎片,这样就有能导致将来分类内存的时候 minorgc 不足,
# 老年代碎片过多也不足,这样就会造成并发失败,此时 CMS 的老年代的垃圾回收器就不能正常工作了
# 此时垃圾回收器就会退化为 SerialOld ,做一次单线程的串行的垃圾回收之后,才能正常工作,
# 如果发生了这种退化的情况,那么垃圾回收的时间就会一下子变得很长,这也是这个 CMS 垃圾回收器最大的问题
首先多个 cpu 并行的执行,此时老年代发生了内存不足,这些线程都到了一个安全点暂停下来了。此时我们的 cms 垃圾回收器就开始工作了,他会执行一个初始标记的动作(只标记一些根对象,会很快),此时仍需要 STW。
等到初始标记完成之后,用户线程就可以恢复运行了,与此同时垃圾回收线程就可以并发标记,把剩余的那些垃圾标记出来。
等到并发标记完成后,然后进行重新标记,需要 STW(因为在并发标记的同时用户线程也在并发执行,有可能产生新的垃圾)。
重新标记完成之后,用户线程又可以继续运行,垃圾回收线程进行并发的清理。
在初始标记的时候,并发线程的数量受下面参数的影响
# 设置并行的垃圾回收线程数,一般等于 cpu 的核数
-XX:ParallelGCThreads=n
# 设置并发的垃圾回收线程数,一般设为并行的垃圾回收线程数的四分之一
-XX:ConcGCThreads=threads
# 在并发清理的时候,无法处理此时产生的新的垃圾,我们称之为浮动垃圾,得等到下次做垃圾清理的时候才可以清除
# 需要预留一些空间来保留这些浮动垃圾。下面参数的意思是
# 执行 CMS 垃圾回收的时候的内存占比,假设 percent 为八十,则当老年代的内存占比到百分之八十的时候,我就执行一次垃圾回收,这样子就是为了预留一些空间来存放浮动垃圾
-XX:CMSInitiatingOccupancyFraction=percent
# 在重新标记的这个阶段,有可能新生代的对象引用老年代的对象,此时进行重新标记的时候必须扫描整个堆
# 会影响性能,下面这个参数是在重新标记之前先对新生代进行一次垃圾回收,减轻重新标记时的压力。+就是打开,-就是禁用
-XX:+CMSScavengeBeforeRemark
4.4 并行和并发
并行:cpu 有 4 核,当发生垃圾收集的时候,用户线程暂停,垃圾回收器开启 4 条线程同时开启工作。会 STW。
并发:当发生垃圾收集的时候,用户线程不暂停,用户线程和垃圾收集线程同时开启工作,他们同时去抢占 cpu。会减少 STW 的时间。
4.5 G1
全称为 Garbage First ,是 jdk1.9 默认的垃圾回收器。适用的场景为:
1、同时注重吞吐量和低延迟(内部是基于并发的),默认的暂停目标是 200 ms。可以通过参数进行调整。
2、超大堆内存,会将堆划分为多个大小相等的 Region
3、整体上是 标记+整理 算法,两个区域之间是 复制算法
# 启用 G1 垃圾收集器
-XX:+UseG1GC
# 设置堆内存一块的大小,size 只能是 1、2、4、8 这种值
-XX:G1HeapRegionSize=size
# 调整暂停目标的毫秒值
-XX:MaxGCPauseMillis=time
4.5.1 G1 垃圾回收阶段
G1 的垃圾回收过程分为三个部分:
第一个阶段是 Young Collection 是对新生代的垃圾收集。
第二个阶段是 Young Collection + Concurrent Mark 即新生代的垃圾收集 + 并发标记。
第三个阶段是 Mixed Collection 混合收集。
这三个阶段是一个循环的过程,刚开始是新生代的垃圾收集,当老年代的内存超过阈值之后,就行进行新生代的垃圾收集 + 并发标记。然后再进行混合收集,即对新生代的幸存区和老年代进行一次规模较大的收集。等混合收集结束,Eden 区内存被释放掉 ,则再进入第一阶段。是一个循环的过程。
4.5.2 Young Collection
内存布局如下所示,上面我们说过,这个 G1 收集器把内存分为大小相等的区域,每个区域都可以作为 Eden 区、Survivor 区和老年代等。
白色的格子表示还未被占用的内存空间。E 代表 Eden 区,当然也需要设置 Eden 区的大小,当 Eden 区被占满之后,就会触发新生代的 Young Collection 垃圾回收,会触发 STW
新生代的垃圾回收会使用 copping 的算法将存活的对象放入幸存区,如下图所示:
等到再工作一段时间,我的幸存区的对象也比较多了。继续触发新生代的垃圾回收,其中有一部分的对象存活到了 15 岁,则晋升到老年代来存储(下图中的 O),不够年龄的则继续放到另一个幸存区存储,如下图所示:
4.5.3 Young Collection + Concurrent Mark
在 Young GC 时会进行 GC Root 的初始标记,当老年代占用堆空间比例达到阈值时,会进行并发标记(不会 STW),什么时候进行并发标记,由下面的 JVM 参数决定。
# 默认45%
-XX:InitiatingHeapOccupancyPercent=percent
以下图为例,当 O (老年代)占比达到百分之四十五之后,就会进行并发标记
4.5.4 Mixed Collection
在混合收集阶段会对 E、S、O 进行全面的垃圾回收,如下图所示,
混合收集阶段的新生代垃圾回收操作:Eden 区存活的对象会被复制到 S 区里面,另外一些 S 区的幸存对象不够年龄的也会被放到 S 区中,符合晋升条件的对象会被放入到 O 里面去。
混合收集阶段的老年代垃圾回收操作:老年代也时采用复制的算法,把存活的对象复制到一个新的老年代对象里面去,G1 收集器会根据你的最大暂停时间去有选择的进行一个回收,有时候我们的堆内存太大了,老年代的垃圾回收可能需要的时间较长,有可能就达不到我们设置的最大暂停时间,为了达到这个目标,这个 G1 就会从老年代里面挑出回收价值最高的那几个区(释放空间最大),即只挑一部分区进行垃圾回收。
需要注意的是:在最终标记和拷贝存活阶段都会 STW。
配置最大暂停时间参数如下:
-XX:MaxGCPauseMillis=ms
4..5.5 Full GC
各种垃圾收集器在内存不足时所发生的 gc 名称如下所示:
4.5.5.1 SerialGC
新生代内存不足发生的垃圾收集 -- minor gc
老年代内存不足发生的垃圾收集 -- full gc
4.5.5.2 ParallelGC
新生代内存不足发生的垃圾收集 -- minor gc
老年代内存不足发生的垃圾收集 -- full gc
4.5.5.3 CMS
新生代内存不足发生的垃圾收集 --minor gc
老年代内存不足,并发失败了以后才叫 full gc,判断的依据就是 gc 日志,如果打印了 full gc 字样才叫做 full gc。
4.5.5.4 G1
新生代内存不足发生的垃圾收集 -- minor gc
老年代内存不足,当达到阈值的时候(默认 45%),会触发并发标记的阶段,以及后面的混合收集的阶段,如果此时你的回收速度高于业务线程产生垃圾的速度时,这个时候还不叫 full gc,此时还处于并发垃圾收集的阶段;若此时你的回收速度小于业务线程产生垃圾的速度时,此时并发收集就失败了,就会触发 full gc,就很慢了。
4.5.6 Young Collection 跨代引用
新生代垃圾回收的跨代引用(老年代引用新生代)问题,先回顾下新生代垃圾回收的过程,先找到根对象,然后可达性分析,再找到存活的对象,在对存活的对象进行复制,复制到幸存区,这里面就会存在一个问题,我们要找新生代的根对象,根对象有一部分是存储在老年代的,但老年代存活的对象是比较多的,如果去遍历老年代去找根对象,显然效率是非常低的,因此采用的是一种卡表(Card Table)的技术。
卡表(Card Table),把老年代再次细分,分成一个个的 Card,每个 Card 大约 512K,如果老年代其中有一个对象引用了新生代的对象,那么对应的这个卡,我们就把他标记为脏卡,这样的好处就是我将来就不用遍历整个老年代了,只需要去关注那些脏卡对象就可以了,减少搜索范围,提高效率。
下图中,右侧红色的区域就是脏卡区,他们都有对象引用了 Eden 区的对象,所以这些都是脏卡区,在 Eden 区里面会有对应的 Remembered Set 会记录 Incoming Reference 外部的一些引用(都有哪些脏卡),将来对 Eden 进行垃圾回收的时候就可以先通过 Remembered Set 知道它对应的哪些脏卡,然后再对这些脏卡区遍历 GC ROOT ,这样就减少了 GC ROOT 的遍历时间。
我们是通过 post-write barrier 写屏障技术,在对象的引用发生变更时去更新脏卡,这是个异步的操作,不会立刻去完成这个脏卡的更新,它会把更新的指令放到一个 dirty card queue 中,将来由一个线程完成这个脏卡的更新操作。
4.5.7 Remark 重新标记
我们在学习 CMS 和 G1 垃圾收集器的时候提到了两个阶段,一个是并发标记阶段,一个是重新标记阶段(Remark),我们先看下面这张图,此图是并发标记时对象的处理状态,黑色表示处理完成的,灰色表示正在处理的,白色表示还未处理的。等 GC 完成会黑色不会发生变化,灰色变成黑色,白色有线连着的会存活变成黑色,没有线连着的白色就会被回收,最终还是白色的。
我们来看一个例子, B 对象处于正在处理的阶段,因为存在强引用此时 B 就变为了黑色将来会存活,我们此时处于并发标记阶段,即用户线程和 GC 线程同时运行。
假设此时用户线程断开了 B 对 C 的引用,等到 GC 线程扫描到 C 的时候,就会认为它是可以被回收的,将来就是白色的。
我们此时考虑另外一种情况,此时用户线程又改变了 C 的引用地址,比如说 C 对象当成了 A 对象的属性,此时 C 的引用又发生了改变,这个时候问题就来了,因为 C 之间就被 GC 线程标记过了是要被回收的,而此时 C 又被 A 引用了,又不能被回收了。此时就会出现问题。
所以我们要对对象的引用做进一步的调查,就是我们刚刚说的重新标记阶段,就是为了防止这一现象的产生。
当我们的对象引用发生改变时,JVM 就会给他加入一个写屏障,只要你的对象引用发生了改变,这个写屏障的代码就会被执行,比如说我们刚才把 C 的引用给了 A,说明 C 的引用发生了变化,这个时候就会触发写屏障,即把 C 对象加入一个 satb_mark_queue 队列当中,并且把 C 变成灰色,表示它还没有处理完,如下所示。
等到整个并发标记结束了,接下来进入重新标记阶段,此时会 STW,GC 线程就会从队列中把对象都取出来再做一次检查。
五、垃圾回收器调优
5.1 预备知识
1、掌握 GC 相关的 VM 参数,会基本的空间调整
2、掌握相关的工具
3、明白一点:调优和应用、环境有关,没有放之四海而皆准的法则。
5.2 调优领域
内存、锁竞争、cpu 占用、io
5.3 确定目标
需要根据你的实际业务场景选择合适的回收器,如果是互联网企业,可能更关注的是低延迟,即响应时间更短,可以选择 CMS、G1、ZGC 等。
如果是进行科学运算,可能追求的是高吞吐量,延长一点点响应时间也是没有问题的,只能选择 ParallelGC。
5.4 最快的 GC 是不发生 GC
查看 FullGC 前后的内存占用,如果你的虚拟机经常发生 GC,需要考虑下面几个问题
1、数据是不是太多了?是不是加载了不必要的数据到内存里面。
2、数据表现是否太臃肿?能用基本类型就不用包装类型。
3、是否存在内存泄漏?一直向集合里面添加元素但不移除。
5.5 新生代调优
5.5.1 新生代特点
1、所有的 new 操作的内存分配非常廉价
2、死亡对象的回收代价是零
3、大部分对象用过即死
4、Minor GC 的时间远远低于 Full GC
5.5.2 越大越好吗
使用 -Xmn 参数来配置新生代内存的大小。
如果新生代内存设置的小了,肯定是不太好,因为新生代小,可用空间就少,创建对象时一旦发现内存不足。就会触发 minorGC。
如果新生代内存设置的很大,肯定也是不好,因为新生代内存大了,那么相对而言老年代的内存肯定就小了,触发老年代的 fullGC 会更频繁,fullGC 比 minorGC STW 的时间更长。
Oracle 给了一个建议,即新生代内存大于堆内存 25% ,小于堆内存 50% 的比例,即堆内存的 四分之一 到 二分之一 之间。还是稍微大点好。
5.5.3 理想容量
新生代能容纳所有【 并发量*(请求-响应)】的数据。
幸存区大到能够保留【当前活跃对象+需要晋升的对象】。
晋升阈值配置得当,让长时间存活对象尽快晋升。
# 调整最大晋升阈值
-XX:MaxTenuringThreshold=threshold
# 打印晋升的一些详细信息
-XX:+PrintTenuringDistribution
5.6 老年代调优
5.6.1 CMS 垃圾收集器
1、CMS 的老年代内存越大越好。
2、先尝试不做调优,如果没有 FullGC ,就证明系统已经很不错了,否则先尝试调优新生代。
3、如果老年代经常发生 FullGC,先观察 FullGC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3 。
# 当老年代内存达到 percent 时就进行垃圾回收
-XX:CMSInitiatingOccupancyFraction=percent
5.7 案例
5.7.1 FullGC 和 Minor GC 频繁
现象:程序运行期间 FullGC 和 MinorGC 频繁。
分析:GC 特别频繁说明空间比较紧张。如果是新生代的空间紧张,等到业务高峰期来了,大量的对象被创建,很快就把我的新生代的空间塞满了,塞满了之后还会造成一个后果,幸存区由于空间紧张,它里面对象的晋升阈值就会降低,导致很多生存周期很短的对象也会被晋升到老年代去。此时老年代里面就存储了很多的这种生存周期很短的对象,进一步触发老年代的这种 FullGC 的频繁发生。
解决:通过工具观察堆空间的大小,确实发现新生代的内存设置的太小了,根据之前的经验,内存优化需要先从新生代开始,即增大新生代的内存。
5.7.2 Full GC 单次暂停时间特别长
现象:请求高峰期发生 Full GC,单次暂停时间特别长(CMS)。
分析:首先查看 GC 日志,看下 CMS 哪个阶段耗费的时间较长,假设是重新标记阶段耗时较长。
解决:可以使用 -XX:+CMSScavengeBeforeRemark 参数,使得在重新标记之前先对新生代的对象做一次垃圾清理,清理完对象少了,重新标记阶段的耗时也就短了。
5.7.3 老年代充裕发生 Full GC
现象:老年代充裕情况下,发生 Full GC (1.7)。
分析:CMS 可能由于空间不足导致并发失败,或者是空间碎片较多都会导致 FullGC,但是经过排查,在 GC 日志里面并没有发现并发失败的错误提示,说明老年代的空间是充裕的。此时想到部署的 jdk 版本为 1.7,不是现在的 1.8。1.8 是元空间作为方法区的实现,而 1.7 以前的 jdk 采用的是永久代作为方法区的实现。在 jdk1.7 及以前的版本中,永久代的空间不足也会导致 FullGC
解决:增加永久代的内存即可。