JVM的垃圾回收机制详解

垃圾回收机制

Java程序不要求我们手动操作内存,而是由JVM为我们提供了一套全自动的内存管理机制,JVM会自动判断对象的回收时机

对象存活判定算法

既然要回收内存空间,就需要判断对象在什么时候需要被JVM回收,下面介绍一些常用的垃圾回收算法

引用计数法

通常在操作对象时,我们操作的是对象的引用,这样就会创建一个引用变量

String str = "This is a String."; //这里的str就是一个引用对象,我们通过引用对象来操作对象
str = null; //此时str不再指向上面的对象

我们Java程序员通常是通过引用变量来操作对象的,这样如果一个对象还有操作价值,我们就会用它的引用变量来操作它;因此,我们可以通过引用变量的计数来判断一个对象是否还在被使用

  • JVM会为每个对象都创建一个引用计数器,存放该对象被引用的次数
  • 每当有新的引用变量指向该对象时,就会让 该对象的引用对象 + 1
  • 当引用对象指向其他地方或设为null时,会让 该对象的引用对象 - 1
  • 当该对象的引用计数为0时,就可以看做本对象已经不再被使用了

这样会存在两个对象相互引用的问题,下面给出一个例子

对象a中有一个指针a.p指向b,同样地,b中也有一个指针b.p指向a。这样ab之间就形成了互相引用;如果此时我们使a,b都为null,这是显然显然已经无法使用到a,b中的指针了,但这个指针仍然指向原来的地址,其指向对象的计数器不为0,就仍然不会被清理;

所以说,引用计数法不是最好的。

可达性分析法

目前的主流编程语言,一般都会使用可达性分析法来检测对象的存活情况,它采用了类似树结构的搜索机制

首先每个对象的引用都有机会成为该对象的树的根节点,根节点的选拔标准如下

  • 位于虚拟机栈的帧栈中的本地变量表中所应用到的对象(理解为方法中的局部变量),同样也包含JNI引用的对象
  • 类的静态成员引用的对象
  • 方法区中常量池里引用的对象,例如上面演示的String类型的对象
  • 带有锁的对象(synchronized)
  • 虚拟机内部需要用到的对象

当已经存在的根节点不满足其存在条件时,根节点与对象之间的连接将被断开,如果断开后的对象没有被任何根节点所引用,说明该对象不再被使用

image-20230306165153841

所以,对与可达性分析算法,如果某个对象无法到达任何根节点,则说明此对象是不可能再被使用的。

最终判定

虽然上面介绍的算法能基本判定哪些对象是可以被回收的,但是不代表被判定为可回收的对象就一定会被回收

对于java的Object类中的finalize()方法,其会在被垃圾回收期回收之前调用;如果执行finalize()时,当前对象若成功建立了可达性树,则该对象不会被垃圾回收期回收

这里给出一个例子

public class Main {
    private static Test a;
    public static void main(String[] args) throws InterruptedException {
        a = new Test();

        //这里直接把a赋值为null,这样前面的对象我们不可能再得到了
        a  = null;

        //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行)
        System.gc();

        //等垃圾回收一下()
        Thread.sleep(1000);

        //我们来看看a有没有被回收
        System.out.println(a);
    }

    private static class Test{
        @Override
        protected void finalize() throws Throwable {
            System.out.println(this+" 开始了它的救赎之路!");
            a = this;
        }
    }
}

同时,同一个对象的finalize()方法只会有一次调用机会,也就是说,如果我们连续两次这样操作,那么第二次,对象必定被回收:

public static void main(String[] args) throws InterruptedException {
    a = new Test();
    //这里直接把a赋值为null,这样前面的对象我们不可能再得到了
    a  = null;
    //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行)
    System.gc();
    //等垃圾回收一下
    Thread.sleep(1000);
    
    System.out.println(a);
    //这里直接把a赋值为null,这样前面的对象我们不可能再得到了
    a  = null;
    //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行)
    System.gc();
    //等垃圾回收一下
    Thread.sleep(1000);
    
    System.out.println(a);
}

需要注意的是,finalize()方法也并不是专门防止对象被回收的,我们可以使用它来释放一些程序使用中的资源等。

最后,总结成一张图:

image-20230306165250173


垃圾回收算法

分代收集机制

我们在判断对象是否需要被回收时,如果采用逐一判断的方法,会导致效率的降低。所以就产生了分代收集机制,它汤我们可以对堆中的对象进行分代管理。

对于有些经过多次判断都被未被判定为需要被回收的对象,我们可以将他们放在一起,作为一代,并使垃圾收集器减少扫描此区域对象的频率,这样就可以提高垃圾回收的效率

JVM将堆内存划分为新生代老年代永久代(这里的永久带是HotSpot虚拟机特有的概念)。不同的分代内存回收机制也存在一些不同点;

以HotSpot虚拟机为例,新生代被划为三块,一块较大的Eden空间和两块较小的Survivor空间,默认大小比为8:1:1;而老年代被扫描的评率比较低;永久代则一般存放类信息(方法区的实现)

image-20230306165311823

运行方式

  • 对于所有新创建的对象,都会进入新生带的Eden区(Eden即伊甸园,初生的对象被丢进伊甸园,合理),对于新生代内的垃圾扫描,会先扫Eden区
  • 紧接着,在经过一次垃圾回收后,未被清理的Eden内的对象会进入到Servivor区内的From区域。最后From区域和To区域会进行一次互换
  • 接着进行下一次扫描,先重复上面的操作,但是在将Eden的对象防区From区后,会将To内的对象进行一次年龄和判定(年龄指进过垃圾清理的轮次),若有对象的年龄大于15(默认值),则会被放入老年代,否则就继续移动到From区重复交换判断
  • 以此类推,循环往复

垃圾收集也可以有不同的种类

  • Minor GC - 次要垃圾回收,主要进行新生代区域的垃圾收集。

    ​ 触发条件:新生代的Eden区容量已满时。

  • Major GC - 主要垃圾回收,主要进行老年代的垃圾收集。

  • Full GC - 完全垃圾回收,对整个Java堆内存和方法区进行垃圾回收。

    ​ 触发条件1:每次晋升到老年代的对象平均大小大于老年代剩余空间

    ​ 触发条件2:Minor GC后存活的对象超过了老年代剩余空间

    ​ 触发条件3:永久代内存不足(JDK8之前)

    ​ 触发条件4:手动调用System.gc()方法

空间分配担保

现在假设一种极端情况,在经过了一次GC清理后,Eden区仍有大量对象,此时Survivor区也容不下这么多对象了,现在该怎么办?

现在就可以利用空间分配担保机制,将Survivor区所无法容纳对象直接送到老年代,让老年代进行分配担保;即当新生代无法容纳更多对象时,可以把新生代中的对象移动到老年代中,这样新生代就腾出了空间来容纳更多对象

这里又有一个新问题,你既然要把对象从新生代直接扔到老年代,如果老年代也装不下呢?这时就需要在装入老年代前进行一次判断,判断之前的每次垃圾回收时进入老年代的平均大小是否小于当前老年代的剩余空间。如果小于,也不一定放得下,因为是平均大小;如果大于,就会先进行一次大规模的垃圾回收,若回收后老年代还是装不下,则抛出OOM错误。

image-20230306165425918


下面介绍几种算法,来实现具体的回收过程

标记-清除算法

首先标记处所有需要回收的对象,然后直接对他们进行回收。(也可以反着来,标记不需要回收的对象)

image-20230306165444019

本算法显而易见的非常简单,但是这样清理,会导致大量的内存碎片,造成内存空间的利用率降低

标记-复制算法

上面直接删除的算法会造成空间利用率低,我们换一种思路接着来

首先将内存对半分,右侧区域暂时置空,这是扫描左侧区域,将不需要清理的对象复制到右侧区域,然后将左侧对象全部清空,然后重复这个步骤即可,这样就避免了内存的空间碎片

image-20240117154823892

本算法适用于新生代(因为新生代的对象会被频繁回收)

标记-整理算法

上面的标记-复制算法适用于新生代,那老年代呢?既然都到老年代了,都是些腿脚不便不会频繁移动的老登了,自然就最好不要把他们左右折腾,这时就产生了标记-整理算法

我们首先将所有待回收的对象整齐地排列在一段内存空间中,并对需要回收的对象进行标记,然后将所有需要回收的对象丢到尾部,这样这段空间的前半部分是无需回收的对象,后半部分是需要回收的对象,直接将后半部分清理就行了。

image-20230306165514101

显而易见的,本算法的效率低,并且因为涉及到内存位置的移动,程序需要进行暂停

所以在实际应用中,我们常将标记-整理算法和标记-清理算法混合使用,在内存空间碎片不算太多时,可以使用标记清除法,当碎片比较多了,我们就使用一次标记整理法


垃圾收集器实现

Serial收集器

在JDK1.3.1之前,其是JVM新生代区域垃圾收集器的唯一选择。他是单线程的,在他工作时,需要暂停所有线程,直到他干完活。对于新生代它使用标记-复制,对于老年代他采用标记-整理算法

image-20230306165527009

优点:设计简单高效

缺点: 在清理时会暂停线程

ParNew收集器

ParNew对Serial进行了升级,相当于是它的多线程版本

image-20230306165542516

Parallel Scavenge/Parallel Old收集器

Parallel Scavenge同样是一款面向新生代的垃圾收集器,同样采用标记复制算法实现,在JDK6时也推出了其老年代收集器Parallel Old,采用标记整理算法实现

image-20230306165555265

与ParNew收集器不同的是,它会自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间,这种自适应机制,能够很好地权衡当前机器的性能,根据性能选择最优方案。

目前JDK8采用的就是这种 Parallel Scavenge + Parallel Old 的垃圾回收方案。

CMS收集器

在JDK1.5中,出现了CMS收集器,它支持并发收集垃圾,第一次实现了垃圾收集器与用户线程同时工作

它主要采用标记清除算法

image-20230306165610810

它的收集过程分为以下四个阶段

  • 初始标记(需要暂停用户线程):根据之前提到过得GC树根,找到他的节点对象,并标记他们
  • 并发标记:遍历上面标记的对象
  • 重新标记(需要暂停用户线程):由于并发标记阶段可能某些用户线程会产生变化,这里需要再次标记一遍
  • 并发清除:最后就可以直接将所有标记好的无用对象进行删除,因为这些对象程序中也用不到了,所以可以与用户线程并发运行。

优点:支持并发

缺点:由于采用标记清除法,会产生内存碎片; 因为和用户线程并发执行,会降低用户线程执行速度

CMS已经很好了?下面这位G1更是重量级

Garbage First (G1) 收集器

G1也是一款划时代的收集器,在JDK7时推出,主要面向服务器端。

G1将Java堆区划分为2048个大小相同的独立Regin块,每个块的大小被控制在1~32MB,且都为2的N次幂,大小在JVM生命周期内不会改变。

分块了有什么用呢?G1通过分块,让每一个块都可以自由的扮演任意角色,收集器会根据块的角色采取不同的回收策略。此外,G1还存在一个Humongous区域,它专门用于存大对象(大小超过块的一半)

image-20230306165629129

它的回收过程与CMS大体类似:

image-20230306165641872

分为以下四个步骤:

  • 初始标记(暂停用户线程):根据GC树根,找到他们的直接关联的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(暂停用户线程):对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象。
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。

元空间

JDK8之前,Hotspot虚拟机的方法区实际上是永久代实现的。

在JDK8之后,Hotspot虚拟机不再使用永久代,而是采用了全新的元空间。类的元信息被存储在元空间中。元空间没有使用堆内存,而是与堆不相连的本地内存区域。所以,理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。这项改造也是有必要的,永久代的调优是很困难的,虽然可以设置永久代的大小,但是很难确定一个合适的大小,因为其中的影响因素很多,比如类数量的多少、常量数量的多少等。

image-20230306165703340

因此在JDK8时直接将本地内存作为元空间(Metaspace)的区域,物理内存有多大,元空间内存就可以有多大,这样永久代的空间分配问题就讲解了,所以最终它变成了这样:

image-20230306165714662


引用类型

强引用

强引用是Java的默认引用,对任何一个对象的复制对象都会产生其强引用,比如Object o = new Object()的操作,obj就是new Object()的强引用

特点: 只要强引用存在,被引的对象就不会被垃圾回收

软引用

相比于强引用,在JVM内存不足时,被软引用的对象才会被回收,当内存充足时,不会轻易的回收被软引用的对象

可以通过以下的方法来创建一个软引用

public class Main {
    public static void main(String[] args) {
        //强引用写法:Object obj = new Object();
        //软引用写法:
        SoftReference<Object> reference = new SoftReference<>(new Object());
        //使用get方法就可以获取到软引用所指向的对象了
        System.out.println(reference.get());
    }
}
弱引用

相比于上面两种引用,在垃圾回收时,弱引用不管当前内存是否充裕,都会被回收

可以通过以下的方法来创建一个弱引用:

public class Main {
    public static void main(String[] args) {
        WeakReference<Object> reference = new WeakReference<>(new Object());
        System.out.println(reference.get());
    }
}
虚引用(鬼引用)

虚引用就可以看做没有引用,随时都可能被回收,它本身就不算个引用。

最后,Java中4种引用的级别由高到低依次为: 强引用 > 软引用 > 弱引用 > 虚引用


参考文档

走进JVM

Java的四种引用类型

  • 20
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值