大数据学习-Java JVM垃圾回收机制

一、概述:

本文作为大数据基础的Java部分,上一篇说明了Linux的常用命令,今天学习了Java的高级特性,我们关注与Java的内存管理的机制,也就是垃圾是如何回收的俗称GC问题,这里用《深入理解Java虚拟机》的要解决的问题作为概述,解决三个问题:

  • 哪些内存需要回收?

    线程公有的部分,一般是Java堆 和方法区 ,两个算法判断是否需要回收 引用计数法可达性分析算法(也称跟搜索法)

  • 什么时候回收?

    Java堆是分不同的类,比如新生代,老年代,永久代是有不同的分类算法和时机进行回收

  • 如何回收?

    运用JVM中不同的垃圾回收策略

    • 标记-清除算法
    • 复制算法
    • 标记整理算法
    • 分代收集算法(解决什么时候回收的问题)

二、哪些内存需要回收?

先看一下基本的JVM内存模型,五种基本内存模型,并做一下简单概述:

4034766-d3267a2b1d68299a.png
五种基本Java内存模型

两种类型:

  • 所有线程共享的数据区:
    1. 方法区: 存储已被虚拟机加载的类信息、常量、静态变量、即时编译后代码等数据。常量池位于方法区,并使用永久代来实现方法区
    2. Java堆区: 我们常说用于存放对象的区域
  • 线程私有(隔离)数据区:
    1. 虚拟机栈: 方法执行时创建一个栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息。每个方法一个栈帧,互不干扰
    2. 本地方法栈: 用于存放执行native方法的运行数据
    3. 程序计数器: 当前线程所执行的字节码的指示器,通过改变计数器来选取下一条需要执行的字节码指令

我们基本是要回收Java堆区,因此也称之为GC堆,下面用两个算法来表示到底什么才是需要回收的对象

  1. 引用计数法:计算对象的引用数量

    引用计数算法是通过计算对象的引用数量来决定对象是否可以被回收。

    引用计数算法是垃圾收集器中的早期策略。在这种方法中,堆中的每个对象实例都有一个引用计数。当一个对象被创建时,且将该对象实例分配给一个引用变量,该对象实例的引用计数设置为 1。当任何其它变量被赋值为这个对象的引用时,对象实例的引用计数加 1(a = b,则b引用的对象实例的计数器加 1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数减 1。特别地,当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器均减 1。任何引用计数为0的对象实例可以被当作垃圾收集。

    但是也有很多问题,如果两个对象循环相互引用,而实例化的对象引用已经为null,对象却有引用,永远无法回收,简单代码如下:

    public class ReferenceCountingGC {
      
            public Object instance = null;
    
            public static void testGC(){
    
                ReferenceCountingGC objectA = new ReferenceCountingGC ();
                ReferenceCountingGC objectB = new ReferenceCountingGC ();
    
                // 对象之间相互循环引用,对象objA和objB之间的引用计数永远不可能为 0
                objectA.instance = objectB;
                objectB.instance = objectA;
    
                 //虽然实例化的对象已经为null,但是因为objectA一直被引用,所以引用数量不为0
                objectA = null;
                objectB = null;
    
                System.gc();
        }
    }
    

  2. 可达性分析算法(根搜索法):引用图论判断对象是否可达

    可达性分析算法(根搜索法)是通过计算对象的引用链是否可达来决定对象是否可以被回收。

    可达性分析算法(根搜索法)是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,通过一系列的名为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的

三、如何回收以及什么时候回收?

JVM中常见的垃圾回收策略:标记-清除算法、复制算法、标记-整理算法、分代回收算法

  1. 标记-清除算法

    标记-清除算法分为标记和清除两个阶段。该算法首先从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象并进行回收,如下图所示。

    4034766-13cdbc4c09143874.png
    标记-清除算法

从图中能看到,每次要扫描内存,标记,再扫描并且清除相应的内存块,缺点也就相应展示出来了,效率并不高效,需要进行扫描标记再扫描进行删除,另外空间会产生非常多的不连续碎片,会造成大对象的分配时进行二次扫描。

  1. 复制算法

    为了解决效率问题,复制算法应运而生复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法适用于对象存活率低的场景,比如新生代。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。该算法示意图如下所示:

    4034766-2b378497680c13c4.png
    复制算法

事实上,现在商用的虚拟机都采用这种算法来回收新生代。因为IBM公司研究发现,新生代中的对象是”朝生夕死“,所以需要复制的对象很少,效率还不错。实践中会将新生代内存分为一块较大的Eden空间和两块较小的Survivor空间 (如下图所示),每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的90% ( 80%+10% ),只有10% 的内存会被“浪费”。

—摘自深入了解Java虚拟机

  1. 标记-整理算法

    复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。标记整理算法的标记过程类似标记清除算法,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,类似于磁盘整理的过程,该垃圾回收算法适用于对象存活率高的场景(老年代),其作用原理如下图所示。

4034766-39b5c974a52dbfb2.png
标记-整理算法

标记整理算法与标记清除算法最显著的区别是:标记清除算法不进行对象的移动,并且仅对不存活的对象进行处理;而标记整理算法会将所有的存活对象移动到一端,并对不存活对象进行处理,因此其不会产生内存碎片。标记整理算法的作用示意图如下:

4034766-826e319853ac9745.png
标记整理算法的作用
  1. 分代收集算法(解决什么时候回收的问题)

    对于一个大型的系统,当创建的对象和方法变量比较多时,堆内存中的对象也会比较多,如果逐一分析对象是否该回收,那么势必造成效率低下。分代收集算法是基于这样一个事实:不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率。当代商用虚拟机使用的都是分代收集算法:新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记清除算法或者标记整理算法。Java堆内存一般可以分为新生代、老年代和永久代三个模块,如下图所示:

    4034766-ebfaf37e58464fe7.png
    Java堆内存的三代世家

    • 新生代(Young Generation)

    新生代的目标就是尽可能快速的收集掉那些生命周期短的对象,一般情况下,所有新生成的对象首先都是放在新生代的。新生代内存按照 8:1:1 的比例分为一个eden区和两个survivor(survivor0,survivor1)区,大部分对象在Eden区中生成。在进行垃圾回收时,先将eden区存活对象复制到survivor0区,然后清空eden区,当这个survivor0区也满了时,则将eden区和survivor0区存活对象复制到survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后交换survivor0区和survivor1区的角色(即下次垃圾回收时会扫描Eden区和survivor1区),即保持survivor0区为空,如此往复。特别地,当survivor1区也不足以存放eden区和survivor0区的存活对象时,就将存活对象直接存放到老年代。如果老年代也满了,就会触发一次FullGC,也就是新生代、老年代都进行回收。注意,新生代发生的GC也叫做MinorGC,MinorGC发生频率比较高,不一定等 Eden区满了才触发。

    • 老年代(Old Generation)

    老年代存放的都是一些生命周期较长的对象,就像上面所叙述的那样,在新生代中经历了N次垃圾回收后仍然存活的对象就会被放到老年代中。此外,老年代的内存也比新生代大很多(大概比例是1:2),当老年代满时会触发Major GC(Full GC),老年代对象存活时间比较长,因此FullGC发生的频率比较低。

    • 永久代(Permanent Generation

    永久代主要用于存放静态文件,如Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如使用反射、动态代理、CGLib等bytecode框架时,在这种时候需要设置一个比较大的永久代空间来存放这些运行过程中新增的类。

  2. 结论

    • 标记-清除算法:会存在效率和内存碎片问题
    • 复制算法:为了解决标记清除的问题,但是会浪费空间,进行分区复制回收,适用于新生代
    • 标记-整理算法:为了解决复制算法空间浪费的问题,进行标记清除并且整理,适用于老年代
    • 分代回收算法:解决合适的时间去回收内存,根据对象的存活时间进行分类
      • 老年代:标记-清理/标记-整理算法
      • 新生代:复制算法
展开阅读全文

没有更多推荐了,返回首页