java虚拟机垃圾回收基本概念

  • Post author:
  • Post category:java




基本概念

java并不是第一门使用内存动态分配和垃圾收集的语言,最早的是1960年的Lisp语言

垃圾回收的三个问题:

  • 哪些内存需要回收
  • 什么时候回收
  • 怎么回收

java的垃圾收集机制,极大的提高了开发的效率,至今仍然在发展迭代

垃圾的定义:

  • 垃圾是指在运行程序中没有被任何指针指向的对象,这个对象就是需要被回收的垃圾

如果不能及时的对内存中的垃圾进行清理,这些垃圾对象就会占用内存空间一直保留到应用程序结束,被保留的空间无法被其他对象使用,有可能会造成内存溢出

为什么需要GC:

  • 对于高级语言来说,基本的认知就是,如果不进行垃圾回收,内存是迟早要消耗完的
  • 垃圾回收除了可以回收没用的对象,还可以清理内存中的记录碎片,整理碎片,可以把所占用的内存移到堆的一段,有利于jvm把内存分配给新的对象
  • 随着发展,场景越来越复杂,用户也越来越多,没有GC就难以保证程序的正常运行



早期的GC

在c/c++的时代,垃圾回收是手动进行的,使用new关键字申请,使用delete关键字进行内存的释放,这样会很灵活,但是管理内存会很繁琐,如果忘记释放,会导致内存泄漏,随着时间的增加,内存的使用会越来越高,有可能造成内存溢出,进而导致程序的崩溃

但是有了垃圾回收以后,就不需要手动的释放了。自动的内存分配和垃圾回收是现代开发语言的标配



java的垃圾回收

  • 自动的内存管理,无需开发人员手动参与内存的分配与回收,降低了内存泄漏和内存溢出的风险
  • 自动内存管理机制,可以把开发人员从繁重的内存管理中释放出来,专注于业务开发,但是这样内存管理就成为了一个黑匣子,如果过度依赖于自动,会弱化开发人员在程序出现内存溢出等问题时解决问题的能力,所以对于这种自动化的技术,必须要有必要的监控和调节

java会对堆和方法区进行垃圾回收,重点是堆区(栈没有GC,pc寄存器不仅没有GC也没有oom)

从次数上来说:

  • 频繁收集新生代
  • 较少收集老年代
  • 基本不动方法区(1.7永久代,1.8元空间)



垃圾回收的算法



标记阶段

  • 堆里存放者几乎所有的java对象实例,在GC执行垃圾回收行为之前。需要先区分出哪些对象是存活对象,哪些是已经死亡的对象,只有被标记为已死亡的对象,GC才会在执行垃圾回收时,释放掉对应的内存空间, 这个阶段的目的其实就是为了确定哪些对象是垃圾,可以被回收
  • 当一个对象已经不再被任何存活的对象引用时,就可以认为已经死亡
  • 判断对象死亡的方式有:引用计数算法、可达性分析算法



引用计数算法

对每个对象都保存一个整型的引用计数器属性,用于记录对象被引用的情况,只要对象被引用了计数器就加1,引用失效就减1,当计数器为0,就表示不在被引用

实现简单,便于标识,判定效率高,没有延迟

  • 但是需要单独的字段去存储,额外增加了存储空间
  • 每次引用和引用失效都需要更新计数器,增加了时间开销
  • 最重要的是无法处理循环引用的情况(例如:p引用b,b引用c,c引用d,d又引用b,当p不在引用b时,bcd依旧在相互引用,而得不到回收,就会内存泄漏),这是很致命的缺陷,导致java的垃圾回收器中没有使用这类算法

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hii19sod-1660484258289)(C:\Users\IsTrueLove\AppData\Roaming\Typora\typora-user-images\image-20220813205925865.png)]

所以java没有采用这种算法,但是有的语言是使用了的,比如python



可达性分析算法

  • 又称为根搜索算法或者追踪性垃圾收集,相对于引用计数算法,同样具备简单和执行效率高的特点,还可以解决引用计数算法的循环引用问题,防止内存泄漏
  • java和c# 选择的就是可达性分析

可达性分析的思路:

  • 以根对象集合为起点(根集合 GC Roots 就是一组必须活跃的引用),按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
  • 使用可达性分析算法后,内存中存活的对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链
  • 如果对象没有与任何引用链相连,就是不可达的,意味着对象已经死亡
  • 只有被根对象集合直接或者间接连接的对象才是存活对象

如果想要使用可达性分析算法来判断内存是否可回收,分析工作必须在能保证一致性的快照中进行,如果不能满足的话,分析的结果也会无法保证,所以这就是GC必须要 ” Stop The World” (stw)将用户线程停下来的重要原因,即使是号称不会停顿的CMS 收集器,枚举根节点时,也是必须停顿的

Gc Roots 包括下面的中元素:

  • 栈中的引用的对象
  • 本地方法栈中JNI(本地方法)引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 同步锁synchronized持有的对象
  • java虚拟机内部的引用(各种常驻对象,比如各种异常对象,系统 类加载器等)
  • 除了这些固定的GC Roots 集合外,还有可能会临时的加入一些对象,比如:分代收集和局部回收 (例如回收新生代,也有可能把老年代的对象当作GC Roots,也就是记忆集RSet)



finalization机制

  • java提供了对象终止(finalization)机制,允许开发人员提供对象被销毁之前的自定义处理逻辑

  • 当垃圾回收器发现没有引用指向一个对象,也就是垃圾回收此对象之前,总会先调用这个对象的finalize()方法

  • finalize()允许被子类重写,用于对象被回收时进行资源的释放,例如关闭文件、数据库连接等

  • object的finalize() 是一个空方法,这个方法不建议主动调用,应该交给垃圾回收机制调用,具体原因有:

    • finalize()可能会导致对象的复活
    • finalize()方法的执行时间无法保证,完全由GC线程决定,如果不发生GC,finalize()方法将没有执行机会
    • finalize()会影响GC效率
  • finalize()方法和c++的析构函数比较相似,但是java采用的是基于垃圾回收器的自动内存管理机制,有本质区别

由于finalize 的存在,虚拟机中的对象一般处于三种可能的状态(没有被任何对象引用,可以认为对象已经死亡,但是并非一定要清除,在特定的条件下,对象有可能会复活):

  • 可触及的:从根节点开始,可以达到这个对象
  • 可复活的:对象的所用引用都被释放了,但是对象有可能在finalize 中复活
  • 不可触及的:对象的finalize 被调用,并且没有复活,就会进入不可触及状态,这个状态的对象不可能复活,因此finalize 只会被调用一次

只有在不可触及时,才会被回收

一个对象是否要被回收,至少要经过两次标记过程

  • 一次是判定对象到GC Roots 没有引用链,就进行第一次标记
  • 需要判断该对象是否必要执行finalize()方法

    • 如果对象没有重写object的finalize()方法,或者该方法已经被调用过了,那么就会认为finalize()没必要执行,会直接把对象判定为不可触及对象
    • 如果重写过了,且没有执行过,就会被放到一个队列中,由虚拟机创建一个低优先级的线程去执行finalize()
    • 之后会对这个队列中的对象进行第二次标记,如果对象在finalize()方法中与任何一个引用链上的对象建立了联系,那么在第二次标记时,对象会被移除“即将回收”集合,如果之后,对象继续出现没有任何引用存在的情况,也不会在调用finalize了,对象会直接变为不可触及状态



垃圾清除阶段

当标记完垃圾后,就需要进行垃圾回收了,释放掉无用对象所占用的空间,以便由足够的空间为新对象分配内存,目前jvm中比较常见的三种垃圾收集算法为:

  • 标记-清除算法 (mark-sweep)
  • 复制算法
  • 标记-压缩算法 (mark-compact)



标记-清除算法

是一种非常基础、常见的垃圾收集算法,1960年就应用于Lisp语言

当堆中的有效空间被耗尽的时候,就会停止整个程序,进行标记和清除两项工作

  • 标记:Collector从根节点开始遍历,标记所有被引用的对象,一般是在对象的header中记录为可达对象(也就是说标记的是可达对象)
  • 清除:Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在header中没有标记为可达对象,就将其回收(清除非可达对象)

这种算法比较基础,但是效率不高(需要进行两次遍历);在GC的时候需要停止整个程序,用户体验很差;这种方式清理出来的内存不是连续的,容易产生内存碎片,还需要维护一个空闲列表

清除,也并不是真的赋空,而是把需要清除的对象的地址保存在空闲列表中,当有新的对象需要分配内存时,如果垃圾的空间足够,就会被新对象覆盖



复制算法

为了解决标记-清除算法效率不高的问题,与1963年提出

将活着的内存空间分为两块,每次只使用一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块,交换两个内存的角色,就完成了垃圾回收

复制算法的优点:

  • 没有标记和清除过程,实现简单,运行高效
  • 可以保证空间的连续性,不会出现碎片问题

缺点是:

  • 需要双倍的内存空间,只有一半的空间在使用
  • 对于G1这种分拆为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销都不小
  • 复制算法最好需要复制的对象不能太多,否则并不理想(极端情况下,会全部都复制过去,所以比较适用于对象生命周期短的区域,例如新生代)



标记-压缩算法

又称标记-整理算法,复制算法适用于存活对象较少的场景,例如新生代,但是对于老年代来说,存活对象会很多,复制的成本会很高,而且老年代空间很大,有一半空间不使用,代价很大

标记-清除算法确实可以用于老年代,但是效率并不理想,而且会有内存碎片问题(老年代有可能会有很多大对象,内存碎片问题更加严重),所以在标记-清除算法的基础上产生了标记-压缩算法(1970年)

  • 第一阶段和标记-清除算法一样,从根节点开始标记所有被引用的对象
  • 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放
  • 之后,在清理边界外的所有空间

标记-压缩算法的最终效果等同于标记-清除算法执行完成后,在进行一次内存碎片整理,因此也可以称为标记-清除-压缩算法,两者的区别就是是否移动对象,是否移动存活对象是优缺点并存的

优点:

  • 解决了内存碎片的问题
  • 消除了复制算法内存减半的代价

缺点是:

  • 效率相对来说较低
  • 在移动对象时,如果对象被其他对象引用,需要调整引用的地址
  • 移动过程中,需要暂停用户应用程序

三种算法各有优劣,没有最好的算法,只能是具体问题具体分析



分代收集算法

分代收集算法基于:不同的对象的生命周期不同,因此不同生命周期的对象采取不同的收集方式,以便提高回收效率,一般是把对分为新生代、老年代,不同的年代采用不同的算法,提高垃圾回收的效率

目前所有的垃圾回收器都是采用分代收集算法来实现的

新生代采用复制算法(不光是幸存者区,从伊甸园区到幸存者区也是复制算法)

老年代一般采用标记-清除,标记-压缩算法两种算法结合使用:

  • 标记阶段和对象的存活数量成正比
  • 清除阶段和所管理的区域大小成正相关
  • 整理阶段和存活对象的数据成正比



增量收集算法

上面的几个算法,不可避免的都会暂停用户线程,等待垃圾回收完成,如果垃圾回收时间很长,会很影响用户体验,所以就有了实时垃圾收集算法:增量收集算法的诞生

核心思想是:如果一次性处理所有垃圾,可能会造成系统的长时间停顿,可以让垃圾收集线程和用户线程交替执行,每次只收集一小片区域的内存,接着切换到用户线程继续执行,依次反复,直到垃圾收集执行完成

增量收集算法的基础仍然是标记-清除和复制算法,着重处理了线程间冲突的问题,允许垃圾收集线程分段完成任务

虽然减少了系统卡顿的时间,但是这种算法会造成线程间的频繁切换,使得垃圾回收的总成本上升,降低了吞吐量



分区算法

一般来说,堆空间越大,一次垃圾回收的时间就越长,为了控制回收时间,可以把大块的内存区域分割成多个小区间,每个小的区间都独立使用,独立回收

分代:分为年轻代、老年代,

分区:把堆划分成连续的小区间region



版权声明:本文为persistence_PSH原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。