JVM之垃圾回收机制和内存分配策略

垃圾回收的垃圾是什么

哪些内存

  • 方法区:存放方法指令,“类”
  • 堆区:存放对象信息
  • Java调用栈/本地方法调用栈,存放方法中调用的栈帧,局部变量
  • 运行时常量池:类文件的常量池的数据
  • PC每个线程独有的,存放下一条指令的地址
  • 堆,方法区,运行时常量池是共享的,PC和栈是私有的
  • 对于这样划分逻辑上的区域,我们对于不同的区域进行不同的处理,比较方便
  • JMM是CPU和内存的抽象,工作存储区(寄存器和缓存),主存储(内存)

关于内存什么时机分配

  • PC什么时候被分配:一个线程被创建的时候 那么对应着当线程被终止的时候,就会被回收

  • 栈中的内存什么时候被分配:一个方法被调用的时候,这个线程的方法产生一个栈帧,那么在调用结束的时候让这个栈帧出栈

    • 对于线程来说,线程创建的时候分配栈内存 线程结束回收栈内存
    • 每一个栈帧分配多少内存基本是在类结构确定下来就已知道了(尽管在运行期间由即时编译器进行一些优化,但是在基于基本概念模型的讨论中,大体可以认为在编译器是可知的)
    • 本地方法栈根虚拟机栈类似
  • 堆中的内存:一个对象被实例化的时候,那么在对象不再使用的时候,就会回收

  • 方法区和运行时常量池:一个类加载的时候分配内存,那么对应在一个类被卸载的时候回收内存(满足下面的条件只是允许被回收,不是一定要被回收)

    • 首先明确方法区中存储了什么——常量池中存储了字面常量和对应的符号引用
      • 对于常量,如果没有任何的字符串对象引用常量池中的常量,且虚拟机中也没有其他地方引用这个字面量,就可以进行垃圾回收
    • 对于方法区中存储的类信息是否不再使用进行卸载就比较苛刻了
      • 该类的所有实例对象都被回收,也就是Java堆中不存在该类以及该类其任何派生子类的实例
      • 加载该类的类加载器已经被回收了
      • 该类对应的java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  • 这些分配和回收中,其实只有对象不被使用这个点是比较难明确的

img

所以我们的 GC主要探讨的就是堆内存回收的问题。PC和栈,方法区的回收是比较确定,所以就将GC的问题聚焦在了堆的内存回收和方法区(方法区一般比较少,因为太过苛刻)的回收

img

如何判断一个对象是不是垃圾

如何判断垃圾对象

  • 怎么判断呢?靠引用的持有情况。在理想情况下,应用不再使用一个对象的任何一个引用,那么这个对象一定不再被使用了
  • 在理想情况下,我们是将所有不再被使用的对象都被回收,但是理想情况是不好到达的,所以就会出现两种退而求其次的情况
    • 回收的对象一定是垃圾对象,但是不保证所有的垃圾对象回收——可以接受
    • 并不保证回收的一定是垃圾对象,但保证所有垃圾对象都能回收

引用计数法

image-20221113203337716

  • 思想:当一个对象被引用时,就当引用对象的值加一,当值为 0 时,就表示该对象不被引用,可以被垃圾收集器回收。
  • 存在的问题:
    • 这个引用计数法听起来不错,但是有一个弊端,如下图所示,循环引用时,两个对象的计数都为1,导致两个对象都无法被释放。
    • 所以需要额外的大量的处理才能保证正确地工作

可达性分析算法

image-20221113205113867

我们假定内存回收是整体的回收,GC期间,整个时间是静止的(也就是这几张图的结构是不会变化的,应用程序也不再进行执行任何指令)

  • JVM 中的垃圾回收器通过可达性分析来探索所有存活的对象
  • 扫描堆中的对象,看能否沿着 GC Root 对象为起点的引用链找到该对象,如果找不到,则表示可以回收
  • 可以作为GC Root对象
    1. 虚拟机栈(栈帧中的本地变量表)中引用的对象,比如当前正在运行的方法所使用到的参数,局部变量,临时变量
    2. 方法区中类静态属性引用的对象,比如Java类的引用类型静态变量
    3. 方法区中常量引用的对象,比如字符串常量池(StringTable)中的引用
    4. 本地方法栈中JNI(一般说的Native方法)引用的对象
    5. Java虚拟机内部的引用,如基本数据类型对应的Class文件,一些常驻的异常对象等,还有系统类加载器
    6. 所有被同步锁(synchronized关键字)持有的对象
    7. 反应Java虚拟机内部情况的JMXBean,JVMMTI中注册的回调,本地方法缓存等

Java的引用

[Java的引用]: Java的五大引用 - 刘颂成 - 博客园 (cnblogs.com)(https://www.cnblogs.com/lscCurry/p/16887018.html)

垃圾回收基础算法

标记清除

标记:引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象。标记的是引用的对象,不是垃圾!!

清除:堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header中 没有标记为可达对象,则将其回收

  • 这里所谓的清除并不是真的置空,而是把需要清除的对象地址(起始地址和结尾地址)保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的位置。

    如果内存规整

    • 采用指针碰撞的方式进行内存分配

    如果内存不规整

    • 虚拟机需要维护一个列表

    • 空闲列表分配

存在的问题

  • 这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表

image-20221113214215736

标记整理

标记复制算法在对象存活率比较高的时候就要进行较多的复制操作,效率也会降低,更关键,如果不想浪费那一半的空间,还需要额外的空间进行分配担保,我们的老年代存活几率比较大,所以一般不采用这种标记复制算法

  • 因为标记清除的存在内存碎片的问题,所以采用这种标记整理的思想——类似我们操作系统的紧凑技术
  • 缺点:这是处理的过程是非常耗费时间的,导致速度非常慢

image-20221113214240272

标记复制

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

image-20221113214643848

分代垃圾回收

因为对这种内存碎片我们没有很好的解决方法,所以对内存进行进一步划分,对不同区域有不同的处理这种思想来缓解

  • 弱分代假说 绝大多对象都是朝生夕灭
  • 强分代假说:熬过多次的垃圾回收过程的对象就越难以消亡
  • 跨代引用假说:跨代引用相对于同代引用来说仅占少部分
    • 因为对象不是孤立的,对象之间是存在跨代引用,可能老年代的对象指向新生代,也有可能是新生代指向老年代,为了找出这个区域的存活对象,我们在清除新生代的垃圾时所以不得在GC ROOT之外,再额外遍历整个老年代的对象来确保可达性分析的正确性,这样是耗时的,为内存回收带来了很大的性能负担
    • 根据假说,我们不应该为少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需要再新生代建立一个全局的数据结构(记忆集)——下文介绍的卡表

image-20221114003713742

  • 我们的新生对象首先放到我们的新生代的伊甸区
  • 新生代空间不足时,触发 Minor gc ,eden 区 和 from 区存活的对象使用 - copy 复制到 to 中,存活的对象年龄加一,然后交换 from to
    • 因为我们新生代经过一次垃圾回收,会有大量的对象死亡,真正存活的对象很少(进入幸存区)所以采用这种标记+复制的算法
  • minor gc 会引发 stop the world,暂停其他线程,等垃圾回收结束后,恢复用户线程运行
  • 当幸存区对象的寿命超过阈值时,会晋升到老年代,最大的寿命是 15(对象头存储GC年龄的位数是4bit)对象年龄存储在我们的对象头
    • 也不是说我们的对象到15岁才进我们的老年代
    • 如果当内存紧张的时候,可能对象的寿命没到阈值,也会提前进入老年代(分配担保机制)
      • 在发生Minor GC之前,虚拟机必须要先检查老年代最大可用的连续空间是否大于新时代所有对象总空间,如果这个条件成立,那么这一次Minor GC是安全的,为什么不满足是冒险的呢? 我们新生代一般采用的是复制收集算法,但是为了内存利用率,只使用一个幸存区空间作为轮回备份,因此当出现了大量对象在Minor GC之后还是存活,那么需要我们的老年代来进行分配担保,把幸存区无法容纳的对象直接送入老年代,因为我们的老年代有这样的担保,如果连续的内存空间可能会出现不足与为新生代担保
      • 在JDK U24之后改为只要老年代的连续空间大于新生代总大小或者历次晋升的平均大小,就会进行Minor GC,否则触发 Full GC
    • -XX:MaxTenuringThreshold设置晋升的年龄
    • 动态对象年龄判断
      • 如果在Survivor中空间低于或者等于某年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于后者等于该年龄的对象就可以直接进入老年代
  • 当我们的老年代的内存不够,会尝试进行一次Minor GC,但是内存还是不够,我们的老年代也还是也放不下去,那么就会触发一次Full GC(时间更久)
    • 大量的GC,只会进行新生代的GC,所以耗时并不高,但是随着新生代GC的进行,不断会有对象进入老年代,老年代也会触发阈值,导致老年代GC,老年代GC发生,通常已经进行了新生代的GC,所以也可以认为老年代GC是Full GC
    • 老年代的GC通常可能是标记+清除或者是标记+整理,而且对象非常多,所以耗时比较久
    • 大对象可以一开始就进入老年代
      • 通过-XX PretenureSizeThresgold参数来设置大于多少内存直接进入老年代——只对Serial和ParNew有效
      • 为什么呢:因为我们新生代采用的标记复制算法,这样避免在Eden区以及两个Survivor区之间的来回复制,导致大量的内存复制操作

相关的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

GC分析实例

演示的代码

package cn.itcast.jvm.t2;

import java.util.ArrayList;

/**
 *  演示内存的分配策略
 */
public class Demo2_1 {
    private static final int _512KB = 512 * 1024;
    private static final int _1MB = 1024 * 1024;
    private static final int _6MB = 6 * 1024 * 1024;
    private static final int _7MB = 7 * 1024 * 1024;
    private static final int _8MB = 8 * 1024 * 1024;

    // -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc -XX:-ScavengeBeforeFullGC
    public static void main(String[] args) throws InterruptedException {
        ArrayList<byte[]> list = new ArrayList<>();
        list.add(new byte[_8MB]);//为了测试大对象
        list.add(new byte[_8MB]);
        /*list.add(new byte[_7MB]);
        list.add(new byte[_6MB]);*/

        //多线程演示
        /*  new Thread(() -> {
            ArrayList<byte[]> list = new ArrayList<>();
            list.add(new byte[_8MB]);
            list.add(new byte[_8MB]);
        }).start();

        System.out.println("sleep....");
        Thread.sleep(1000L);*/
    }
}

新生代垃圾回收

image-20221114150956896

  • 当新生代内存紧张的时候,不到达年龄阈值也可以进入老年代

大对象直接入老年区

image-20221114151127510

关于工作线程溢出对进程的影响

image-20221114154112620

HotSpot算法实现细节

这一部分知识是后面经典垃圾回收器的前置知识,可以先看后面,在回头看着

根节点枚举

我们JVM中采用的是可达性分析算法来判定是不是垃圾,但是GC RootS尽管目标明确,但是查找过程并不是一件容易的事情,现在Java应用越做越大,光是方法区的大小都可能有数百上千兆,里面的类,常量更是恒河沙数,逐个检查肯定是不现实的

  • 迄今为止,所有的收集器在根节点枚举这一步(初始标记)这一步骤都是需要暂停用户线程的(Stop The World),因为根节点的枚举我们需要必须保证在一个能够保障一致性的快照中才能得以进行——这里的快照说的在整个枚举根节点的过程中 对象引用链就像是冻结在某个时间点一样

  • 为了提高枚举的速度,我们采用了OopMap这种数据结构来达到目的——对栈进行扫描,找到哪些地方存储了对象的引用。

    然而,栈存储的数据不止是对象的引用,因此对整个栈进行全量扫描,显然是很耗费时间,影响性能的。因此,在 HotSpot 中采取了空间换时间的方法,使用 OopMap 来存储栈上的对象引用的信息。在 GC Roots 枚举时,只需要遍历每个栈桢的 OopMap,通过 OopMap 存储的信息,快捷地找到 GC Roots。

  • img

    • ◉ 栈里和寄存器内的引用
      在即时编译中,在特定的位置记录下栈里和寄存器里哪些位置是引用

    • ◉ 对象内的引用
      类加载动作完成时,HotSpot 就会计算出对象内什么偏移量上是什么类型的数据
      注:把存储单元的实际地址与其所在段的段地址之间的距离称为段内偏移,也称为有效地址或偏移量,因此,实际地址=所在段的起始地址+偏移量

这样我们的收集器就可以直接知道这些信息,并不需要真正一个不漏地从方法区等GC RootS开始查找

记忆集与卡表

卡表只是我们记忆集的一种具体实现方式,类似Map和HashMap的关系

为什么需要记忆集

在我们的分代收集理论的时候,提到了有关对象跨代引用带来的问题(并不是只有新生代和老年代存在这种问题,比如我们的G1部分区域收集会存在相似的问题),为了解决这种问题,引入了记忆集这种数据结构——在新生代中存储

image-20221116175743149

以上这种情况,我们不可能因为新生代的收集,去遍历老年代吧,这样非常耗时,所以采用空间换时间的思想

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,比如我们把这个指针保存在我们的新生代,指针指向的区域是我们的老年代,就说明被指向老年代的哪些区域引用我们这个新生代的对象

img

三种精度选择

  • 字长精度
  • 对象精度
  • 卡精度(卡表)

卡表

卡表的最简单的形式只是一个字节数组,而HotSpot也是这样做的

CARD_TABLE[this address >> 9]=1;//类似于取余操作 

image-20221116181036331

这样我们的字节数组的每一个元素都对应着器标识的内存区域中的一块特定大小的内存块,这个内存块也被称为卡页(HotSpot使用的是2^9次幂,即512字节)

  • 一个卡页的内存中通常包含了不止一个对象,只要卡页内有一个对象的字段存在跨代指针,那就对应卡表的数组元素的值标识为1,称这个数据变脏
  • 在垃圾回收的时候,我们只需要关心卡表的中的变脏的元素,就能轻易得出哪些卡页内存卡中包含跨代指针,将它们加入GC Roots中一并扫描
  • 卡表的维护需要写屏障的支持——新老生代的指向发生变换

写屏障

写屏障使用了记忆集来缩减 GC Roots 扫描范围的问题,但是没有解决卡表元素如何维护的问题,例如如何变脏,什么时候变脏

有其他分代区域对象引用了本区域对象时,其对应的卡表元素就应该变脏,

变脏的时机原则上应该发生在引用类型字段赋值的那一刻。

如果是解释执行的字符串,虚拟机有充分的介入空间。但经过即时编译后的代码

已经是纯粹的机器指令流了,因此必须找到一个在机器码层面的手段把维护卡表的动作

放到每一个赋值操作之中。

写屏障(Write Barrier)

HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。注意,这不是内存屏障。

写屏障可以看做是虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生

一个环形(Around)通知,提供程序执行额外的动作,赋值前的部分的写屏障叫做写前屏障(Pre-Write Barrier),

赋值后的则叫写后屏障(Post-Write Barrier)。

void oop_field_store(oop* field, oop new_value) {
    // 引用字段赋值操作
    *field = new_value;
    // 写后屏障,在这里更新卡表
    post_write_barrier(field, new_value);
}

应用写屏障后,虚拟机会为所有赋值操作生成相应指令,无论更新的是不是老年代对新生代对象的引用,

每次只要对引用进行更新,都会触发更新卡表的动作。这个额外的开销与Minor GC时扫描整个老年代的代价要低得多。

并发的可达性分析--三色标记

img

这张图中最后 B C H 是不可达的,是垃圾

三色代表的意义

  • 白色:表示对象尚未被垃圾收集器访问过,显然在可达性分析刚刚开始之前,所有对象都是白色的,若在可达性分析结束的阶段,还是白色的对象,则表明是不可达
  • 黑色:表示对象以及被垃圾收集器访问过,且这个对象的所有引用都已经扫描过,也就是说黑色表示对象存活,黑色对象不可能直接指向某个白色对象
  • 灰色:表示对象已经被垃圾收集器访问过,但是这个对象上至少存在一个引用没有被扫描过

image-20221116213800134

三色标记遍历过程

假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

  1. 初始时,所有对象都在 【白色集合】中;
  2. 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
  3. 从灰色集合中获取对象:
    3.1. 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
    3.2. 将本对象 挪到 【黑色集合】里面。
  4. 重复步骤3,直至【灰色集合】为空时结束。
  5. 结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。

注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。

可能出现的问题——如果我们的用户线程都被冻结,只有垃圾回收的线程在工作,那么不会出现任何问题,但是如果是用户线程和收集器线程并发执行,那么就可能出现一些一致性带来的问题

  • 浮动垃圾(多标):将原本应该被清除的对象,误标记为存活对象。后果是垃圾回收不彻底,不过影响不大,可以在下个周期被回收;
  • 对象消失(漏标):将原本应该存活的对象,误标记为需要清理的对象。后果很严重,影响程序运行,是不可容忍的。

多标——浮动垃圾

image-20221116220514428

此刻之后,对象B/C/D是“应该”被回收的。然而因为B已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存

这部分本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。

另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

漏标

image-20221116222905721

B到D 断开,A引用 D

此时切回GC线程继续跑,因为B已经没有对D的引用了,所以不会将D放到灰色集合;尽管因为D重新引用了G,但因为A已经是黑色了,不会再重新做遍历处理。
最终导致的结果是:D会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

漏标必须要同时满足以下两个条件:

  1. 赋值器插入了一条或者多条从黑色对象到白色对象的新引用;
  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

这两个条件必须全部满足,才会出现对象消失的问题。那么我们只需要对上面条件进行破坏,破坏其中的任意一个,都可以防止对象消失问题的产生。这样就产生了两种解决方案:

  • 增量更新:Incremental Update。
  • 原始快照:Snapshot At The Beginning,SATB。
  • 增量更新与原始快照在 HotSpot 中都有实际应用,其中增量更新用在 CMS 中,原始快照用在了 G1、Shenandoah 等回收器中。
  • HotSpot 虚拟机中,不管是新增还是删除,这种记录的操作都是通过写屏障实现的。我们可以将写屏障理解为 JVM 对引用修改操作的一层 AOP,注意它与内存屏障是两个不同的东西。

增量更新

增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用时,就将这个新加入的引用记录下来,待并发标记完成后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次——可以简化理解为黑色对象一旦新插入指向白色对象的引用,它就变为灰色对象

img

原始快照

原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,并发扫描结束后,再将这些记录过的引用关系中的会色对象为根,重新扫描一次。——在这可以简化理解为,无论引用关系删除是否,都会按照刚开始扫描的那一刻的对象图快照来进行搜索

image-20221116231821864

会出现这种浮动垃圾问题,但是是不致命的

image-20221116231948450

三色标记法与现代垃圾回收器

现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。

对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

  • CMS:写屏障 + 增量更新
    • image-20221116234409808
  • G1:写屏障 + SATB(原始快照)
    • image-20221116234237296
  • ZGC:读屏障
    • image-20221116234901141

工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。

为什么G1用SATB?CMS用增量更新?

增量更新:黑色对象新增一条指向白色对象的引用,那么要进行深入扫描白色对象及它的引用对象。

原始快照:灰色对象删除了一条指向白色对象的引用,可能会产生了浮动垃圾,好处是不需要像 CMS 那样 remark,再走一遍 root trace 这种相当耗时的流程。

我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。

经典垃圾回收器

相关的概念

  • 并行收集:指多条垃圾收集线程并行工作,但此时通常默认用户线程仍处于等待状态
  • 并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,因为用户线程并没有冻结,所以还可以相应服务请求
  • 吞吐量:即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99% 。

串行回收器

Serial 收集器

  • 单线程
  • 堆内存较少,适合个人电脑

Serial 收集器是最基本的、发展历史最悠久的收集器
特点:单线程、简单高效(与其他收集器的单线程相比),采用复制算法。对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)!

  • 单线程的意义不仅仅是说明它只会使用一个处理器或者一条收集线程去完成垃圾收集工作,更重要的是强调它在进行垃圾回收的时候,必须暂停其他所有工作的线程
  • 还是HotSpot虚拟机运行在客户端模式下默认的新生代收集器

image-20221114184753761

-XX:+UseSerialGC   =serial + serialOld //开启SerialGC

安全点:让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象
因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本

  • 特点:同样是单线程收集器,采用标记-整理算法
  • 这个收集器主要意义也是供客户端模式下的HotSpot的虚拟机下使用
  • 如果在服务器有两种用途
    • 在JDK5之前的版本与之前的版本中与Parallel Scanvenge收集器搭配使用
    • 另一种就是作为CMS收集器发生失败时的后备预案

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程并行版本,跟Serial一样也是串行的,也是用于新生代的回收

  • 这里的串行表示的是我们的用户线程和垃圾回收线程是串行的 ParNew的多线程表示的是垃圾回收线程之间的多线程并行并发执行
  • 特点:多线程、ParNew 收集器默认开启的收集线程数与CPU的数量相同,在 CPU 非常多的环境中,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。和 Serial 收集器一样存在 Stop The World 问题
  • 除了Serial收集器,目前只有它能和CMS收集器配合工作(我们的CMS收集器也是跨时代的收集器)
    • 为什么只有ParNew能与CMS收集器配合:
      CMS是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;
      CMS作为老年代收集器,但却无法与JDK1.4已经存在的新生代收集器Parallel Scavenge配合工作;这是因为Parallel Scavenge(以及G1)都没有使用传统的GC收集器代码框架,而另外独立实现,而其余几种收集器则共用了部分的框架代码。

查看源图像

吞吐量优先收集器

  • 多线程
  • 堆内存较大,多核 cpu
  • 单位时间内,STW 的时间最短 0.2 0.2 = 0.4,也就是让我们的总体的收集垃圾时间最短
  • 响应时间优先注重的一次停顿的时间,需要保证跟用户有良好的交互,而吞吐量优先则是需要最高效率的利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务

image-20221114190417924

XX:+UseParallelGC ~ -XX:+UseParallelOldGC //表示使用对应的垃圾回收器
-XX:+UseAdaptiveSizePolicy // 是一个开关参数 表示采用自适应的策略 主要调整新生代的大小(eden和幸存区的比例大小)还有晋升老年代的年龄大小
-XX:GCTimeRatio=ratio //表示垃圾时间所占的比例 1/ration+1,默认的ration为99 比例就为0.01 100分钟只允许一分钟进行垃圾回收
-XX:MaxGCPauseMillis=ms //表示一次垃圾回收的时间不超过多少ms
//这两个目标其实是冲突,想让一次GC的时间短,得让堆变小,一次回收的垃圾会少一点 但是堆变小了,我们的垃圾回收的频率就会变高,吞吐量可能就下来了
- XX:ParallelGCThreads=n //表示垃圾回收时并行的线程数量

Parallel Scavenge 收集器

与吞吐量关系密切,故也称为吞吐量优先收集器
特点:属于新生代收集器也是采用标记复制算法的收集器(用到了新生代的幸存区),又是并行的多线程收集器(与 ParNew 收集器类似)

该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与 ParNew 收集器最重要的一个区别

GC自适应调节策略:

  • Parallel Scavenge 收集器可设置 -XX:+UseAdptiveSizePolicy 参数。
  • 当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRation)、
  • 晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最合适的停顿时间或者最高的吞吐量,这种调节方式称为 GC 的自适应调节策略。

Parallel Scavenge 收集器使用两个参数控制吞吐量:

XX:MaxGCPauseMillis=ms 控制最大的垃圾收集停顿时间(默认200ms)
XX:GCTimeRatio=ratio 直接设置吞吐量的大小

Parallel Old 收集器

是 Parallel Scavenge 收集器的老年代版本
特点:多线程,采用标记-整理算法(老年代没有幸存区)

响应时间优先

  • 多线程
  • 堆内存较大,多核 cpu
  • 尽可能让 STW 的单次时间最短 ,为了让用户有更好的交互体验

image-20221114192256101

 XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld 
 //使用CMS垃圾回收器搭配着ParNewGC回收新生代 可能发生并发失败的问题,CMS导致退化为SerialOld回收器
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads //设置并行的线程数 设置并发的线程数
-XX:CMSInitiatingOccupancyFraction=percent //因为我们的并发清理的时候,会产生新的垃圾,所以不能到堆满才开始清理,这个percent表示到堆的比例就开始回收
-XX:+CMSScavengeBeforeRemark //我们重新标记,因为运行所以可能会有新生代的对象,如果我们的重新标记 有新生代对象指向了老年代(但是这个新生代可能是垃圾),会导致我们的可达性分析的时间较长而且无用,所以再重新标记前对新生代进行垃圾回收

CMS 收集器

Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器

  • 特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片
  • 应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如 web 程序、b/s 服务

CMS 收集器的运行过程分为下列4步:

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

  • 并发标记:就是从GC Roots直接关联的对象开始遍历整个对象图的过程,找出存活对象且用户线程可并发执行,所以不需要Stop The World。

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

  • 并发清除:对标记的对象进行清除回收,清除的过程中,可能任然会有新的垃圾产生这些垃圾是在这次CMS的并发回收进行回收,只能下一次进行回收,所以这些垃圾就叫浮动垃圾。并发清除的时候用户线程还行需要运行,是需要内存空间的,所以我们不能等堆都快慢了才进行回收,需要设置一个比例,到达这个比例就开始回收

    • 可能面临的风险:并发清除过程用户线程是还在执行的,如果当用户需要存入一个很大的对象时,新生代放不下去,老年代由于浮动垃圾过多,导致我们的内存无法满足用户分配新对象的需求,就会出现一次并发失败,所以必须启动后备方案,冻结用户线程,临时启动 serial Old 收集器,将老年代垃圾进行标记-整理,当然这也是很耗费时间的!
  • 虽然CMS的初始标记和重新标记也存在Stop The World的问题,但是着两个阶段时间很短,所以总体上对应每次GC停止时间还是比较短的

为什么这样不是追求优秀的吞吐量

  • 在我们的并发标记和并发清除的时候,虽然不会导致我们的用户线程停止,但缺会因为占有了一部分线程(或者说是CPU的计算能力)而导致我们的应用程序变慢,降低了总体的吞吐量——默认的回收线程是(处理器核心数+3)/4

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

G1 收集器

定义: Garbage First 它也是一款跨时代的垃圾回收器

2004 论文发布
2009 JDK 6u14 体验 不足于商用
2012 JDK 7u4 官方支持

JDK 8U40提供了并发的类卸载的支持

2017 JDK 9 默认

适用场景:

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

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

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

实现的思想

  • G1开创的基于Region的堆内存布局是它能实现上述目标的关键:G1与之前的垃圾回收器不同,G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的堆划分为多个大小相等的独立区域,每一个Region都可以根据需要,扮演伊甸区,幸存区,老年代的功能
  • Region中还有一类特殊的Humongous区域,是用来专门存储大对象,G1认为只要大小超过了一个Region容量的一半的对象就可判定为大对象,而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为把Humongous Region作为老年代的一部分进行看待
  • Region之间采用的是标记复制算法,但是总体上来看是基于标记整理

G1 垃圾回收阶段

image-20221114195351091

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

与之前的经典垃圾回收器不同,最大的改变是在于之前的垃圾回收器收集的目标要么是整个新生代,要么是老年代,要么就是Full GC,整个堆的回收,而G1跳出这个禁锢,它可以面对堆内存任何部分来组成回收集来进行回收,衡量的标准不再是它属于哪个分代,而是哪个内存中的垃圾多,回收效益最大,就比如Mixed Collection

Young Collection

image-20221114195941362
分代是按对象的生命周期划分,分区则是将堆空间划分连续几个不同小区间,每一个小区间独立回收,可以控制一次回收多少个小区间,方便控制 GC 产生的停顿时间!
E:eden,S:幸存区,O:老年代

  • 采用的是复制算法

  • 新生代E的存活对象复制到S幸存区

  • S幸存区的对象复制到另一个幸存区,或者是GC年龄到了进入O老年代

新生代收集会产生 STW !

Young Collection + CM

  • 在 Young GC 时会进行 GC Root初始化标记

  • 老年代占用堆空间比例达到阈值时,进行并发标记(不会STW),类似我们CMS的并发标记,由下面的 JVM 参数决定 -XX:InitiatingHeapOccupancyPercent=percent (默认45%)

image-20221114200444817

Mixed Collection

image-20221114201056183

会对 E S O 进行全面的回收

  • 重新标记会 STW 因为之前的阶段有并发标记,所以可能会产生新的垃圾对象,所以需要最终标记
  • 拷贝存活会 STW

-XX:MaxGCPauseMills=xxms 用于指定最长的停顿时间!
问:为什么有的老年代被拷贝了,有的没拷贝?
因为指定了最大停顿时间,如果对所有老年代都进行回收,耗时可能过高。为了保证时间不超过设定的停顿时间,会回收最有价值的老年代(回收后,能够得到更多内存)

另一个角度四个步骤

  • 初始标记:仅仅是标记一下GC Root能之间关联到的对象,并且修改TAMS指针的值
    • 垃圾收集堆用户线程的影响还体现在回收过程中新创建对象的内存分配上,因为我们的用户线程要进行运行就跨代会持续有新对象被创建,G1为每一个Region设计了两个TAMS的指针,把Region中的一部分空间划分出来用并发回收过程中的新对象分配,并发回收时新分配的对象地址必须在这个两个指针位置以上,G1收集器默认在这个地址以上的对象是被隐式标记的,默认它们是存活的,不纳入回收范围
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆的对象图,找出要回收的对象,这个阶段耗时比较长,但可与用户程序并发执行
    • 当我们对象图扫描完成了,还需要处理SATB(原始快照)记录下的在并发时有引用变动的对象(删除灰色对象指向白色对象,需要记录)
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划,可以自由选择任意多个Region构成回收集,会造成STW

Full GC

SerialGC

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足发生的垃圾收集 - full gc
    • 虽然老年代的单独回收叫Major GC,但是老年代的回收之前会有一次Minor GC,所以将老年代的回收成为Full GC(收集整个堆)

ParallelGC

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

CMS

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足
    • 只有CMS收集器有单独收集老年代的行为
    • 因为是并发的进行回收,所以用户线程还在运行,可能出现内部不足以用户线程分配对象,导致并发失败,退化为Serial Old,进行Full GC

G1

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 混合收集 Mixed GC,指目标是收集整个新生代以及部分的老年代的垃圾回收,目前只有G1拥有
  • 老年代内存不足 (老年代所占内存超过阈值)
    • 如果垃圾产生速度慢于垃圾回收速度,不会触发 Full GC,还是并发地进行清理
    • 如果垃圾产生速度快于垃圾回收速度,便会触发 Full GC,然后退化成 serial Old 收集器串行的收集,就会导致停顿的时候长。

Young Collection 跨代引用

新生代回收的跨代引用(老年代引用新生代)问题(可能一些根对象在老年代)

image-20221114205405761

在这里插入图片描述

卡表 与 Remembered Set

  • Remembered Set 存在于E中,用于保存新生代对象对应的脏卡(这样在寻找GC ROOT就不需要遍历整个老年代,只需要关注脏卡就行)
  • 脏卡:O 被划分为多个区域(一个区域512K),如果该区域引用了新生代对象,则该区域被称为脏卡

在引用变更时通过 post-write barried + dirty card queue

concurrent refinement threads 更新 Remembered Set

JDK 8u20 字符串去重

  • 将所有新分配的字符串(底层是 char[] )放入一个队列

  • 当新生代回收时,G1 并发检查是否有重复的字符串

  • 如果字符串的值一样,就让他们引用同一个字符串对象

  • 注意,其与 String.intern() 的区别

  • String.intern() 关注的是字符串对象

  • 字符串去重关注的是 char[]

  • 在 JVM 内部,使用了不同的字符串标

优点与缺点

节省了大量内存
新生代回收时间略微增加,导致略微多占用 CPU
-XX:+UseStringDeduplication

JDK 8u40 并发标记类卸载

在并发标记阶段结束以后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类

  • 一般是我们自己定义类加载器才能被卸载
  • JDK的是不行的

JDK 8u60 回收巨型对象

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

在这里插入图片描述

JDK 9 并发标记起始时间的调整**
并发标记必须在堆空间占满前完成,否则退化为 FulGC
JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent
JDK 9 可以动态调整
-XX:InitiatingHeapOccupancyPercent 用来设置初始值
进行数据采样并动态调整
总会添加一个安全的空挡空间

各个垃圾收集器之间的搭配

image-20221117203717484

  • 两个垃圾回收器之间有连线就表明可以配合在一起使用
  • Serial Old和CMS之间的连线,是表示Serial Old 是CMS并发清理失败的后备方案,Serial Old是串行的标记整理
  • 绿色虚线:JDK14表示废弃使用
  • 蓝色虚线:表示JDK14删除CMS垃圾回收器
  • 红色虚线:在JDK8中将这两个组合声明为废弃,JDK9完全取消这两个的支持
  • jdk1.7 默认垃圾收集器Parallel Scavenge(新生代【标记-复制算法】)+Parallel Old(老年代【标记整理算法】
  • jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
  • jdk1.9 默认垃圾收集器G1

GC调优思路

查看虚拟机参数命令

java -XX:+PrintFlagsFinal -version | findstr "GC"//在控制台运行

1
可以根据参数去查询具体的信息

调优领域

  • 内存
  • 锁竞争
  • cpu 占用
  • io

确定目标

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

  • CMS G1 ZGC——低延迟
  • ParallelGC——高吞吐量
  • Zing

最快的 GC

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

查看 Full GC 前后的内存占用,考虑以下几个问题

数据是不是太多?

resultSet = statement.executeQuery(“select * from 大表 limit n”)

数据表示是否太臃肿

  • 对象图
  • 对象大小 16 Integer 24 int 4

是否存在内存泄漏

  • static Map map …
  • 第三方缓存实现

新生代调优

新生代的特点

  • 所有的 new 操作分配内存都是非常廉价的
    • TLAB thread-lcoal allocation buffer 为每个线程分配缓冲区,为了保证线程安全
  • 死亡对象回收零代价——因为复制算法是复制活的对象
  • 大部分对象用过即死(朝生夕死)
  • Minor GC 所用时间远小于 Full GC

新生代内存越大越好么?

不是

  • 新生代内存太小:频繁触发 Minor GC ,会 STW ,会使得吞吐量下降
  • 新生代内存太大:老年代内存占比有所降低,会更频繁地触发 Full GC。而且触发 Minor GC 时,清理新生代所花费的时间会更长

新生代内存设置为内容纳[并发量*(请求-响应)]的数据为宜

幸存区需要能够保存 当前活跃对象+需要晋升的对象

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

-XX:MaxTenuringThreshold=threshold
-XX:+PrintTenuringDistrubution

老年代调优

以 CMS 为例:

CMS 的老年代内存越大越好
先尝试不做调优,如果没有 Full GC 那么说明已经很好,否者先尝试调优新生代。
观察发现 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3

-XX:CMSInitiatingOccupancyFraction=percent

案例

  • 案例1:Full GC 和 Minor GC 频繁
  • 案例2:请求高峰期发生 Full GC,单次暂停时间特别长(CMS)
  • 案例3:老年代充裕情况下,发生 Full GC(jdk1.7)
posted @ 2022-11-18 12:47  刘颂成  阅读(234)  评论(0编辑  收藏  举报