GC垃圾回收机制

什么是垃圾(Garbage)?

  • 垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾
  • 如果不及时堆内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用.甚至可能会导致内存溢出

垃圾回收相关算法

垃圾标记阶段---引用计数算法

  • 在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象.只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段

如何标记一个死亡对象?

  • 简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡

判断对象存活一般有两种方式:

  • 引用计数算法
  • 可达性分析算法

引用计数算法

  • 引用计数算法比较简单,对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况.
  • 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1.只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收.
  • 优点:
    • 实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性
  • 缺点:
    • 它需要单独的字段存储计数器,增加了存储空间的开销
    • 每次赋值都需要更新计数器,伴随着加法和减法操作,增加了时间开销
    • 引用计数器有一个严重的问题,即无法处理循环引用的情况.这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法
image-20210113175816538

可达性分析算法

  • 相较于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生
  • 相较于引用计数算法,这里的可达性分析就是Java,C#选择的.这种类型的垃圾收集通常也叫做追踪性垃圾收集

基本思路:

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

Java语言中,GC Roots包括以下几类元素

  • 虚拟机栈中引用的对象
    • 比如:各个线程被调用的方法中使用到的参数,局部变量等
  • 本地方法栈内JNI(本地方法)引用的对象
  • 方法区中类静态属性引用的对象
    • 比如:Java类的引用类型静态变量
  • 方法区中常量引用的对象
    • 比如:字符串常量池里的引用
  • 所有被同步锁synchronized持有的对象

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行.这点不满足的话分析结果的准确性就无法保证


对象存活还是死亡?

  • 如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了.一般来说,此对象需要被回收.但事实上,也并非是"非死不可"的,这时候他们暂时处于"缓刑"阶段.一个无法触及的对象有可能在某一个条件下"复活"自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态.如下:
    • 可触及的:从根节点开始,可以到达这个对象
    • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活.
    • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态,不可触及的对象不可能被复活,因为finalize()只会被调用一次.
  • 以上3种状态中,是由于finalize()方法的存在,进行的区分.只有在对象不可触及时才可以被回收.
  • finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放.通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件,套接字和数据库链接等

finalize()方法的作用

  • finalize()是Object的protected方法,子类可以覆盖该方法以实现资源清理工作,GC在回收对象之前调用该方法。
  • finalize()与C++中的析构函数不是对应的。C++中的析构函数调用的时机是确定的(对象离开作用域或delete掉),但Java中的finalize的调用具有不确定性
  • 不建议用finalize方法完成“非内存资源”的清理工作,但建议用于:① 清理本地对象(通过JNI创建的对象);② 作为确保某些非内存资源(如Socket、文件等)释放的一个补充

标记清除(Mark-Sweep)算法

image-20210228015956864

执行过程:

当堆中的有效内存空间被耗尽的时候,就会停止整个程序(Stop The World),然后进行两项工作,第一项是标记,第二项是清除.

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

缺点:

  • 效率不算高
  • 在进行GC的时候,需要停止整个应用程序,导致用户体验差
  • 这种方式清理出来的空闲内存是不连续的,产生内存碎片.需要维护一个空闲列表

注意:何为清除?

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里.下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放.

复制算法

核心思想:

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

即新生代的S0,S1区相互转换的过程

image-20210114154416026

优点:

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

缺点:

  • 需要两倍的内存空间
  • 对于G1这种分拆成为大量的region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者是时间开销也不小

注意:复制算法适合垃圾比较多,需要复制的对象比较少的情况

标记压缩算法

执行过程:

第一阶段和标记-清除算法一样,从根节点开始标记所有被引用的对象

第二阶段将所有的存活对象压缩到内存的一端,按顺序排放

最后,清理边界外所有的空间

  • 标记-压缩算法的最终效果等同于标记-清除算法执行完成后,在进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩算法

  • 二者的本质差异在于

    • 标记-清除算法是一种非移动式的回收算法
    • 标记-压缩是移动式的.是否移动回收后的存活对象是一项优缺点并存的风险决策.

优点:

  • 消除了标记-清除算法中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可
  • 消除了复制算法中,内存减半的高额代价

缺点:

  • 效率上来说,标记-整理算法要低于复制算法
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址.
  • 移动过程中,需要全程暂停用户应用程序.即:STW
image-20210114164222599

对比三种算法

Mark-Sweep Mark-Compact Copying
速度 中等 最慢
空间开销 少(但会堆积碎片) 少(不堆积碎片) 通常需要活对象的2倍大小(不堆积碎片)
移动对象

分代收集算法

目前几乎所有的GC都是采用分代收集算法来执行垃圾回收的

在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点.

  • 年轻代(Young Gen)

特点:区域相对老年代较小,对象生命周期短,存活率低,回收频繁

这种情况复制算法的回收整理,速度是最快的.复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收.而复制算法内存利用率不高的问题,通过hotspot的两个survivor的设计得到缓解

  • 老年代(Tenured Gen)

特点:区域较大,对象生命周期长,存活率高,回收不及年轻代频繁

这种情况存在大量存活率高的对象(通常对象也比较大),复制算法明显变得不合适,一般是由标记-清除或者是标记-清除与标记-整理的混合实现


以Hotspot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高,而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳,将采用Serial Old执行Full GC以达到对老年代内存的整理

增量收集算法

在STW状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成.如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性.为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集算法的诞生

基本思想:

如果以此向将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行,每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,以此反复,直到垃圾收集完成.

总的来说,增量收集算法的基础仍是传统的标记-清除的复制算法.增量手机算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记,清理或复制工作

缺点:

使用这种方式,由于在垃圾回收过程中,间断性的还执行了应用程序代码,所以能减少系统的停顿时间.但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降.


内存泄露与内存溢出

内存泄露

  • 严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄露
  • 但实际情况很多时候一些不太好的时间会导致对象的生命周期边的很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄露”
  • 尽管内存泄露并不会立刻引起程序崩溃,但是一旦发生内存泄露,程序中的可用内存就会被逐步消耗,直至耗尽所有内存,最终出现OutOfMemory
image-20210115161814841

举例:

  1. 单例模式

    单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄露的产生

  2. 一些提供close的资源未关闭导致内存泄露

    数据库连接,网络连接,io俩姐等必须手动close,否则是不能被回收的

内存溢出

  1. Java虚拟机的堆内存设置不够

    比如:可能存在内存泄漏问题

    也很有可能是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过-Xms,-Xmx来调整

  2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集

    对于老版本的JDK,因为永久代的大小是有限的,并且JVM对永久代垃圾回收非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题。

    随着元数据区的引入,方法区内存已经不再那么窘迫。

  • javadoc中对OOM的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存
  • 这里面隐含着一层意思,在抛出OOM之前,通常垃圾收集器会被触发,尽其所能区清理出空间
    • 例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象
  • 当然,也不是在任何情况下垃圾收集器都会被触发
    • 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接抛出OOM

引用

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用,软引用,弱引用和虚引用4中,这4中引用强度一次递减

# 强引用:
> 最传统的"引用"的定义,是指在程序代码之中普遍存在的引用赋值,即类似"Object obj = new Object();"这种引用关系.
`无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象`

# 软引用:
> 在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收.
如果这次回收后还没有足够的内存,才会抛出内存溢出异常  `内存不足即回收`

# 弱引用
> 被弱引用关联的对象只能生存到下一次垃圾收集之前.当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象
`发现即回收`

# 虚引用
> 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例.
为一个对象设置虚引用关联的`唯一目的就是能在这个对象被收集器回收时收到一个系统通知`
posted @ 2021-02-28 02:13  longda666  阅读(175)  评论(0)    收藏  举报