Java中的垃圾回收机制

Java内存:

图解:

  • 私有内存区——伴随线程的产生而产生,一旦线程终止,私有内存区也会自动消除。
  • 程序计数器:指示当前程序执行到了哪一行,执行Java方法时记录正在执行的虚拟机字节码指令地址;执行本地方法时,计数器值为null。
  • 虚拟机栈:用于执行Java方法,栈针存储布局边聊表,操作数栈,动态链接,方法返回地址和一些额外的符加信息。程序执行时入栈;执行完成后栈针出栈。
    GC主要就是在Java堆中进行的
    Java堆:Java虚拟机管理的内存中最大的一块,所有线程共享,几乎所有的对象实例和数组都在这类分配内存。
    堆内存又分为:新生代和老年代,并且一般新生代的空间比老年代大。

为什么需要垃圾回收:
  在程序执行的过程中,会产生一系列的对象(占用内存的代表),这些都会存储在内存中。一部分对象在生命周期结束后,依然会占用一部分内存。这些占用内存却没有再次使用的对象,我们称之为“垃圾”,而对“垃圾”占用的内存的回收,就是垃圾回收。
  在没有垃圾回收机制的语言里,垃圾回收操作需要程序猿来完成,这常常会导致错误。
  内存泄漏:忘记释放一部分内存,导致那一部分内存不可用,并且占用着总的内存空间,如果这种情况一直存在着,那么就可能导致内存空间被占满,系统崩溃。
  垂直指针:忘记初始化指向释放了内存的指针(指向曾经存在的对象,但是对象已经不存在了),当再次调用指针的时候,指针指向了空内存,引起bug。
  释放错误的内存:人为的操作难免会发生错误,如果释放了错误的内存,也会导致程序发生莫名其妙的bug。

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

  垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法!

垃圾回收主要分为两步:
  1、垃圾对象的判断
  2、垃圾对象的回收
垃圾对象的判断:

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

public class Test {
    public static void main(String[] args) {
        MyObject obj1 = new MyObject();
        MyObject obj2 = new MyObject();
        
        obj1.object = obj2;
        obj2.object = obj1;
          
        obj1 = null;
        obj2 = null;
    }
}

  这段代码是用来验证引用计数算法不能检测出循环引用。最后面两句将obj1obj2赋值为null,也就是说obj1obj2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

2.可达性分析算法:
  通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,当一个对象到GC Roots没有任何路径相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则此对象为垃圾对象。

在Java语言中,可作为GC Roots的对象包括下面几种:
  a) 虚拟机栈中引用的对象(栈帧中的本地变量表);
  b) 方法区中类静态属性引用的对象;
  c) 方法区中常量引用的对象;
  d) 本地方法栈中JNI(Native方法)引用的对象。

3.java对象的引用:
  无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在Java语言中,将引用又分为强引用、软引用、弱引用、虚引用4种,这四种引用强度依次逐渐减弱。

  • 强引用

  在程序代码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

  • 软引用

  用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

  • 弱引用

  也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

  • 虚引用

  也叫幽灵引用或幻影引用(名字真会取,很魔幻的样子),是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在这个对象被收集器回收时收到一个系统通知。

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

4.对象死亡(被回收)前的最后一次挣扎:
  即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
  第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记;
  第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。在finalize()方法中没有重新与引用链建立关联关系的,将被进行第二次标记。
  第二次标记成功的对象将真的会被回收,如果对象在finalize()方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

 5.方法区如何判断需要回收:
  方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
  • 加载该类的ClassLoader已经被回收;
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾对象的回收:

先来看下垃圾回收的几种常用算法
复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

“标记-清除”(Mark-Sweep)算法
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象;
它主要有两个不足:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片;

标记-整理算法
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。具体流程见下图:
 

那么,垃圾对象是如何回收的呢?答案是分代回收。
分代回收策略
  不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。
  分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

   #年轻代(Young Generation)的回收策略:
a) 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
b) 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。
c) 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。
d) 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

   #老年代(Old Generation)的回收策略:
a) 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
b) 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

#持久代(Permanent Generation)的回收算法
用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代也称方法区,具体的回收可参见

finalize()和System.gc()方法介绍:

  提到GC就要提到finalize()方法,该方法是在jvm确定了一个对象符合GC的条件下执行的,用于对一些外部资源的释放等操作,但是何时对这个对象回收我们就不知道了;需要注意的是在jvm调用了该方法后,这个符合GC的对象也不一定最后就被回收了,因为在执行了finalize()方法后由于在方法体给对该方法进行了一些操作,使得该对象不符合GC的条件,例如将一个引用指向这个对象,最终导致该对象不会被GC,但这也只能求这个对象依次。

  同样还有System.gc()方法,这个方法的调用,jvm也不会立即执行对对象的回收,gc()仅仅是提醒jvm可以回收该方法了,但实际上要根据jvm内存需求来确定何实回收这个可以回收的对象。

  finalize()和System.gc()的区别:

  首先finalize()方法是jvm调用的,但是在回收期间不一定每个对象都会调用这个方法进行收尾工作,这也是这个方法不被提倡使用的原因。而System.gc()方法可以人为调用进行标记一个对象可以被回收。

  最后我们从何时回收对象比较,finalize()标记的对象是在被标记后的第二次回收时进行回收,而System.gc()方法没有这种规定,它只是被标记,何时回收由jvm决定。

public class Test {     
    @Override
    protected void finalize() throws Throwable {
        super.finalize(); 
       System.out.println("调用");
    }
    public static void main(String[] args){ 
        Test test = new Test();
        test=null;
        System.gc();
    }
}

分析:

    我们这里创建了Test类并重写了finalize()方法,然后我在主方法里创建了一个Test对象,并使其引用为空(此时符合回收条件)我们先调用System.gc()。

结果:控制台打印出——>  调用

    我们发现执行了finalize()方法,OK,我们现在将System.gc()注释掉,我们会发现并没有输出“调用”,也就是没有调用finalize()方法,这就是不一定每个垃圾对象jvm都会自动调用finalize()方法。

常见的垃圾回收器有哪些:

  • Serial收集器(复制算法)
    新生代单线程收集器,标记和清理都是单线程,优点是简单高效。是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。
  • Serial Old收集器(标记-整理算法)
    老年代单线程收集器,Serial收集器的老年代版本。
  • ParNew收集器(停止-复制算法) 
    新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
  • Parallel Scavenge收集器(停止-复制算法)
    并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。
  • Parallel Old收集器(停止-复制算法)
    Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先。
  • CMS(Concurrent Mark Sweep)收集器(标记-清理算法)
    高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择。

GC是什么时候触发的(面试最常见的问题之一)

  由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Scavenge GC和Full GC。

  Scavenge GC

  一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。

  Full GC

  对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。有如下原因可能导致Full GC:

a) 年老代(Tenured)被写满;

b) 持久代(Perm)被写满;

c) System.gc()被显示调用;

d) 上一次GC之后Heap的各域分配策略动态变化;

这里有篇关于垃圾回收器的文章也不错——跳转链接

参考文章:https://www.cnblogs.com/1024Community/p/honery.html
              https://blog.csdn.net/weixin_41835916/article/details/81530733
              https://www.cnblogs.com/chenpi/p/5588851.html
              https://blog.csdn.net/a602519773/article/details/82529240
              https://blog.csdn.net/weixin_36027342/article/details/79973294

posted @ 2019-07-25 14:28  暖然  阅读(650)  评论(0编辑  收藏  举报