java垃圾回收与内存分配策略

判断对象是否存活

  1. 引用计数算法

    无法解决循环引用问题:

    1
    2
    3
    4
    5
    6
    7
    ReferenceCountingGC objA = new ReferenceCountingGC();
    ReferenceCountingGC objB = new ReferenceCountingGC();
    objA.instance = objB;
    objB.instance = objA;
    objA = null;
    objB = null;
    System.gc();
  2. 可达性分析算法(Reachability Analysis)

    以GC Roots对象作为起始点,树形搜索引用链(Reference Chain),不可达对象为不可用对象。

    GC Roots:

    • 虚拟机栈中的本地变量表中的引用对象
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈中引用对象
  3. 引用类型扩充
    • 强引用(Strong Reference): 不会被GC
    • 软引用(SoftReference): 在内存溢出溢出发生之前会被二次回收
    • 弱引用(WeakReference): 下一次gc肯定会被回收
    • 虚引用(PhantomReference): 专门用来发出一个被GC的系统通知
  4. finalize()
    • 一个对象只被执行一次
    • 如果此方法中将this关联到一个强引用,那么可以避免被GC
    • 从不会使用这个方法
  5. 回收方法区
    • 回收废弃常量
    • 回收类
      • 该类的所有实例都已被回收,java堆中没有该类的任何实例
      • 该类的ClassLoader已被回收
      • 该类的java.lang.Class对象没有被引用,无法再任何地方运用反射来访问该类的方法

垃圾收集算法

  1. 标记-清除算法
    • 简单直接
    • 效率不高
    • 产生大量的内存碎片
  2. 复制算法
    • 将内存分块,每次gc时将存活对象复制到另一块,一次清理已使用块。
    • 适合回收新生代,IBM研究表明98%的对象都是朝生夕死
  3. 标记-整理算法
    • 让存活对象向内存的一端移动,直接清除掉边界以外的内存
    • 避免了复制算法必须浪费一定空间或者需要额外空间做分配担保的缺点
    • 适合老年代

java堆分代简述(主要来自Google)

分代思想中,将内存对象分为新生代和老年代,以使用不同的gc机制。

新生代/老年代 大小默认比例为1/2(jvm参数为–XX:NewRatio)

新生代又被分为Eden、From Survivor、To Survivor,默认比例为8:1:1

这两个单词神级命名,伊甸园与幸存者,传神了。

在不考虑内存不够的情形下:

  • 新创建的对象实例会保存在Eden区
  • 在经历第一次gc后,没被回收的幸存者会进入Survivor区,并对age计数为1岁
  • 每次gc都会给Survivor中的对象加一岁
  • 当age达到指定岁数后,默认15,该对象会进入老年代

在Survivor不足时,需要依赖其他内存区域(老年代)进行分配担保(Handle Promotion)

HotSpot算法实现

  1. 枚举根节点

    由于Stop The World事情,如果每次从GC Roots节点遍历庞大的引用链,那么必然会消耗很多时间,所以必须优化gc roots的检查速度。

    在HotSpot虚拟机中,使用了一组称为OopMap的数据结构,在类加载完成时,HotSpot将对象内什么偏移量上是什么类型的数据计算出来,一遍gc扫描时,直接可以通过查找这个map快速得到引用信息。

  1. 安全点(SafePoint)

    安全点是为了解决一个问题:如果为每一条指令生成OopMap,那么需要消耗大量的额外空间,GC的空间成本将会很高。

    其实HotSpot记录了一些特定位置,称为安全点,程序执行时并非在所有地方都可能停下来GC,只有在到达安全点时才暂停。

    SafePoint通常是在指令序列复用的地方产生,如方法调用、循环跳转、异常跳转等。

    GC中断进程的方案主要有2种:

    • 抢先式中断(Preemptive Suspension)

    • 主动式中断(Voluntary Suspension)

      GC需要中断线程时,不直接对线程操作,之为他设置一个标志,线程执行时主动轮询这个标志,发现为真时主动中断挂起。

  2. 安全区域(Safe Region)

    Safe Region是为了解决SafePoint设计的一个问题:如果程序没有占用CPU时间,如线程处于Sleep或者Blocked状态,这时线程不可能响应JVM的中断请求。

    Safe Region: 在一段代码片段中,引用关系不会发生变化,在此区域的GC都是安全的。Safe Region是对SafePoint的一种扩展。

垃圾收集器

Collectors

  1. Serial收集器

    • 新生代收集器
    • 单线程收集器
    • 由于其简单直接,是Client模式下的默认收集器
  2. ParNew收集器

    • 新生代收集器
    • Serial的并行多线程版本
    • Server模式下的默认收集器
  3. Parallel Scavenge收集器

    目标是为了追求可控制的吞吐量,所以Parallel Scavenge收集器又称为“吞吐量优先”收集器

    吞吐量(Throughput):CPU用于运行用户代码与CPU总耗时的比值,吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

    提供了GC自适应的调节策略(GC Ergonomics):虚拟机会根据当前系统运行情况收集性能监控信息,动态调整参数:

    • 新生代大小-Xms
    • Eden与Survivor区比例-XX:SurvivorRatio
    • 晋升老年代对象的大小-XX:PretenureSizeThreshold

      以提供最合适的停顿时间或最大吞吐量。

  4. Serial Old收集器

    Serial收集器的老年代版本

  5. Parallel Old收集器

    Parallel Scavenge收集器的老年代版本

  6. CMS收集器

    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

    基于“标记-清除”算法,步骤:

    • 初始标记(CMS initial mark)
    • 并发标记(CMS concurrent mark)
    • 重新标记(CMS remark)
    • 并发清除(CMS concurrent sweep)
  7. G1收集器(Garbage-First)

    • 并行与并发
    • 分代收集
    • 空间整合
    • 可预测的停顿

      核心思路–“化整为零”:将java堆划分为多个大小相等的独立区域(Region),保留新生代与老年代的概念,但不再是物理隔离,它们都是一部分Region的集合。

      如何解决跨Region(分代回收中可达性分析必须扫描老年代的引用关系,因为有可能老年代引用指向了新生代对象)之间的对象引用关系问题:

      G1中每个Region都有一个Remembered Set。虚拟机发现程序对Reference类型数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference是否与之处于不同的Region。如果是,则通过CardTable将相关引用信息记录到被引用对象所属的Region的Remembered Set中。这样就无需全堆扫描地进行GC Roots枚举。

      回收步骤:

    • 初始标记(Initial Marking)
    • 并发标记(Concurrent Marking)
    • 最终标记(Final Marking)
    • 筛选回收(Live Data Counting and Evacuation)

内存分配策略

  1. 对象优先在Eden区分配

    对象在新生代Eden区分配。如果Eden区没有足够空间,则发起一次Minor GC。


    Minor GC: 新生代GC,Java对象大多朝生夕死,所以minor gc十分频繁,回收速度也较快。

    Major GC/Full GC: 老年代GC,速度慢。


  2. 大对象直接进入老年代

    大对象:需要大量连续内存空间的对象,典型的就是长字符串以及数组。

    还有种更坏的对象,叫做“朝生夕灭”的“短命大对象”,写程序时应当避免。

    /*
    注释,看不见(误)
    这个在犁书项目计算字符串在排版中的实际位置时总是出现,在方法本地栈区分配超大的字符串数组,然而这个对象会随着方法的执行完毕快速变成垃圾,替jvm抱怨一句:宝宝心里苦。
    /

  3. 长期存活的对象将进入老年代

    -XX:MaxTenuringThreshold设置对象晋升老年代的年龄阈值,默认15

  4. 动态对象年龄判定

    如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,那么年龄大于或等于该年龄的对象将直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。

  5. 空间分配担保

    发生Minor GC之前,jvm会检查老年代最大可用的连续空间是否大于新生代所有对象总和:

    • 如果大于,那么Minor GC肯定是安全的,因为老年代提供了充分的晋升空间。

    • 如果小于,那么jvm会查看HandlePromotionFailure的值是否允许冒险担保失败:

      • 如果允许,那么会检查老年代最大可用的连续空间是否大于以往晋升到老年代对象的平均大小:

        • 如果大于,尝试进行Minor GC
        • 如果小于,Full GC
      • 如果不允许担保失败,改为Full GC

      这里有两个精彩的比喻:

    • 担保

      Minor GC采用了复制手机算法后,如果Survivor空间的轮换备份不足以容纳新生代对象时,必须委托老年代空间为其担保

    • 冒险

      与生活中的贷款相似,如果老年代提供担保,那么老年代本身必须有容纳这些对象的剩余空间(手头有足够的空闲现金)。而这个风险,就是老年代的剩余空间大于以往的平均晋升大小,但是这一次的担保空间不足,只能进行一次Full GC来腾出更多空间。

      虽然担保失败时绕的圈子是很大的,但大部分情况,还是会允许冒险,避免Full GC过于频繁,而这基于了一个对动态概率取平均值大多数情况下有效的信心。