当前位置: 首页 > news >正文

JVM中的垃圾收集(GC)

JVM中的垃圾收集(GC)


0.简介

欢迎来到我的博客:TWind的博客

我的CSDN::Thanwind-CSDN博客

我的掘金:Thanwinde 的个人主页

对于JVM,最重要的莫过于对垃圾的收集,可以说GC是整个JVM最重要的部分了,没有之一

对于GC的研究一直在进行也一直在进化,从一整块到分区,从分区到两态,从单线程到多线程,从阻塞到并发,GC的发展标志着Java的发展

所以,如果能对GC有着独到的理解,会对你学习Java有着不小的裨益

当然,光看本文是完全不够的,充其量只能达到一个大致了解,在此我强烈推荐《JVM高级特性与最佳实践》,是入门的不二选择


1.什么是GC

​ GC,即Garbage Collection,众所周知,Java在运行时会产生相当多的数据:譬如各种新引用,对象,类等等,而这些东西都存储在堆以及元空间之中(1.8之后),而当这些东西堆积起来,就很可能超过内存上限,然后OOM报错

​ 那当然不能这样,因为Java本来的目标就是构建一个能长时运行且稳定的系统。所以人们注意到,有很多对象其实用不到:那么就可以将其回收,节省出空间,而这种行为就被形象的称为:Garbage Collection,垃圾回收

​ 垃圾回收可以说是Java中最重要的一环,没有之一:它直接决定了整个JVM的稳定性以及性能,各种对于GC收集器的研究也一直在进行,每一次GC技术的突破都是Java性能的突破

​ 下面就让我们来逐渐学习GC。


2.如何判断垃圾

​ 如何判断一个对象是不是垃圾呢?在研究人员的不断尝试下,目前主要出现了两种方法:=-

①.引用计数法

​ 这是最原始的一种方法,通过统计对象的被引用数来判断是否回收

​ 举个例子,假如对象A被引用了一次,假如某个时间这个引用突然断开了,那么对象A就变成了没有被引用,那他就会被回收

​ 但是显而易见的,这种方法有很多额外情况需要注意,就比如AB互相引用,虽然没有其他对象引用他们但由于他们互相引用而无法将他们回收

​ 所以需要很多的额外判断才能将这种方法实际应用,Java并没有使用这种方法,目前也只有python等语言用了这种方法

②.可达性算法

​ 可达性算法是Java采用的算法,它的原理是,从一些必须要保留的对象出发,遍历所有能遍历到的对象,把这些对象加入一个清单中,以此类推

然后清理掉所有的不在这个清单的对象,这就是可达性算法的一个雏形

​ 在Java中,这些必须保留对象被称为GC root,通常是一些很重要的对象,比如:

  • 虚拟机栈引用的对象
    例如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
  • 处于存活状态的线程对象
    例如Thread
  • 方法区静态属性引用的对象
    例如java类的引用类型静态变量
  • 方法区常量引用的对象
    例如字符串常量池的引用
  • 本地native方法引用的对象
  • Java虚拟机内部的引用
    例如基本数据类型对应的class对象,一些常驻的异常对象(如NullPointerException、OutOfMemoryError),还有类加载器
  • 所有被同步锁持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

这些对象,以及被他们引用的对象,都会被“标记”,一定不会被标记

至于其他的对象,就会被回收掉

那这个过程是怎么进行的?这里就会涉及到一个三色标记法

具体来说:

把每个对象看成一个带颜色的节点,会从GC root开始,遍历所有节点的引用链,有如下规则:

  • 黑:GC root或是被GC root ,其他黑色节点直接或间接引用的节点
  • 灰:还没有遍历完应用链的节点
  • 白:没有被黑色节点直接或间接引用的节点

我们可以通过下面的图辅助理解:

在这里插入图片描述

但是对于一些支持并发的收集器,会在第一次标记后进行第二次标记来处理在这中间新产生的对象

具体来说,如果在一个黑色的对象新接上去一个对象,这个对象由于没有被标记,还是白色,如果不重复标记这个白色对象就会被错误的回收掉

对于这种情况,已经有大佬严格证明了:

只有在一个黑色的对象新接上去一个对象时会发生

只有在删除了所有灰色到白色的引用

只要破坏了这两个条件中的一个,就不会引起任何问题

具体的标记行为会因为垃圾收集器的不同而有所差异,但是基本的行为基本就是这样,只有一些细节上的处理不同,这些会在下文中处理


3.如何处理垃圾

似乎这个标题有点废话,垃圾当然得被清扫,但问题在于:如何清扫,怎么清扫,什么时候清扫,清扫完如何恢复,能不能边产生垃圾边清扫(并发)?

在GC的发展历程中,我们可以看到一个趋势:垃圾收集器越来越复杂,越来越趋向于多线程,效率也越来越高,功能越来越复杂

在最原始的GC中,一切都非常的简单:

暂停所有线程,标记所有没有被GC root引用的对象,全部清除,这样的话会产生大量的内存碎片(内存不连续)

碎片过多到影响到分配新对象,会触发整理,非常消耗性能

然而,随着理论和计算机性能的发展与进步,尤其是分代收集理论

分代收集

  • 弱分代假说

绝大多数对象都是朝生夕灭的

  • 强分代假说

熬过越多次垃圾收集过程的对象就越难以消亡

  • 跨代引用假说

跨代引用相对于同代引用来说仅占极少数

根据这个理论我们可以发现,我们好像可以把内存分为两个部分:一个来装“新对象”,一个用来装“老对象”,因为老对象一般很稳定,不会很快消亡,新对象基本是朝生夕灭,对于那些经过了很多次GC的对象,就将其加入到老对象

这就基本是**“分代收集”**的大致雏形:分为新生代(新对象),老年代(老对象),两边采用不同的策略,分别管理,能极大的提升效率

下面是大致的描述图:
在这里插入图片描述

当内存不够的时候,会优先对新生代进行GC(Minor GC),绝大多数情况下都能基本回收到很多内存,如果内存还是不够,就会触发full GC:对所有区域进行GC,对于一些特殊的垃圾收集器,有major GC,会只对老年代进行GC

但是,这样仅仅是提高了效率,关于内存碎片的问题貌似还是没有被处理?

这里就设计到了不同的清扫策略了:

标记-清除

这是最原始的方法,直接清除,会产生内存碎片

标记-复制

会将内存块再分出一个单独的区域,每次标记完会将存活的对象移到一个单独的区域,并将其他的内存全部清除

这样的方法完美的契合新生代的概念:反正每次收集完,只能留下一小部分,那只需要预留一小部分区域即可,不会造成很大的内存损失

具体的策略会根据不同垃圾收集器的策略而变化,但是大致是这么个思想,下面是一个典型的模型:

在这里插入图片描述

在正常工作时,会空出一个Survivor,意味着使用的内存只有 总内存减一个Survivor

在GC的时候,会将所有存活的对象移到这个空着的Survivor,以此往复

这样就能很好的在内存碎片与效率之间做出一个平衡

一般来说,新生代:老年代为1 : 2,新生代中Survivor :其他大概为2 : 9,具体的实现以及比例是由用户和收集器处理的

比如G1,就没有严格的把整个内存将其分成两个新生代和老年代,而是动态的,但根本的思路大致不变

标记-整理

这里和标记-清除不同在于,在清除之后会将其整理,使其内存连续

虽然这种移动对象很花时间,需要处理大量的引用,但是相比起建立一个不连续内存的地址表并维护来说还是挺划算

老年代因为不像新生代那样朝生夕死,并不能采用标记复制这种简单直观的方法,也只能接受这个“必要之恶”了

跨代引用

对于一些新生代的对象,它可能被老年代的对象引用,在原本的minor GC处理之中,原本是不涉及到老年代的,但是由于可能有跨代引用:

新生代的对象也可能存在GC root,那你GC时就得扫描整个老年代找到所有的跨代引用,不然你就会错误的回收掉一些不该回收的对象

为了优化这个操作,JVM采用了**“记忆集”**,最常用的形式本质就是一个大致的表,里面每一个元素对应着一块内存,当这块内存出现了跨代引用,就会把这个元素标记上

那到时候GC的时候就只用扫描这些被标记的内存块,大大加速了这个过程

至于这个内存块要有多大,完全由GC决定,但一般是选择是一块内存区域,这一般称之为“卡精度”,其实现就称之为“卡表”,也就是上面的内容

至于怎么样去实现这个标记,这里用到了写屏障,并不是Java中的那个写屏障,这里是指赋值操作中会看是不是引用了其他区域的对象,如果是老年代到新生代就会把自己的那块内存“变脏”,也就是标记,有点类似于Spring的AOP


4.GC收集器

安全点

SafePoint,绝对是GC中一个非常关键点,顾名思义,它是一个安全点:让GC程序能够在这里放心大胆的操作

因为对于一些需要暂停全部线程的操作,不是每一个时间点都能暂停的:

  • 不能太多,这样会严重的拖累效率
  • 不能太少,这样会增大GC的负担,也有Out of Memory的风险

所以一般来说安全点都是选在一下会长时间运行的点:抛出异常,方法调用,循环跳转之类的

而如果在更底层来看,安全点设置与否取决于线程状态的转换:
在这里插入图片描述

从这个状态机可以看出来,安全点的检测都发生在状态改变的情况下,在/hotspot/src/share/vm/utilities/globalDefinitions.hpp里面可以看到

// JavaThreadState keeps track of which part of the code a thread is executing in. This
// information is needed by the safepoint code.
//
// There are 4 essential states:
//
//  _thread_new         : Just started, but not executed init. code yet (most likely still in OS init code)
//  _thread_in_native   : In native code. This is a safepoint region, since all oops will be in jobject handles
//  _thread_in_vm       : Executing in the vm
//  _thread_in_Java     : Executing either interpreted or compiled Java code (or could be in a stub)
//
// Each state has an associated xxxx_trans state, which is an intermediate state used when a thread is in
// a transition from one state to another. These extra states makes it possible for the safepoint code to
// handle certain thread_states without having to suspend the thread - making the safepoint code faster.
//
// Given a state, the xxx_trans state can always be found by adding 1.
//
enum JavaThreadState {_thread_uninitialized     =  0, // should never happen (missing initialization)_thread_new               =  2, // just starting up, i.e., in process of being initialized_thread_new_trans         =  3, // corresponding transition state (not used, included for completness)_thread_in_native         =  4, // running in native code_thread_in_native_trans   =  5, // corresponding transition state_thread_in_vm             =  6, // running in VM_thread_in_vm_trans       =  7, // corresponding transition state_thread_in_Java           =  8, // running in Java or in stub code_thread_in_Java_trans     =  9, // corresponding transition state (not used, included for completness)_thread_blocked           = 10, // blocked in vm_thread_blocked_trans     = 11, // corresponding transition state_thread_max_state         = 12  // maximum thread state+1 - used for statistics allocation
};

_thread_uninitialized = 0

  • 未初始化状态:这个状态意味着线程尚未开始初始化(即线程对象还未完全初始化)。这种状态应该是“不应该发生”的状态,因为在 JVM 启动时,线程会被初始化并进入某个可执行的状态。

_thread_new = 2

  • 新线程状态:表示线程刚刚开始执行,但尚未执行任何初始化代码,可能正在操作系统初始化过程中。这通常是线程刚创建但还未进入实际的执行阶段。

_thread_new_trans = 3

  • 新线程过渡状态:这是线程从 _thread_new 状态过渡到其他状态时的中间状态。该状态通常不常用,主要用于安全点机制的优化。

_thread_in_native = 4

  • 在本地代码中:线程正在执行 本地代码(即通过 JNI 调用的本地方法,或是线程执行的原生操作)。这个状态是 安全点区域,因为在本地代码执行时,所有的对象指针(OOPs)会被存储在 jobject 句柄中,因此可以更容易地进行堆栈扫描和垃圾回收。

_thread_in_native_trans = 5

  • 本地代码过渡状态:这是线程从 _thread_in_native 状态过渡到其他状态时的中间状态。

_thread_in_vm = 6

  • 在 JVM 中:线程正在执行 JVM 代码(即执行 HotSpot 的 C++ 代码)。这通常涉及诸如对象分配、垃圾回收、JIT 编译、线程调度等操作。

_thread_in_vm_trans = 7

  • JVM 中代码过渡状态:这是线程从 _thread_in_vm 状态过渡到其他状态时的中间状态。

_thread_in_Java = 8

  • 在 Java 代码中:线程正在执行 Java 代码(包括解释执行的字节码、JIT 编译后的代码,或者是正在执行 stub 代码)。这是一个关键的状态,因为它代表着线程在运行 Java 代码,通常会在安全点时暂停线程。

_thread_in_Java_trans = 9

  • Java 代码过渡状态:这是线程从 _thread_in_Java 状态过渡到其他状态时的中间状态。

_thread_blocked = 10

  • 被阻塞状态:线程正在被某种方式阻塞,通常是在等待某个锁(例如,在同步方法或同步块中等待获取锁)。在这个状态下,线程是阻塞的,不会继续执行,直到它获得相应的资源。

_thread_blocked_trans = 11

  • 线程阻塞过渡状态:这是线程从 _thread_blocked 状态过渡到其他状态时的中间状态。

_thread_max_state = 12

  • 最大状态值:这是一个常量,用于表示线程状态枚举的最大值 + 1。它通常用于统计和分配资源时的标记。

你可以看出,可以分为三大部分:不可变,可变,过渡态,而只有过渡态会有安全点

那当到达安全点具体会干什么?这还是取决于收集器

  • 抢先式中断

这种状态下,当需要执行GC时,系统会强行停止所有线程,对于没有在安全点的线程,会恢复其工作直到其到达安全点,这种方法现在几乎没有使用

  • 主动式中断

这里更像Java中对于中断的处理办法:系统只是设置了一个标志位,当线程进入过渡态会轮询这个状态位,如果发现要中断,就会尽可能快的将自己挂起

现在绝大多数GC收集器都用的是这种方法

安全区域

如果在GC中有些线程注定无法响应:比如说已被挂起,处于Sleep,Blocked,在长循环中,就会采用“安全区域”

本质是大号的安全点,保证在这个区域内没有任何引用改变,程序进入区域等同于进入安全点,退出安全区域会看GC完没有,完成了就退出,反之挂起

现在,如果所有线程都被成功停止了,就该收集器大展身手了


Serial收集器(单线程典型)

这是Java最早的收集器,奠定了收集器的基本雏形,由于历史原因它是单线程的,也就是说就算你是多核电脑,GC也只会由一个核心完成

具体的流程如下:

在这里插入图片描述

在第一个被称为“SafePoint”的地方,即安全点,Serial会暂停掉所有线程,然后用一个核心完成所有过程,也就是先标记,再处理,非常的简单且易懂,但代价就是对于多核电脑没有性能增益,现在只适合在一些单核电脑上工作

对于minorGC(图中第一次),采用标记-复制,fullGC(第二次)采用标记-整理


CMS收集器(多线程典型)

CMS是Java一个典型且成功的多线程收集器,实现了在最为耗时的标记和清理的并发运行

在这里插入图片描述

开始时,会进行初始标记,这里会短暂的暂停所有的线程,然后CMS会标记所有的GCroot

然后恢复,开始并发的标记那些不可达的对象

标记完了,会再次暂停所有线程,重新标记那些中途产生的对象(见上文)

接着恢复,开始并发清理,清理完就会重置该线程回到业务线程

这个便是大多数传统多线程收集器的典型,CMS的问题在于,无法处理一些在并发清理过程中产生的新垃圾,称之为“浮动垃圾”,即便如此,它也很优秀了


G1收集器

G1收集器被称为“全能收集器”,原因在于它在很多方面都很出色,更重要的一点是它突破了原有的收集器的传统架构:

他把所有的内存划为了一个个“区”(Region)

在这里插入图片描述

每个Region大小在1MB ~ 32MB之间,并可以根据策略自由的去组成新生代,老年代,Survivor

这种优势让G1有了一个特性:可以自定义停顿时间

G1可以通过参数设定一个停顿参数,让其G1只能在这个时间内去清理垃圾(G1是多线程清理垃圾)

G1的全名是Garbage First ,意为垃圾优先,G1会维护一个价值表,会根据一个Region垃圾的多少和清理大约需要的时间计算,优先清理价值高的

这些全新的设计使得G1成为了一个新一代的垃圾收集器

但是代价是,为了支持这些Region,G1不得不维护一个非常复杂的卡表来应对跨代引用,这带来了大量的写屏障以及内存占用

对于工作流程,没有太大的变化:

在这里插入图片描述

G1比起CMS,处理垃圾的时候会暂停所有的线程来处理垃圾

同时,回收也变成了筛选回收:先回收有价值的


ZGC收集器

如果说G1是新时代的,那么ZGC完全可以说是跨时代的,它新颖的设计以及高效的设计完全可以说是收集器中耀眼的新星

篇幅限制,后面我会单独写一篇文章来介绍ZGC

相关文章:

  • idea运行到远程机器 和 idea远程JVM调试
  • 【C++】C++中的友元函数和友元类
  • 【科技核心期刊推荐】《计算机与现代化》
  • PaddleNLP
  • MongoDB05 - MongoDB 查询进阶
  • 极限平衡法和应力状态法无限坡模型安全系数计算
  • 阿里云-接入SLS日志
  • Wpf布局之Border控件!
  • ​扣子Coze飞书多维表插件-创建数据表
  • GPT,GPT-2,GPT-3 论文精读笔记
  • mapstate
  • 打通Dify与AI工具生态:将Workflow转为MCP工具的实践
  • 养老保险交得越久越好
  • 【ad-hoc】# P12414 「YLLOI-R1-T3」一路向北|普及+
  • 《弦论视角下前端架构:解构、重构与无限延伸的可能》
  • 商业秘密保护新焦点:企业如何守护核心经营信息?
  • 【硬核数学】2.1 升级你的线性代数:张量,深度学习的多维数据语言《从零构建机器学习、深度学习到LLM的数学认知》
  • STM32——MDK5编译和串口下载程序+启动模式
  • 信创背景下应用软件迁移解析:从政策解读到落地实践方案
  • 详细的说一下什么是Arduino?