JVM垃圾回收机制

一、概述

说起垃圾回收(GC),大部分人都把这项技术当做Java语言的伴生产物。事实上,GC的历史比Java久远,早在1960年Lisp这门语言中就使用了内存动态分配和垃圾回收技术。
在Java中,程序员不需要去关心内存动态分配和垃圾回收的问题,顾名思义,垃圾回收就是释放垃圾占用的空间,这一切都交给了JVM来处理。这时候有人就会疑惑了,既然GC已经为我们解决了这个矛盾,我们还需要学习GC么?答案是肯定的,那究竟什么时候我们还需要用到的呢?
1、 排查内存溢出
2、 排查内存泄漏
3、 性能调优,排查并发瓶颈
本文主要解答三个问题:
1、哪些内存需要回收?(对象是否可以被回收的两种经典算法: 引用计数法 和 可达性分析算法)
2、如何回收?(三种经典垃圾回收算法(标记清除算法、复制算法、标记整理算法)及分代收集算法)
3、使用什么工具回收?(垃圾收集器)

二、哪些内存需要回收?

猿们都知道JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法–对象是否可以被回收的两种经典算法: 引用计数法 和 可达性分析算法!

三、判断对象是否存活算法

3.1 引用计数算法
引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数器。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。
3.1.1 优缺点
优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。
3.1.2.1 代码示例

public class abc_test {

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        
        MyObject object1=new MyObject();
        MyObject object2=new MyObject();
        
        object1.object=object2;
        object2.object=object1;//test1与test2存在相互引用 
        
        object1=null;
        object2=null;
         System.gc();//通知回收

    }

}

class MyObject{
    
     MyObject object;
     
}

虽然最后将test1和test2赋值为null,也就是说test1和test2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数都不为0,那么垃圾收集器就永远不会回收它们。运行程序,从内存分析看到,事实上这两个对象的内存被回收,这也说明了当前主流的JVM都不是采用的引用计数器算法作为垃圾判定算法的。

3.2 可达性分析算法
这种方案是目前主流语言里采用的对象存活性判断方案。
基本思想是:通过一系列的称为“GC ROOTS”的对象作为起始点,从这些节点向下搜索,搜索所走的路径称为引用链,当一个对象到GC Roots节点没有任何引用链相连时(图论中的说法是,从GC Roots到这个对象所走节点不可达),则证明此对象是不可用的。可达对象即为“存活”对象。

在这里插入图片描述
object5,object6 和 object7 便是不可达对象,视为“死亡状态”,应该被垃圾回收器回收。

GC Roots 究竟指谁呢?

我们可以猜测,GC Roots 本身一定是可达的,这样从它们出发遍历到的对象才能保证一定可达。那么,Java 里有哪些对象是一定可达呢?主要有以下四种:

虚拟机栈(帧栈中的本地变量表)中引用的对象。
方法区中静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中 JNI 引用的对象。
每次垃圾回收器会从这些根结点开始遍历寻找所有可达节点。

四、引用的几种类型

说了这么多,其实我们可以看到,所有的垃圾回收机制都是和引用相关的,那我们来具体的来看一下引用的分类,到底有哪些类型的引用?每种引用都是做什么的呢?

Java中存在四种引用,每种引用如下:

1、 强引用

只要引用存在,垃圾回收器永远不会回收被引用的对象,类似于:

Object obj = new Object();

只有当obj这个引用被释放之后,对象才会被释放掉,这也是我们经常所用到的编码形式。

2、 软引用
用来描述一些还有用但是非必需的对象。对于软引用关联的对象,在系统发生内存溢出之前,将会把这些对象列入回收范围进行回收,如果回收后还没有足够内存,才会抛出内存溢出异常。SoftReference类来实现软引用。可以通过以下代码实现

Object obj = new Object();

SoftReference sf = new SoftReference(obj);

obj = null;

sf.get();//有时候会返回null

这时候sf是对obj的一个软引用,通过sf.get()方法可以取到这个对象,当然,当这个对象被标记为需要回收的对象时,则返回null;
软引用主要用于实现类似缓存的功能,在内存足够的情况下直接通过软引用取值,无需从繁忙的真实来源查询数据,提升速度;当内存不足时,自动删除这部分缓存数据,从真正的来源查询这些数据。

3、 弱引用
也是用来描述一些非必需的对象,但是它的强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收只被弱引用关联的对象。WeakReference类实现弱引用。
可以通过如下代码实现

Object obj = new Object();

WeakReference wf = new WeakReference(obj);

obj = null;

wf.get();//有时候会返回null

wf.isEnQueued();//返回是否被垃圾回收器标记为即将回收的垃圾

弱引用是在第二次垃圾回收时回收,短时间内通过弱引用取对应的数据,可以取到,当执行过第二次垃圾回收时,将返回null。

弱引用主要用于监控对象是否已经被垃圾回收器标记为即将回收的垃圾,可以通过弱引用的isEnQueued方法返回对象是否被垃圾回收

4、 虚引用(幽灵/幻影引用)
称为幽灵引用或幻影引用,它是最弱的引用。一个对象是否有虚引用的存在,完全不会对她的生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被垃圾收集器回收时收到一个系统通知。PhantomReference类实现虚引用。
垃圾回收时回收,无法通过引用取到对象值,可以通过如下代码实现

Object obj = new Object();

PhantomReference pf = new PhantomReference(obj);

obj=null;

pf.get();//永远返回null

pf.isEnQueued();//返回从内存是否已经删除

虚引用是每次垃圾回收的时候都会被回收,通过虚引用的get方法永远获取到的数据为null,因此也被成为幽灵引用。

虚引用主要用于检测对象是否已经从内存中删除。

罗列这四个概念的目的是为了说明,无论引用计数算法还是可达性分析算法都是基于强引用而言的。

五、对象死亡被回收前的最后一次挣扎

即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。

第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记;

第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。在finalize()方法中没有重新与引用链建立关联关系的,将被进行第二次标记

第二次标记成功的对象将真的会被回收,如果对象在finalize()方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活
  但是需要注意的是,一个对象的finalize()方法最多只会被执行一次,即执行过了,后面垃圾回收时就不会再执行了,因此,死里逃生的机会只有一次,即只有一次机会可以在对象finalize()方法中重新与引用链建立了关联关系。


/**   
 * 此代码演示了两点:   
 * 1.对象可以在被GC时自我拯救。   
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次   
 */    
public class FinalizeEscapeGC {    
 
  public static FinalizeEscapeGC SAVE_HOOK = null;    
 
  public void isAlive() {    
   System.out.println("yes, i am still alive :)");    
  }    
 
  @Override    
  protected void finalize() throws Throwable {    
   super.finalize();    
   System.out.println("finalize mehtod executed!");    
   FinalizeEscapeGC.SAVE_HOOK = this;    
  }    
 
  public static void main(String[] args) throws Throwable {    
   SAVE_HOOK = new FinalizeEscapeGC();    
 
   //对象第一次成功拯救自己    
   SAVE_HOOK = null;    
   System.gc();    
   //因为finalize方法优先级很低,所以暂停0.5秒以等待它    
   Thread.sleep(500);    
   if (SAVE_HOOK != null) {    
    SAVE_HOOK.isAlive();    
   } else {    
    System.out.println("no, i am dead :(");    
   }    
 
   //下面这段代码与上面的完全相同,但是这次自救却失败了    
   SAVE_HOOK = null;    
   System.gc();    
   //因为finalize方法优先级很低,所以暂停0.5秒以等待它    
   Thread.sleep(500);    
   if (SAVE_HOOK != null) {    
    SAVE_HOOK.isAlive();    
   } else {    
    System.out.println("no, i am dead :(");    
   }    
  }    
}

运行结果:

finalize mehtod executed!    
yes, i am still alive :)    
no, i am dead :(

从运行结果可以看出,SAVE_HOOK对象的finalize()方法确实被GC收集器调用过,且在被收集前成功逃脱了。
另外一个值得注意的地方是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。
有关finalize()方法的建议:
需要特别说的是,finalize() 方法,不建议开发人员使用这种方法拯救对象。
应当尽量避免使用它,因为它不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受它所做的一个妥协。
它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。
有些教材中描述它适合做“关闭外部资源”之类的工作,这完全是对此方法用途的一种自我安慰。
finalize() 能做的工作,使用try-finally 或者其它方法都更适合、及时,所以作者建议大家可以忘掉此方法存在。

六、方法区如何判断是否需要回收

上面主要介绍的是堆中对象的回收。
  方法区主要回收的内容有:废弃常量和无用的类。
  对于废弃常量也可通过引用的可达性来判断,回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果在这时候发生内存回收,而且必要的话,这个“abc”常量就会被系统“请”出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
  但是对于无用的类则需要同时满足下面3个条件:
该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
加载该类的ClassLoader已经被回收;
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
满足上面3个条件,仅仅是可以回收,而并不是和对象一样,不使用了就必然回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class及-XX:+TraceClassLoading、 -XX:+TraceClassUnLoading查看类的加载和卸载信息。

七、常用的垃圾回收算法

Java1.2之前主要通过引用计数器算法来标记是否需要垃圾回收,而1.2之后都使用可达性算法来收集垃圾,而收集后的垃圾是通过什么算法来回收的呢?
1、 标记-清除算法
2、 复制算法
3、 标记-整理算法

7.1 标记-清除算法
在这里插入图片描述
标记-清除算法采用从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如上图所示。
标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片!

这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:
在这里插入图片描述
从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

7.2 复制算法
在这里插入图片描述
复制算法采用从根集合扫描,并将存活对象复制到一块新的,没有使用过的空间中,这种算法当控件存活的对象比较少时,极为高效,但是带来的成本是需要一块内存交换空间用于进行对象的移动。
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。每次垃圾回收时,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:
在这里插入图片描述
这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

7.3 标记-整理算法
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。具体流程见下图:
在这里插入图片描述
在这里插入图片描述

7.4 分代收集算法

分代收集算法是目前大部分JVM的垃圾收集器采用的垃圾回收算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)–方法区。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法
  堆内存分为年轻代和年老代,非堆内存指方法区,主要用于存储一些类的元数据,常量池等信息。之所以将Java内存按照分代进行组织,主要是基于这样一个“弱假设” - 大多数对象都在年轻时候死亡(新生代中98%的对象都是"朝生夕死"的)。同时,将内存按照分代进行组织,使得我们可以在不同的分代上使用不同的垃圾回收算法,使得整个内存的垃圾回收更加有效。
  目前大部分垃圾收集器对于新生代都采取复制算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少;而由于老年代的特点是每次回收都只回收少量对象,一般使用的是标记-清除算法或标记-整理算法。

7.4.1 年轻代的垃圾回收
HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to,一般情况下是2个)。大小默认比例为8:1:1。

因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,只需要付出少量存活对象的复制成本就可以完成收集。复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区(to区),新创建的对象不会放在Survivor区,是用于GC后的对象存放
对象第一次存放到Survivor区时年龄设为1,对象在Survivor区中每熬过一次Minor GC(在两个Survivor区交替存在),年龄就会增加1岁,当它的年龄增加到一定程度时(默认15),就会被移动到老年代中。
在GC开始的时候,对象只会存在于Eden区(伊甸区)和名为“From”的Survivor区(幸存区),Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程。
Minor GC后,还有大量存活的对象,幸存区To存放不下了,就需要老年代进行分配担保,把Survivor区中无法容纳的对象直接保存到年老代中。(当老年代也没有空间容纳时,执行full GC来让老年代腾出更多空间,老年代gc后也没有空间就只有抛outofmemory异常了)
在这里插入图片描述
 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高( (Eden满了或达到一定比例))。
7.4.2老年代(Old Generation)的回收算法
内存比新生代也大很多,当老年代内存满时触发Major GC即Full GC(深入理解java虚拟机一书指明这两个是同一概念,书的92页注意栏),Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。 full gc一般使用的是标记-清除算法或标记-整理算法进行垃圾回收。
7.4.5 持久代回收
即方法区的垃圾回收,参看标题六
持久代对垃圾回收没有显著影响,因此jvm没有明确规定什么时候进行方法区回收,可以不要求虚拟机在方法区实现垃圾收集。

垃圾回收文章:
https://www.cnblogs.com/aspirant/p/8662690.html
https://www.cnblogs.com/wabi87547568/p/5282892.html
https://blog.csdn.net/u011534095/article/details/78845080
https://blog.csdn.net/notonlyrush/article/details/78748247
https://www.cnblogs.com/sjxbg/p/9388615.html
http://www.importnew.com/26821.html
https://www.cnblogs.com/ludashi/p/6694965.html
https://baijiahao.baidu.com/s?id=1569365120207349&wfr=spider&for=pc

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值