JVM完整图文学习笔记(含拓展知识广度学习)第二章:GC垃圾回收

目录

如何判断对象可以回收

引用计数法

工作流程

缺点:循环引用问题

引用计数法的优缺点

可达性分析算法

概念

分析思路

GC Roots 可以是哪些? 

可达性算法解决循环依赖的原理

在可达性分析之后不可达的对象会立即判定为死亡吗?

总结

四种引用

概述 

① 强引用

② 软引用

软引用的实践理解:

③ 弱引用

④ 虚引用

⑤ 总结

垃圾回收算法

标记清除

标记阶段: 

清除阶段:

特点:

 标记整理

标记阶段:

整理阶段: 

特点:

复制回收

特点: 

分代垃圾回收

概述

步骤细化

启动新生代GC

进入老年代空间

记录集

老年代GC

相关 VM 参数

问题

当新生代进行了一次minor gc后晋升元素,但老年代的空间不足了,会执行什么步骤

Major GC和Full GC的区别

什么时候会执行Full GC 

一个大对象如果内存空间大于伊甸园空间会怎么样

一个线程导致了OOM(堆内存溢出)后会不会导致其他线程噶了

垃圾回收器

分类

串行回收器(Serial Collector)

吞吐量优先垃圾回收器(Throughput Collector)

响应时间优先垃圾回收器(Concurrent Collector)

串行回收器

吞吐量优先回收器

吞吐量优先回收器工作时,CPU会飙升到100%

响应时间优先回收器

细节

三色标记法

并发标记带来的问题

问题一:非垃圾变成了垃圾

问题二:垃圾变成了非垃圾

增量更新和原始快照(SATB)

读写屏障

增量更新

原始快照SATB

方案选择

总结

G1(Garbage First)(重点!)

G1 垃圾回收阶段

Young Collection

Young Collection + 并发标记

Mixed Collection

Full GC 

跨代引用

记忆集

卡表

DK8u20字符串去重 

DK8u60回收巨型对象

垃圾回收调优

选择恰当的垃圾回收器

最快的 GC 是没有GC

新生代调优

新生代的特点

调优策略 

增大新生代内存(-Xmn :设置新生代的初始和最大值)

调节幸存区大小

晋升阈值

老年代调优

调优解决不了,那就升级硬件配置

案例实战

Full GC 和 Minor GC 频繁

请求高峰期发生 Full GC,单次暂停时间特别长(CMS) 

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


如何判断对象可以回收

引用计数法

        引用计数法是一种常见的垃圾回收算法,用于判断对象是否可以回收。它基于统计每个对象被引用的次数,当计数为零时,即表示对象不再被引用,可以被回收。(早期Python用,Java不用)

工作流程

        在程序中,每当创建一个对象引用,引用计数会增加。当引用被置为 null对象被重新赋值或者超出作用域等情况时,引用计数会减少当计数为零时,即表示对象不再被引用,可以被回收。

缺点:循环引用问题

        引用计数法在处理循环引用时遇到困难。如果两个对象相互引用,它们的引用计数永远不会变为零,导致内存泄漏。为了解决这个问题,可以通过其他机制如引入"根集"来辅助判断循环引用的存在。        

为了解决引用计数法的循环引用问题,可以采用辅助算法如可达性分析算法,通过标记和清除的方式识别出不再可达的对象。

引用计数法的优缺点

  • 引用计数法的优点包括实时性高、回收对象的代价低、回收对象产生的停顿短
  • 它也存在一些缺点,如循环引用问题计数器的维护开销无法解决内存碎片化等。

 引用计数法更适合实时性要求高分配和回收开销敏感的应用场景。但在处理循环引用和内存碎片问题上相对劣势。

可达性分析算法

  • JVM 中的垃圾回收器通过可达性分析来探索所有存活的对象

概念

可达性分析算法:也可以称为根搜索算法、追踪性垃圾收集。

        相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。

分析思路

所谓 "GC Roots”根集合就是一组必须活跃的引用(不会被垃圾回收的对象引用)。

基本思路:

  • 可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。

  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接间接连接着,搜索所走过的路径称为引用链(Reference Chain)

  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。

  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。,即 GC Root 到该对象不可达,则认为该对象不可用。

GC Roots 可以是哪些? 

  • 虚拟机栈中引用的对象           

                比如:各个线程被调用的方法中使用到的参数、局部变量等。

  • 本地方法栈内 JNI(通常说的本地方法)引用的对象

  • 方法区中类静态属性引用的对象

                比如:Java类的引用类型静态变量

  • 方法区中常量引用的对象

                比如:字符串常量池(string Table)里的引用

  • 所有被同步锁 synchronized 持有的对象

  • Java虚拟机内部的引用。

           基本数据类型对应的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。

  • 反映 java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。


可达性算法解决循环依赖的原理

  1. 根集的识别:可达性分析算法首先确定根集(GC Roots),这些根对象是整个程序的起点。根集包括栈中的本地变量、静态变量、系统类等。通过标识出根集对象,可以从这些对象开始追踪可达的对象。

  2. 标记阶段:从根集对象出发,进行对象的标记。通过追踪对象之间的引用关系,可达性分析算法遍历对象图,标记可达对象。在标记过程中,标记已经访问过的对象。

  3. 辅助数据结构:为了解决循环依赖问题,可达性分析算法使用辅助数据结构,如记忆集(Remembered Set)或者存活对象集合(Live Object Set)。这些数据结构用于记录可能存在循环依赖的对象,而不会重复遍历该循环依赖路径

  4. 对象标记过程中的检测:当可达性分析算法在标记过程中访问到一个对象时,它会检测该对象是否处于记忆集或者存活对象集合中。如果该对象已经标记过或者已在集合中,就不再继续遍历该对象的引用关系,从而避免无限循环

  5. 非可达对象的回收:在标记阶段完成后,未被标记的对象即为非可达对象,可以被回收。这些非可达对象很可能是循环依赖关系中的对象。

        通过使用辅助数据结构和检测机制,可达性算法能够有效解决循环依赖带来的回收问题。它能够正确地判断对象的可达性,并进行垃圾回收。这样,即使存在循环依赖关系,也能够保证引用计数为零的对象被正确回收,避免内存泄漏的问题。


在可达性分析之后不可达的对象会立即判定为死亡吗?

        如果一个对象被判定为不可达,这时候会再判断一下该对象是否有必要执行 finalize() 方法,如果没有该方法,或者方法已经被执行过,则没有必要被执行,直接标记为死亡,否则会把该对象放入 F-Queue 队列。如果该方法正常执行,则标记为死亡;如果在该方法内又重新把自己与引用链上的任何对象建立关联,则该对象会重新复活。

总结

总结一句话就是,堆空间外的一些结构,比如虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间进行引用的,都可以作为 GC Roots 进行可达性分析。

        除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。比如:分代收集和局部回收(Partial GC)。

        如果只针对 Java 堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入 GCRoots 集合中去考虑,才能保证可达性分析的准确性。


四种引用

概述 

① 强引用
  • Java编程中经常new的一个对象就是强引用,例如Student stu = new Student("张三");中stu就是一个强引用。
  • 强引用具有以下特点:
  1. 被强引用关联的对象不会被垃圾回收器回收。注意: 当JVM的内存空间不足时,宁愿抛出OutOfMemoryError异常使得程序止,也不愿意回收具有强引用对象来解决内存不足的问题!
  2. 强引用可能会发生内存泄露
② 软引用
  • 软引用的生命周期比强引用短,使用 SoftReference 类来创建软引用,具体方法如下。这里的sf才是软引用,而stusf软引用的对象。

    Student stu = new Student();
    SoftReference<Student> sf = new SoftReference<Student>(stu);
    stu = null;  // 使对象只被软引用关联
    
  • 软引用的特点:

    1. 如果一个对象只具备软引用,当JVM内存空间足够时,不会被回收;当JVM内存空间不足了,就会GC该对象
    2. 软引用可用来实现内存敏感的高速缓存,例如图片缓存框架中缓存图片就是通过软引用的。
    3. 软引用可以和一个引用队列(ReferenceQueue) 联合使用:如果软引用所关联的对象被垃圾回收器回收, JVM就会把这个软引用加入到与之关联的引用队列中。
软引用的实践理解:

假设我们现在要在网上下载一些图片资源(这里用指定大小byte【】来模拟图片资源的所占空间)

如果我们采用的是强引用的方式来进行:

/**
 * 演示 软引用
 * -Xmx20m -XX:+PrintGCDetails -verbose:gc  // 设置堆内存为20M
 */
public class Code_08_SoftReferenceTest {

    public static int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) throws IOException {
        method2();
    }

    // 设置 -Xmx20m , 演示堆内存不足,
    public static void method1() throws IOException {
        ArrayList<byte[]> list = new ArrayList<>();

        for(int i = 0; i < 5; i++) {
            list.add(new byte[_4MB]);
        }
        System.in.read();
    }

        运行 mehtod1 方法,会抛堆内存溢出异常,因为 mehtod1 中的 list 都是强引用。哪怕内存满了触发垃圾回收也不能回收这些强引用的对象,最后导致内存溢出。

        但是实际场景中,对于这种内存敏感的操作,我们要求哪怕是再其他时间重试获取都不要他内存溢出,为此,我们可以将其引用改为一个软引用,确保内存不足时可以回收掉这些对象进行一个内存释放,避免内存溢出

// 演示 软引用
    public static void method2() throws IOException {
        ArrayList<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());
        }
    }

上面的代码中,当软引用引用的对象被回收了,但是软引用还存在,所以,一般软引用需要搭配一个引用队列一起使用。这样子才能收集那些无意义的软引用对象(关联了引用队列,当软引用所关联的 byte[] 被回收时,这个无用的软引用自己会加入到 queue 中去,最终queue中的对象都是那些无用的软引用对象,可以直接移除掉)

// 演示 软引用 搭配引用队列
    public static void method3() throws IOException {
        ArrayList<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[]> ref : list) {
            System.out.println(ref.get());
        }
    }

 

③ 弱引用
  • 弱引用的生命周期比软引用还要短,弱引用是通过WeakReference类实现的,ThreadLocal中的key就用到了弱引用

  • 弱引用的具体实现如下:

    Object obj = new Object();
    WeakReference<Object> wf = new WeakReference<Object>(obj);
    obj = null;// 去除强引用
    
  • 弱引用的特点:

    1. 被弱引用关联的对象一定会被回收,也就是说被弱引用关联的对象只能存活到下一次垃圾回收发生之前
    2. 当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
    3. 弱引用同样可以和一个引用队列(ReferenceQueue)联合使用,也同样适用于内存敏感的缓存。如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。同样也可以配合ReferenceQueue 使用
  • 与软引用的区别: 软引用关联的对象在内存空间不足够时才被回收,而弱引用关联的对象无论内存是否充足都会被回收。

        在Java的垃圾回收(GC)机制中,终结器引用(Finalizer Reference)是一种特殊的引用关系。终结器引用用于与对象的终结器(Finalizer)方法相关联。终结器是一种特殊的方法,在对象被回收前,由GC调用以执行一些清理操作。

        当一个对象被创建时,可以通过重写finalize()方法来定义一个终结器。当对象被GC判定为可回收时,它会被放置在一个待清理队列中,并在稍后的时间执行终结器方法。与这个对象相关联的终结器引用也会被加入到终结器引用队列中。

        终结器引用是一种弱引用(Weak Reference),它的特点是在GC运行时,无论内存是否充足,只要发现了对象的终结器引用,就会将其加入到终结器引用队列中。通过检测终结器引用队列,可以得知即将执行终结器方法的对象,并且允许用户在终结器方法中执行一些特定的清理操作,如关闭文件、释放资源等。

        然而,终结器引用的使用存在一些问题。首先,终结器的执行时间是不确定的,可能会导致延迟对象的回收。此外,由于终结器在单独的线程中执行,可能会引发竞争条件和不可预测的行为。因此,在Java 9及后续版本中,推荐使用Cleaner类代替终结器,以提供更可控和可靠的清理操作。

        综上所述,终结器引用是一种弱引用,用于与对象的终结器方法相关联。终结器引用被GC检测到时,会将其加入终结器引用队列中,供用户在对象被回收前执行一些特定的清理操作。但由于终结器的限制和潜在问题,建议在新的代码中使用更可靠的Cleaner类来实现资源清理操作。

④ 虚引用
  • 虚引用又称为幽灵引用或者幻影引用,使用 PhantomReference 来创建虚引用。具体示例如下:

    Object obj = new Object();
    ReferenceQueue queue = new ReferenceQueue();
    PhantomReference<Object> phantomObj = new PhantomReference<Object>(obj , queue);
    obj = null; //去除强引用
    
  • 虚引用的特点:

    1. 虚引用关联的对象在任何时候可能被GC回收,就像没有引用一样,因此可能发生内存泄露。
    2. 一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象
    3. 为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知
    4. 虚引用必须和引用队列 (ReferenceQueue) 联合使用

如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,它不能单独使用也不能通过它访问对象,虚引用必须和引用队列(RefenenceQueue)联合使用。
虚引用的主要作用是跟踪对象垃圾回收的状态。仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制

换句话说,设置虚引用的唯一目的,就是在这个对象被回收器回收的时候收到一个系统通知或者后续添加进一步的处理。

必须配合引用队列使用,例如配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,
由 Reference Handler 线程调用虚引用相关方法释放直接内存

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

⑤ 总结
引用类型GC的条件取得目标对象方式是否发生内存泄露
强引用never直接获取有可能
软引用内存不足通过 get()方法不可能
弱引用不管内存是否足够,在GC时都要被回收通过 get()方法不可能
虚引用unknown无法获得有可能

垃圾回收算法

标记清除

        标记清除算法(Mark-Sweep Algorithm)是一种最基本的垃圾回收算法,用于回收不再使用的内存空间。它通常用于垃圾收集器的老年代(Old Generation)或整个堆的垃圾回收阶段。标记清除算法由两个阶段组成:标记阶段清除阶段

标记阶段: 

  • 在标记阶段,垃圾收集器首先会从根对象(如全局变量、活动线程的栈、寄存器等)开始遍历整个对象图,标记所有与根对象可达的对象,即被引用的对象。
  • 这个过程通过深度优先搜索(DFS)或广度优先搜索(BFS)等算法实现,将可达的对象做上标记(通常用"Mark"表示),而没有标记的对象则被认为是垃圾对象。

清除阶段

  • 在清除阶段,垃圾收集器会遍历整个堆,回收所有没有标记的对象,即垃圾对象。
  • 这些垃圾对象所占用的内存空间将被释放,重新变为可用的内存。

这里有个点需要注意一下,一个对象所占用的空间可以理解为是一个房子,当垃圾对象被清除后,它释放的空间我们不可以把它想象为把房子的面积收回来,下次直接基于可造房子的总面积来动态分配空间,而是说,一个对象被回收了,它人走了,但是房子还在那,只是说可以被比房子面积小或等于的新对象入住而已,那么这样会导致,如果一个对象很大,那么这些小房子它不能入住,需要在一个空地新开一个房子,那这些小房子不就闲置了吗?所以,这就是标记清除算法的一个弊端——会产生内存碎片

特点

  • 标记清除算法的主要优点是简单和容易实现。它不需要额外的数据结构,只需遍历一次对象图即可完成标记和清除操作。不需要移动地址来整理压缩出空间,这样子引用的地址就不会改变,对于一些变量的引用就不需要改动,效率较高
  • 但是,标记清除算法有一个明显的缺点,即产生大量的内存碎片。由于标记清除算法只是简单地回收垃圾对象,并不对内存空间进行整理,因此会导致内存碎片的产生,从而影响到堆的空间利用率

 标记整理

标记阶段:

标记整理算法的标记阶段和编辑清除算法的标记阶段一样

整理阶段: 

将存活对象向内存的一端移动,所有存活对象都被压缩到一段连续的内存空间中。然后,释放整理阶段之后其他一端的内存空间。

特点:

  • 优点:回收后不会产生内存碎片,能够提高内存分配时的效率。
  • 缺点:需要进行对象的移动操作,可能会增加垃圾回收的时间开销。
    • 更新引用:当存活对象被移动时,相关的引用也需要被更新,指向对象的新位置。这会涉及到全局范围内的引用更新操作,增加了额外的开销和复杂性。

    • 并发问题:如果在整理阶段执行的同时,其他线程在访问对象时,可能会导致引用不一致的问题。需要采取一些额外的措施来保证引用的一致性,如线程同步或停止其他线程操作等。

    • 悖循环问题:如果在整理阶段进行中,出现了相互引用的对象,即循环引用的情况,可能会出现悖循环问题。某个对象移动后,其它对象的引用指向了已经移动的位置,导致引用关系不再准确。

    • 频繁移动的性能开销:标记整理算法需要将存活对象移动到一端,并释放另一端的内存空间。如果存活对象的移动频率很高,会带来较大的性能开销


复制回收

特点: 

其实它和标记整理算法有点像

  • 不会有内存碎片
  • 需要占用两倍内存空间

JAVA的GC机制是统筹以上三种垃圾回收算法的结果,并不是单一的一个算法的作用,对于不同的情况会采用不同的算法就行垃圾的回收,从而达到一个最优解的情况


分代垃圾回收

概述

  • 新创建的对象首先分配在 eden 区(伊甸园区)
  • 新生代空间不足时,触发 minor gceden 区 和 from 区存活的对象使用 - copy 复制到 to 中,存活的对象年龄加一,然后交换 from to(复制回收算法)
  • minor gc 会引发 stop the world,暂停其他线程,等垃圾回收结束后,恢复用户线程运行。

这是因为当执行minor gc后存留的对象会进行一个位置移动操作,这样子它的内存地址就会发生改变,如果此时不暂停其他线程的话,这些引用对象的地址变化可能带来混乱(不过放心,minor gc的stop the world的时间很短的)

  • 当幸存区对象的寿命超过阈值时,会晋升到老年代,最大的寿命是 15(4bit)
  • 当老年代空间不足时,会先触发 minor gc,如果空间仍然不足,那么就触发 full fc ,对新生代和老年代都进行一次垃圾回收,停止的时间更长!

步骤细化

首先,我们需要将堆空间隔离成4个空间:

生成空间

两个幸存空间

老年代空间

根据变量  new_start,  survivor1_start,  survivor2_start,  old_start 4个变量引用开头地址.  

大小分别默认为  140k,  28k,  28k,  940k

同时准备一个记录集数组,$rs

启动新生代GC

所有新分配的对象,都将进入生存空间,当生成空间满了之后,将启动GC复制算法.

将正在使用的幸存空间作为from空间,未使用的作为to空间,生成空间和from幸存空间的活动对象都会被复制到to幸存空间中

这些活动对象的年龄都会增加1岁

注意:在执行新生代GC中,除了会引用根的活动对象,还得将老年代空间的对象当成根,将老年代引用的对象作为活动对象处理

进入老年代空间

当幸存空间的对象活过一定的年龄之后,将通过GC复制算法,将对象复制到老年代空间当中

记录集

        分代垃圾回收的优点就是只将垃圾回收的重点放到新生代对象身上,以此来缩减GC的时间,但是在上面我们知道,老年代的变量也可能引用到新生代的变量,那就意味着我们需要搜索整个老年代空间的所有对象去找引用,这样就大大的削减了分代垃圾回收的机制

        因此,我们通过记录集来记录老年代对象到新生代对象的引用,在新生代GC时,不去搜索老年代堆空间,而是直接找到记录集中记录存在引用关系的老年代对象进行关联

老年代GC

老年代GC直接使用了标记-整理算法

        老年代中的对象生命周期通常较长,因此使用标记整理法更为合适。标记整理法可以解决老年代中的内存碎片问题,并且能够提高内存的利用效率。对于老年代这样的长存活对象区域,消除内存碎片对于整体性能的提升有重要作用

        相比之下,标记清除法会在回收过程中产生内存碎片,这对于老年代来说并不适合。内存碎片可能导致老年代的内存分配效率下降,增加了垃圾回收的时间开销。

        需要注意的是,虚拟机的实现可能会根据具体情况选择适合的垃圾回收算法。不同的垃圾回收器(如CMS、G1等)可能会采用不同的策略来进行老年代的垃圾回收。但总体而言,标记整理法在老年代中更常见且较为有效。


相关 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
FullGC 前 MinorGC-XX:+ScavengeBeforeFullGC

问题

当新生代进行了一次minor gc后晋升元素,但老年代的空间不足了,会执行什么步骤

  1. 检查老年代的空间:在将对象提升到老年代后,会检查老年代的空间是否足够容纳这些对象。如果老年代的剩余空间足够,那么程序可以继续正常执行。如果老年代的空间不足以容纳提升的对象,则会触发一次 Major GC(老年代的垃圾回收)。

  2. 执行 Major GC:如果老年代的空间不足,系统会触发一次 Major GC。Major GC 的目标是回收老年代中的垃圾对象,释放一些内存空间。这样,新的对象可以被分配到老年代。Major GC 可能涉及标记、回收、整理等步骤,具体的执行过程取决于垃圾回收器的实现策略。

  3. 内存压缩(Optional):在 Major GC 过程中,某些垃圾回收器可能会执行内存压缩操作,以进一步优化内存空间的利用和分配效率。内存压缩会移动对象,并更新相关的引用。

  4. 执行完 Major GC 后,如果老年代的空间仍然不足,那么可能会抛出 OutOfMemoryError(内存溢出错误),表示无法分配所需的内存空间。


Major GC和Full GC的区别

Major GC 不等于 Full GC,它们的操作对象范围不一样。

        Major GC 是指对老年代进行的垃圾回收操作,它主要针对老年代中的对象进行标记、回收和整理。它的目标是清理老年代中不再使用的对象,以释放内存空间。

        Full GC 是指对整个堆内存进行的垃圾回收操作,包括新生代老年代的所有对象。它可能涉及到对新生代和老年代的标记、回收和整理,以回收整个堆内存中的垃圾对象。

        所以,Major GC 只是 Full GC 的一个部分,它只针对老年代进行操作。Full GC 是对整个堆内存进行操作的垃圾回收过程,包括新生代和老年代。


什么时候会执行Full GC 

  1. 显式调用:可以通过代码中的 System.gc() 方法显式触发 Full GC。但是,建议少用这种方式,因为垃圾回收的执行时机通常由垃圾收集器自行决定更为合适。

  2. 老年代空间不足:当老年代的空间不足以容纳新生成的对象时,会触发一次 Major GC,也就是 Full GC(包含关系,Major GC属于Full GC的一部分)。这种情况下,Full GC 会回收整个堆内存,并尽可能释放更多的空间。

  3. 永久代/元空间空间不足:在旧版的 Java 虚拟机中,永久代用于存储常量、类信息等,而在较新的版本中,永久代被元空间所取代。当永久代/元空间不足时,触发 Full GC 可以清理无用的类信息、常量等。

  4. CMS GC 的后备清理(CMS Concurrent Mode Failure):在使用 CMS(Concurrent Mark Sweep)垃圾收集器时,如果并发标记阶段无法进行,则会触发 Full GC 来进行后备清理。这通常是由于并发标记占用过多时间而导致的。


一个大对象如果内存空间大于伊甸园空间会怎么样

        这里会有三种情况

  1. 提前进入老年代:如果一个对象无法在伊甸园空间分配,如果老年代的空间足以放下该大对象,虚拟机会将其直接分配到老年代(Tenured Generation)。这是为了避免频繁地将大对象复制到存活区和老年代之间。

  2. 触发一次垃圾回收:如果将大对象分配到伊甸园空间的行为无法满足,虚拟机可能会触发一次垃圾回收(通常是 Minor GC),以清理伊甸园空间中的垃圾对象,为大对象腾出足够的空间。

  3. 抛出 OutOfMemoryError:如果老年代的空间不足以容纳该大对象,并且Minor GC后无法使用其他可用的内存空间进行分配,那么虚拟机还会最后拼一把,来个Full GC,如果还是不行,那么虚拟机会抛出 OutOfMemoryError 异常,表示内存不足。这意味着应用程序无法继续执行,需要进行相应处理,如增加堆内存大小或优化对象分配方式。


一个线程导致了OOM(堆内存溢出)后会不会导致其他线程噶了

一个线程OOM后会清空线程占用的堆内存

一个线程导致堆内存溢出(OOM)通常不会直接导致其他线程死亡。OOM 是指堆内存耗尽,无法继续为对象分配内存空间,从而导致应用程序出现异常并终止。

在发生OOM时,虚拟机会抛出 OutOfMemoryError 异常,这是一个致命错误,会中断当前线程的执行并触发线程的终止。然而,其他线程通常会继续执行,除非它们直接或间接依赖于那个导致OOM的线程的执行结果或资源。

需要注意的是,OOM可能会导致整个应用程序的异常终止,包括所有线程的结束。这通常取决于具体的应用程序设计和OOM发生的位置。


垃圾回收器

分类

  • 并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态
  • 并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上
  • 吞吐量:即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99% 

串行回收器(Serial Collector)

        串行回收器是最基本的垃圾回收器,它以单线程方式执行垃圾回收操作。在进行垃圾回收时,它会暂停所有应用程序的线程(STW:stop the word),适合于单核处理器低并发堆内存较小环境。其特点是简单高效,适用于对系统资源要求不高且关注最小化垃圾回收开销的场景。

吞吐量优先垃圾回收器(Throughput Collector)

        吞吐量优先垃圾回收器主要关注系统的吞吐量,即最大化应用程序的运行时间,垃圾回收的时间相对较短。主要代表是并行回收器(Parallel Collector),它使用多线程并行地执行垃圾回收操作,充分利用多核处理器的优势。通过并行执行,可以显著提高垃圾回收的吞吐量。

让单位时间内, STW 的时间最短 0.2 0.2 = 0.4 ,垃圾回收时次数少,多吃少餐,这样就称吞吐量高

响应时间优先垃圾回收器(Concurrent Collector)

        响应时间优先垃圾回收器主要关注系统的响应时间,即最小化垃圾回收对应用程序的停顿时间。主要代表是并发标记扫描回收器(Concurrent Mark and Sweep Collector),它在应用程序运行的同时进行垃圾回收操作,减少了垃圾回收对应用程序的停顿时间。它通过并发地标记和清理垃圾对象,从而使得垃圾回收的过程与应用程序的运行可以并发进行。

尽可能让单次 STW 的时间最短 0.1 0.1 0.1 0.1 0.1 = 0.5,少吃多餐,单次垃圾回收时间占比最低,这样就称响应时间快

串行回收器

打开指令: -XX:+UseSerialGC = Serial + SerialOld

  1. 单线程:串行回收器只使用单线程进行垃圾回收操作(这里单线程指的是 垃圾回收单线程),因此在进行垃圾回收时会暂停应用程序的所有线程。这可能导致较长的停顿时间,特别是对于较小的堆内存和较小的对象集合。

  2. 简单高效:由于串行回收器基于简单的算法(新生代用复制回收算法,老年代用标记整理算法),没有复杂的多线程并发控制,因此实现简单且高效。它适用于资源受限的环境,对系统资源要求不高。

  3. 适用于单核处理器或低并发环境:由于串行回收器是单线程的,因此对于单核处理器或低并发环境是较为适合的选择。


吞吐量优先回收器

parallel并行,指的是,多个垃圾回收器可以并行的运行,占用不同的cpu。但是在此期间,用户线程是被暂停的,只有垃圾回收线程在运行。

# 虚拟机参数
# 并行
-XX:+UseParallelGC ~ -XX:+UseParallelOldGC
-XX:+UseAdaptiveSizePolicy
-XX:GCTimeRatio=ratio
-XX:MaxGCPauseMillis=ms
-XX:ParallelGCThreads=n

吞吐量优先回收器工作时,CPU会飙升到100%

当吞吐量优先回收器(例如并行回收器)工作时,CPU飙升到100%是正常现象,因为并行回收器的设计目标是最大化系统的吞吐量,充分利用多核处理器的并行计算能力

以下是引起CPU飙升的一些原因:

  1. 并行执行:并行回收器使用多个线程并行执行垃圾回收操作(默认它的垃圾回收线程等于它的CPU核数),这意味着在执行过程中会同时利用多个CPU核心。每个线程都在进行垃圾回收操作,而垃圾回收的工作量通常是非常大的,因此会占用大量的CPU资源,导致CPU利用率的飙升。

  2. 对整个堆进行扫描:吞吐量优先回收器通常会对整个堆(包括年轻代和老年代)进行扫描处理,以找出可回收的垃圾对象。这涉及到大量的内存操作对象遍历,这些操作同样会占用大量的CPU资源。

  3. 应用程序暂停:在并行回收器工作期间,为了保证垃圾回收的正确性,通常会暂停应用程序的所有线程。这意味着在垃圾回收的过程中,应用程序无法继续执行,CPU资源会被完全用于垃圾回收操作,导致CPU利用率的飙升


响应时间优先回收器

  • 多线程并发:ParNew 回收器(年轻代) + CMS(Concurrent Mark Sweep)回收器(老年代)以下主要讲CMS
  • 适合堆内存较大,多核 cpu
  • 目标:尽可能让 单次STW的时间 最短
  • 问题:
    1. “标记清除算法”:导致空间碎片化,无法存储大对象,进而导致”并发失败“。临时启用 SerialOld 收集器进行标记整理
    2. “并发标记、并发清理”:产生 “浮动垃圾”,需要预留空间。
    3. “重新标记”:先做新生代回收再重新扫描,减轻压力
  • CMS 收集器
    Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器
    特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片
    应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如 web 程序、b/s 服务

CMS 收集器的内存回收过程是与用户线程一起并发执行的,可以搭配 ParNew 收集器(多线程,新生代,复制算法)与 Serial Old 收集器(单线程,老年代,标记-整理算法)使用。

初始标记:标记 GC Roots 能直接到的对象。速度很快但是仍存在 Stop The World 问题。    

并发标记:进行 GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。 

重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在 Stop The World 问题  

并发清除:对标记的对象进行清除回收,清除的过程中,可能任然会有新的垃圾产生,这些垃圾就叫浮动垃圾,如果当用户需要存入一个很大的对象时,新生代放不下去,老年代由于浮动垃圾过多,就会退化为 serial Old 回收器(串行回收器),将老年代垃圾进行标记-整理,当然这也是很耗费时间的!

因为内存碎片,导致内存不足会让虚拟机提前进行一次fullgc,这里是不会退化成serialold的。而退化成serialold是因为并发清理阶段产生的浮动垃圾超过了cms设定的预留空间


细节

  • 垃圾回收的并发数受参数影响。
    • -XX:ParallelGCThreads=n 表示并行的垃圾回收线程数,一般跟cpu数目相等
    • -XX:ConcGCTreads=threads 并发的垃圾回收线程数目,一般是ParallelGCThreads的 1/4。即一个cpu做垃圾回收,剩下3个cpu留给人家用户线程。
  • CMS垃圾回收器对cpu的占用率并不高,但是用户线程不能完全占用cpu,吞吐量变小了。
  • CMS在执行最后一步并发清理的时候,由于其他线程还在运行,就会产生新的垃圾,而新的垃圾只有等到下次垃圾回收才能清理了。这些垃圾被称为浮动垃圾,所以要预留一些空间来存放浮动垃圾。
  • -XX:CMSInitiatingOccupancyFraction=percent,开始执行CMS垃圾回收时的内存占比,早期默认65,即只要老年代内存占用率达到65%的时候就要开始清理,留下35%的空间给新产生的浮动垃圾。
  • -XX:+CMSScavengeBeforeRemark。在重新标记阶段,有可能新生代的对象会引用老年代的对象,重新标记时需要扫描整个堆,做可达性分析时,只要新生代的引用存在,不管有没有必要,都会通过新生代引用找到老年代,但是这其实对性能影响有些大。因为新生代对象很多,且很多要作为垃圾被回收。可达性分析又会通过新生代引用去找老年代,但是就算找到了老年代,这些新生代还是要被回收,也就是说,本来没有必要查找老年代。所以在重新标记之前,把新生代先回收了,就不会存在新生代引用老年代,然后去查找老年代了。
  • 新生代的回收是通过-XX:+UseParNewGC,垃圾回收之后,新生代对象少了,自然重新标记的压力就轻了。
  • 因为CMS基于标记清除,有可能会产生比较多的内存碎片。这样的话,会造成将来给对象分配空间时,空间不足时,如果minorGC后内存空间也不足。那么由于标记清除,老年代的空间也不足,造成并发失败。于是CMS退化成SerialOld串行地垃圾回收,通过标记整理,来得到空间。但是这样会导致垃圾回收的时间变得很长(要整理),结果本来是响应时间优先的回收器,响应时间长,给用户造成不好的体验。

三色标记法

 在CMS垃圾回收器中提到了,在CMS的并发清理阶段才产生的垃圾对象,会被当做浮动垃圾,留到下一次GC再清理。其实在并发标记阶段,由于用户线程在并发运行,也可能会导致引用关系发生改变,导致标记结果不准确,(尽管有STW机制下的重复标记来校验,但是由于多线程下的时间片轮询操作,可能会导致有一些在并发标记中修改过的引用不被GC检测到,这样子即使在重复标记阶段也不能重新标记该引用对象)从而引发更加严重的问题,这些发生变更的数据会在重新标记阶段被处理,那么会出现什么问题?又是如何处理的呢?

CMS算法的基础是通过可达性分析找到存活的对象,然后给存活的对象打个标记,最终在清理的时候,如果一个对象没有任何标记,就表示这个对象不可达,需要被清理,标记算法就是使用的三色标记。并发标记阶段是从GC Root直接关联的对象开始枚举的过程

  对于三色标记算法而言, 对象会根据是否被访问过(也就是是否在可达性分析过程中被检查过)被分为三个颜色:白色、灰色和黑色:

  • 白色:这个对象还没有被访问过,在初始阶段,所有对象都是白色,所有都枚举完仍是白色的对象将会被当做垃圾对象被清理。
  • 灰色:这个对象已经被访问过,但是这个对象所直接引用的对象中,至少还有一个没有被访问到,表示这个对象正在枚举中。
  • 黑色:对象和它所直接引用的所有对象都被访问过。这里只要访问过就行,比如A只引用了B,B引用了C、D,那么只要A和B都被访问过,A就是黑色,即使B所引用的C或D还没有被访问到,此时B就是灰色。

根据这些定义,我们可以得出:

  • 在可达性分析的初始阶段,所有对象都是白色,一旦访问了这个对象,那么就变成灰色,一旦这个对象所有直接引用的对象都访问过(或者没有引用其它对象),那么就变成黑色
  • 初始标记之后,GC Root节点变为黑色(GC Root不会是垃圾),GC Root直接引用的对象变为灰色
  • 正常情况下,一个对象如果是黑色,那么其直接引用的对象要么是黑色,要么是灰色,不可能是白色(如果出现了黑色对象直接引用白色对象的情况,就说明漏标了,就会导致对象误删,后面会介绍如何解决),这个特性也可以说是三色标记算法正确性保障的前提条件。

算法大致的流程是(初始状态所有对象都是白色):

  • 首先我们从GC Roots开始枚举,它们所有的直接引用变为灰色,自己变为黑色。可以想象有一个队列用于存储灰色对象,会把这些灰色对象放到这个队列中
  • 然后从队列中取出一个灰色对象进行分析:将这个对象所有的直接引用变为灰色,放入队列中,然后这个对象变为黑色;如果取出的这个灰色对象没有直接引用,那么直接变成黑色
  • 继续从队列中取出一个灰色对象进行分析,分析步骤和第二步相同,一直重复直到灰色队列为空
  • 分析完成后仍然是白色的对象就是不可达的对象,可以作为垃圾被清理
  • 最后重置标记状态

这里以一个例子进行说明,假设现在有以下引用关系:

         首先,所有GC Root的直接引用(A、B、E)变为灰色,放入队列中,GC Root变为黑色:

         然后从队列中取出一个灰色对象进行分析,比如取出A对象,将它的直接引用C、D变为灰色,放入队列,A对象变为黑色:

   继续从队列中取出一个灰色对象,比如取出B对象,将它的直接引用F变为灰色,放入队列,B对象变为黑色:

        继续从队列中取出一个灰色对象E,但是E对象没有直接引用,变为黑色:

        同理依次取出C、D、F对象,他们都没有直接引用,那么变成黑色(这里就不一个一个的画了):

        到这里分析已经结束了,还剩一个G对象是白色,证明它是一个垃圾对象,不可访问,可以被清理掉。

并发标记带来的问题

        如果整个标记过程是STW的,那么没有任何问题,但是并发标记的过程中,用户线程也在运行,那么对象引用关系就可能发生改变,进而导致两个问题出现。

问题一:非垃圾变成了垃圾

        比如我们回到上述流程中的这个状态:

此时E对象已经被标记为黑色,表示不是垃圾,不会被清除。此时某个用户线程将GC Root2和E对象之间的关联断开了(比如 xx.e=null;)

后面的图就不用画了,很显然,E对象变为了垃圾对象,但是由于已经被标记为黑色,就不会被当做垃圾删除,姑且也可以称之为浮动垃圾

问题二:垃圾变成了非垃圾

        如果上面提到的浮动垃圾你觉得没啥所谓,即使本次不清理,下一次GC也会被清理,而且并发清理阶段也会产生所谓的浮动垃圾,影响不大。但是如果一个垃圾变为了非垃圾,那么后果就会比较严重。比如我们回到上述流程中的这个状态:

标记的下一步操作是从队列中取出B对象进行分析,但是这个时候GC线程的时间片用完了,操作系统调度用户线程来运行,而用户线程先执行了这个操作:A.f = F;那么引用关系变成了:

接着执行:B.f=null;那么引用关系变成了:

 好了,用户线程的事儿干完了,GC线程重新开始运行,按照之前的标记流程继续走:从队列中取出B对象,发现B对象没有直接引用,那么将B对象变为黑色:

 接着继续分别从队列中取出E、C、D三个灰色对象,它们都没有直接引用,那么变为黑色对象

到现在所有灰色对象分析完毕,你肯定已经发现问题了,出现了黑色对象直接引用白色对象的情况,而且虽然F是白色对象,但是它是垃圾吗?显然不是垃圾,如果F被当做垃圾清理掉了,那就GG 

增量更新和原始快照(SATB)

        上面一共出现了两个问题,从结果上来看,可以这样描述:

  • 一个本应该是垃圾的对象被视为了非垃圾
  • 一个本应该不是垃圾的对象被视为了垃圾

  对于第一个问题,我们前文也提到了,即使不去处理它也无所谓,大不了等到下次GC再清理。最重要的是第二个问题,如果误清理了正在被使用的对象,那就是实打实的BUG了。那么如何解决这个问题呢?

  出现这个问题的主要原因是,一个对象从被B引用,变更为了被A引用。那么对于A来说就是多了一个直接引用,对于B来说就是少了一个直接引用。我们可以从这两个方面入手来解决这个问题,对应了也有两个方案,分别是增量更新(Incremental Update)原始快照(SATB,Snapshot At The Beginning)

读写屏障

        在这讲述解决方案之前,要描述两个名词:读屏障写屏障。注意,这里的屏障和并发编程中的屏障是两码事儿。这里的屏障很简单,可以理解成就是在读写操作前后插入一段代码,用于记录一些信息、保存某些数据等,概念类似于AOP。

增量更新

        增量更新是站在新增引用的对象(也就是例子中的A对象)的角度来解决问题。所谓增量更新,就是在赋值操作之前添加一个写屏障,在写屏障中记录新增的引用。比如,用户线程要执行:A.f = F;那么在写屏障中将新增的这个引用关系记录下来。标准的描述就是,当黑色对象新增一个白色对象的引用时,就通过写屏障将这个引用关系记录下来。然后在重新标记阶段,再以这些引用关系中的黑色对象为根,再扫描一次,以此保证不会漏标
  在我们这个例子中,在并发标记阶段,A是一个黑色对象,F是一个白色对象,A引用了F,这个引用关系会被记录下来,然后通过这个记录在重新标记阶段再从A对象开始枚举一次,保证如果A还是保持着F的引用,那么F会被正确标记;如果A到F的引用在并发标记阶段又断开了,此次枚举也无法访问到它,活该被清除。

  要实现也很简单,在重新标记阶段直接把A对象(和其它有相同情况发生的对象)变为灰色,(因为灰色对象会再次进行枚举),放入队列中,再来一次枚举过程。要注意,在重新标记阶段如果用户线程还是继续执行,那么这个GC永远可能也做不完了,所以重新标记需要STW,但是这个时间消耗不会太夸张。如果实在重新标记阶段耗时过长,那么可以尝试在重新标记之前做一次Minor GC。

原始快照SATB

原始快照是站在减少引用的对象(也就是例子中的B对象)的角度来解决问题。所谓原始快照,简单的讲,就是在赋值操作(这里是置空)执行之前添加一个写屏障,在写屏障中记录被置空的对象引用。比如,用户线程要执行:B.f=null;那么在写屏障中,首先会把B.f记录下来,然后再进行置空操作。记录下来的这个对象就可以称为原始快照。
  那么记录下来之后呢?很简单,之后直接把它变为黑色。意思就是默认认为它不是垃圾,不需要将其清理。当然,这样处理有两种情况

        一种情况是,F的确不是垃圾,直到清理的那一刻,都仍然有至少一个引用链能访问到它,这没有什么问题;

        另一种情况就是F又变成了垃圾。在上述的例子中,就是A到F的引用链也断了,或者直接A都成垃圾了,那F对象就成了浮动垃圾。对于浮动垃圾,前面不止一次就提到了,直接不用理会,如果到下一次GC时它仍然是垃圾,自然会被清理掉。

方案选择

        从增量更新和原始快照的实现(理论上)就可以发现,原始快照相比于增量更新来说效率会更高,因为不用在重新标记阶段再去做枚举遍历,但是也就可能会导致有更多的浮动垃圾。G1使用的就是原始快照,CMS使用的是增量更新。
  既然原始快照可能会有更严重的浮动垃圾问题,那么为什么不使用增量更新呢?原因可能很简单,就是因为简单。想象一下,G1虽然也是基于年轻代和老年代的分代收集算法,但是年轻代和老年代被弱化为了逻辑上,其所管理的内存被划分为了很多region,对象跨代引用带来的问题在G1中要比传统的分代收集器更加突出,虽然有Remember Set方案缓解,但是相对来说在重新标记阶段进行再次遍历枚举的代价会大很多。最重要的是,重新标记(最终标记)阶段是会STW的,如果这个阶段花费太多的时间去做可达性分析,那么就违背了G1低延时的理念。

总结

        这里有一个需要注意的点,重新标记阶段会STW,以此保证标记结果的正确性(主要是漏标)。到现在你可能理解了,垃圾收集器中所描述的:并发清理阶段产生的垃圾会被当做浮动垃圾,只能留待下一次GC被清理。那么实际上是怎么回事呢?其实就很简单了,只要在并发清理阶段产生的对象,直接就认为是黑色对象,全部都不是垃圾。如果一个对象最终成了垃圾,那它就是浮动垃圾,如果没成垃圾,那么标记为黑色也没有什么问题。因为到了清理阶段,标记工作已经完成,没有办法再找到合适的方式去处理这个问题,不然一次GC可能永远也结束不了。
  话说回来,对于上面漏标的情况,你可能还有一个疑问:在并发标记过程中,除了引用关系发生变更的情况,如果用户线程直接创建了一个新对象,这个对象默认是白色,又直接和黑色对象关联,那又该当如何呢?也就是白色对象可能是从其他对象的引用链上”转移“过来的,也可能就是一个新对象。其实可以想象的到,对于新对象加入到黑色节点,我们无法使用原始快照,但是可以使用增量更新,或者直接简单处理,和并发清理阶段一样:在这期间创建的新对象都认为不是垃圾(比如标记为黑色),如果成了垃圾,那就是浮动垃圾,还是留待下一次GC处理。总之,标记的总体原则就是,“另可放过,不可杀错”。


G1(Garbage First)(重点!)

定义: Garbage First
适用场景:

  • 同时注重吞吐量和低延迟(响应时间)
  • 超大堆内存(内存大的),会将堆内存划分为多个大小相等的区域
  • 整体上是标记-整理算法,两个区域之间是复制算法

相关参数:
JDK8 并不是默认开启的,所需要参数开启

-XX:+UseG1GC
-XX:G1HeapRegionSize=size
-XX:MaxGCPauseMillis=time

为什么名字叫 Garbage First(G1)呢?

因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。每一个区域都是伊甸园、幸存区、老年代中的一个

G1 垃圾回收阶段

Young Collection:对新生代垃圾收集
Young Collection + Concurrent Mark:如果老年代内存到达一定的阈值了,新生代垃圾收集同时会执行一些并发的标记。
Mixed Collection:会对新生代 + 老年代 + 幸存区等进行混合收集,然后收集结束,会重新进入新生代收集。


Young Collection
  • 将新对象分配到各个伊甸园区域(图中的E区域)

  • 当堆中伊甸园区域被逐渐被占满后,触发 Young Collection 并 STW 和 进行初始标记。将伊甸园区域幸存的对象使用复制的算法拷贝到幸存区域

  • 将幸存区域中没有超过阈值的对象拷贝到另一个幸存区域,将幸存区域中超过阈值的对象晋升到老年代区域


Young Collection + 并发标记
  • Young Collection:在 Young GC 时会进行 GC Root 的初始标记

  • 并发标记:老年代区域占用堆空间比例达到阈值时,进行 并发标记(不会 STW),由下面的 JVM 参数决定

    -XX:InitiatingHeapOccupancyPercent=percent (默认45%)

Mixed Collection
  • 最终标记(Remark)会 STW(这个步骤类似于CMS中的重复标记,由于同步并发操作过程中的引用改变)

  • 拷贝存活(Evacuation)会 STW会选择回收价值最高(获得的空间大小、回收所需时间)的老年代区域进行回收,以满足暂停时间的设置-XX:MaxGCPauseMillis=ms


Full GC 

  • SerialGC 串行垃圾回收器

新生代内存不足发生的垃圾收集 - minor gc

老年代内存不足发生的垃圾收集 - full gc(准确来说是major gc)
  • ParallelGC 并行垃圾回收器

新生代内存不足发生的垃圾收集 - minor gc

老年代内存不足发生的垃圾收集 - full gc(准确来说是major gc)
  • CMS 并发标记清除垃圾回收器

新生代内存不足发生的垃圾收集 - minor gc

  • 老年代内存不足发生的垃圾收集 - major gc
  • 并发清除失败导致老年代内存不足 - full gc
  • G1 

新生代内存不足发生的垃圾收集 - minor gc

  • 老年代内存达到一定比例 - mixed gc
  • 混合收集失败导致老年代内存不足 - full gc(多线程)

CMS和G1的老年代内存不足不一定会触发Full GC,以G1为例

G1当老年代的内存达到设定的阈值(默认为45%)后就会触发并发标记阶段和Mixed Collection阶段,再这个过程中,有两种情况:

  • 如果回收垃圾的速度 > 生成垃圾的速度快,那么这个时候采用的还是并发垃圾收集阶段,虽然后续的重新标记数据拷贝过程会有STW,但是由于其STW的时间很短。因此是不能算是Full GC的        
  • 如果回收垃圾的速度 < 生成垃圾的速度快,那么这个时候的并发收集就会失败了,就会化为一个串行垃圾回收器来进行Full GC

跨代引用

堆空间通常被划分为新生代和老年代,所谓跨代引用,一般是指老年代对象引用了新生代的对象。如下图的X和Y引用:

我们知道新生代的垃圾收集通常很频繁(朝生夕死),如果老年代对象引用了新生代的对象,那么在回收新生代(Young GC)的时候,需要跟踪从老年代到新生代的所有引用。这是很可怕的 

记忆集

跨代引用主要存在于Young GC的过程中,除了常见的GC Roots之外,如果老年代有对象引用了的新生代对象,那么老年代的对象也属于GC Roots(如上图中的老年代对象B和C)对象,但是如果每次进行Young GC我们都需要扫描一次老年代的话,那我们进行垃圾回收的代价实在是太大了,因此收集器在新生代上建立一个全局的称为记忆集的数据结构来记录这种引用关系。

Rset(Remember Set),简单来说就是一种抽象数据结构用来存老年代对象对新生代的引用(即引用X和Y)

卡表

卡表(CardTable)在很多资料上被认为是对记忆集的实现(我其实不大能理解,但先这样吧😂,它定义了记忆集的记录精度、与堆内存的映射关系等),由于在Young GC的过程中,需要扫描整个老年代,效率非常低,所以 JVM 设计了卡表,如果一个老年代的卡表中有对象指向新生代, 就将它设为 Dirty(标志位 1,反之设为0),下次扫描时,只需要扫描卡表上是 Dirty 的内存区域即可。 而卡表和记忆集的关系可以理解为一个HashMap,类似于下图的样子。

这个时候根据记忆集合卡表的记录,我可以直接确定扫描记忆集确定Card[1]的位置,而不需要扫描整个老年代。

在Hotspot虚拟机中,卡表是一个字节数组,数组的每一项对应着内存中的某一块连续地址的区域,即数组的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作卡页(Card Page)。 一般来说,卡页大小都是以 2 的 N 次幂的字节数,假设使用的卡页是 2 的 10 次幂,即 1M,内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,分别对应了地址范围为 0x0000~0x03FF、0x0400 ~ 0x07FF、0x0800~0x011FF 的卡页内存(0x03FF,十六进制转为十进制也就是1024k=1M)。


DK8u20字符串去重 

  • -XX:+UseStringDeduplication
  • 优点:节省大量内存
  • 缺点:略微多占用了 cpu 时间,新生代回收时间略微增加
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 内部,使用了不同的字符串表

DK8u60回收巨型对象

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

垃圾回收调优

选择恰当的垃圾回收器

低延迟/高吞吐量? 选择合适的GC

追求低延迟用:

  • ParallelGC

追求吞吐量用:

  • CMS / G1 /  ZGC

最快的 GC 是没有GC

首先排除减少因为自身编写的代码而引发的内存问题

  • 查看 Full GC 前后的内存占用,考虑以下几个问题
    • 数据是不是太多?

eg:    resultSet = statement.executeQuery(“select * from 大表”) 针对这个实例,我们将数据库的众多数据都封装为对象存入到一个集合中,那么此时容易发生堆内存溢出OOM,而不是GC了,因为此时这些对象还处于被引用的状态

  • 数据表示是否太臃肿
    • 对象图
    • 对象大小 16 Integer 24 int 4  

这里尽量采用小对象,不要没事就用大对象,比如查询数据库的时候尽量用VO不要用最大的封装类,或者能用int(四字节)就不要用Integer(24字节),让对象小一点,精致点

  • 是否存在内存泄漏
    • static Map map …
    • 软引用
    • 弱引用
    • 第三方缓存实现

新生代调优

新生代的特点

  • 所有的 new 操作的内存分配非常廉价,因为它仅仅是在堆上分配一块内存空间

TLAB:thread-local allocation buffer(可防止多个线程创建对象时的干扰)

每个线程都在Eden区中分配私有的区域,即TLAB;

当new一个对象时,首先检查TLAB缓冲区中是否有可用内存,如果有,会优先在这一块空间进行对象的分配。之所以这么做,因为对象的分配也有线程安全的问题:比如线程1需要使用这段内存,在分配还没结束的过程中,线程2也要用这段内存,就会造成内存的分配混乱。因此在做对象的内存分配时,也要做一个线程的并发安全的保护,当然这个操作是由jvm做的

  • 死亡对象的回收代价是零(新生代中的gc算法都是复制算法)

新生代中的垃圾收集算法通常是基于复制算法。在Minor GC(新生代回收)中,只有存活的对象被复制到另一个区域,而已经死亡的对象不会被复制,因此确实不需要为死亡对象付出额外的回收代价

  • 大部分对象用过即死

新生代中的绝大多数对象并不会存活很长时间,因此在Minor GC中被回收

  • Minor GC 的时间远远低于 Full GC

Minor GC(局部回收)通常只清理新生代,并且只涉及到较小的内存区域,因此其执行时间通常较短。相比之下,Full GC(全局回收)需要清理整个堆内存,包括新生代和老年代,因此所需的时间更长。

调优策略 

增大新生代内存(-Xmn :设置新生代的初始和最大值)

        因为增大新生代内存将提供更多的空间用于存放刚刚创建的对象,从而减少了新生代发生Minor GC的频率

        此外, 当新生代的对象经过多次Minor GC后仍然存活时,它们将会被晋升到老年代。通过增大新生代内存,可以延迟这些对象的晋升或减少它们晋升的比例。这有助于减少老年代的内存压力。

新生代内存越大越好么?

不是越大越好

  • 新生代内存太小:频繁触发Minor GC,会STW,会使得吞吐量下降

如果设置小了,可用空间少,创建新对象时,一旦发现新生代空间不足,就会触发MinorGC,会产生STW,产生短暂的暂停

  • 新生代内存太大:
    • 老年代内存占比有所降低(堆空间一定前提下),新生代认为空闲空间很多,新创建的对象不会触发GC。
    • 但是老年代的空间紧张,再触发垃圾回收,就是FullGC了。
    • FullGC的STW要比MinorGC更长。
    • 如果新生代过大,引发的垃圾回收类型可能就是FullGC,就需要占用更长的时间才能完成垃圾回收。会更频繁地触发Full GC。而且触发Minor GC时,清理新生代所花费的时间会更长 

oracle给的建议是:堆的25% < 新生代 < 堆的50%

吞吐量与新生代大小的关系

        但是总的原则啊,我们还是要将新生代调的尽可能大。 你刚才不是。还说啊,新生代的空间大了,那将来这个垃圾回收的时间变长了?但是我们刚才还有一个因素没有考虑进去。什么因素呢?就是新生代垃圾的回收都是复制算法。算法也是分成两个阶段,第1个阶段标记,第2个阶段呢去进行复制,那么这两个阶段哪个阶段花费的时间更多一些呢,其实是复制。因为复制牵扯到这个对象的占用内存块移动。另外呢你要更新对象引用的地址,这个速度相对更耗时一些。而新生代的对象,大家想想啊绝大部分都是朝生夕死的,也就是说最终只有少量的对象啊,只有少量的对象会存活下来的,所以既然是少量的对象存货,那它复制所占用的时间其实也是相对较短的,而标记时间,相对于复制时间来讲,显得不是那么重要,所以我们新生代调大的情况下。因为主要耗费的时间还是在复制上。即使增的很大效率也不会有特别明显的下降,这是对新生代大小设置的一个补充。

新生代内存设置为能容纳[并发量*(一次请求-响应所产生的对象)]的数据为宜


调节幸存区大小

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

新生代还有一块区域,我们称之为叫幸存区。幸存区呢,它的内存设置要遵从这么几个规则。

第1个呢,就是我们要考虑到幸存区啊,它也要足够的大,大到呢,能够保留当前的活跃对象和需要晋升的对象。这是什么意思呢?解释一下啊,幸存区中,你可以把它看成有两类对象:第1类对象呢,它是生命周期较短,也许下一次垃圾的时候就要把它回收掉了,但是由于现在还正在使用,它暂时不能回收,所以它就留在我们的幸存区中;另一类呢是他肯定将来会被晋升到老年的 ,因为大家都在用它,但是由于年龄还不够,所以暂时还存活在幸存区当中,没有被晋升。那幸存区中呢,就可以看成是这两类对象,存储的都是这两类对象。一类是马上就要被回收的一类是将来肯定要晋升的

那幸存区呢,你的大小就得大到把这两类对象都能够容纳。为什么这么说?这里要提到幸存区的一个晋升规则如果幸存区比较小,它就会由jvm动态的去调整你的这个晋升的阈值。也许你本来有一些对象,轮不到他晋升,它的寿命还不够,但是由于幸存区的内存太少,导致提前把一些对象晋升到老年代区。也许是刚才我们说的这种存活时间较短的对象,提前被晋升到了老年代,那这样有什么问题呢?或者说有什么缺点呢,就是如果你本来一个存活时间短的对象被晋升到了老年代,那就意味着,他得等到老年代内存不足时触发Full GC时才能把它当成垃圾进行回收。所以这就是变相的延长了这个对象的生存的时间。这是一个不太好的地方。我们最好能实现,就是这种存活时间短的,在下次新生代的垃圾回收里就把它回收掉了。真正需要,长时间存活的对象,我才把它晋升到老年代,这是一条规则


晋升阈值

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

-XX:MaxTenuringThreshold=threshold  调整最大晋升阈值

-XX:+PrintTenuringDistribution

事物呢还是都有两面性,那么我们一方面希望的是存活时间短的对象让他留在幸存区,以便下一次垃圾回收能把它回收掉;而另一方面呢,我们又希望这个长时间存活的对象,他应该尽快的被晋升

为什么这么说呢,因为如果你是一个长时间存活的对象,你把它留在幸存区里,只能够浪费我们幸存区的这个内存,并且呢,因为我们的新生代垃圾回收都是复制算法,要把这个幸存区中的这些对象,下次存活了又要把它进行复制复制,从from复制到to,我们前面也说过,那么这个新生代复制算法主要的耗费时间就是在这个对象的复制上,如果有大量的这些长时间存活的对象,他们不能及早的晋升,那么他们就相当于留在这个幸存区,被复制来复制去,这样呢,对我的性能其实反而是一个负担。所以遇到这种情况呢就要设置一下它的晋升阈值,把晋升阈值调的比较小,让这些长时间存活的对象呢,能够尽快的晋升到老年代区。这个参数:-XX:MaxTenuringThreshold=threshold  可以调整最大晋升阈值。

有的时候我们还需要把它晋升的一些详细信息显示出来,便于我去判断到底应该把晋升阈值设置为多少更为比较合适,相关的参数是下面这个参数:-XX:+PrintTenuringDistribution 这个参数呢,带上以后它就会在每次垃圾回收时把survival幸存区中的这些存活对象、详情显示出来。第一列,就是它的一个对象的年龄我们可以从这个事例中看到,年龄为1的,是刚逃过一次MinorGCC的这个对象,它占用的空间大小是多少;年龄2的对象在这个幸存区中的对象占用了多少的空间;为3的占用空间是多少这是年龄为1、2、3的对象各自占用空间数,后面是他们的累计总和数——空间占用的累计总和,比如说12加起来是3058888,123加起来是31784800。通过每次把这个幸存区中不同年龄的对象它所占用的空间打印,我们可以更细致的去决定到底把最大的晋升阈值调成多少比较合适,让那些长时间存在的影响能够尽早的晋升。这就是对新生代内存调优的一个说明。


老年代调优

以 CMS 为例:

  • CMS 的老年代内存越大越好,因为如果老年代满了会导致转变为单线程的串行Full GC 
  • 先尝试不做调优,如果没有 Full GC 那么已经,否者先尝试调优新生代。
  • 观察发现 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3
-XX:CMSInitiatingOccupancyFraction=percent // 调节老年代阈值比例

调优解决不了,那就升级硬件配置

案例实战

Full GC 和 Minor GC 频繁

  1. 增加堆内存:频繁GC可能是因为堆内存不足导致的。可以通过调整Java虚拟机的堆内存参数(如-Xmx和-Xms)来增加堆内存的大小,以减少GC的频率。

  2. 优化对象的生命周期:一些对象的生命周期很短,频繁创建和销毁可能导致频繁的Minor GC。对于这种情况,可以考虑使用对象池或重用对象的方式,避免对象的频繁分配和销毁。

  3. 优化对象的内存使用:检查应用程序中的对象使用情况,确保及时释放不再使用的对象,避免内存泄漏问题。尽量减少不必要的对象引用、避免过度创建临时对象。

  4. 使用合适的垃圾收集器:根据应用程序的特性和需求选择合适的垃圾收集器和相应的参数配置。不同的垃圾收集器在内存分配和回收策略上有所不同,可能对GC的频率和效果产生影响。例如,Serial GC适用于小规模应用,而CMS或G1 GC适用于大型应用。

  5. 分析GC日志和堆内存快照:启用GC日志并分析其内容,可以了解GC的原因和频率。结合使用堆内存快照工具(如Eclipse Memory Analyzer、VisualVM等)可以更深入地分析内存泄漏和对象使用情况,找出可能导致频繁GC的问题。

  6. 调整垃圾收集器参数,提高新生代的空间:根据GC日志和内存快照的分析结果,根据实际情况调整垃圾收集器的参数,如堆大小、新生代比例、晋升阈值等。这些参数的调整可能需要结合实际场景进行多次实验和测试。

  7. 优化应用程序的性能:频繁GC可能是应用程序本身性能问题导致的。通过优化算法、调整数据结构、减少IO操作等方式,提升应用程序的性能,可以减少GC的频率。

请求高峰期发生 Full GC,单次暂停时间特别长(CMS) 

  1. 增加堆内存:通过增加堆内存的大小(使用-Xmx参数),可以减少Full GC的频率,减轻垃圾收集器的压力,并降低暂停时间。当堆内存足够大时,CMS收集器可以更好地完成标记和清理阶段的工作。

  2. 调整CMS相关的参数:CMS收集器有一系列的相关参数可以调整,例如:

    • -XX:+UseCMSInitiatingOccupancyOnly:只在达到阈值时开始CMS收集,避免在完全空闲时触发CMS。
    • -XX:CMSInitiatingOccupancyFraction:设置CMS开始收集的阈值百分比。
    • -XX:+UseCMSCompactAtFullCollection:开启Full GC后进行碎片整理。
    • -XX:CMSFullGCsBeforeCompaction:设置进行Full GC后进行碎片整理的次数。

    调整这些参数可以根据具体情况优化CMS收集器的行为。

  3. 选择合适的垃圾收集器:除了CMS收集器,还可以尝试其他垃圾收集器,如使用G1(Garbage-First)收集器。G1收集器在处理大堆内存和高并发性能方面表现良好,并且可以避免长时间的暂停。

  4. 寻找应用程序瓶颈:优化应用程序自身的性能可能有助于减少Full GC时间。通过分析应用程序的代码和性能瓶颈,可能可以减少垃圾的产生,减少对象的创建和销毁,从而降低Full GC的频率和时间。

  5. 分析GC日志和堆内存快照:启用GC日志并分析其内容,密切关注Full GC发生的具体原因,并进行堆内存快照分析,以了解对象分配和管理的问题。

  6. 考虑升级到最新的JDK版本:不同版本的JDK中,对垃圾收集器和调优参数可能做了一些改进和优化。因此,升级到最新的JDK版本可能会带来更好的性能和更低的GC暂停时间。

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

  1. 调整新生代的大小:增加新生代的大小(使用-XX:NewSize和-XX:MaxNewSize参数),可以减少对象过早进入老年代的频率,从而降低Full GC的触发。适当调整新生代和老年代的比例,可以优化垃圾收集的效率。

  2. 调整老年代的参数:在JDK 1.7中,可以通过一些参数来调整老年代的行为。以下是一些常用的参数:

    • -XX:MaxTenuringThreshold:设置对象进入老年代之前的存活次数阈值。适当增大这个值可能会减少过早晋升至老年代的对象,从而降低Full GC的频率。
    • -XX:TargetSurvivorRatio:设置Eden区和Survivor区的比例。适当调整这个比例可能会减少Survivor区不够用导致对象进入老年代的情况。
  3. 分析GC日志和堆内存快照:启用GC日志并分析其内容,密切关注Full GC发生的具体原因,并进行堆内存快照分析,以了解对象分配和管理的问题。这将有助于确定是否存在内存泄漏或对象持久性等问题。

  4. 调整垃圾收集器:JDK 1.7默认使用Parallel Old收集器作为老年代的收集器。根据实际情况和硬件配置,可以尝试使用其他收集器,如CMS(Concurrent Mark and Sweep)或G1(Garbage-First)。这些收集器可能在Full GC的性能和效率上有所不同,可以根据实际需求选择合适的收集器。

  5. 升级到更高版本的JDK:JDK 1.7的垃圾收集器性能相对较低,升级到更高版本的JDK可能会有更好的垃圾收集器优化和性能改进。

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

学徒630

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值