JVM——垃圾回收

JVM——垃圾回收

顾名思义:垃圾回收就是清除堆中的不再使用的对象,腾出空间,以供新的对象使用。

如何判断对象是垃圾

首先,jvm需要知道堆中什么对象是垃圾,什么对象不是垃圾,这就引出2种解决方案

  1. 引用计数法:检查对象是否被引用,并记录引用的数量,即检查一个类对象是否被其他类对象引用,就像下面这种。

    public Class Person{
      public Order order;
    }
    public Class Order{
    }
    

    但是这种检查方式有一个弊端:两个类相互依赖,那么就形成了一个死循环,还是就上面的例子做一个拓展,可以看出person和order形成了一个相互依赖的关系,那么这2个类的实例对象将在堆内存中永远存在。而这两个类对象可能在使用过一次后就不再使用了,这显然是不可行的,所以这个方法在实际的jvm运行中并不采用。

    public Class Person{
     public Order order;
    }
    public Class Order{
      public Person person;
    }
    
  2. 可达性分析:在描述可达性分析原理之前,我先引入一个概念“GC ROOT”,如果一个存在于堆中的类对象存在一条链路可以到达GC root,那么就认为此对象不是垃圾,反之就是垃圾,如下图所示,order通过person可以到达GC_ROOT,那么order就不是垃圾,并且person也不是垃圾(因为它也可以到达GC_ROOT

    GC_ROOT

    person

    order

    什么可以成为GC_ROOT

    1. 栈帧中“局部变量表”中的对象引用:虚拟机中栈中有栈帧意味这此刻正有一个线程正在执行,或许此栈帧正在被执行,或许是排在其它栈帧之下等待被执行,但无论如何,此栈帧必须使用本地变量表中的数据,那么这种由局部变量表中的某个变量指向到堆中某个对象的对象就不能被标记为垃圾(标记成垃圾了,若没等到线程执行完,这个对象就被当成垃圾清理了,运行到需要使用这个对象的时候,jvm在堆中找不到,岂不是要报空指针异常,而且是一串串地报错)。
    2. 方法区中的static成员变量:这部分在加载后就已经存在了,而且这个变量是静态的,也就是说是在jvm运行的整个生命周期中都不会被销毁的,那么它所指向的堆中的对象自然也不可以被回收
    3. 本地方法栈中方法使用的变量:本地方法栈是每个线程各自拥有的且相互隔离互不共享的一块内存区域,在线程执行完成之前,本地方法栈中方法使用的变量所指向的堆中的对象也不可以被回收。
    4. 常量引用:Java中定义一个常量的语法static final 数据类型 常量名 = new ...,可见:使用了static,那么这个常量就一定存储在方法区中,所以它所指向的对象在jvm运行的整个生命周期中都不会被回收。
    5. 类加载器:可以把它看作加载类的工具,
    6. Thread类

回收策略

标记-清除法

jvm扫描堆中的所有对象,根据可达性分析算法标记出堆中的垃圾对象,然后清除掉它们。如下图(我用方块代表对象,蓝色代表不是垃圾的对象,黄色代表垃圾对象,白色代表尚未被使用的堆内存)

执行后

由此可以见,虽然jvm清理了所有的垃圾对象,但是留下来的内存是不连续的,这就产生了大量的内存碎片,如果此时生成一个很大的对象需要存进来,虽然空白内存总和容量满足让大对象存入,但实际上是大量的内存碎片,这个大对象就无法被存入。

标记-整理法

jvm扫描堆中的所有对象,根据可达性分析算法标记出堆中的垃圾对象,把不是垃圾的对象一个一个按照排列,遇到被标记为垃圾的对象所占据的内存块,则直接迁移覆盖掉,这样既达到了垃圾收集的效果,也让内存碎片不再产生;不过这种一个一个对象地挪,非常消耗资源,1.对象从一个内存区域挪到另一个区域。2.对象挪完后,需要更新所有引用此对象的变量/常量的地址值(挪了位置,内存地址肯定不一样啦)。

执行后

复制法

jvm将内存等分为2块(暂且称之为a,b区,a区存对象,b区什么也不存),根据可达性分析算法标记出堆中的垃圾对象后,将a区标记的非垃圾对象一个一个复制到b区,整齐码放。挪完后将a区内存清空。下一次垃圾清理又把b区当作a区,a区当作b区,由a区将非垃圾对象一个个复制过去,整齐码放。以此循环往复。

执行后

垃圾回收器

上述了3种算法,jvm根据堆内存中不同区的不同特性以及算法的特性,使用特定的算法进行垃圾回收。

新生代:大多数对象朝生夕死,适用复制法

老年代:大多数对象长期存活,适用标记-清除法标记-整理法

jvm自带的实现上述回收策略的垃圾回收器

  • 新生代

    • Serial GC:实现复制法,单线程执行,执行时会中断业务线程执行,效率低

    • ParNew GC:实现复制法,多线程执行,执行时中断业务线程执行

    • ParallelScavenge GC:实现复制法,多线程执行,执行时不影响业务线程执行

  • 老年代

    • CMS GC:实现标记-整理法,并发执行,执行时与业务线程一起执行

    • Serial Old GC:实现标记法,单线程执行,执行时会中断业务线程执行,效率低

    • Parallel Old GC:实现标记-整理法,多线程执行,执行时不影响业务线程执行,

  • 老年代和年轻代

    • G1 GC:将整个Java堆内存划分为多个大小相等的区域(Region)。每个区域可以是一个Eden区、一个Survivor区或一个Old区。这种分区的目的是为了更好地控制和管理内存的使用。
      • 在初始标记阶段:G1会中断业务线程,全扫描所有的对象,标记所有的垃圾对象。
      • 并发标记阶段:G1与业务线程同时运行,标记新增的垃圾对象。
      • 最终标记阶段:G1多线程并发执行,中断业务线程,以最快的速度再次标记垃圾对象。
      • 筛选回收阶段:G1多线程并发执行,尽最大努力回收垃圾对象。

其实你也许会奇怪,为什么G1会执行这么多次垃圾对象标记行为,因为在真实的运行环境中,应用以最快的速度完成垃圾回收无疑是最好的,但是面对如此大的内存空间(服务器内存通常很大),jvm无论是多线程还是单线程地进行内存回收都是需要消耗大量时间的,而且例如Serial这样的回收器还会中断业务线程,这无异于增加了用户等待的时间。那么G1的初始标记阶段采用多线程标记+并发标记阶段+最终标记是尽最大能力标记垃圾对象,筛选回收阶段并不会回收所有的垃圾对象(时间不够的前提下),而是尽力回收垃圾对象;剩下的垃圾对象留到下次gc回收。这其实是一种在用户等待时间和垃圾回收之间的平衡的方法,毕竟鱼和熊掌不可兼得。G1 GC的停顿时间可以通过-XX:MaxGCPauseMillis设置,单位毫秒,总耗时减去前面3个阶段耗时就是筛选回收阶段的时间,这个时间段内可能无法回收完所有垃圾对象,所以才说是尽力回收。

停顿时间:jvm进行垃圾回收所耗时间

吞吐量=业务代码执行时间/(业务代码执行时间+垃圾收集时间)

吞吐量越高,则证明垃圾收集时间越少

posted @   勤匠  阅读(12)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示