如何判断对象可以回收

引用计数法

  • 当一个对象被引用是,就当引用对象的值+1,当引用对象的值为0时,则说明该对象没有被引用,那么就可以被垃圾回收器回收
  • 这个引用计数法听起来很不错,而且实现起来也非常的简单,可是它有一个弊端,如下图所示,当两个对象循环引用时,两个对象的计数都未1,就导致这两个对象都无法被释放

可达性分析算法

  • JVM垃圾回收机制的可达性分析算法,是一种基于引用的垃圾回收算法。其基本思想是通过一系列被称为"GC Roots"的根对象作为起点,寻找所有被根对象直接或间接引用的对象,将这些对象称为"可达对象",而没有被找到的对象则被视为"不可达对象",需要被回收。
  • 形象一点理解就是我有一串葡萄,我把这串葡萄拿起来,连在根上的葡萄就是可达对象,而掉在盘子里的葡萄就是不可达对象,需要被回收
  • 在JVM中,有四种类型的GC Roots对象:
    1. 虚拟机栈中引用的对象:虚拟机栈是用于存储方法调用和执行的栈空间。当一个方法被调用时,会在栈中创建一个栈帧,用于存储该方法的局部变量、参数和返回值等信息。如果栈帧中包含对某个对象的引用,那么这个对象就被视为GC Roots对象。
    2. 方法区中类静态属性引用的对象:方法区是用于存储类信息、常量池、静态变量等信息的内存区域。当一个类被加载到方法区时,其中的静态属性会被分配在方法区中,如果这些静态属性中包含对某个对象的引用,那么这个对象就被视为GC Roots对象。
    3. 方法区中常量引用的对象:常量池是方法区的一部分,用于存储常量。如果常量池中包含对某个对象的引用,那么这个对象就被视为GC Roots对象。
    4. 本地方法栈中JNI引用的对象:JNI是Java Native Interface的缩写,用于在Java程序中调用本地方法(即由C或C++等语言编写的方法)。当本地方法被调用时,会在本地方法栈中创建一个栈帧,如果该栈帧中包含对某个对象的引用,那么这个对象就被视为GC Roots对象。
  • 可达性分析算法基于这些GC Roots对象,通过遍历所有的引用链,找到所有可达对象,将它们标记为存活对象,而没有被找到的对象则被视为不可达对象,需要被回收。
  • 可达性分析算法的主要优点是可以处理复杂的引用结构,例如循环引用、交叉引用等情况,能够识别出所有可达对象,从而准确地进行垃圾回收。但是,它也有一些缺点,例如需要耗费较多的时间进行垃圾回收、可能会出现漏标和误标等问题。为了解决这些问题,JVM中还采用了其他的垃圾回收算法,如标记-清除算法、复制算法、标记-整理算法等,以提高垃圾回收的效率和准确性。

引用类型

  • 在Java中,对象不仅可以被正常引用,还可以被特殊的引用类型引用。这些引用类型决定了垃圾回收器如何对对象进行回收。
  • JVM中共有五种引用类型,它们分别是
    1. 强引用(Strong Reference):是最常见的引用类型,也是默认的引用类型。如果一个对象具有强引用,那么即使内存空间不足,垃圾回收器也不会回收它。只有当该对象的所有强引用都失效时,对象才会被回收
    2. 软引用(Soft Reference):是一种比强引用弱一些的引用类型。如果一个对象只具有软引用,那么当内存空间不足时,垃圾回收器可能会回收它。软引用通常用于实现内存敏感的缓存
      • 可以配合引用队列来释放软引用自身
    3. 弱引用(Weak Reference):是一种比软引用更弱一些的引用类型。如果一个对象只具有弱银用,那么垃圾回收器在下一次运行时,无论内存空间是否足够,都会回收该对象。若引用通常用于实现在对象可用时进行操作的场景
      • 可以配合引用队列来释放软引用自身
    4. 虚引用(Phantom Reference):是最弱的一种引用类型。如果一个对象只具有虚引用,那么在任何时候都可能被垃圾回收器回收。虚引用通常用于追踪对象被垃圾回收的状态
      • 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
    5. 终结器引用(Final Reference):是一种特殊的弱引用类型,它只在对象被回收时被添加到引用队列中。当垃圾回收器准备回收一个对象时,会先执行对象的finallize()方法,如果finalize()方法中没有重新让对象与其他对象建立联系,那么这个对象就会被回收,并且它的Final引用会被加入到引用队列中。Final引用通常用于对象回收后的清理工作
  • 软引用、弱引用和虚引用则是用于管理一些比较特殊的对象。使用引用队列可以方便地跟踪这些特殊对象的垃圾回收状态,帮助我们及时释放对象占用的资源。
  • 使用引用队列来管理这些特殊对象的方式是,在创建这些对象时,同时指定一个引用队列。当垃圾回收器回收这些对象时,会自动将其添加到与之关联的引用队列中。我们可以在某个时刻检查引用队列中是否有特殊对象,并对其进行处理,比如将其从相应的数据结构中删除,以便释放特殊对象占用的资源。

软引用应用

  • 前面说软引用通常用于实现内存敏感的缓存,那现在我们来演示一下
1
2
3
4
5
6
7
8
9
10
11
12
// 添加VM参数-Xmx20m
public class Demo_18 {

private 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]);
}
}
}
  • 上面的代码中,由于我们设置堆内存为20M,而代码中5次循环也需要20M的内存,所以到最后肯定会报内存溢出的错误
1
2
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.demo.Demo_18.main(Demo_18.java:20)
  • 强引用会报堆内存错误,那我们来试试弱引用,前面说弱引用会在内存不足时被垃圾回收器回收,那我们额外添加两个VM参数,监测垃圾回收情况-XX:+PrintGCDetails-verbose:gc
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
30
31
32
33
34
35
36
import java.io.IOException;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;

/**
* 演示软引用
* -Xmx20m -XX:+PrintGCDetails -verbose:gc
*/
public class Demo_18 {

private 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 < 15; i++) {
list.add(new byte[_4MB]);
}*/
soft();
}

public static void soft() {
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());
}
}
}
  • 运行结果如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[B@27c170f0
1
[B@5451c3a8
2
[B@2626b418
3
[GC (Allocation Failure) [PSYoungGen: 2207K->496K(6144K)] 14495K->13153K(19968K), 0.0031771 secs] [Times: user=0.00 sys=0.01, real=0.00 secs]
[B@5a07e868
4
[GC (Allocation Failure) --[PSYoungGen: 4704K->4704K(6144K)] 17362K->17378K(19968K), 0.0012234 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 4704K->4500K(6144K)] [ParOldGen: 12673K->12634K(13824K)] 17378K->17135K(19968K), [Metaspace: 3361K->3361K(1056768K)], 0.0054571 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) --[PSYoungGen: 4500K->4500K(6144K)] 17135K->17167K(19968K), 0.0007514 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 4500K->0K(6144K)] [ParOldGen: 12666K->733K(8704K)] 17167K->733K(14848K), [Metaspace: 3361K->3361K(1056768K)], 0.0063756 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[B@76ed5528
5
循环结束:5
null
null
null
null
[B@76ed5528
  • 我们来分析一下运行结果
    • 前三次循环时内存充足,没有触发垃圾回收。
    1
    2
    3
    4
    5
    6
    [B@27c170f0
    1
    [B@5451c3a8
    2
    [B@2626b418
    3
    • 但是第四次循环时,内存已经很紧张了,所以回收了一些新生代的内存
    1
    2
    3
    [GC (Allocation Failure) [PSYoungGen: 2207K->496K(6144K)] 14495K->13153K(19968K), 0.0031771 secs] [Times: user=0.00 sys=0.01, real=0.00 secs] 
    [B@5a07e868
    4
    • 第五次循环时,一次垃圾回收后,内存空间仍然不足,然后又触发了一次垃圾回收,回收了大部分新生代和老年代的内存
    1
    2
    3
    4
    5
    6
    [GC (Allocation Failure) --[PSYoungGen: 4704K->4704K(6144K)] 17362K->17378K(19968K), 0.0012234 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    [Full GC (Ergonomics) [PSYoungGen: 4704K->4500K(6144K)] [ParOldGen: 12673K->12634K(13824K)] 17378K->17135K(19968K), [Metaspace: 3361K->3361K(1056768K)], 0.0054571 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
    [GC (Allocation Failure) --[PSYoungGen: 4500K->4500K(6144K)] 17135K->17167K(19968K), 0.0007514 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
    [Full GC (Allocation Failure) [PSYoungGen: 4500K->0K(6144K)] [ParOldGen: 12666K->733K(8704K)] 17167K->733K(14848K), [Metaspace: 3361K->3361K(1056768K)], 0.0063756 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
    [B@76ed5528
    5
    • 其代价就是将前四个byte数组占据的内存释放掉了,只保留了第五次循环时的byte数组
    1
    2
    3
    4
    5
    6
    循环结束:5
    null
    null
    null
    null
    [B@76ed5528
  • 软连接在内存敏感的程序下的好处,当内存空间比较有限时,一些不重要的对象可以用软连接去管理它,当空间紧张时,就可以将它释放掉

引用队列

  • 前面说软引用可以配合引用队列来释放软引用自身,用下面的代码来演示一下(其实就是上面的代码修改了一下),注意添加VM参数-Xmx20m -XX:+PrintGCDetails -verbose:gc
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
30
31
32
33
34
35
36
37
38
39
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;

/**
* 演示软引用, 配合引用队列, VM参数:-Xmx20m -XX:+PrintGCDetails -verbose:gc
*/
public class Demo_19 {
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());
}
}
}
  • 运行上面的代码,结果如下,软引用对象只剩一个,符合我们的预期
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[B@27c170f0
1
[B@5451c3a8
2
[B@2626b418
3
[GC (Allocation Failure) [PSYoungGen: 2207K->496K(6144K)] 14495K->13087K(19968K), 0.0011815 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[B@5a07e868
4
[GC (Allocation Failure) --[PSYoungGen: 4704K->4704K(6144K)] 17296K->17352K(19968K), 0.0008897 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 4704K->4549K(6144K)] [ParOldGen: 12647K->12585K(13824K)] 17352K->17135K(19968K), [Metaspace: 3359K->3359K(1056768K)], 0.0051949 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) --[PSYoungGen: 4549K->4549K(6144K)] 17135K->17183K(19968K), 0.0007508 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 4549K->0K(6144K)] [ParOldGen: 12633K->733K(8704K)] 17183K->733K(14848K), [Metaspace: 3359K->3359K(1056768K)], 0.0058980 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
[B@76ed5528
5
============下面输出引用队列中的软引用对象==============
[B@76ed5528
  • 但如果我们不移除队列中无用的引用对象,那么输出结果是这样的,队列中存储的null会占据存储空间,最终可能会导致内存泄漏,所以我们一定要记得手动移除队列中无用的引用对象
1
2
3
4
5
6
============下面输出引用队列中的软引用对象===========
null
null
null
null
[B@76ed5528

垃圾回收算法

标记清除

  • 标记清除是一种常见的垃圾回收算法之一,其工作原理如下:

    1. 标记阶段(Mark):从根对象开始遍历内存中的所有对象,将所有能够被访问到的对象做上标记,表示这些对象是活动的。
    2. 清除阶段(Sweep):遍历整个内存空间,将未被标记的对象视为垃圾,将其占用的内存资源进行回收。
  • 标记清除算法的优点在于它简单易用,可以快速地回收大量的垃圾对象。但是,它也存在一些缺点,例如在清除和压缩阶段中可能会产生较大的内存碎片,从而影响后续的内存分配效率。此外,标记清除算法无法处理循环引用的情况,需要借助其他算法来处理循环引用问题,例如标记-压缩算法和复制算法等。

标记整理

  • 在标记整理算法中,标记和清除阶段和标记清除算法是一样的,只有在内存整理阶段不同。在整理阶段,标记整理算法会将所有存活的对象向内存的一端移动,之后将移动后的内存末尾地址作为堆的新起始地址,之前的地址空间被标记为空闲,可以用来分配新的对象。
  • 需要注意的是,标记整理算法的整理阶段可能会比较耗时,因为它需要移动对象,这也是它的一个缺点。但是,由于它可以消除内存碎片,提高内存利用率,所以在需要长时间运行的应用程序中,标记整理算法通常比标记清除算法更加适用。

复制

  • 复制算法是一种基于内存分区的垃圾回收算法,它将内存分成两个区域:From空间和To空间。在正常情况下,所有的对象都被分配在From空间中。当需要进行垃圾回收时,算法会扫描From空间中的所有对象,并将存活的对象复制到To空间中。复制完成后,From空间中的对象都可以被视为垃圾,并可以被回收。
  • 接下来,详细介绍一下复制算法的工作原理:
    1. 内存分配:在程序运行过程中,对象的内存分配只在From空间中进行。当From空间快要用完时,算法会触发一次垃圾回收操作。
    2. 扫描存活对象:在进行垃圾回收时,算法会遍历From空间中的所有对象,并标记存活的对象。为了标记存活对象,复制算法使用了一种叫做可达性分析的技术,这个技术可以判断一个对象是否是存活对象。与标记清除算法和标记整理算法不同的是,复制算法并不需要进行标记和清除的分离过程,因为复制算法是将存活对象复制到To空间中,所以只要扫描完From空间中的所有对象,并将存活对象复制到To空间中,就可以直接清除From空间了。
    3. 复制存活对象:在扫描存活对象之后,算法会将所有存活对象从From空间复制到To空间。复制的过程是按照对象的存活顺序进行的,也就是说,如果对象A引用了对象B,那么对象B会被先复制到To空间中。复制完成后,To空间的使用量会变得很满,From空间的使用量则变得很少。
    4. 交换空间:在复制完所有存活对象之后,From空间中剩余的对象都可以视为垃圾,并可以被回收。为了保证下一次的内存分配,To空间和From空间会进行交换,也就是说,To空间成为了新的From空间,From空间成为了新的To空间。这样一来,内存分配就可以在新的From空间中进行了。

小结

  • 总结一下这三种垃圾回收算法的优缺点
    1. 标记清除:速度较快,但会产生内存碎片
    2. 标记整理:速度较慢,但是不会产生内存碎片
    3. 复制:不会产生内存碎片,但是需要占用双倍内存空间

分代垃圾回收

  • JVM(Java虚拟机)的分代垃圾回收是一种优化内存回收的技术。它利用对象的生命周期来将堆(heap)分为不同的区域,然后针对不同区域的特点采用不同的垃圾回收算法。
  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发minor gc伊甸园from存活的对象使用copy复制到to中,存活的对象年龄+1并且交换fromto
  • minor gc会引发stop the world(砸瓦鲁多!!),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命是15
    • Java中的对象头中确实分配了一定的字节用来记录对象的年龄,而这个字节的位数是4,因此其二进制最大值为1111,即十进制的15
  • 当老年代空间不足,会先尝试触发minor gc,如果之后空间仍不足,那么触发full gc,STW的时间更长

相关VM参数

含义 参数
堆初始大小 -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
Full GC 前 Minor GC -XX:+ScavengeBeforeFullGC

垃圾回收器

串行收集器(Serial收集器)

  • 这是最简单的垃圾回收器,它通过一个单线程进行垃圾回收,因此它的优点是简单高效,但缺点是在大型应用程序中可能会出现停顿时间过长的问题。

  • 串行收集器的缺点主要是单线程执行垃圾回收操作,不能充分利用多核CPU的计算能力,同时垃圾回收操作会阻塞应用程序的运行,可能会导致长时间的停顿。因此,在大型的、多线程的应用程序中,通常不适合使用串行收集器进行垃圾回收。

  • 对应的VM参数:-XX:+UseSerialGC = Serial + SerialOld

  • 下图中,其他CPU需要等待CPU 2执行完垃圾回收后,才能继续运行

吞吐量优先收集器(Parallel收集器)

  • JDK 1.8 默认采用的就是这种垃圾回收器
  • 它是一种基于多线程并行执行的垃圾回收器,它的主要目标是提高应用程序的吞吐量,即在单位时间内处理更多的请求。
  • 相关VM参数
    1. -XX:+UseParallelGC ~ -XX:+UseParallelOldGC:
      • 这两个参数分别用于开启并行垃圾回收器和并行老年代垃圾回收器。其中,-XX:+UseParallelGC用于开启并行垃圾回收器,-XX:+UseParallelOldGC用于开启并行老年代垃圾回收器。使用这两个参数可以开启Parallel收集器。
    2. -XX:+UseAdaptiveSizePolicy:
      • 这个参数用于自适应地调整Java堆大小和垃圾收集器的参数,以达到更好的垃圾回收效果。当开启该参数时,JVM会自动调整Eden区、Survivor区、老年代的大小以及垃圾回收线程的数量等参数,以达到更高的垃圾回收效率。
    3. -XX:GCTimeRatio=ratio:
      • 这个参数用于设置垃圾回收所占用CPU时间和应用程序运行时间的比率。默认值是99,表示垃圾回收时间最多可以占用1%的CPU时间,计算公式为1/(1 + ratio)
    4. -XX:MaxGCPauseMillis=ms:
      • 这个参数用于设置最大垃圾回收暂停时间。默认值是200ms。如果设置该参数,JVM会尽可能地控制垃圾回收暂停的时间不超过该值。如果需要更短的垃圾回收暂停时间,则可以将该值设置为较小的数值。
    5. -XX:ParallelGCThreads=n:
      • 这个参数用于设置垃圾回收线程的数量。默认值是CPU核心数的1/4。可以根据应用程序的需求和硬件环境来调整该参数的值,以达到更好的垃圾回收效果。

CMS收集器(响应时间优先)

概述

  • CMS(Concurrent Mark Sweep)是一种垃圾回收算法,它的设计目标是在最短的停顿时间内回收垃圾。它通过在一个线程中进行垃圾回收并在应用程序线程中同时运行,从而减少停顿时间。下面详细介绍CMS垃圾收集器的工作原理和优缺点。

工作流程

  • CMS垃圾收集器的工作可以分为以下几个阶段:
    1. 初始标记阶段(Initial Marking):在这个阶段中,CMS垃圾收集器会暂停所有应用程序线程,并且在内存中标记出所有被直接引用的对象。这个过程由一个线程来完成,因此它的停顿时间比较短。
    2. 并发标记阶段(Concurrent Marking):在这个阶段中,CMS垃圾收集器会在应用程序线程运行的同时标记出所有被间接引用的对象。这个过程由多个线程并发执行,因此它的停顿时间比较短。
    3. 重新标记阶段(Remark):在这个阶段中,CMS垃圾收集器会暂停所有应用程序线程,并且重新标记出在并发标记阶段中有变化的对象。这个过程由一个线程来完成,因此它的停顿时间比较短。
    4. 并发清除阶段(Concurrent Sweeping):在这个阶段中,CMS垃圾收集器会在应用程序线程运行的同时清除所有标记为垃圾的对象。这个过程由多个线程并发执行,因此它的停顿时间比较短。
    5. 最终标记阶段(Final Remark):在这个阶段中,CMS垃圾收集器会暂停所有应用程序线程,并且重新标记出在并发清除阶段中有变化的对象。这个过程由一个线程来完成,因此它的停顿时间比较短。

优缺点

  • 优点

    1. 可以在最短的停顿时间内回收垃圾,不会出现长时间的停顿现象,因此适合响应时间比较敏感的应用程序。
    2. 采用并发的垃圾收集方式,能够与应用程序并发执行,不会阻塞应用程序的执行,因此对于需要保证应用程序响应性能的场景非常适用。
  • 缺点

    1. 对 CPU 的使用比较敏感,在高负载情况下可能会影响应用程序的性能。
    2. 由于并发清除阶段无法整理内存,可能会出现内存碎片化的问题,导致后续垃圾回收过程需要更多的时间。
    3. 在处理大量的垃圾时可能会导致应用程序的性能下降,因为需要占用一定的 CPU 资源。

相关VM参数

  1. -XX:ParallelGCThreads=n:
    • 指定并行垃圾回收器的线程数。默认值是处理器核心数。
  2. -XX:ConcGCThreads=threads:
    • 指定并发垃圾回收器的线程数。默认值是处理器核心数的1/4
  3. -XX:CMSInitiatingOccupancyFraction=percent:
    • 指定 CMS 垃圾回收器开始执行垃圾回收的阈值。当老年代空间使用达到指定百分比时,CMS 垃圾回收器会开始执行垃圾回收。
  4. -XX:+CMSScavengeBeforeRemark:
    • 启用 CMS 垃圾回收器在进行重新标记之前执行新生代的垃圾回收。这可以减少重新标记的时间,从而减少应用程序暂停的时间。

G1

概述

  • G1(Garbage-First)是一种现代化的垃圾回收器,它在JDK 7中首次引入,并在JDK 9中被标记为默认垃圾回收器。
    • 与之前的垃圾回收器相比,G1的最大优势在于它可以更好地处理占据大量堆内存的Java应用程序,从而避免了传统垃圾回收器在大内存情况下可能出现的停顿

工作原理

  • G1采用了一种不同于传统垃圾回收器的回收方式,它将内存划分为大小相等的多个区域(Region),每个区域的大小通常为1MB32MB。G1将这些区域分为新生代和老年代,其中新生代通常占整个堆内存的5%到10%,老年代则占90%到95%。
  • G1将堆内存划分为多个区域后,它就可以对每个区域分别进行垃圾回收,从而避免了FULL GC带来的长时间停顿。

工作流程

  • G1的垃圾回收流程如下
    1. 初始标记(Initial Mark):G1在此阶段会扫描堆内存中的根对象,并标记所有的根对象,以便后续回收时可以快速定位到这些根对象
    2. 并发标记(Concurrent Mark):在此阶段,G1会与应用程序并发的执行,扫描堆内存中的所有存活对象,并标记这些对象。在整个过程中,应用程序可以继续执行,因此在这个阶段不会产生长时间的停顿
    3. 确定标记(Remark):在并发标记结束后,G1需要在一个短暂的停顿期间重新扫描堆内存,以便确定所有的存活对象已经被标记
    4. 清除(Cleanup):在确定标记阶段结束后,G1会对未标记的区域进行回收,并将回收得到的空闲区域加入到空闲列表中,以便下次回收内存时使用

垃圾回收阶段

  • 在G1垃圾回收器中,垃圾回收的过程可以分为三种阶段:Young CollectionYoung Collection + Concurrent Mark(简称Young Collection + CM)和Mixed Collection

  • 下面我们分别介绍一下这三种垃圾回收阶段和特点:

    1. Young Collection(年轻代垃圾回收)

      • Young Collection是指对年轻代(Young Generation)的垃圾回收。在G1垃圾回收器中,年轻代通常是由几个连续的Region组成的。在年轻代垃圾回收期间,G1垃圾回收器会扫描年轻代内的所有对象,并标记那些被引用的对象。然后,它会将未被引用的对象回收掉,并将被引用的对象移动到下一个垃圾回收周期的存活区域(Survivor Region)中。
    2. Young Collection + Concurrent Mark(年轻代垃圾回收 + 并发标记)

      • Young Collection + CM是:指在Young GC时会进行GC Root的初始标记,当老年代占用堆空间比例达到阈值时,对整个堆内存进行并发标记。由下面的VM参数决定
        • -XX:InitiatingHeapOccupancyPercent=percent (默认45%)
      • 在并发标记过程中,G1垃圾回收器会扫描所有的可达对象,并标记它们。并发标记可以与应用程序并行进行,不会停顿应用程序的线程。当并发标记完成后,G1垃圾回收器就可以知道哪些对象是存活的,哪些对象是垃圾了。
    3. Mixed Collection(混合垃圾回收)

      • Mixed Collection是指对整个堆内存进行垃圾回收,包括年轻代和老年代(Old Generation)的垃圾回收。在Mixed Collection期间,G1垃圾回收器会尽可能地回收那些垃圾占用空间较大的Region,并将它们标记为"空闲"状态,以便在以后的内存分配中使用。与Young Collection不同,Mixed Collection不仅回收年轻代的对象,还回收老年代的对象,因此它需要更长的停顿时间。同时,Mixed Collection也会和并发标记一起执行,以最小化应用程序的停顿时间。
  • 新生代内存不足发生的垃圾收集:minor gc

  • 当G1垃圾回收器的回收速度跟不上Java应用程序产生垃圾的速度时,垃圾对象在堆内存中不断增加,导致堆内存空间的压力增大。当堆内存空间的压力达到G1垃圾回收器的阈值时,会触发FULL GC。

跨代引用

  • 当进行新生代垃圾回收时,需要找到新生代对象的跟对象来确定哪些对象是存活的,那些对象需要被回收。然而根对象中可能有一部分存活在老年代中,如果每次都要遍历老年代来查找这些根对象,将会导致效率低下
  • 为了解决这个问题,G1垃圾回收器采用了一种卡表技术,将老年代划分为多个区域,并将每个区域划分为一组成为的块。每个卡的大小约为512KB,如果老年代中的一个对象引用了新生代中的对象,那么该卡就被标记为脏卡
  • 在新生代对象引用发生变化时,使用post-write barrier和dirty card queue技术来更新卡表,将引用变更信息添加到Remembered Set中,标记为脏卡
  • 在进行新生代垃圾回收时,只需要扫描这些脏卡中的对象,即可找到所有的跨代引用对象,避免了对整个老年代进行遍历的开销,提高了垃圾回收的效率。

重新标记阶段

  • 在G1垃圾回收器的remark阶段中,它使用三色标记法来标记存活对象,并结合SATB和写屏障来跟踪对象的引用关系,以便在标记阶段中更准确地标记存活对象。
  • remark阶段开始时,G1垃圾回收器会将所有被标记为灰色的对象加入到处理队列中,然后开始遍历处理队列中的对象。对于每个灰色对象,G1垃圾回收器会扫描其所有引用域,并将任何未被标记为黑色的引用加入到SATB中,以便在后续标记阶段中更准确地跟踪引用关系。
  • 同时,在遍历处理队列中的对象时,G1垃圾回收器会将灰色对象的引用对象标记为灰色,将灰色对象标记为黑色,并将其从处理队列中移除。这个过程会一直持续,直到处理队列中不再有灰色对象。
  • 在remark阶段中,SATB写屏障起到了重要作用。当程序在运行时修改一个对象的引用时,SATB写屏障会记录这个修改,并将相关信息保存到一个记录缓存中。在remark阶段中,G1垃圾回收器会遍历记录缓存中所有被标记为脏的对象,并遍历这些对象的引用域,以确定它们所引用的对象是否需要被标记为灰色。
  • 通过三色标记法、SATB和写屏障的结合使用,G1垃圾回收器可以更准确地标记存活对象,并且在标记阶段中只标记那些需要被标记的对象,从而提高垃圾回收器的性能和效率。

字符串去重

  • 当新生代中的对象被晋升到老年代时,如果这些对象是字符串类型,那么会将它们放入一个特殊的队列中,而不是直接将它们放入老年代中。这个队列称为String Deduplication Queue,可以理解为字符串去重队列。

  • G1垃圾回收器在remark阶段中会扫描String Deduplication Queue中的所有字符串,检查是否有重复的字符串。如果发现有重复的字符串,就会让它们引用同一个char[],以达到字符串去重的目的。需要注意的是,这里去重的对象是char[],而不是String对象本身,与String.intern()不同。

  • 为了实现这一功能,G1垃圾回收器内部维护了一个字符串表,用来存储所有已经去重的char[]。当遍历String Deduplication Queue时,G1垃圾回收器会将每个字符串的char[]进行哈希,然后在字符串表中查找是否存在相同哈希值的char[],如果存在,就会比较这两个char[]的内容是否相同,从而确定是否是同一个字符串。如果是同一个字符串,就将这个字符串对象的引用指向字符串表中的char[],从而达到字符串去重的目的。

  • 总之,G1垃圾回收器的字符串去重功能可以大幅度减少堆内存中的重复字符串,从而降低垃圾回收器的内存占用和垃圾回收的开销,提高程序的性能。

    • 优点:节省大量内存
    • 缺点:稍微多占用了CPU时间,新生代回收时间略微增加

并发标记类卸载

  • 在 G1 垃圾回收器进行并发标记后,它可以识别出哪些类不再被使用。当一个类加载器的所有类都不再被使用时,就可以卸载该类加载器所加载的所有类。
  • G1 垃圾回收器默认开启了类卸载功能,可以通过 JVM 参数 -XX:+ClassUnloadingWithConcurrentMark 显式启用。

回收巨型对象

  • 对象大小超过了G1 Heap中的Region大小的一半时,被称为巨型对象(Huge Object)。
  • G1不会将巨型对象分配到Region中,而是直接分配在Humongous区域中。Humongous区域是一组连续的Region,大小为2的幂次方倍。如果一个巨型对象的大小超过了Humongous区域的大小,则会跨越多个Humongous区域。
  • 在标记阶段,G1会优先处理巨型对象,以便在回收过程中更好地利用它们的空间。这样可以避免因巨型对象被其他对象占用而导致的内存浪费。
  • 在G1中,老年代中所有的incoming引用会被跟踪,如果某个巨型对象的incoming引用计数为0,说明该对象不再被其他对象引用,可以直接被回收。这个过程发生在新生代回收之前,可以帮助释放空间。

并发表及起始时间调整

  • 前面说如果G1垃圾回收器的回收速度跟不上Java应用程序产生垃圾的速度时,垃圾对象在堆内存中不断增加,导致堆内存空间的压力增大。当堆内存空间的压力达到G1垃圾回收器的阈值时,会触发FULL GC
  • 并发标记阶段是 G1 垃圾回收的一个重要阶段,需要在堆空间占满之前完成,否则就会退化为 FullGC。为了避免这种情况的发生,JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent 参数来手动设置并发标记的触发阈值(默认为45%),以确保在这个阈值之前就完成并发标记。而在 JDK 9 及以后的版本中,可以根据实际情况动态调整并发标记的触发阈值,以避免 Full GC 的发生。
  • -XX:InitiatingHeapOccupancyPercent 参数用于设置并发标记的初始触发阈值,但是 G1 在进行并发标记时会进行数据采样,并根据采样数据动态调整并发标记的触发阈值,以确保并发标记可以在堆空间不被填满的情况下完成。同时,G1 还会在堆空间中留出一定的安全空档空间,以避免因为堆空间占满而触发 Full GC

优缺点

  • 优点:
    1. 分区回收:G1将堆内存划分为多个区域,并对每个区域分别进行回收,从而避免FULL GC带来的长时间停顿
    2. 并发回收:G1在标记和清除阶段都可以与应用程序并发执行,从而避免了长时间的停顿
    3. 预测性停顿:G1可以根据应用程序的内存使用情况预测合适需要进行FULL GC,从而在垃圾回收过程中尽可能地减少停顿时间
    4. 空间整合:G1可以在回收过程中对空间进行整合,从而可以更好的利用堆内存
    5. 可预测性:G1可以设置预期的停顿时间,以便开发人员可以更好地控制应用程序的性能
  • 缺点
    1. 初始标记和确定标记阶段需要短暂的停顿时间,虽然停顿时间短暂,但是仍然可能影响应用程序的性能
    2. G1需要更多的CPU资源来完成垃圾回收,因为它需要同时处理多个分区
    3. 对于小型应用程序,G1可能会产生过多的内存分配和垃圾回收开销,从而导致应用程序性能下降

垃圾回收调优

预备知识

  1. 掌握GC相关的VM参数,会基本的空间调整
  2. 掌握相关工具
    • jsp
    • jmap
    • jconsole
    • jvisualvm
  3. 明白一点:调优跟应用、环境有关,没有放之四海而皆准的法则,都是具体情况具体分析

调优领域

  • JVM的GC调优是Java应用程序性能调优的一个重要方面。在进行GC调优之前,需要先确定应用程序的性能瓶颈,通常可以从内存、锁竞争、CPU占用和IO等方面入手进行调优。
  1. 内存调优

    • 内存调优主要是针对JVM的堆内存和非堆内存进行调优。在Java应用程序中,大部分的对象都是分配在堆内存中的。因此,对于堆内存的调优非常重要。
    • 如果堆内存的大小设置过小,就会频繁地进行垃圾回收,导致应用程序的性能下降。如果堆内存的大小设置过大,就会浪费系统资源。
    • 对于非堆内存,可以考虑调整JVM的元空间大小来减少元数据的内存占用。
  2. 锁竞争调优

    • Java中的锁机制是实现并发的重要手段,但是锁的竞争会导致应用程序的性能下降。因此,锁竞争调优是Java应用程序性能调优的一个重要方面。
    • 可以通过使用synchronized关键字和Lock接口来进行锁竞争调优。synchronized关键字是Java中实现锁的一种简单方式,但是在高并发场景下,由于锁粒度较大,容易导致锁竞争。
    • 因此,在高并发场景下,可以考虑使用Lock接口来实现锁。Lock接口支持更细粒度的锁,并且可以控制锁的获取和释放。
  3. CPU占用调优

    • CPU占用调优是指通过优化代码来减少CPU的占用率,提高应用程序的性能。
    • 可以通过以下方式来进行CPU占用调优:
      1. 减少线程数:线程数过多会导致CPU占用率上升,因此可以通过减少线程数来降低CPU占用率。
      2. 避免无用循环:无用循环会导致CPU占用率上升,因此可以通过优化代码来减少无用循环的出现。
      3. 避免递归调用:递归调用会导致CPU占用率上升,因此可以通过优化代码来避免递归调用的出现。
  4. IO调优

    • IO调优是指通过优化IO操作来提高应用程序的性能。
    • 可以通过以下方式来进行IO调优:
      1. 减少IO操作:减少IO操作可以减少IO调用的次数,从而提高应用程序的性能。
      2. 使用缓存:缓存可以减少IO调用的次数,从而提高应用程序的性能。
      3. 使用异步IO:异步IO可以在IO操作期间释放CPU资源,从而提高应用程序的性能。

确定目标

  • 我们首先要清楚我们的应用程序是干什么的,即确定一个垃圾回收的目标
    • 如果是做一些科学运算、批量处理,那么追求的就是高吞吐量,延长一点点响应时间对我们没有太大影响
    • 如果是做一些互联网、金融、电子商务项目,那么响应时间就是一个非常重要的指标了,如果每次垃圾回收都延长了响应时间,就会给用户造成不好的体验
  • 确定了目标以后,我们再来选择合适的垃圾回收器
    • 对于高吞吐量的应用程序,通常可以选择 Parallel GC 等并行垃圾回收器来实现高效的垃圾回收。
    • 而对于需要低延迟、高响应时间的应用程序,可以选择 CMSG1ZGCZing 等低延迟垃圾回收器来实现。

最快的GC

  • 最快的GC是不发生GC,即尽可能减少内存分配和回收的次数。因此,建议在代码中尽量避免频繁地创建和销毁对象,或者使用不必要的大型数据结构。
  • 查看FULL GC前后的内存占用,考虑以下几个问题
    1. 数据是不是太多?
      • 例如下面的SQL语句
      1
      SELECT * FROM 大表
      • 查询大表时,如果数据量太大,会占用大量的内存空间,导致频繁的GC,甚至可能直接内存溢出,建议在查询时加上限制条件(如limit)来减小数据量。
      1
      SELECT * FROM 大表 LIMIT n
    2. 数据表示是否太臃肿?
      • 如果数据结构过于庞大,例如包含大量不必要的字段或者对象引用,也会占用大量内存空间。
      • 例如在Java中,Integer是一个对象,而int是一个基本数据类型。因此,当我们使用Integer时,实际上是创建了一个对象来存储整数值。相对地,使用int时,只需要直接在栈上分配一个内存空间来存储整数值,不需要再创建对象,因此内存占用更小。
        • Integer对象占用的内存空间包含了对象头、对象的值等信息,一般需要占据16个字节
        • 而int类型只需要占用4个字节的内存空间。
    3. 是否存在内存泄露?
      • 例如我们创建一个静态的Map对象
      1
      static Map<Object, Object> map = new HashMap<>();
      • 一直向静态Map中存储数据,会导致静态Map中的内存占用越来越大,可能导致频繁的GC和内存溢出。因为静态变量属于类,不会被垃圾回收器自动释放。这种情况可以使用软引用或弱引用来引用该对象,以便在内存不足时自动释放缓存数据。
      • 但还是不建议使用Java来实现缓存,而是推荐使用第三方缓存实现,例如Redis等

新生代调优

  • 在新生代调优之前,我们先来回顾一下新生代的特点

    1. 所有的new操作的内存分配都非常廉价,即分配速度很快。
    2. 死亡对象的回收:Java虚拟机有自动垃圾回收机制,用于回收不再被使用的对象。在新生代中,回收死亡对象的代价为零,因为它们可以很容易地被回收。
    3. 大部分对象用过即死:大部分对象的生命周期很短,使用一次就被丢弃了,这是因为Java中的对象通常用于执行一些特定的任务,而不是被持续使用。
    4. Minor GC:Java虚拟机会周期性地进行垃圾回收操作,其中一种是Minor GC,用于回收新生代中的对象。由于新生代中的对象生命周期短,因此Minor GC的时间通常比Full GC(用于回收老年代中的对象)短很多。
  • 调优参数:-Xmn设置新生代大小

    • 那么是设置的越大越好吗?不是的,官方文档中给出的解释如下
    • -Xmn
      • Sets the initial and maximum size (in bytes) of the heap for the young generation (nursery). GC isperformed in this region more often than in other regions. If the size for the young generation is too small, then a lot of minor garbage collections are performed. If the size is too large, then only full garbage collections are performed, which can take a long time to complete. Oracle recommends that you keep the size for the young generation greater than 25% and less than 50% of the overall heap size.
      • 设置新生代堆的初始大小和最大大小(以字节为单位)。垃圾回收在新生代中比其他区域更频繁地进行。如果新生代的大小过小,则会执行很多次Minor GC。如果大小过大,则只执行FULL GC,这可能需要很长时间才能完成。Oracle 建议您将新生代的大小保持在总堆大小的 25% 以上且低于 50%。
  • 新生代大小最好能容纳所有并发量 * (请求-响应)的数据

    • 例如一次请求响应需要512KB内存,并发量为10000,那么差不多就是5GB
  • 幸存区要大到能够保留当前活跃的对象 + 需要晋升的对象

    • 如果幸存区过小,JVM会灵活调整晋升的阈值,从而可能导致某些对象被提前晋升到老年代,从而将其从幸存区释放。当幸存区不足以容纳所有存活的对象时,垃圾回收器会选择将一部分对象直接晋升到老年代,而不是等待这些对象达到晋升的阈值再晋升。
    • 这样做的问题在于,新生代中的对象通常比老年代中的对象生命周期短,如果过早地将这些对象晋升到老年代,可能会导致老年代中存在许多生命周期很短的对象,从而增加垃圾回收的压力,甚至导致堆内存溢出。
    • 因此,为了变过早地将对象晋升到老年代,通常建议将幸存区设置的足够大,以便能够容纳当前活跃对象与需要晋升对象的总和。这也可以减少对象直接晋升到老年代的情况,提高了年轻代的空间利用率,并减轻垃圾回收的压力。
  • 晋升阈值也要配置得当

    • 调整晋升阈值是为了让存活时间较长的对象能尽早进入老年代,这样可以减少在幸存区进行复制操作的次数和数量。
    • 相关VM参数:
      • 调整最大晋升阈值:-XX:MaxTenuringThreshold=threshold
      • 垃圾回收时打印存活对象详情:-XX:+PrintTenuringDistribution
      1
      2
      3
      4
      5
      Desired survivor size 48286924 bytes, new threshold 10 (max 10)
      - age 1: 28992024 bytes, 28992024 total
      - age 2: 1366864 bytes, 30358888 total
      - age 3: 1425912 bytes, 31784800 total
      ...

老年代调优

  • 以CMS为例
    • CMS的老年代内存越大越好
    • 先不进行调优,如果没有发生FULL GC,那么说明程序运行良好,应该先尝试新生代调优
    • 观察发生FULL GC时老年代内存占用,将老年代内存预设增加 1/4 ~ 1/3。
      • 然后逐步调整-XX:CMSInitiatingOccupancyFraction参数的值,找到一个合适的设置值。
      • 一般来说,可以将该参数设置在75% ~ 85%之间

案例

案例1

  • Full GCMinor GC 频繁
  • 如果 Full GCMinor GC 频繁发生
    • 那么可能就是我们之前提到过的,幸存区过小,导致某些短生命周期对象晋升到了老年代
  • 可以尝试调整内存分配比例
    • 通过调整新生代和老年代的内存分配比例来减少 Minor GCFull GC 的频率。
    • 一般情况下,新生代的大小应该比老年代小,可以尝试逐步增加新生代的大小,减少老年代的大小

案例2

  • 请求高峰期发生 Full GC,单次暂停时间特别长 (CMS)
  • 首先我们来分析一下具体是哪部分耗时较长,由于我们采用的是CMS收集器,在 CMS 收集器中,重新标记阶段通常是导致单次暂停时间变长的主要因素之一。
  • 如果我们在重新标记之前进行一次新生代的垃圾回收,可以减少堆中对象的数量,从而减少重新标记的时间。
  • 具体使用 -XX:+CMSScavengeBeforeRemark 参数,可以让 CMS 在重新标记之前进行一次新生代的垃圾回收。

案例3

  • 老年代充裕情况下,发生 Full GC (CMS jdk1.7)