美文网首页
GC与内存分配策略——《深入理解JVM》读书笔记

GC与内存分配策略——《深入理解JVM》读书笔记

作者: HaoR_W | 来源:发表于2018-03-27 04:30 被阅读0次

    GC三大问:
    哪些内存需要回收?
    什么时候回收?
    怎么回收?

    程序计数器虚拟机栈本地方法栈3个区域的内存回收不需要过多考虑。Java堆方法区的内存回收是垃圾回收器的关注重点。因为只有在程序运行期间才知道会创建哪些对象,这部分的内存分配和回收都是动态的。

    一、判断对象是否已死

    1. 引用计数算法(Reference Counting)

    原理

    给对象添加一个引用计数器,每一个地方引用它时加1;引用失效减1;任何时刻计数器为0的对象就是不可能再被使用的。

    问题

    很难解决对象间的相互循环引用问题。

    public class ReferenceCountingGC {
    
        public Object instance = null;
    
        private static final int _1MB = 1024 * 1024;
    
        private byte[] objLoad = new byte[2 * _1MB]; // 用来占内存,以便在GC日志中查看
    
        public static void testGC() {
            ReferenceCountingGC objA = new ReferenceCountingGC();
            ReferenceCountingGC objB = new ReferenceCountingGC();
            objA.instance = objB;
            objB.instance = objA;
    
            objA = null;
            objB = null;
    
            System.gc(); // 若使用reference counting则无法回收objA和objB
        }
    }
    

    2. 可达性分析算法(Reachability Analysis)

    原理

    通过一系列的称为“GC Roots”的对象作为起始点,开始向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何的引用链相连(GC Roots到这个对象不可达)时,该对象是不可用的。如图中的object 5/6/7。

    原理示意图

    可作为GC Roots的对象

    (1)虚拟机栈(栈帧中的本地变量表)中引用的对象。

    (2)方法区中类静态属性引用的对象。

    (3)方法区中常量引用的对象。

    (4)本地方法栈中JNI(Java Native Interface, 即Native方法)引用的对象。

    3. 引用的类型

    起因

    起初reference类型数据存放另一块内存的起始地址,这种情况下一个对象只有被引用和没被引用两种状态,无法描述一些:当内存空间还足够时则保存在内存之中;若内存空间在GC后依然紧张则可以抛弃的对象。主要引用场景是系统的缓存功能。

    分类

    (1) 强引用(Strong Reference)

    一般见到的类似Object obj = new Object()这样的引用。主要强引用还存在,垃圾收集器永远不会收掉被引用的对象。

    (2) 软引用(Soft Reference)

    有用但非必须的对象。对软引用关联的对象,在系统将发生MemoryOverflowError之前,将把这些对象列入回收范围进行第二次回收。若还没有足够的内存才会抛出MemoryOverflowError。使用SoftReference类实现。

    (3) 弱引用(Weak Reference)

    非必须对象。只能生存到下次GC之前。无论内存是否足够都会回收弱引用关联的对象。由WeakReference类实现。

    (4) 虚引用(Phantom Reference)

    最弱的引用关系,一个对象是否有虚引用的存在无关其生存时间,也无法通过虚引用来取得一个对象实例。唯一目的是在该对象被GC回收时收到一个系统通知。由PhantomReference类实现。

    4. 对象死亡的过程

    (1)在进行可达性分析后发现该对象没有与GC Roots相连接的引用链,会被第一次标记

    (2)进行筛选,判断对象是否有必要执行finalize()方法;若对象没有覆盖finalize()或者finalize()方法已被JVM调用过(即同一个对象无法两次使用finalize()自救),则没必要执行,否则继续。

    (3)对有必要执行finalize()方法的对象,将其放置在F-Queue队列中,并在稍后由一个由JVM自动建立的、低优先级的Finalizer线程去执行,即触发这个方法但不承诺会等待其运行结束(避免在该对象的finalize()中执行缓慢或者陷入死循环时阻碍F-Queue中其他对象)。

    (4)稍后GC对F-Queue中对象进行第二次标记,若对象在finalize()中成功拯救自己(重新与引用链上的任何一个对象建立关联),则会被移出“即将回收”的集合。

    注意

    finalize()方法是Java诞生之初为使C/C++程序员更容易接受它所做的妥协,运行代价高昂,不确定性大,无法保证各个对象的调用顺序finalize()能做的所有工作,使用try-finally或其他方法都能做得更好、更及时,故不建议使用。

    5. 回收方法区

    方法区(HotSpot中的永久代)的GC性价比比较低;堆中,尤其是新生代中的GC性价比比较高。

    永久代的GC主要回收两部分:废弃常量无用类

    废弃常量

    没有被引用的常量池中的对象(包括类(接口)、方法、字段的符号引用)。

    无用类

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

    JVM可以对无用类进行回收,但不是必然。HotSpot可使用-Xnoclassgc进行设置。

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

    二、垃圾收集算法

    1. 标记-清除算法(Mark-Sweep)

    思想

    分成标记清除两个阶段:先标记出所有需要回收的对象;之后统一回收。

    是最基础的收集算法,后续的都是基于这种思路并对其不足进行改进得到的。

    不足

    • 效率问题,标记和清除的效率都不高
    • 空间问题,标记清除之后会产生大量不连续的内存碎片。可能导致分配较大对象时无法找到足够的连续内存而不得不提前触发另一次GC动作。

    2. 复制算法(Copying)

    思路

    将可用内存分成大小相同的两块,每次只使用一块。用完后就将还存活的对象复制到另一块上面,然后把已使用过的内存空间一次清理掉。

    优点

    实现简单,运行高效。

    不足

    在对象存活率较高的时候需要较多的复制操作,效率会变低。如果不想浪费50%的空间,就需要额外的空间进行分配担保以应对被使用的内存中所有对象都100%存活的极端情况。

    应用

    现在的商业虚拟机都采用这种收集算法来回收新生代

    依据:新生代中98%的对象是“朝生暮死的”。

    • 将新生代所用的内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。
    • 当回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一个Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

    HotSpot虚拟机默认Eden和Survivor的大小比例是8 : 1,故只有10%的内存会被“浪费”。当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion)。

    3. 标记-整理算法(Mark-Compact)

    思路

    由于复制收集算法不适合老年代,根据老年代的特点提出的算法。标记过程同“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是将所有存活的对象都向一端移动,然后直接清理掉边界以外的内存

    4. 分代收集算法(Generational Collection)

    当前商业虚拟机的GC采用的算法。根据对象存活周期的不同将内存(Java堆)划分为:新生代老年代,并根据特点采用最适当的收集算法。

    新生代(Young Generation)

    每次GC时都有大批对象死去,只有少量存活。使用复制算法,只需付出少量存活对象的复制成本就可完成收集。

    老年代(Old Generation)

    对象存活率高、没有额外空间对其进行分配担保。需使用标记-清理或者标记-整理算法来进行回收。

    三、内存分配与回收策略

    总体大方向上,对象在堆上分配,主要分配在新生代的Eden区上,如果启用了本地线程分配缓冲,将按线程优先在TLAB上分配。不过规则不是百分百固定的(取决于GC和JVM的相关设置)。

    1. 普遍内存分配规律

    (1) 对象优先在Eden分配

    大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间时,JVM会发起一次Minor GC。

    Minor GC和Full GC

    • 新生代GC(Minor GC):发生在新生代的GC动作,非常频繁,速度也较快。
    • 老年代GC(Major GC/Full GC):发生在老年代的GC,经常会伴随着至少一次的Minor GC(并非绝对)。一般会比Minor GC慢10倍以上。

    (2) 大对象直接进入老年代

    大对象

    需要大量连续内存空间的Java对象,比如很长的字符串和数组。经常出现大对象容易导致内存还有不少空间的时候就提前触发GC以获取足够的连续空间。

    写程序时应尽量避免“朝生夕灭”的“短命大对象”。

    JVM参数-XX:PretenureSizeThreshold=SIZE可以设置判断大对象的阈值。

    (3) 长期存活的对象会进入老年代

    对象年龄(Age)计数器

    JVM给每个对象定义的一个计数器。
    年龄初始化:若对象在Eden出生并经过第一次Minor GC后仍然能存活,并能被Survivor容纳的话,会被移动到Survivor空间中,并设年龄为1。
    年龄变化:对象在Survivor区中每熬过一次MinorGC年龄就加一岁。当达到特定年龄(默认15岁)后会晋升到老年代中。该阈值可由-XX:MaxTenuringThreshold设置。

    (4) 其他情况下的对象进入老年代

    动态对象年龄判定

    年龄达到阈值不是对象晋升老年代的唯一途径。如果在Survivor空间中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象可以直接进入老年代。

    2. 空间分配担保

    发生Minor GC之前,JVM会先检查老年代最大可用的连续空间是否大于新生代的所有对象总空间,如果条件成立则Minor GC是可以确保安全的。

    若条件不成立,则:
    (1)若HandlePromotionFailure设置为允许担保失败,则会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
    (1a)若大于,将尝试进行一次Minor GC(有风险)。
    (1b)若小于,改为进行一次Full GC。

    (2)若设置为不允许冒险,则进行一次Full GC。

    关于风险

    Minor GC时老年代可能需要担保一些Survivor无法容纳的对象。但老年代的剩余空间可能不能容纳这些对象。

    评估风险

    新生代中会有多少对象活下来在完成内存回收之前时不知道的,故取之前每次GC晋升到老年代的对象容量的平均大小作为经验值,与老年代剩余空间比较,来决定是否进行Full GC来让老年代腾出更多空间。





    03/26/2018

    相关文章

      网友评论

          本文标题:GC与内存分配策略——《深入理解JVM》读书笔记

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