学习JVM(2)垃圾回收(GC)

1.如何判断对象可以被回收

1.1引用计数法

所谓的引用计数法就是,只要一个对象被其他变量所引用一次,就给它计数+1,引用两次计数就变成2,如果某一个变量不在引用它了,就给它计数-1,当这个对象引用计数变为0的时候,意味着没有变量再引用它了,那它就可以作为一个垃圾进行一个回收

引用计数法听起来不错,但是它有一个很重要的弊端,它会出现一个循环引用的问题

A对象引用B对象,B对象又引用A对象,就导致,它们两个的计数都是1,就不能被回收,会导致内存泄漏,就有点像我们线程中的死锁。

内存泄漏是指程序在运行过程中未能正确管理其使用的内存,导致本应释放的内存没有被及时回收,进而造成内存资源浪费的情况。当程序持续发生内存泄漏时,它会占用越来越多的内存,最终可能导致系统性能下降、响应变慢甚至崩溃。

python的虚拟机的垃圾回收使用的就是引用计数法,不过它又引入了其他东西来解决这些问题,我们Java垃圾回收使用的可达性分析算法

1.2可达性分析算法

  • Java虚拟机中的垃圾回收器采用可达性分析算法来探索所有存活的对象
  • 扫描堆中的对象,看是否能够沿着GC Root对象为起点的引用链找到该对象,找不到表示可以回收

根对象,也就是GC Root到底是什么,其实就是肯定不能被回收的对象,我们就称之为根对象。每次垃圾回收之前,Java虚拟机都会去扫描堆中的对象,看看每一个对象是不是刚刚提到的根对象直接或者间接的引用,如果是,那么这个对象就不能被回收,反之如果没有被根对象引用,那么就可以作为垃圾被回收

哪些对象可以作为GC Root?

  • System Class:这些就是系统类,这些系统类都是我们启动器加载的类,也就是一些核心的,你在运行期间肯定会用到的。比如说Object类,HashMap,System,IO类,String等等。
  • Native Stack:第一篇中也讲过,Java虚拟机在执行一些方法的时候,它必须调用操作系统的方法,操作系统的方法它在引用时,引用的一些Java对象也是可以作为根对象。
  • Thread:就是我们的活动线程,一个线程中会有很多栈帧,栈帧内使用的一些对象可以被看做是根对象。
  • Busy Monitor:在Java对象中有同步锁机制,也就是synchronized关键字,那么被synchronized加锁的对象肯定是不能被当做是垃圾的,要是被回收了,谁来解锁呢。

1.3四种引用

首先我们要明确的一点是,这些引用不是个抽象概念,它们本身就是一个个变量或者对象,通过引用对象来引用被引用的对象。

1.3.1 强引用

强引用是最普遍的引用,一般把一个对象赋给一个引用变量,这个引用变量就是强引用。(强引用是个引用变量,它是和栈帧一起创建一起被释放的,所以GC不会回收它)

//  强引用
String s = new String();

// 或者
String s1 = "a"
String s2 = s1
// s2 强引用 s1

在一个方法的内部有一个强引用,这个引用保存在Java中,而真正的引用内容保存在Java中。

如果一个对象具有强引用,垃圾回收器不会回收该对象,当内存空间不足时,JVM 宁愿抛出 OutOfMemoryError异常。如果强引用对象不使用时,需要弱化从而使GC能够回收,如下:

//帮助垃圾收集器回收此对象
s2=null;

显式地设置s2对象为null,或让其超出对象的生命周期范围,则GC认为该对象不存在引用,这时就可以回收这个对象,具体什么时候收集这要取决于GC算法

/**
* 强引用举例
*/
public class StrongRefenenceDemo {
 
    public static void main(String[] args) {
        Object o1 = new Object();
        Object o2 = o1;
        o1 = null;
        System.gc();
        System.out.println(o1);  //null
        System.out.println(o2);  //java.lang.Object@2503dbd3
    }
}

o1=null,按道理来说可以被回收了,但是又被o2强引用,因此o1,o2 都不会被回收

1.3.2 软引用

软引用是一种相对强引用弱化了一些的引用,需要用java.lang.ref.SoftReference 类来实现,也就是说它就是一个对象

String str=new String("abc");                                     // 强引用
SoftReference<String> softRef=new SoftReference<String>(str);     // 软引用

如果一个对象只具有软引用(只要有强引用就不能被回收),则内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

软引用通常用在对内存敏感的程序中,比如高速缓存就有用到软引用,内存够用的时候就保留,不够用就回收。

1.3.3 弱引用

弱引用的使用和软引用类似,只是关键字变成了 WeakReference,也是一个对象

Strin str = new String();
WeakReference<String> wStr = new WeakReference<String>(str);

弱引用的特点是不管内存是否足够,只要发生 GC,都会被回收。

弱引用也可以和引用队列结合使用,来用于释放内存

1.3.4 虚引用

虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收

虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。

虚引用需要java.lang.ref.PhantomReference 来实现,也是一个对象

A a = new A();
ReferenceQueue<A> rq = new ReferenceQueue<A>();
PhantomReference<A> prA = new PhantomReference<A>(a, rq);

虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。

虚引用主要用来跟踪对象被垃圾回收器回收的活动。

我上一篇讲直接内存的时候,提到过虚引用,其实就是因为直接内存不被JVM所管理,当ByteBuffer被释放了之后,但是直接内存是不会被释放的,所以我们把虚引用对象(在这里也就是Cleaner)入队,对象里面储存着直接内存的地址,然后调用Unsafe.freeMemory将直接内存释放,再把自己虚引用对象自己也释放掉,防止内存泄漏

1.3.5 终结器引用

终结器引用也是一个对象,是 java.lang.ref.Finalizer 类的实例

终结器引用用于实现父类Object对象的finalize() 方法,无需手动编码,其内部配合必须引用队列使用,在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象,然后调用它的finalize()方法,第二次GC时才回收被引用的对象。

也就是说,当没有强引用之后,JVM会帮我们创建这个对象的终结器引用,当一次GC时,终结器引用先入队,但是这时候内容对象还没有被释放,等到第二次GC时,会有一个叫做Finalizer的线程进行查看,是否存在终结器引用,如果有,那就调用finalize()方法把内容对象给释放掉,然后再把终结器对象释放掉。

但是这个Finalizer线程优先级很低,被执行的机会很少,并且实际上的垃圾回收是在第二次GC的时候,就会导致的这个对象的finalize()方法迟迟不被调用,这个对象所占用的内存也迟迟得不到释放,因此不推荐使用finalize来释放资源。

1.3.6 引用队列

引用队列的作用是什么?为什么引用对象不能和被引用的内容对象一起被释放,而是先释放被引用的,再把引用对象放入引用队列再释放?

1.引用队列(ReferenceQueue)的作用是什么?

        核心作用:提供一种机制,让你能“监听”到某个对象被垃圾回收器回收的事件。

假设你实现了一个缓存,缓存中的对象使用 SoftReference WeakReference 来持有,这样当内存不足或对象不再强引用时,这些对象可以被回收。

但问题是:你怎么知道某个对象已经被回收了?你不能一直去遍历所有引用并调用 get() 来检查是否为 null —— 这效率低,且无法区分“还没回收”和“已回收”。

所以 ReferenceQueue 出现了。当你创建引用时,把它和一个 ReferenceQueue 关联:

ReferenceQueue<MyObject> queue = new ReferenceQueue<>();
WeakReference<MyObject> ref = new WeakReference<>(obj, queue);

obj 被 GC 回收后,JVM 会自动将 ref 这个引用对象本身放入 queue 中。

你就可以通过轮询或监听这个队列,知道“某个对象已经被回收了”,然后做清理工作(比如从缓存中移除 key)。

Reference<? extends MyObject> polled = queue.poll();
if (polled != null) {
    System.out.println("有对象被回收了!是这个引用:" + polled);
    // 可以在这里执行资源清理、缓存清理等
}

2.为什么不能直接一起释放“引用对象”和“被引用对象”,而是要先放队列再处理?
        1. “引用对象”和“被引用对象”是两个不同的对象
  • MyObject obj = new MyObject(); → 被引用对象
  • WeakReference<MyObject> ref = new WeakReference<>(obj); → 引用对象(本身也是一个 Java 对象)

它们在堆上是两个独立的对象。

GC 的目标是回收 不再被使用的数据对象(如 obj),而 ref 是一个控制结构,用于跟踪 obj 的生命周期。

        2. 如果直接释放引用对象,你就永远不知道“回收事件”发生了

设想一下:

GC 发现 obj 没有强引用了 → 直接把 obj ref 一起回收。

那你如何知道 obj 被回收了?你失去了所有通知手段。

而现实中,很多场景需要这种“回收通知”:

  • 缓存清理(WeakHashMap 就是基于这个机制)
  • 资源释放(如文件句柄、网络连接的包装)
  • 内存泄漏检测工具
  • PhantomReference 用于对象 finalize 后的清理(比 finalize 更安全)

所以 JVM 设计了一个“中间状态”:

  1. 先回收 obj(实际数据)
  2. 然后把 ref(引用包装)放入队列
  3. 程序可以读取队列,得知“谁被回收了”
  4. 最后 ref 自己也会被回收(当没人引用它时)

这就像是:

“死亡通知单”不能和“死者”一起火化,必须留下来通知家属。

ref 就是那张“死亡通知单”。

        3.内存开销问题:会不会造成内存泄漏?

你可能会担心:“那不是多了一个 ref 对象一直存在吗?会不会内存泄漏?”

答案是:不会。

  • 一旦你从 ReferenceQueue 中取出 ref,并且没有其他地方引用它 → 它就变成不可达对象。
  • 下一次 GC 会正常回收 ref 本身。

所以整个生命周期是:

创建 ref → obj 被回收 → ref 入队 → 你处理 ref → ref 不再被引用 → ref 被回收

干净、可控、无泄漏。

2.垃圾回收算法

2.1 标记清除(Mark Sweep)

如图,就是直接把对象释放了,释放是不是意味着要把内存每个字节给清零呢?注意:是不会的。它只需要把内存的起始和结束的地址记录下来,放在一个叫做空闲的地址列表里就可以了,那么下次去分配内存的时候,它就到空闲列表里去找,是否有一块可以容纳的空间放置我的新对象

  • 优点:就是速度快,释放只需要记录一下内存的起始和结束地址就完成了
  • 缺点:它容易产生这种内存碎片,比如说,我们需要分配一块比较大的ArrayList,我们知道数组的内存是连续的,这就导致,其实空间是很大的,但是很零散,可能单拎一块碎片没办法放下ArrayList

2.2 标记整理(Mark Compact)

如图,在清除的同时,它会把可用的对象向前移动,让我的内存更为紧凑,整理之后,连续的空间就更多了,这样就不会造成我们上面说的内存碎片的问题了

  • 优点:显然优点就是没有内存碎片
  • 缺点:由于整理牵扯到了对象的移动,大家想,如果你有一些局部变量应用了这些GC Root,那肯定要改变这些引用的访问地址呀,那么涉及到的工作肯定就多一些

2.3 复制(Copy)

如图,这个过程是这样的,有两块区域From和To

将GC Root或者还不能被回收的对象放入To区域中,这样From区域中只剩可以被回收的对象了

然后将From回收

再把现在的From当做下一次GC的To,现在的To当做是下一次GC的From(也就是交换From和To)

  • 优点:不会有内存碎片
  • 缺点:需要占用双倍的内存空间

上面三种算法呢,JVM会根据不同的情况来采用,不会说只用其中一种算法,是用多种算法来共同实现,协同工作,具体的实现就是下面讲的分代的垃圾回收机制

3.分代垃圾回收

3.1分代垃圾回收的介绍

我们将堆中的内存分为两块,新生代老年代

在新生代中,又分为伊甸园(Eden)、幸存区From(From Survivor)、幸存区To(To Survivor)下面我来讲解一下整个垃圾回收的流程

  1. 伊甸园,顾名思义,所有对象刚被创建都是放在伊甸园中
  2. 当伊甸园的内存放满了之后,会触发一次MinorGC(专门用来回收新生代的对象)
  3. 会采用可达性分析算法进行一个判断,看哪些对象是可以被回收的。
  4. 然后通过上面将的复制算法,将伊甸园中不可回收的对象放入幸存区To中。
  5. 在复制之后它会让幸存区To里的对象寿命+1(刚开始寿命是0)
  6. 然后将From和To交换位置(上面复制算法讲过)

然后伊甸园空间就被释放出来了

那么伊甸园内存又满了怎么办,那就再触发一次垃圾回收MinorGC

  1. 这次要把伊甸园和From里面的不可回收垃圾放入To中
  2. 继续寿命+1(不是说第一次不死,就不会被回收了,只是单纯标记存活了几次)
  3. 然后Form和To继续交换位置,这个时候伊甸园又空了

幸存区中的对象不会永远在幸存区中呆着,当它的寿命超过阈值,默认最大阈值为15,这说明,你这个对象,价值比较高,经常在使用,那没必要把你一直在幸存区留着,因为我再垃圾回收还是没法回收你,那么这个时候,达到15阈值的这个对象就晋升为老年代(把它放入老年代中),因为老年代的垃圾回收频率比较低,不会轻易的进行垃圾回收

那么如果说,伊甸园放不下了,From里面也放不下了,老年代也放满了,这时候来了一个新对象,它就会执行Full GC(当老年代的空间不足,它就会执行一次Full GC,Full GC会对整个新生代和老年代都进行清理)

Minor GC会引发stop the world,是什么意思呢?就是我们在垃圾回收的时候,我必须暂停其他用户的线程,由我的垃圾回收线程来完成垃圾回收的动作,当垃圾回收完成之后,其他的用户线程才能继续运行。

其实也很好理解,我们在垃圾回收的时候,对象储存的地址会发生改变,这个时候要是多个用户线程同时运行,就发生会混乱,你对象都移动了,用原来的地址找对象就找不到了

不过MinorGC回收的很快,线程暂停的时间比较短,因为新生代本来大部分对象就是垃圾,复制的对象只有很少的一部分

当对象的寿命超过阈值,会晋升至老年代,最大寿命是15(4 bit 二进制1111就是15)但是我们在不同的垃圾回收器,阈值也不一样。有的时候当空间紧张时,也许你这个对象寿命还没有到15,它也会提前晋升到老年代中

当老年代空间不足时,会先尝试触发一次Minor GC,如果空间仍然不足,就会触发Full GC,Full GC的stop the world的时间比Minor GC要更长。因为老年代的对象比较多而且它使用的算法和新生代也不一样的,可能是标记清除,可能是标记整理,如果是标记整理,速度就会比较慢

如果Full GC清理完之后,内存还是不足,就会报java.lang.OutOfMemoryError: Java heap space的错误

3.2 相关VM参数(GC相关参数,调试观察分代垃圾回收流程)

幸存区比例是8:1:1,假如有10MB,伊甸园8MB,From和To各1MB

public class Demo {
    private static final int _512KB = 1024 * 512;

    private static final int _1MB = 1024 * 1024;
    private static final int _6MB = 1024 * 1024 * 6;
    private static final int _7MB = 1024 * 1024 * 7;
    private static final int _8MB = 1024 * 1024 * 8;

    // -Xms24M -Xmx24M -Xmn10M -XX:+PrintGCDetails -XX:+UseSerialGC -verbose:gc
    public static void main(String[] args) {
//        List<byte[]> list = new ArrayList<>();
//        list.add(new byte[_512KB]);
    }
}

Modify options里面找到 Add VM options,然后调整JVM参数,run一下程序,就会看到

新生代、老年代和元空间,各自的内存大小和使用比例,后面是它们内存的地址

可自行调试观察分代垃圾回收的流程

值得一提的是,垃圾回收时还有一种策略,大对象直接晋升到老年代,在我们放入_8MB的对象时,新生代放不下,因为即使你再怎么垃圾回收,你也放不下超出新生代本身内存大小的内容,那么这个时候这个_8MB对象会被直接晋升到老年代,并且连Minor GC也不会触发

如果达不到自己想要的效果,可以自行调整-Xms -Xmx -Xmn参数

当新生代老年代内存都不足的时候,就会报OOM错误,也就是  java.lang.OutOfMemoryError: Java heap space     

4. 垃圾回收器

垃圾回收器分为三类,串行、吞吐量优先、响应时间优先的垃圾回收器

4.1 串行(SerialGC

顾名思义,就是底层是一个单线程的垃圾回收器,当垃圾回收的时候,其他线程全都暂停

适用场景:

  1. 堆内存较小的时候
  2. CPU核数多了也没用,因为你只有一个线程,也就是适合个人电脑

开启串行垃圾回收器的指令:-XX:+UseSerialGC=serial + serial0ld

可以看到,串行垃圾回收器分为serialserialOld两个部分

serial和serialOld是分开工作的

serial是工作在新生代,使用的是复制算法,serial会触发Minor GC

serialOld是工作在老年代,使用的是标记整理算法,serialOld会触发Full GC

当进行垃圾回收的时候,所有线程都要运行到一个安全点,因为之前说过,垃圾回收会改变对象的地址,然后进行阻塞,由垃圾回收线程来完成垃圾的回收。

serial和serialOld的工作流程都和上面相同,只是回收的算法有所不同

4.2 吞吐量优先(Parallel GC)

目标:它是多线程的,在单位时间内,尽可能让STW(stop the world)时间最短。(单位时间内垃圾回收的时间越短,吞吐量越高)

适用场景:

  1. 堆内存较大
  2. 多核CPU,你CPU少了,多个线程争抢一个CPU,效率还不如串行单线程

吞吐量优先垃圾回收器的指令:

  1. -XX:+UseParallelGc 和-xx:+UseParalle10ldGc,其实在1.8之后JDK默认使用的就是吞吐量优先,前者回收新生代,使用复制算法,后者回收老年代,使用标记整理算法,这两个你只要开启一个,它就会连带着把另外一个开启
  2. -XX:+UseAdaptivesizePolicy 这个参数启用了 JVM 的自适应大小策略(+ 表示开启)JVM  的垃圾回收器会根据应用程序的运行时行为动态地调整新生代(Young Generation)中各个区域(如 Eden 区和 Survivor 区)的大小,以及新生代与老年代(Old Generation)之间的比例,包括整个堆的大小和晋升阈值也会受这个开关的影响
  3. -XX:GCTimeRatio=ratio  该参数用于设置应用程序运行时间与垃圾回收时间之间的目标比率。它为 JVM 的自适应 GC 策略(如 Parallel GC 的自适应大小策略)提供了一个性能目标。
    • 计算公式:假设 n = <ratio> JVM 会努力使垃圾回收时间占总时间的比例不超过 1 / (1 + n)
    • 默认值: ratio该参数的默认值通常是 99,这意味着 JVM 默认的目标是将 GC 时间控制在总时间的 1% 以内(1/(1+99))。也就是说100分钟内,你只能用1分钟用于垃圾回收

    • 如果达不到1%这个目标,它会自动调整速率,它是怎么调整的呢?一般来说,就是调大堆的大小,这样垃圾回收的次数就会变少,时间就会变短,从而达到吞吐量的提高

    • 一般默认值99的目标是很难达到的,我们一般设置为19,也就是(1/(1+19))=0.05

  4. -XX:MaxGCPauseMillis=ms(毫秒) 该参数用于设置垃圾回收器(GC)的最大目标暂停时间
    • 其实就是让垃圾回收的时间不能超过设定的ms
    • 这个参数主要与 JVM 的自适应大小策略
    • 注意!!这个参数和上面那个参数的冲突的,上面会调大堆的大小,就会导致一次垃圾回收的时间变长,因此这个参数自行调整,会调小堆的大小,来达到垃圾回收时间的缩短
  5. -XX:ParallelGcThreads=n 可以控制多线程回收的线程数量,默认和CPU的核数有关

流程和串行是一样的,都有一个安全点,不同的就是,多线程大家一起回收,线程的数量默认和CPU的核数有关,值得一提的是,当时垃圾回收的时候,因为多线程大家一起清理,会导致CPU的占用率一下子飙升,清理完之后,马上大幅度下降

4.3 响应时间优先(CMS)

目标:它是多线程的,单位时间内,尽可能让单次的STW(stop the world)时间最短

吞吐量优先是:单位时间内,比如说1小时内,触发了2次垃圾回收,每次0.2s ,0.2 + 0.2 = 0.4s

响应时间优先是:单位时间内,比如说1小时内,触发了5次垃圾回收,每次0.1,0.1*5 = 0.5s

适用场景:

  1. 堆内存较大
  2. 多核CPU,你CPU少了,多个线程争抢一个CPU,效率还不如串行单线程

响应时间优先的相关指令:

  1. -XX:+UseParNewGC 和 XX:+UseConcMarkSweepGC ~ Serial0ld
    1. 前者工作在新生代,使用复制算法,后者工作在老年代,使用标记清除算法,UseConcMarkSweepGC和上面垃圾回收器不同,上面的都是并行的,而它是并发的,也就是说,它在执行的时候,是允许其他线程同时运行,但是有的时候,UseConcMarkSweepGC会发生并发失败的问题,这个时候它就会采取一个补救的措施,退化成SerialOld,也就是我们上面提到过的单线程中工作在老年代的SerialOld
  2. -XX:ParallelGcThreads=n 和 -XX:ConcGcThreads=threads
    1. -XX:ParallelGcThreads=n 这个上面讲过可以控制并行多线程回收的线程数量,默认和CPU的核数有关
    2. -XX:ConcGcThreads=threads 这个是控制并发线程的数量,一般这个参数我们设置为并行线程数的1/4,因为你需要留一些线程给用户线程执行工作
  3. -XX:CMSInitiatingOccupancyFraction=percent 让 CMS 在老年代占用率达到某个低于 100% 的阈值(如 70% 或 80%)时就提前启动,为浮动垃圾预留空间,避免并发模式失败。
  4. -XX:+CMSScavengeBeforeRemark 在CMS 的“重新标记”(Remark)阶段开始之前,强制执行一次新生代(Young Generation)的垃圾收集(即 Minor GC 或 Scavenge)。就是可能新生代的对象会引用老年代的对象,那么在重新标记的时候,就回去扫描一遍新生代,但是新生代中的对象大部分都是被当做垃圾回收掉的,那么就会导致很多没必要的扫描,因此加上这个参数之后就表示,在老年代的回收之前,先触发一次Minor GC 清理一下新生代

上面这张图是回收老年代时,并发的流程。

  1. 在一开始的时候,会造成STW,它需要先去初始标记一下,标记什么呢?它只标记一些根对象,所以标记速度是非常快的
  2. 接下来用户线程就可以开始运行了,与此同时它还需要并发标记,把剩余的那些垃圾给它找出来,因为它不用暂停STW,所以它的响应时间的很快的,几乎不影响你的用户线程工作
  3. 等到并发标记以后,它还需要做一步重新标记,这里又要STW,标记什么呢?你的用户线程在运行的时候,可能会产生一些新的对象,也会改变一些对象的引用,可能会对你的垃圾回收做一些干扰,所以需要重新标记
  4. 然后用户线程又可以运行了,这个时候它再做一次并发的清理

CMS在运行的时候,因为它的并发线程只有1/4,它的CPU占用率是没有上面那个这么高的,但是与此同时,还用其他的用户线程在同时运行,但是因为有一些线程被垃圾回收占用了,会导致我们用户线程可使用的CPU线程减少,也就是说,对我们用户线程的吞吐量是有影响的(就是线程数少了,同一单位时间内,用户线程能处理的数据变少了)

在我们并发清理的时候,这些用户线程还会产生新的垃圾,那么这些垃圾只能留到下一次GC触发的时候才会被清理,这些垃圾被称之为浮动垃圾

影响:

  1. 内存占用: 浮动垃圾会占用堆内存空间,减少了可用内存。如果浮动垃圾积累过多,可能会加速老年代的填充,从而更早地触发下一次 GC
  2. 潜在的 Full GC 风险: 在 CMS 中,如果老年代空间因为浮动垃圾等原因被填满,而并发收集还未来得及完成,就会发生“并发模式失败”(Concurrent Mode Failure),导致 JVM 退回到使用 Serial Old 收集器进行长时间的 Full GC,这是非常不希望看到的。

对于 CMS,可以通过 -XX:CMSInitiatingOccupancyFraction=<value> 参数,让 CMS 在老年代占用率达到某个低于 100% 的阈值(如 70% 或 80%)时就提前启动,为浮动垃圾预留空间,避免并发模式失败。

还有就是,上面提到了,老年代使用标记清楚算法,所以会导致出现很多内存碎片,这样就会造成,将来我在分配对象的时候,Minor GC后新生代不足,老年代因为内存碎片也不足,也会导致并发失败,UseConcMarkSweepGC就会退化为SerialOld(标记整理),但是CPU占用率和时间一下子就会上去,这也是CMS最大的问题

4.4 G1(Garbage First)

1. 介绍

在JDK9时,被设置为默认(JDK8默认是并行也就是吞吐量优先),G1就是取代了CMS,在JDK9时CMS垃圾回收器已经被废弃了,它也是一个多线程,并发的一个垃圾回收器,目标上和CMS是一致的,它追求的是低延迟

当内存较小的时候CMS和G1运行速度差距不大,但是当内存很大的时候,G1优势就很明显了

适用场景:

  • 同时注重吞吐量(Throughput)和低延迟(Low latency),也是自适应,默认的暂停目标是 200 ms,当然你要是注重吞吐量,可以把暂停的目标时间提高,增加吞吐量
  • 超大堆内存(随着硬件的发展,服务器可以使用的内存越来越大),会将堆划分为多个大小相等的 Region(区域),每个区域都可以独立的作为伊甸园幸存区老年代
  • 整体上是标记+整理算法,但是两个Region区域之间是复制算法

默认情况下,堆被划分为 2048 个 Region(可通过 -XX:G1HeapRegionSize 调整,大小可以是 1MB, 2MB, 4MB, 8MB, 16MB, 32MB,且必须是 2 的幂)。

相关JVM参数:

  1. -XX:+UseG1GC G1在JDK1.8还不是默认的,需要使用这个参数启动它
  2. -XX:G1HeapRegionsize=size 设置Region区域的大小
  3. -XX:MaxGCPauseMillis=time 设置暂停的目标时间

  • 关键点: 这些 Region 在内存地址上不需要连续。一个代(如新生代)由一组逻辑上属于该代的 Region 组成,这些 Region 在物理内存上可以是分散的。

“化整为零”的好处: 在之前,当你堆内存很大时候,不可避免的需要长时间的停顿,但是G1 不再需要一次性回收整个新生代或老年代。它可以选择性地回收一部分 Region,从而将一次可能很长的 GC 停顿,分解成多次很短的停顿。

G1的垃圾回收有这么三个阶段:

  1. 新生代的回收
  2. 新生代+并发标记
  3. 混合回收

这三个阶段是一个循环的过程

2. 新生代回收阶段

白色的就是一个个空闲的Region区域,E就是伊甸园,S就是幸存区,O就是老年代这个阶段流程和之前都是一样的,会触发STW,会进行GC Root的初始标记,E中满了放入S中,寿命阈值到了,放到O中

3. 新生代回收和并发标记阶段

当老年代占用堆空间比例达到阈值的时候,进行并发标记(不会STW,不会影响用户的工作线程),阈值由下面的JVM参数设置:-XX:InitiatingHeapOccupancyPercent=percent (默认45%),就是整个老年代占用到整个堆空间的45%时,它就会进行一次并发标记

并发标记 (Concurrent Marking) - 并发: GC 线程与用户线程并发运行,遍历整个堆的对象图,标记所有存活对象。使用“三色标记”算法,并通过“写屏障”(Write Barrier)处理并发修改。

这个周期是 G1 实现混合 GC的基础,它本身大部分是并发的,目的是识别老年代中哪些 Region 的垃圾最多(即“回收收益最高”)。

生成一个按“回收价值”排序的 Region 列表,为 Mixed GC 提供候选区域。

4. 混合回收阶段

会对E、S、O全面垃圾回收

  • 重新标记 :会STW,最终标记的作用就是,在我之前的并发标记的时候,可能会漏掉一些对象
  • 拷贝存活:会STW,最终标记完成,对存活的对象进行一个拷贝

对于老年代的拷贝来讲,并不是所有的老年代都会被拷贝,因为老年代一多,全部拷贝的话很可能就超过了我们之前设置的最长暂停时间了,所以G1会优先复制它认为垃圾比较多的老年代区域,这样回收的多,复制的少,性价比高,当然如果老年代数据和区域没有那么多的时候,也会全部都复制。这也就是为什么叫做Garage First,就是它会优先收集某些区域的意思

它就可以通过少量,多次,回收的方式,减少STW停顿,通过持续回收老年代的“垃圾大户”,有效控制了老年代的增长,避免了传统 Full GC

5. Full GC

  • SerialGC
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - fill gc
  • ParallelGC
    • 新生代内存不足发生的垃圾收集-minor gc
    • 老年代内存不足发生的垃圾收集 - fill gc
  • CMS
    • 新生代内存不足发生的垃圾收集-minor gc
    • 老年代内存不足
  • G1
    • 新生代内存不足发生的垃圾收集-minor gc
    • 老年代内存不足

CMS和G1类似,以G1为例:我们之前SerialGC和ParallelGC老年代内存不足时都会触发Full GC,G1不同,刚刚提过,它有一个:-XX:InitiatingHeapOccupancyPercent=percent (默认45%)阈值,到这个阈值之后,就会触发一次并发标记和混合回收的阶段

这里有两种情况

  1. 当我回收的速度比你垃圾产生的速度要快,我还来得及打扫,虽然也有STW暂停,但是这个暂停时间还是很短的,这个时候还算不上Full GC
  2. 当你垃圾产生的速度大于我回收垃圾的速度,我来不及打扫了,这个时候并发回收就失败了(和CMS类似)这个时候就会退化为串行的垃圾收集器,这个时候就会触发Full GC(其实现在的Full GC已经变成多线程了,但是毕竟是Full GC,还是慢),当然它的STW时间就长很多了

如何判断?就看我们的打印日志,要是出现了Full GC的字样,就说明触发了Full GC

6. G1 的关键技术:Remembered Set (RSet)(跨代引用)

  • 问题: 由于 Region 是独立的,一个 Region 中的对象可能被堆中任何其他 Region的对象引用。在回收某个 Region 时,如何知道哪些外部引用指向了它里面的对象?如果扫描整个堆,就失去了“化整为零”的意义。
  • 解决方案:Remembered Set (RSet)
    • 每个 Region 都有一个 RSet。
    • RSet 记录了哪些其他 Region 中的对象引用了本 Region 中的对象
    • 例如,Region A 的 RSet 会记录“Region B 的某个对象引用了 Region A 的对象 X”。
  • 实现: 通常是一个哈希表,Key 是外部 Region 的索引,Value 是该 Region 内部引用本 Region 对象的卡页(Card)索引。
  • 维护: 通过 “写屏障”(Write Barrier) 机制自动维护。每当代码执行 obj.field = new_obj 这样的引用更新时,JVM 会在写操作前后插入一小段代码(即写屏障),检查 new_obj 是否在当前 Region 之外,如果是,则更新目标 Region 的 RSet。
    • 核心问题:为什么需要写屏障?想象一下,G1 把整个大房子(堆内存)分成了很多独立的小房间(Region)。

    • 住在 301 房间(Region A)的小明(一个对象)认识住在 502 房间(Region B)的小红(另一个对象)。
    • 有一天,小明搬家了,住进了 405 房间(Region C)。
    • 问题来了:谁来通知 502 房间(Region B)的管理员,小红不再被 301 房间的人引用了?同时,谁来通知 405 房间(Region C)的管理员,小红现在被 405 房间的人引用了?
    • 如果等到垃圾回收(GC)那天,管理员(GC 线程)才挨个房间去问“你们都认识谁?”,那效率就太低了,违背了 G1 “只扫相关区域”的高效原则。

      写屏障(Write Barrier)就是这个“通知管理员”的机制。它就像一个无处不在的“监控探头”和“自动通知系统”,专门盯着“谁认识谁”这种关系的变化。

    • “屏障”是什么? 它不是物理屏障,而是 JVM 在字节码层面自动插入的、非常短小的监控代码,监控对象的地址到底有没有被改变。对 Java 程序员完全透明。(其实感觉就类似于Spring的AOP)

  • 作用: 在 GC 扫描一个 Region 时,只需扫描其 RSet 中记录的那些外部 Region 的相关卡页,而不需要扫描整个堆,极大地提高了 GC 效率。

7. 重新标记(remark)

在CMS和G1我们都提到过初始标记和重新标记,但是没有仔细讲过重新标记是什么

“重新标记”是一个Stop-The-World(STW)阶段,即在此期间,所有用户线程必须暂停

 1. 核心目标
  • 修正标记结果: 扫描在“并发标记”阶段结束后,由于用户线程运行而导致对象引用关系发生变化的所有地方。
  • 确保准确性: 最终确定所有存活对象,杜绝漏标,保证 GC 的正确性。
  • 处理“根”和“跨代引用”: 重点扫描 GC Roots 和跨 Region/跨代的引用(如通过 Remembered Set - RSet)。

2. 主要工作内容

重新标记阶段会执行一系列扫描任务,主要包括:

  • 扫描 GC Roots: 再次扫描线程栈、寄存器、全局变量等 GC Roots,确保从根直接可达的对象都被正确标记。
  • 扫描被修改的引用(通过写屏障记录): 这是最关键的部分。并发标记期间,JVM 通过“写屏障”(Write Barrier)机制记录了所有被修改过的对象引用字段(或包含这些字段的内存区域,称为“卡页”Card)。
    • 重新标记阶段会遍历所有被“脏卡”(Dirty Card)标记的区域。
    • 对这些区域内的对象进行重新扫描,检查它们的引用关系是否发生了变化,从而修正标记。
  • 扫描 Remembered Set (RSet): 对于像 G1 这样使用 RSet 的收集器,需要扫描 RSet 中记录的“跨 Region 引用”。例如,如果 Region A 的 RSet 记录了“Region B 引用了我”,那么在回收 Region A 时,重新标记阶段就需要去扫描 Region B 中可能引用 Region A 对象的部分。
  • 处理特殊对象: 扫描 StringTable、JNI Handles 等特殊数据结构。

3. 使用的算法:SATB (Snapshot-At-The-Beginning)

G1 收集器在重新标记阶段主要使用 SATB 算法来保证正确性。

  • 核心思想: 在并发标记周期开始时(T0),对堆中的对象引用关系做一个逻辑上的“快照”(Snapshot)。
  • 规则: 在这个“快照”中是存活的对象,以及在此之后新创建的对象,都必须被视为存活。
  • 如何实现? 依赖“写屏障”。
    • 当一个对象的引用字段被修改时(obj.field = new_obj),写屏障会先记录下旧的引用值obj.field 原来指向的对象)。
    • 这个被记录的旧引用对象,会被加入到一个“待处理队列”中
    • 在“重新标记”阶段,GC 会扫描这个队列中的所有对象,确保它们被正确标记(即使它们的引用被切断了,也要保证在 幸存区To 快照中它们是存活的,所以不能被回收)。
  • 优点: 能有效防止漏标,实现相对简单。
  • 缺点: 可能会多标一些在 幸存区To之后很快死亡的对象(浮动垃圾),但保证了安全性。

(补充:CMS 使用的是增量更新 Incremental Update 策略,思路略有不同,但目标一致。)

8. G1_字符串去重(JDK8u20)

在JDK8u20之前,字符串去重我们就是采用StringTable的intern的方式进行去重,在JDK8u20之后,G1引入了一种新的去重方式

String s1 = new String("hello"); // char[] {'h','e','l','l','o'}
String s2 = new String("hello"); // char[] {'h','e','l','l','o'}
  • 将所有新分配的字符串放入一个队列
  • 当新生代回收时,G1并发检查是否有字符串重复
  • 如果它们值一样,让它们引用同一个 char[]
  • 注意,与 string.intern()不一样
    • String.intern()关注的是字符串对象
    • 而字符串去重关注的是 char[]
    • 在 JVM 内部,使用了不同的字符串表

使用-XX:+UsestringDeduplication参数就可以开启

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

9. G1_类卸载(JDK8u40并发标记类卸载)

所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类。

主要针对的是自定义的类加载器,系统的类加载器是不会被回收的,因为我们框架中是会有很多自定义的类加载器的。不过它的回收条件比较苛刻,首先这些类它们的实例都被回收掉了,这些类所在的类加载器其中的类都不再使用了,这时候它就会把类加载器里面的所有类都给卸载掉

10. G1_巨型对象

JDK8u60版本中,对G1中的功能进行了增强,就是可以让它回收巨型对象

除了E、S、O,还有H(巨型对象区),它也许会占用多个Region区域

  • 一个对象大于region 的一半时,称之为巨型对象
  • 因为拷贝成本很高,G1不会对巨型对象进行拷贝
  • 回收时被优先考虑
  • G1会跟踪老年代所有 incoming 引用(就是卡表中有哪些对象引用这个巨型对象),这样老年代 incoming 引用为0 的巨型对象就可以在新生代垃圾回收时处理掉

11. G1_动态调整阈值

JDK9中对G1功能又有很多的增强,其中一项比较重要的就是并发标记起始时间的调整

  • 并发标记必须在堆空间占满前完成,否则退化为 FullGC
  • JDK9之前需要使用-XX:InitiatingHeapoccupancyPercent来设置老年代和整个堆内存的占比阈值,但是不能动态调整阈值,要的高了呢,容易Full GC,要是低了呢,垃圾回收的又太频繁了
  • JDK9可以动态调整
    • -XX:InitiatingHeapOccupancyPercent 用来设置初始值
    • 会进行数据采样并动态调整
    • 总会添加一个安全的空档空间(会提供一个足够大的空间,来放下浮动垃圾

每个版本都会有不一样的改变,没事可以关注一下JDK的变化

这是oracle的HotSpot,JDK的官方文档地址

5. 垃圾回收调优

1. 最快的GC就是不发生GC

如果你的程序频繁的发生GC,Full GC,你应该扪心自问一下,是不是你的代码写的有问题,导致GC的频繁发生,比如说:

  • 数据是不是太多了?加载了不必要的数据到内存
    • 比如,你要查询数据库 res = "select * from 表",这样就把所有数据都查出来了,数据会被存在堆中,当好多个线程同时进来的时候,很容易导致GC,甚至OOM,最好加个limit n 限制一下查询量
  • 你是数据加载是否太臃肿了
    • 其实也就是查询很多不必要的数据,比如说我要查询一个用户,你可能会用一个表连接,把用户相关的数据全部查出来,用户详情啊,用户订单啊,用户这用户那的,但是数据查出来之后,不一定之后都用得上,这样也会对堆内存造成不必要的浪费
    • 我们引用一个Object,在Java中最少也要占用16个字节,尤其是我们经常使用包装类型比如说Integer,本身数占4个字节,还要对象头,一共需要占24个字节,但是我们一个int只需要4个字节,这是六倍的关系,如果你的内存占用过多,你可以考虑从对象上进行瘦身,能用基本类型的就不用包装类型
  • 是否存在内存泄漏
    • static Map map = new HashMap() 定义一个静态的map变量,然后一直往map里面放数据,导致很多无用的数据没办法被释放
    • 这种对象最好是使用软引用或者弱引用
    • 或者类似于缓存的这种数据最好使用第三方,如redis

2. 新生代调优

在排除了自己的代码的问题之后,GC内存调优都建议先从新生代开始

新生代的特点:

  • 当你new一个对象时,这个对象会先在伊甸园中分配,这个速度非常非常快的
    • 为什么呢?每个线程都会在伊甸园中分配一块区域叫做TLAB(thread-local allocation buffer)它就是每个线程局部的,私有的,分配了一个缓冲区,当我new一个对象之后,它会检查TLAB中有没有可用内存,如果有,它会优先在这个区域中进行内存分配,
    • 为什么这么做呢?因为我们线程分配也有一个安全问题,比如说线程1和线程2同时进来,看到一块内存是空着的,都想用,就会出现问题,所以要做一个线程安全的保护,使用TLAB就可以减少线程之间的冲突
  • 新生代中死亡对象回收代价为零,我们新生代使用的垃圾回收算法都是复制算法,将伊甸园和From区复制到To区,剩下伊甸园和From中的垃圾不需要去访问、扫描或移动它们,所以这个对象回收代价极低可以忽略不计
  • 新生代中大部分对象都是用过即死
  • 因为上面这点,所以Minor GC的时间远远低于Full GC

我们的调优是尽可能调大新生代在老年代中的占比:

  • 如果新生代的内存太少会导致Minor GC的频繁触发,会出现STW停顿
  • 如果新生代的内存太多,势必会导致老年代的内存过小,一旦老年代的内存过小会触发Full GC,成本就比较高了

所以我们要在尽可能调大新生代的内存的原则上,平衡新生代和老年代的内存

官方推荐是,新生代要在25%到50%之间,但其实还是有问题的,如图,在你调大内存空间的时候,一开始吞吐量会增大,后面其实会变小的,为什么呢?当你内存过大了之后,你垃圾回收扫描的空间变大了,STW的时间变长了,整个垃圾回收的成本变高了,吞吐量就下降了

因为新生代的对象是朝生既死的,并且我们使用的是复制算法,最后其实要复制的数据并不太多,所以当我们调大内存,它的效率也不会有很明显的变化

那么我们到底应该怎么调整新生代的大小呢,我们理想情况下就是,我们的新生代可以容纳下一次请求响应过程中所产生的所有对象在乘以并发量,也就是【并发量*(请求 + 响应)的数据】,比如说请求响应的对象一共占到512KB的内存,并发量为1024,那么这个时候新生代比较理想的内存就是512*1000,大概就是512MB。

其实很好理解,要是能容纳整个一次请求响应所需要的内存,就可以不GC,或者很少的GC,因为新生代的大部分对象都是垃圾,所以一个单位时间清理一次,一次就可以清理完,也不会影响下一次的并发请求

3. 新生代的幸存区

1. 幸存区要大到能保留【当前活跃对象 + 需要晋升的对象】

  • 当前活跃对象:就是现在还在被使用,但是将来要被回收
  • 需要晋升对象:就是用的很多,我们知道它要被晋升,但是晋升阈值还不到,还不能晋升

如果我们的幸存区太小了,JVM会自动调整晋升阈值,让晋升阈值变小,因为内存不够了,必须要把一部分幸存区的对象晋升到老年代去,不然放不下了。这就会导致,当前活跃对象其实是要被回收的,但是晋升到老年代了,短期内就不会被清理,导致内存泄漏

2. 晋升阈值要配置得当,让长时间存活的对象尽快晋升

我们知道新生代使用的是复制算法,主要是开销就是复制,我们要把From中复制到To中,如果晋升阈值设置的太大了,会导致幸存区中存留的对象太对了,就会导致,每次复制的对象就很多,开销也就会变大

这个听起来和上面那个有点冲突哈哈哈,其实调优就是一个权衡的过程

3. 老年代调优

以CMS为例

老年代的内存是越大越好,因为我们之前讲过,清理时的并发标记会导致浮动垃圾的出现,如果浮动垃圾过多会导致清理失败,然后垃圾清理就会退化成单线程SerialGC,这个响应时间就会变的特别长,而且太小也会频繁的触发Full GC

对于老年代,一般来说我们先尝试不做调优,先从新生代入手,如果优化了新生代之后,还是一直Full GC,再考虑调优老年代,因为你在调整老年代的时候,势必也会影响新生代

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值