• 「入门篇」初识JVM (下下) - GC


    垃圾收集主要是针对堆和方法区进行;程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于> 线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。

    GC - Java垃圾回收机制

    判断一个对象是否可被回收

    1. 引用计数算法

    给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。

    这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

    正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法

    示例代码
    public class ReferenceCountingGC {
    
        public Object instance = null;
    
        public static void main(String[] args) {
            ReferenceCountingGC objectA = new ReferenceCountingGC();
            ReferenceCountingGC objectB = new ReferenceCountingGC();
            objectA.instance = objectB;
            objectB.instance = objectA;
        }
    }
    

    2. 可达性分析算法

    通过 GC Roots 作为起始点进行搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。(能够到达到的对象都是存活的,不可达的对象可被回收)

    哪些对象可以作为 GC Roots 呢?

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

    对象可以被回收,就代表一定会被回收吗?

    即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize方法。当对象没有覆盖 finalize方法,或 finalize方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

    被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

    3. 方法区的回收

    因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,因此在方法区上进行回收性价比不高。

    主要是对常量池的回收和对类的卸载。
    在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。
    类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:

    • 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
    • 加载该类的 ClassLoader 已经被回收。
    • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

    可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。

    4. finalize()

    finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。

    当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会调用 finalize() 方法。

    测试代码
    class Resurrection
    {
        public static Resurrection obj;
    
        @Override
        protected void finalize() throws Throwable {
            super.finalize();
            System.out.println("Resurrection finalize called !!!");
            obj = this;//在finalize方法中复活对象
        }
    
        @Override
        public String toString() {
            return "I am Resurrection";
        }
    
        public static void main(String[] args) throws InterruptedException {
            obj = new Resurrection();
            obj = null; //将obj设为null
            System.gc();//垃圾回收
    
            Thread.sleep(1000);//
            if(obj == null) {
                System.out.println("obj is null");
            } else {
                System.out.println("obj is alive");
            }
    
            System.out.println("第2次调用gc后");
            obj = null;//由于obj被复活,此处再次将obj设为null
            System.gc();//再次gc
            Thread.sleep(1000);
            if(obj == null) {
                //对象的finalize方法仅仅会被调用一次,所以可以预见再次设置obj为null后,obj会被垃圾回收,该语句会被调用
                System.out.println("obj is null");
            } else {
                System.out.println("obj is alive");
            }
        }
    }
    
    测试结果
    Resurrection finalize called !!!
    obj is alive
    第2次调用gc后
    obj is null
    

    Object 类中的 finalize 方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言> 的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。忘掉它的存在吧!
    参考:

    引用类型

    无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

    Java 具有四种强度不同的引用类型。

    1. 强引用

    被强引用关联的对象不会被回收。

    使用 new 一个新对象的方式来创建强引用。

    Object obj = new Object();
    

    2. 软引用

    被软引用关联的对象只有在内存不够的情况下才会被回收。

    使用 SoftReference 类来创建软引用。

    Object obj = new Object();
    SoftReference<Object> sf = new SoftReference<Object>(obj);
    obj = null;  // 使对象只被软引用关联
    

    3.弱引用

    被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

    使用 WeakReference 类来实现弱引用。

    Object obj = new Object();
    WeakReference<Object> wf = new WeakReference<Object>(obj);
    obj = null;
    

    4. 虚引用

    又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,虚引用并不会决定对象的生命周期,也无法通过虚引用取得一个对象。

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

    虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

    为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。

    使用 PhantomReference 来实现虚引用。

    Object obj = new Object();
    PhantomReference<Object> pf = new PhantomReference<Object>(obj);
    obj = null;
    

    特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

    垃圾回收算法

    1.标记 - 清除

    将存活的对象进行标记,然后清理掉未被标记的对象。

    不足:

    • 标记和清除过程效率都不高;
    • 会产生大量不连续的内存碎片,导致无法给大对象分配内存。

    2. 标记 - 整理

    让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

    3.复制

    将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

    主要不足是只使用了内存的一半。

    现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。

    HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

    4.分代收集

    现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。

    比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

    一般将堆分为新生代和老年代。

    • 新生代使用: 复制算法
    • 老年代使用: 标记 - 清除 或者 标记 - 整理 算法

    延伸面试问题

    • HotSpot 为什么要分为新生代和老年代?

    垃圾收集器

    以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

    • 单线程与多线程: 单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程;
    • 串行与并行: 串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并形指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

    1. Serial 收集器

    Serial 翻译为串行,也就是说它以串行的方式执行。

    它是单线程的收集器,只会使用一个线程进行垃圾收集工作。
    新生代采用标记-复制算法,老年代采用标记-整理算法。
    它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。

    它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

    2. ParNew 收集器

    它是 Serial 收集器的多线程版本。

    是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。

    默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

    3. Parallel Scavenge 收集器

    与 ParNew 一样是多线程收集器。

    其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。

    停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

    缩短停顿时间是以牺牲吞吐量和新生代空间来换取的: 新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

    可以通过一个开关参数打卡 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

    这是 JDK1.8 默认收集器

    使用 java -XX:+PrintCommandLineFlags -version 命令查看

    -XX:InitialHeapSize=199822912 -XX:MaxHeapSize=3197166592 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops
     -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
    java version "1.8.0_152"
    Java(TM) SE Runtime Environment (build 1.8.0_152-b16)
    Java HotSpot(TM) 64-Bit Server VM (build 25.152-b16, mixed mode)
    

    JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC 参数,则默认指定了-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC 来禁用该功能

    4. Serial Old 收集器

    Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:

    • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
    • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

    5. Parallel Old 收集器

    Parallel Scavenge 收集器的老年代版本

    在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

    6. CMS 收集器

    CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。

    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

    CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

    分为以下四个流程:

    • 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。

    • 并发标记: 进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。

      同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。

    • 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。

    • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。 不需要停顿。

    在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

    优点:并发进行、不需要停顿

    具有以下缺点:

    • 吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高
    • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
    • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

    7. G1收集器

    G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

    被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征

    堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

    G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。

    通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

    每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

    如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

    • 初始标记
    • 并发标记
    • 最终标记: 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
    • 筛选回收: 首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

    具备如下特点:

    • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
    • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
    • 空间整合: 整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
    • 可预测的停顿: 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

    更详细内容请参考: Getting Started with the G1 Garbage Collector

    8. ZGC 收集器

    与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。

    在 ZGC 中出现 Stop The World 的情况会更少!
    详情可以看 :《新一代垃圾回收器 ZGC 的探索与实践》

    内存分配与回收策略

    Minor GC 和 Full GC

    • Minor GC: 发生在新生代上,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
    • Full GC: 发生在老年代上,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

    内存分配策略

    1. 对象优先在 Eden 分配

    大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

    2. 大对象直接进入老年代

    大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

    经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

    -XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

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

    为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
    -XX:MaxTenuringThreshold 用来定义年龄的阈值。

    4. 动态对象年龄判定

    虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

    5. 空间分配担保

    在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

    如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。

    Full GC 的触发条件

    对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

    1. 调用 System.gc()

    只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

    2. 老年代空间不足

    老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

    为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

    3. 空间分配担保失败

    使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第五小节。

    4. JDK 1.7 及以前的永久代空间不足

    在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。

    当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。

    为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

    5. Concurrent Mode Failure

    执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

    面试题

    • 如何判断对象是否死亡(两种方法)。
    • 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。
    • 如何判断一个常量是废弃常量
    • 如何判断一个类是无用的类
    • 垃圾收集有哪些算法,各自的特点?
    • HotSpot 为什么要分为新生代和老年代?
    • 常见的垃圾回收器有哪些?
    • 介绍一下 CMS,G1 收集器。
    • Minor Gc 和 Full GC 有什么不同呢?

    参考

  • 相关阅读:
    卷积神经网络(CNN)理解
    oracle 删除语句(时间范围)
    小程序性能优化
    【MySQL】视图操作
    getBytes方法
    CentOS 7.9 安装 MySQL 8 配置模板
    740 · 零钱兑换 2
    数字化报修管理系统有什么用?如何提升物业报修效率?
    maven配置nexus私服详解
    石油数字孪生可视化管理平台,推动石油行业数字化转型与智能化应用
  • 原文地址:https://www.cnblogs.com/Ghostface/p/16082265.html