跳至主要內容

②JVM 垃圾收集器和内存分配策略

holic-x...大约 35 分钟JAVA基础

②JVM 垃圾收集器和内存分配策略

学习核心

  • 对象已死
    • JAVA中的引用
    • 可达性分析算法
  • 垃圾收集算法
    • 垃圾回收算法
      • 标记-清除算法
      • 标记-整理算法
      • 复制
      • 分代收集
  • 垃圾收集器
    • Serial收集器
    • ParNew收集器
    • Parallel Scavenge收集器
    • Serial Old收集器
    • Parallel Old收集器
    • CMS收集器
    • G1收集器
  • 内存分配和回收策略

学习资料

核心概念

垃圾回收机制学习思路:

  • 什么是垃圾回收?(反向思维:GC-Garbage Collection)

    • 回收发生在哪里?
    • 对象可以在什么时候被回收?
    • 如何被回收?(回收的时机、回收算法)
  • 垃圾回收如何找到存活对象?(GC Roots)

  • 理解垃圾回收算法的迭代:标记、清除、整理、分代假设

  • 垃圾收集器的迭代?(进化:目的是减少STW时间

    • 单线程=》多线程:Serial=》ParNew (同一时刻提供更多的GC线程参与垃圾回收工作)
    • 吞吐量:ParNew=》Parallel Scavenge,提供吞吐量设置参数,尽可能缩短STW
    • 分代收集:区分年轻代、老年代,可以多个垃圾收集器组合引用,也可使用G1(全性能的垃圾收集器)
  • 常见的三种垃圾收集器实现:(Parallel/CMS/G1)

垃圾回收

​ ==垃圾回收:==并不是找到不再使用的对象,然后将这些对象清除掉。它的过程正好相反,JVM 会找到正在使用的对象,对这些使用的对象进行标记和追溯,然后一股脑地把剩下的对象判定为垃圾,进行清理

即垃圾回收机制是通过标记、追溯正在使用的对象,然后将剩下的对象判定为垃圾进行清理

​ 基于垃圾回收机制的衍生分析:

  • GC 的速度和堆内存活对象的多少有关(因为如果这些存活对象太多,JVM做标记和追溯的时候就会很慢),与堆内所有对象的数量无关;
  • GC 的速度与堆的大小无关,32GB 的堆和 4GB 的堆,只要存活对象是一样的,垃圾回收速度也会差不多;
  • 垃圾回收不必每次都把垃圾清理得干干净净,最重要的是不要把正在使用的对象判定为垃圾

回收发生在哪里?(关注堆和方法区的回收)

​ 结合JVM内存区域分析:

​ JVM 的内存区域中,程序计数器、虚拟机栈和本地方法栈这 3 个区域是线程私有的,随着线程的创建而创建,销毁而销毁;栈中的栈帧随着方法的进入和退出进行入栈和出栈操作,每个栈帧中分配多少内存基本是在类结构确定下来的时候就已知的,因此这三个区域的内存分配和回收都具有确定性。

​ 那么垃圾回收的重点就是关注堆和方法区中的内存了,堆中的回收主要是对象的回收,方法区的回收主要是废弃常量和无用的类的回收。

垃圾回收的时机、如何被回收、回收算法

​ 判断对象是否可以被回收可通过引用计数算法和可达性分析算法进行判定

​ 垃圾回收有两个特性:自动性、不可预期性,可通过相应的回收算法执行GC操作

  • 自动性:

    • Java 提供了一个系统级的线程来跟踪每一块分配出去的内存空间,当 JVM 处于空闲循环时,垃圾收集器线程会自动检查每一块分配出去的内存空间,然后自动回收每一块空闲的内存块
  • 不可预期性:

    • 一旦一个对象没有被引用了,该对象是否立刻被回收呢?答案是不可预期的。很难确定一个没有被引用的对象是不是会被立刻回收掉,有可能当程序结束后,这个对象仍在内存中

垃圾回收线程在 JVM 中是自动执行的,Java 程序无法强制执行。可通过调用 System.gc 方法来"建议"执行垃圾收集器,但是否可执行,什么时候执行?仍然不可预期

STW(Stop The World)

​ 思考一种情况:如果在垃圾回收的过程中又有新的对象进入怎么办?为了保证程序不乱套,最好的做法就是暂停用户的一切线程(STW)

​ 垃圾收集过程中,需要暂停应用程序中的所有线程,如果不暂停,则对象间的引用关系会一直不停地发生变化,导致统计无法进行,这种情况称为STW

​ 而垃圾回收的优化目的在于在保证垃圾回收效果的同时:减少STW时间、提升吞吐量

碎片整理

​ 每次执行清除(Sweeping),JVM 都必须保证不可达对象占用的内存能被回收重用。这时候,就像是摆满棋子的围棋盘上,一部分位置上棋子被拿掉而产生了一些零散的空位置。但这(最终)有可能会产生内存碎片(类似于磁盘碎片),进而引发两个问题:

  • 写入操作越来越耗时,因为寻找一块足够大的空闲内存会变得困难(棋盘上没有一整片的空地方)
  • 在创建新对象时,JVM 在连续的块中分配内存。如果碎片问题很严重,直至没有空闲片段能存放下新创建的对象,就会发生内存分配错误(allocation error)

​ 要避免这类问题,JVM 必须确保碎片问题不失控。因此在垃圾收集过程中,不仅仅是标记和清除,还需要执行“内存碎片整理”过程。这个过程让所有可达对象(reachable objects)依次排列,以消除(或减少)碎片。就像是我们把棋盘上剩余的棋子都聚集到一起,留出来足够大的空余区域。

分代假设

​ 执行垃圾收集需要停止整个应用。很明显,对象越多则收集所有垃圾消耗的时间就越长。但可不可以只处理一个较小的内存区域呢?因此引入弱代假设概念,将存活对象归为两类:

  • 对象的的生命周期较短(大部分)
  • 对象可能会存活很长时间(其他)

​ 基于这一假设(根据存活对象进行分类),拆分为年轻代(1个Eden区、2个Survivor区)、老年代

对象已死

1.Java中的引用

对象的访问方式有哪些?

Java 程序会通过栈上的 reference 引用操作堆对象,访问方式由虚拟机决定,主流访问方式主要有句柄和直接指针

​ 句柄:堆会划分出一块内存作为句柄池,reference 中存储对象的句柄地址,句柄包含对象实例数据与类型数据的地址信息。优点是 reference 中存储的是稳定句柄地址,在 GC 过程中对象被移动时只会改变句柄的实例数据指针,而reference 本身不需要修改。

​ 直接指针:堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 存储对象地址,如果只是访问对象本身就不需要多一次间接访问的开销。优点是速度更快,节省了一次指针定位的时间开销,HotSpot 主要使用直接指针进行对象访问

如何判断对象是否是为垃圾?

​ ==引用计数法:==在对象中添加一个引用计数器,如果被引用则计数器+1、引用失效时则计数器-1,如果计数器为0则标记为垃圾。

  • 优点:原理简单、效率高
  • 缺点:可能存在对象间循环引用的问题,导致计数器无法清零

​ ==可达性计数法:==通过GC Roots遍历节点,确认引用链。如果某个对象和GC Roots没有任何关联,则会被标记为垃圾

  • 可做为GC Roots的对象
    • 类静态属性引用的对象
    • 活动线程(虚拟机栈和本地方法栈引用的对象)
    • 常量引用对象
    • JNI 引用对象

直接引用(对象访问)

​ 无论是对象的访问定位,还是对象是否可以被回收的判断等,都离不开引用。而Java中虚拟机HotSpot通过直接引用来访问Java对象的

​ 直接引用就是说指针是直接指向对象实例的,如果想要获取到对象的类型数据信息,则需要再调用对象里维护的类型数据指针

直接引用示意图:

image-20240530193430617

如何理解直接引用是JVM引用的另一种说法这种概念?

​ JVM只规定了reference类型是一个指向对象的引用,并没有规定这个引用如何实现。不同JVM厂商的实现会有所差异,主要有两种方式:直接引用、句柄,而平常分析的HotSpot虚拟机其引用类型就是直接引用,因此一般叙述也就直接说是直接引用

引用的强度分类

​ Java中引用类型的强弱会决定对象能否被垃圾回收,主要分类四种:强、软、弱、虚(强度依次递减)

引用类型被垃圾回收时间用途生存时间
强引用从来不会对象的一般状态JVM停止运行时终止
软引用当内存不足时对象缓存内存不足时终止
弱引用正常垃圾回收时对象缓存垃圾回收后终止
虚引用正常垃圾回收时跟踪对象的垃圾回收垃圾回收后终止

强引用

​ 最常见的引用类型(例如:Object obj = new Object(),通过new产生的引用就是强引用)

​ 如果一个对象还有强引用,那么垃圾回收器绝不会回收它(用途:对象的一般状态)

Object obj = new Object();
String str = "abcd"; // 字符串常量池中的对象不会被垃圾回收

​ 此处字符串常量是一个“全局引用”概念,它强调的是多个引用指向同一个对象

​ 而“强引用”更倾向于强调“单个引用”,只有在和GC Roots断绝关系的时候才会被消灭掉

软引用

​ 软引用来表示对象是有用的,但不是必须的。如果一个对象只有软引用了,那么当内存不足,准备抛出内存溢出异常以前,会先把这些软引用的对象进行回收了,如果回收之后内存还是不够,这时才实际抛出内存溢出异常。(用途:对象缓存)

class Obj{
    String objName;

    public Obj(String objName) {
        this.objName = objName;
    }

    public String getObjName() {
        return objName;
    }

    public void setObjName(String objName) {
        this.objName = objName;
    }
}

// 引用相关(强、软、弱、虚)
public class ReferenceDemo {
    public static void main(String[] args) {
        // 1.强引用
        Obj obj = new Obj("test");
        // 2.软引用
        SoftReference<Obj> softObj = new SoftReference<Obj>(obj);
        System.out.println(softObj.get().getObjName());
    }
}

弱引用

​ 弱引用就更低一级,用来描述一些非必须的对象。当一个对象只有弱引用的时候,只要发生垃圾回收gc,就会被回收。所以弱引用对象活不过下一次gc;(用途:对象缓存)

// 3.弱引用(当对象只有一个弱引用,则只要发生垃圾回收GC就会被回收),弱引用活不过下一次GC
WeakReference<String> weakObj = new WeakReference<>(new String("hello"));
System.out.println("GC执行前:"+weakObj.get());
// 主动通知JVM执行GC操作
System.gc();
Thread.sleep(1000);
System.out.println("GC执行后:" + weakObj.get());

// output
GC执行前:hello
GC执行后:null

​ 强引用和弱引用结合使用案例分析:

Obj sObj = new Obj("Strong"); // 创建了一个Obj对象的强引用
WeakReference<Obj> wrObj = new WeakReference<>(sObj); // 创建了Obj对象的弱引用
System.out.println("调用GC前:" + wrObj.get());
System.gc();
System.out.println("调用GC后:" + wrObj.get());// 因为这个Obj对象还有一个强引用,所以即使调用gc也不会回收弱引用对象
// 取消sObj的强引用
sObj = null;
System.out.println("取消Obj强引用,调用GC前:" + wrObj.get());
System.gc();
System.out.println("取消Obj强引用,调用GC后:" + wrObj.get()); // 此时Obj对象只有一个弱引用,因此调用gc会回收这个弱引用对象


// output
调用GC前:com.noob.jvm.Obj@55f96302
调用GC后:com.noob.jvm.Obj@55f96302
取消Obj强引用,调用GC前:com.noob.jvm.Obj@55f96302
取消Obj强引用,调用GC后:null

虚引用

​ 最弱的一种引用,形同虚设。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚 引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之关联的引用队列中。(用途:未知)

ReferenceQueue<String> queue = new ReferenceQueue<>();
PhantomReference<String> pr = new PhantomReference<>(new String("hello"), queue); // 创建的一个对象的虚引用
System.out.println(pr.get());
System.gc();// gc操作时发现它还有虚引用,就把这个虚引用加入到与其关联的引用队列中
Thread.sleep(1000); // 等待gc完成
System.out.println(queue.poll()); // 验证虚引用是否被加到指定队列中

// output
null
java.lang.ref.PhantomReference@3d4eac69

2.可达性算法

算法基本思路

​ 通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,即GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

image-20240530203726390

​ 例如图示中的Object6、Object7、Object8、Object9虽然有关联,但是它们到GC Roots是不可达的,因此会被判定为可回收的对象

固定可作为GC Roots的对象(非跨区域引用关系)

分类说明

  • 局部变量(Local variables)
  • 活动线程(Active threads)
  • 静态域(Static field)
  • JNI引用(JNI References)
  • 其他对象

细化说明

  • 在虚拟机栈中引用的对象,例如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
  • 在方法区中类静态属性引用的对象,例如Java类的引用类型静态变量
  • 在方法区中常量引用的对象,例如字符串常量池(String Table)里的引用
  • 在本地方法栈中Native方法引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NulIPointExcepiton.OutOfMemoryError)等,还有系统类加载器
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虚拟机内部情况的JM XBean、JVM TI中注册的回调、本地代码缓存等

​ 除了这些固定的GC Roots外,GCRoots还可以扩充的。例如当针对部分区域进行收集时,其他区域对于该区域的引用,也需要加入Gc Roots 集合中进行判断。

​ 举例: 当采用分代收集时,如果此时目标是收集“新生代”,那么“老年代”中如果存在有对新生代对象的引用关系,那么这些老年代的对象就应该看作上面“固定Gc Roots 集合”的补充,需要纳入一起进行可达性分析。(新生代中有对象即便只被老年代引用,也应该判定为存活对象,不能回收)

有关GC Roots的内容,对比JVM内存区域划分图区理解,入口:线程、静态变量、JNI引用去理解分析

# 案例1:方法引用对象
class User{
    public static void main(String[] args) {
        /**
         * 分析:user是栈帧中的本地变量,user就是GC Root
         * 由于user=null,user与new User()对象断开了链接,所以对象会被回收
         */
        User user = new User();
        user = null;
    }
}

# 案例2Java的静态引用对象
class StaticTest{
    public static User user;
    /**
     *  分析:栈帧中的本地变量t是一个GC Root;user是属于Java引用类型的静态变量,它是一个GC Root
     *  st对象给静态成员变量user赋值了变量的引用,因此user指向的对象不会被回收
     *  执行t=null后和new Test()断了链接,因此t对象会被回收
     */
    public static void main(String[] args) {
        StaticTest st = new StaticTest();
        System.out.println("1.StaticTest.user初始化前:" + StaticTest.user); // 未初始化默认为null
        st.user = new User();
        System.out.println("2.StaticTest.user初始化后:" + StaticTest.user); // 初始化后分配地址
        st = null;
        System.out.println("3.StaticTest.user初始化后且执行了t=null后:" + StaticTest.user); // StaticTest.user还是指向new User(),没有断开连接,所以其指向对象不会被回收
        StaticTest.user = null;
        System.out.println("4.StaticTest.user断开连接:" + StaticTest.user);// StaticTest.user断开连接,则其指向对象被回收
    }
}
// output
1.StaticTest.user初始化前:null
2.StaticTest.user初始化后:com.noob.jvm.User@55f96302
3.StaticTest.user初始化后且执行了t=null后:com.noob.jvm.User@55f96302
4.StaticTest.user断开连接:null


  
# 案例3Javafinal对象
 class FinalTest{
    public static final User user = new User();

     /**
      * 常量user引用的对象不会因为ft引用的对象被回收而回收
      */
     public static void main(String[] args) {
         FinalTest ft = new FinalTest();
         System.out.println("ft=null 执行前final变量:" + ft.user);
         ft = null;
         System.out.println("ft=null 执行前final变后:" + FinalTest.user);
     }
 }
// output
ft=null 执行前final变量:com.noob.jvm.User@55f96302
ft=null 执行前final变后:com.noob.jvm.User@55f96302

记忆集Remembered Sets

​ 当对堆进行部分内存区域回收的时候,就会存在跨区域引用的问题,在GC Roots这里讲过,如果存在跨区域的引用关系,那么这种引用即便不是"固定”Gc Roots范畴,那也应该纳入作为Gc Roots集合的补充,一起来进行可达性分析判断。(例如所有堆内存的被划分为(A,B,C,D,E)五个区,当我们这次只对A,B进行回收时,就需要判断C.D.E中是否有引用A.B中的对象)

​ 为了能够找出这种跨区的引用关系,一种直接的方式就是,将“回收区”以外的所有内存区域扫描一遍,看看哪些是有引用回收区里面的对象的。很显然,这种全域扫描的方式性能会极差,是不可接受的。所以就有了记忆集,记忆集列出了从外部指向本块的所有引用。这种引用记录会在引用关系创建,更改时进行维护。当需要进行这种外部引用关系分析时,直接读取记忆集内容就行。

记忆集:存储外部引用的关联关系,避免”跨区引用“场景中需要进行全域扫描所带来的性能问题。可以通过记忆集快速检索与外部引用的关系

垃圾收集算法

1.垃圾回收算法

概念分析

标记:根据GC Roots遍历所有的可达对象的过程称为标记

清除:将未被标记的对象回收掉

整理:将内存理解为一个大数组,其核心思路是移动所有存活对象,并按照内存地址顺序排列,然后将末端的内存地址全部回收。

复制:提供一个对等的内存空间(存在资源浪费问题),将存活的对象复制过去然后清除原内存空间

算法分类&优化

标记-清除:效果一般,会造成内存碎片问题

标记-整理:解决内存碎片问题,效率较”标记-清除“、”复制“算法差

复制:所有算法中效率最高的,缺点在于造成一定的空间浪费

👻标记-清除算法

​ 算法核心:找出所有对象,将存活的对象进行标记,然后清理掉未被标记的对象

image-20240530211525789

👻标记-整理算法

​ 算法核心:找出所有对象,将存活的对象进行标记,然后将存活对象整理到一端,将其他内存区域直接清理掉

image-20240530211701761

👻复制

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

image-20240530211819124

​ HotSpot 虚拟机的将新生代内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。

Eden区存放的是新建的对象,而Survivor区存放的是至少从Eden中存活了一次垃圾收集的对象

👻分代收集

​ 现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。一般将堆分为新生代和老年代,新生代每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放

  • 新生代

    • 绝大多数对象都是朝生夕灭的
    • 复制算法
  • 老年代

    • "大多数"是熬过越多次垃圾收集过程的对象
    • 标记- 清除 或者 标记- 整理 算法

垃圾收集器

​ HotSpot 虚拟机中的7个垃圾收集器,图中有连线的说明是可以一起搭配使用的

垃圾收集器理解记忆:结合图示和表格进行关键字记忆,关注新生代OR老年代、使用算法、分代收集特点、默认收集器的迭代升级去理解

image-20240530212318637

垃圾收集器执行方式算法特点
Serial收集器串行复制算法新生代收集器
ParNew收集器Serial收集器的多线程并行版本复制算法新生代收集器
Parallel Scavenge收集器多线程的垃圾收集器复制算法新生代收集器、吞吐量优先收集器、GC自适应调节策略开关
Serial Old收集器Serial收集器的老年代版本标记-整理算法老年代收集器
Parallel Old收集器Parallel Scavenge收集器的多线程并行版本标记-整理算法老年代收集器
CMS收集器并发收集标记-清除算法老年代收集器、并发收集、低停顿
G1收集器垃圾优先、分代收集复合算法:
复制/标记-整理算法
Region概念

​ 客户端模式下的默认新生代垃圾收集器:Serial收集器

​ 服务端模式下的默认垃圾收集器:Parallel Scavenge加Parallel Old组合

​ JDK9之后:使用**G1(全能垃圾收集器)**进行取代

1.Serial收集器

​ Serial收集器是在进行垃圾收集时,必须暂停其他所有工作线程(STW:Stop The World)。Stop The World听起来很牛,其实并不是啥好事,因为它会导致用户线程停止工作,所以有些真实应用来说是无法接受的。

image-20240531080959065

  • Serial 翻译为串行,也就是说它以串行的方式执行
  • Serial 是新生代的垃圾收集器
  • 算法:复制算法
  • HotSpot虚拟机运行在客户端模式下的默认新生代收集器

2.ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

image-20240531081108841

  • 垃圾收集时多线程并行
  • ParNew是新生代的垃圾收集器
  • 算法:复制算法
  • 是 Server 模式下的虚拟机首选新生代收集器,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器(老年代)配合工作
  • 使用 -XX:ParallelGCThreads 参数来设置GC线程数。

3.Parallel Scavenge收集器

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

吞吐量=运行用户代码的时间/(运行用户代码的时间+运行垃圾收集的时间)

​ Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

image-20240531081236411

  • 吞吐量优先收集器
  • 新生代垃圾收集器
  • 算法:复制算法
  • 两个精确控制吞吐量的参数:
    • 控制最大垃圾收集停顿时间:-XX:MaxGCPauseMillis
    • 直接设置吞吐量大小:XX:GCTimeRatio
  • GC 自适应的调节策略开关:开启开关,就不需要手动指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量
    • XX:+UseAdaptiveSizePolicy

4.Serial Old收集器

是 Serial 收集器的老年代版本,使用标记-整理算法,主要意义也是供客户端模式下的HotSpot虚拟机使用

image-20240531075958988

  • 老年代收集器
  • 算法:标记-整理算法
  • gc时暂停所有用户线程
  • 主要作为客户端模式下的HotSpot虚拟机使用,另外也作为CMS收集器并发收集发生Concurrent Mode Failure时的后备预案使用

5.Parallel Old收集器

​ Parallel Old是Parallel Scavenge收集器的老年代版本,多线程并行收集。目前只能与新生代的Parallel Scavenge收集器搭配使用,可以说Parallel Old就是为Parallel Scavenge而生的。在这之前Paralel Scavenge收集器只能与老年代的Serial Old进行搭配,但是一个多线程,一个单线程,导致吞吐量并没有充分的提升,直到Parallel 0ld收集器出现。

image-20240531081444641

  • Parallel Old为ParallelScavenge而生,只能搭配Parallel Scavenge.Parallel Old采用多线程
  • 算法:标记-整理
  • 在注重吞吐量以及处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合
  • JDK 6时才开始提供

6.CMS收集器

​ CMS(Concurrent Mark Sweep)是一款追求最短停顿时间的收集器

image-20240531080807754

分为以下四个流程:

  • 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快(需要STW)
  • 并发标记: 进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,可以与用户线程并发(不需要STW)
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(需要STW)。比初始标记时间长,比并发标记时间短
  • 并发清除: 清除掉判定为死亡的对象,可以与用户线程并发(不需要STW)

优点: 并发收集、低停顿

问题总结:

吞吐量低:CMS追求用户线程停顿时间少,停顿时间少就只能与用户线程并发执行部分阶段,导致整个垃圾回收需要执行的整体时间会更长(停顿之后专心垃圾收集肯定是最快的),所以吞吐量会降低

“浮动垃圾”问题:“并发清除”阶段,由于gc线程是与用户线程并发的,这个期间用户还会产生新的垃圾,所以一般会预留出一部分内存,不能等到老年代快满的时候才去收集,如果预留的内存不足以存放这部分浮动垃圾的话,就会出现Concurrent Mode Failure。出现这个错误之后,虚拟机将临时启用 Serial Old (临时备案)来替代CMS

标记-清除算法:因为没有整理的过程,所以垃圾收集完之后,会有很多空间碎片,导致需要分配大块连续内存的时候,空间不足

概念扩展理解

如何理解其吞吐量低:因为CMS追求用户线程停顿时间少,就会有更多机会和用户线程并发执行(以打扫教室为例,同样是打扫完一间教室垃圾,如果把全部同学赶出去可能五分钟就做完了,但是如果允许同学同时在教室活动,就会一边打扫还有垃圾一边生成,总用时就会更高)

7.G1收集器

​ Garbage First(简称G1)收集器(垃圾优先),哪一块的垃圾最多就优先清理它。G1能对不同区块的内存进行回收价值和成本排序,即价值越高成本越低的区块会被先回收。此外还能为G1设定性能指标,例如任意1秒内暂停时间不超过 10 毫秒,G1会尽力去达成这个目标。

​ 开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。JDK8Update 40这个版本以后的G1收集器被Oracle官方称为“全功能的垃圾收集器”。JDK9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器

​ G1依然还是采用了分代设计,但是之前的一些垃圾收集器有很大差别,不会在为新生代,老年代等分配规定大小的区域,而是将整个堆分成一个个大小固定的Region区域,每一个Region都可以是新生代(Eden空间、Survivor空间)、老年代的角色。所以Region成为了垃圾收集的最小单元,每一次回收都会是Region的整数倍大小。

image-20240531081744630

Region特性和关键问题总结:

  • Region是垃圾收集的最小单元,每一个Region可以是年轻代(1个Eden空间、2个Survivor空间)、老年代

  • 先估算最有回收性价比的Region块,然后优先回收垃圾最多的块:G1每次收集时只会收集部分region,每次收集时,会先估算每个小块存活对象的总数,回收时垃圾最多的小块会被优先回收

  • Region里面存在的跨Region引用对象如何解决?

    • 使用记忆集(看1.2.3)避免全堆作为GC Roots扫描,G1它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内
  • 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?

    • 回收过程中改变对象引用关系:必须保证其不能打破原本的对象图结构,导致标记结果出现错误。G1收集器则是通过原始快照(SATB)算法来实现的。
    • 回收过程中新创建对象:G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的-部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上

G1收集器运作的4个步骤:

image-20240531081934125

初始标记:仅仅只是标记一下GC Roots能直接关联到的对象(需要STW)

并发标记:从GC Roots开始进行可达性分析,完成对象图的扫描,判断存活对象和可回收对象。做后再处理下SATB记录的有引用变动的对象(无需STW)

最终标记:对用户线程做另一个短暂的停顿,用于处理并发阶段结束后仍遗留 下来的最后那少量的SATB记录(需要STW)

筛选回收:统计各个Region的回收价值和成本并进行排序,根据用户所期望的停顿时间来制定回收计划,筛选任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。(需要STW)

内存分配和回收策略

分代垃圾回收

​ 分代垃圾回收机制的引入目的在于减少STW(Stop The World)时间。基于弱代假设概念,可以将大部分对象划分为两类:

  • 对象的生命周期很短(新生代)
  • 对象可能会存活很长时间(经过了多次GC存活下来的对象进入老年代)

​ 分代垃圾回收器会在逻辑上将堆空间划分为两部分:年轻代(1个Eden空间、2个Survivor空间)、老年代

image-20240531113458130

年轻代(新生代)

​ 年轻代:1个伊甸园空间(Eden)、2个幸存者空间(Survivor)。对象会首先在年轻代中的 Eden 区进行分配,当 Eden 区分配满的时候,就会触发年轻代的 GC(Minor GC)

  • Minor GC触发流程
    • 存活的对象会被移动到其中一个 Survivor 分区(from);
    • 年轻代再次发生垃圾回收,Eden、from 区中的存活对象,会被移动到 to 区。(from 和 to 两个区域,总有一个是空的)
    • Eden、from、to 的默认比例是 8:1:1,所以只会造成 10% 的空间浪费(这个比例由参数 -XX:SurvivorRatio 进行配置的(默认为 8))

老年代

​ 对垃圾回收的优化,就是要让对象尽快在年轻代就回收掉,减少到老年代的对象。对象可以通过多种方式进入老年代:

  • 正常提升:熬过年轻代垃圾回收的对象年龄+1,当对象的年龄达到一定的阈值就会进入老年代
  • 分配担保:如果年轻代的空间不足,又有新的对象需要分配空间,则需要依赖其他内存(此处为老年代)进行分配担保(满足条件的会在老年代直接创建对象)
  • 大对象直接在老年代分配:超出某个阈值大小的对象直接在老年代分配(通过 -XX:PretenureSizeThreshold 配置阈值)
  • 动态对象年龄判定:通过-XX:MaxTenuringThreshold参数指定正常提升的年龄阈值
    • 阈值判断:结合垃圾回收算法的动态计算方式(例如G1是通过TargetSurvivorRatio动态更改对象提升的阈值)进行年龄判定;
    • 特例情况:并不是所有情况都要求要达到阈值才满足提升条件,如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

概念扩展:Minor GC VS Major GC

​ 传统习惯上会将新生代GC(Young GC)称为Minor GC、老年代GC(Old GC)成为Major GC,用于区别整体性的Full GC

什么是Full GC?三者之间的对比open in new window

​ Java中的分代收集概念:垃圾回收器会将堆内存划分为不同的区域(新生代、老年代),当新生代空间不足时触发Minor GC=》清理新生代内存;Major GC则专注于老年代对象的回收;Full GC的触发条件可能相对复杂,它是由虚拟机进行调度的,会同时处理新生代和老年代两个区域的对象,对程序的性能影响也比较大

​ Full GC是Java虚拟机的一种垃圾回收操作,它是指对整个堆内存进行回收(包括新生代和老年代),它会把整个堆内存扫描一遍,回收不再使用的对象并且整理内存的过程,它会暂停所有的应用程序线程,整体回收过程非常慢。因此在JVM调优中,通过适当调整堆内存大小以减少Full GC发生频率也是一个很关键的调优思路。

​ **三者之间的对比:**结合清理对象、触发条件、对程序性能影响(STW停顿时间)等进行阐述

分配和回收策略(总结向)

【1】对象优先在Eden分配:大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC

【2】大对象直接进入老年代:大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。大对象会直接进入老年代,可以设想一下,如果大对象被分配在新生代,又因为新生代多采用复制算法,所以如果一个大对象能存活很久的话,那么复制开销将会是非常大的。

【3】长期存活的对象将进入老年代:对象头里面存储了对象的分代年龄,新生带的对象每经历一次Minor G℃ 年龄就会增加一岁,当年龄达到一定程度(默认15,-XX:MaxTenuringThreshold可配),就会晋升为老年代

【4】动态对象年龄判定:并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄

【5】空间分配担保:结合上述策略,Minor GC有可能会导致一大批对象从新生代进入老年代,那老年代如果放不下怎么办?

  • 每次Minor GC之前都得检查老年代的空间是否能容纳所有新生代对象
    • 如果可以那就安全
    • 如果不可以,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(HandlePromotion Failure);
      • 如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
        • 如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的:
        • 如果小于,就进行FulI GC
      • 如果不允许,那这时就要改为进行一次FuIIGC

对垃圾回收的思考

​ 垃圾回收器的主要目标是保证回收效果的同时,提高吞吐量,减少 STW 的时间

​ 从 CMS 垃圾回收器,到 G1 垃圾回收器,再到现在支持 16TB 大小的 ZGC,垃圾回收器的演变越来越智能,配置参数也越来越少,能够达到开箱即用的效果。

​ 但无论使用哪种垃圾回收器,实际的编码方式还是会影响垃圾回收的效果,在程序设计中可以多注意:减少对象的创建并及时切断与不再使用对象的联系

配置参数

查看当前Java版本默认使用的垃圾回收器(Java17参考)

# 查看当前Java版本默认使用的垃圾回收器(Java17参考)
java -XX:+PrintCommandLineFlags -version

# output
-XX:ConcGCThreads=2 -XX:G1ConcRefinementThreads=9 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=536870912 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=8589934592 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC 
openjdk version "17.0.11" 2024-04-16
OpenJDK Runtime Environment Homebrew (build 17.0.11+0)
OpenJDK 64-Bit Server VM Homebrew (build 17.0.11+0, mixed mode, sharing)

配置参数说明:(结合分代收集理解参数配置,实际上就是针对年轻代和老年代的垃圾收集器的组合应用)

  • -XX:+UseSerialGC 年轻代和老年代都用串行收集器
  • -XX:+UseParNewGC 年轻代使用 ParNew,老年代使用 Serial Old
  • -XX:+UseParallelGC 年轻代使用 ParallerGC,老年代使用 Serial Old
  • -XX:+UseParallelOldGC 新生代和老年代都使用并行收集器
  • -XX:+UseConcMarkSweepGC,表示年轻代使用 ParNew,老年代的用 CMS
  • -XX:+UseG1GC 使用 G1垃圾回收器
  • -XX:+UseZGC 使用 ZGC 垃圾回收器

常用的垃圾回收器有3类:Parallel Scavenge、CMS、G1

  • CMS 的设置参数:-XX:+UseConcMarkSweepGC
  • Java8 的默认参数:-XX:+UseParallelGC
  • Java13 的默认参数:-XX:+UseG1GC
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3