美文网首页JVM程序员java进阶干货
垃圾收集器与内存分配策略(一)

垃圾收集器与内存分配策略(一)

作者: zlcook | 来源:发表于2017-04-05 10:12 被阅读53次
    • 这篇文章主要讲垃圾收集器,下一篇文章再讲内存分配策略。

    1. JVM运行时各个数据区域的内存分配和回收概况

    1.1程序计数器、虚拟机栈、本地方法区

    • 这三个区域随线程而生、随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的进行着出栈、入栈操作。每一栈帧中分配的内存基本上在类结构确定下来是就已知的(尽管在运行期会由JIT编译器进行一些优化,但大体上是编译期可知)。因此这三个区域的内存分配和回收都具备确定性,在这几个区域就不过多考虑回收问题,因为方法结束或线程结束时,内存自然就跟着回收了。本文重点讨论堆和方法 区。

    1.2堆和方法区

    • 这部分内存的分配和回收是动态的,因为一个接口的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序运行期间才能知道会创建哪些对象。这部分的内存分配和回收是本文要讨论的。

    2. 垃圾收集器

    • java中垃圾收集器完成的事
    • 哪些内存需要回收?
    • 什么时候回收?
    • 如何回收?
    • 第2节内容将围绕这3个问题进行叙述。注:这一节所讲内容是针对JVM中的堆内存,第3节讲方法区内存回收

    2.1 对象存活判定算法(哪些内存需要回收?)

    • 在堆中存放着Java中几乎所有对象实例(有的存放在方法区中),垃圾收集器在对堆进行回收前,第一件事就是确定这些对象之中哪些还“存活”着,哪些“死了”(即不可能在被任何途径使用的对象)。第二件事才是进行内存回收。判断对象存活的算法有如下几种,其中JVM使用的是可达性分析算法。

    2.1.1 引用计数算法(Reference Counting)

    • 描述:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1,引用失效时减1,当计数器值为0时对象就不可能在被使用了。
    • ** 优点**:实现简单,判定效率高。
    • 使用情况:微软的COM(Component Object Model)技术、使用ActionScript 3的FlashPlayer、Python语言使用该算法判定对象存活的。
    • 缺点:很难解决对象之间相互循环引用问题。
      如下案例:
    /**
     * testGC()方法执行后,objA和objB会不会被GC呢? 
     * @author zzm
     */
    public class ReferenceCountingGC {
    
        public Object instance = null;
    
        private static final int _1MB = 1024 * 1024;
    
        /**
         * 这个成员属性的唯一意义就是占点内存,以便在能在GC日志中看清楚是否有回收过
         */
        private byte[] bigSize = new byte[2 * _1MB];
    
        public static void testGC() {
            ReferenceCountingGC objA = new ReferenceCountingGC();
            ReferenceCountingGC objB = new ReferenceCountingGC();
            objA.instance = objB;
            objB.instance = objA;
    
            objA = null;
            objB = null;
    
            // 假设在这行发生GC,objA和objB是否能被回收?
            System.gc();
        }
    }
    
    • 在上面这种情况下,实际上两个对象已经不可能在被访问了,但是因为互相引用者对象,导致计数器都不为0,如果采用引用计数器,objA和objB不会被GC收集器回收。
    • 但是在Java中这两个对象会被回收,因为JVM不是采用引用计数算法。所以很好的解决了循环引用问题。

    2.1.2 可达性分析算法(Reachability Analysis)

    • 描述:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相相连时(即从GC Roots到这个对象不可达),则证明此对象是不可用的。
      可达性分析算法判断对象是否可收回
      上图中object5、object6、object7虽然想好有关联,但是到GC Roots是不可达的,所以会被判定为是可回收的对象。
    • Java语言中可作为GC Roots的对象包括下面几种
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    • 方法区类静态属性引用的对象(static).
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象。
    • 方法区中常量引用对象(final)。
    • 使用情况:主流程序语言(java、C#)都使用该算法来判定对象是否存活的。

    2.2 对象什么时候回收(什么时候回收?)

    • 经过可达性分析算法确定了一个对象是一个可回收对象后,那么是不是就需要立即回收该对象呢?在对象要被回收前是否可以做一些事情呢?(比如复活、释放资源),这些就和java中的引用、和对象的finalize()方法有关了。

    2.2.1 引用

    • 无论通过引用计数算法判断对象的引用数量、还是通过可达性分析算法判断对象的引用链是否可达,判定对象存活都与“引用”有关。jdk1.2之前,java中的对象只有被引用和没有被引用两种状态,这样的话GC收集器对该对象只有回收和不回收两种情况,但是对于一些“食之无味、弃之可惜”的对象,我们希望:当内存空间不足时,保留在内存中,如果内存进行垃圾收集后还非常紧张就抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
    • jdk1.2之后,java对引用概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phanton Reference)4种,4种强度依次减弱。
    • 强引用(Strong Reference):类似“Objcet obj = new Object()"这类的引用,只要引用还存在,垃圾收集器永远不会回收被引用的对象。
    • 软引用(Soft Reference):描述一些有用但非必要的对象,对于软引用关联着的对象,在系统发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收,为什么是第二次?因为第一次回收时发现该对象是软引用就不将其列入回收范围。第二次回收后还没有足够内存,才抛出异常。在被回收之前可以通过软引用获得对象。java提供SoftReference类来实现软引用
    • 弱引用(Weak Reference):描述一些有用但非必要的对象,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论内存是否足够,都会被回收。WeakReference类来实现,在被回收之前可以通过弱引用获得对象。。
    • 虚引用(Phanton Reference):最弱,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。唯一目的是当这个对象被收集时能够收到一个系统通知。
      Java中引用的详解
      JAVA四种引用方式

    2.2.2生存还是死亡

    • 即使在可达性分析算法中不可达的对象,也并非”非死不可“,这时候它们暂时处于缓刑阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
    • 第一次标记并进行一次筛选,筛选条件是:对象是否有必要执行finalize()方法(Object的protected方法)。如果没必要执行,则会被回收。如果有必要则执行,则该对象会被放到一个F-Queue队列中,JVM会创建一个低优先级的Finalizer线程去执行队列中对象的finalize方法。对象在finalize方法中可以拯救自己,比如将this赋值给某个变量。finalize最主要目的是用来释放资源,毕竟finalize只会被调用一次。
      有必要执行finalize的条件是1.该对象的finalize方法被覆盖。2.该对象的finalize方法之前没有被调用过。
    • 第二次标记: 在稍后GC将对F-Queue列中对象进行第二次标记,如果这时对象没有拯救自己则就会被回收,否则会被移除”即将回收“集合(即拯救了自己)

    堆内存回收

    2.3垃圾收集算法(如何回收?)

    • 经过上面的可达性分析算法确定一个对象可以回收,以及通过引用类型或者finalize()方法最终确定一个对象的回收时机后,下面要做的事情就是对对象进行回收释放内存的工作了。JVM中如何进行内存回收呢?每个回收算法有什么优缺点呢?

    2.3.1标记-清除算法

    • 分两个阶段"标记“和”清除“:先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。(具体标记过程看上面)

    • 不足和产生产生的问题

    • 效率不高。

    • 空间问题:标记清除后会产生大量不连续内存碎片。碎片太多可能导致以后在程序中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。


      标记-清除算法示意图

    2.3.2复制算法

    • 总策略:可用内存分成两个相等的块。
    • 思想:将可用内存按容量划分为大小相等的两块。每次只使用其中一块,当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
    • 优点
    • 对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动指针按顺序分配内存即可。
    • 缺点
    • 代价高,将内存缩小为原来一半了。


      复制算法示意图

    2.3.3改进的复制算法

    • 补充:JVM的堆中分为新生代和老年代,不同代中存放的对象生存时间不一样,生存时间不一样,那么对不同代的内存区域采用的回收算法就应该充分考虑到它们的特点。

    • 总策略: 1块Eden空间 + 2块Survior空间 + 分配担保。

    • 新生代中的对象98%都是朝生夕死,所以并不需要按照1:1的比例来划分内存空间,所以把内存分为1块较大的Eden空间和2块较小的Survivor空间

    • 回收过程
      每次使用Eden和其中一块Survivor,当回收时,将Eden和Survivor中还活着的对象一次性地复制到另外一块Survior空间上,最后清理掉Eden和刚才使用过的Survivor空间,即2个Survior轮流空着。

    • 注:2个Survivor就可以确保每次回收前至少有一个是空的,用来接收没被回收掉的。

    • Eden和Survior的分配比例
      HotSpot虚拟机默认Eden和Survivor大大小比例是8:1,也就是每次新生代中可用内存为整个新生代容量的90%(80%+10%),只有10%被浪费。

    • 分配担保
      基于上面的Eden和Survior的分配比例,当回收后有大于10%的对象存活的话,那么Survivor空间会不够用,这时就需要使用其他内存(老年代)进行分配担保(Handle Promotion):即把新生代收集下来的存活对象通过分配担保机制复制到老年代中。然后在清理带Enden和刚才使用过的一块Survivor空间。(那么如果老年代的空间也不够存放呢?下面会讲。

    • 使用现状:现在商业虚拟机都采用这种收集算法来回收新生代.(那老年代用什么算法呢)

    • 为什么适合用在新生代中?
      新生代中对象产生的多、存活率低,所以复制操作就很少,回收就快,没有碎片问题,分配内存时也很快。

    2.3.4 标记-整理算法

    • 算法思想
      与标记-清除类似,只是标记完了,不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理带哦端边界以外的内存。
    标记-整理算法示意图
    • 老年代适合采用”标记-整理“
      老年代中对象存活时间长,使用复制算法的话需要进行太多复制操作,效率变低,还有就是如果不想浪费50%空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%活着的极端情况。所以标记整理比较适合。

    2.3.5分代收集算法(*)

    • 当前商业虚拟机的垃圾收集都采用"分代收集“(Generational Collection)算法。
    • 算法思路
    • 根据对象存活周期的不同将内存划分为几块。一般把Java堆中分为新生代和老年代,这样可以根据各个年代的特点采用最适当的收集算法。
    • 新生代中每次垃圾收集都有大批对象死去,少量存活,所以选复制算法。
    • 老年代中对象存活率高、没有额外空间对它进行分配担保,就必须使用”标记-清理“或”标记-整理“算法进行回收。

    3.方法区内存回收

    3.1概述

    • 上面讲的内容都是针对堆中的内存回收,那么方法区呢?堆中的内存在JVM中被分为”新生代“、“老年代”。而方法区的内存被称为“永久代”。Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区进行垃圾收集的“性价比”一般比较低,即回收一次只有很少的内存被释放掉。
    • 永久代中垃圾收集的内容:废弃常量、无用类。

    3.2常量回收

    3.2.1方法区中常量类型

    • 先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
      1、类和接口的全限定名
      2、字段的名称和描述符
      3、方法的名称和描述符

    3.2.2回收过程

    • 回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果在这时候发生内存回收,而且必要的话,这个“abc”常量就会被系统“请”出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

    3.3 无用的类回收

    • 判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是“无用的类”:

    • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。

    • 加载该类的ClassLoader已经被回收。

    • 该类对应的java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

    • 虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class及-XX:+TraceClassLoading、 -XX:+TraceClassUnLoading查看类的加载和卸载信息。

    • 在大量使用反射、动态代理、CGLib等bytecode框架的场景,以及动态生成JSP和OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

    总结

    • 这篇文章主要讲了JVM中垃圾收集器在堆上进行内存回收涉及到的内容:可达性分析算法判断对象是否无用、为对象设置不同引用类型来让对象的回收时机与堆内存是否充足相联系起来、对象的finalize()方法可以在被回收前做一些事情;内存回收涉及到的相关算法,JVM的堆使用了分代收集算法(新生代使用复制算法、老年代使用标记-清除算法)。
    • 并讲了方法区中内存回收:废弃常量、无用类。
    • 下一篇:讲完内存回收相关知识,下一节讲内存分配。堆中的对象是如何分配的,堆中不同的区(新生代、老年代)中的对象怎么分配内存的?。
      参考文章:
      GC在堆和方法区的内存回收
      JVM方法区内存回收

    相关文章

      网友评论

        本文标题:垃圾收集器与内存分配策略(一)

        本文链接:https://www.haomeiwen.com/subject/uymmottx.html