JVM垃圾回收(GC)

2022-01-05 14:14:41 浏览数 (1)

  • 如何识别垃圾
  • 垃圾回收主要方法
  • 分代收集算法
  • 垃圾收集器
  • JVM参数
  • 测试

如何识别垃圾

引用计数法

对象被引用一次,在它的对象头上加一次引用次数,如果没有被引用(引用次数为 0),则此对象可回收

代码 ref 引用了右侧定义的对象,所以引用次数是 1

代码语言:javascript复制
String ref = new String("Java");

如果在上述代码后面添加一个 ref = null,则由于对象没被引用,引用次数置为 0,由于不被任何变量引用,此时即被回收。

引用计数法无法解决一个主要的问题就是循环引用。

代码语言:javascript复制
  // 第一步
  A a = new TestRC("a");
  B b = new TestRC("b");
  // 第二步
  a.instance = b;
  b.instance = a;
  // 第三步
  a = null;
  b = null;

虽然 a,b 都被置为 null 了,但是由于之前它们指向的对象互相指向了对方(引用计数都为 1),所以无法回收,也正是由于无法解决循环引用的问题,所以现代虚拟机都不用引用计数法来判断对象是否应该被回收。

标记算法/可达性算法

可达性算法的原理是以一系列叫做 GC Root 的对象为起点出发,引出它们指向的下一个节点,直到所有的结点都遍历完毕,串成一条引用链,如果对象不在任意一个引用链中,则这些对象会被判断为「垃圾」,会被 GC 回收。

哪些对象是gc root?

1、在虚拟机栈中引用的对象(栈帧中的本地变量表),即当前所有正在被调用的方法的引用类型的参数/局部变量/临时值。

代码语言:javascript复制
public class Test {
    public static  void main(String[] args) {
  Test a = new Test();
  // 原来指向的实例 new Test() 会被回收。
  a = null;
    }
}

2、在方法区中类静态属性引用的对象,例如java类的引用类型静态变量。

代码语言:javascript复制
public  class Test {
    public  static Test s;
    public static  void main(String[] args) {
  Test a = new Test();
  // 类静态属性引用s,指向的对象new Test()依然存活。
  a.s = new Test();
  // 原来指向的实例 new Test() 会被回收。
  a = null;
    }
}

3、在方法区中常量引用的对象,如String字符串常量池里的引用。

代码语言:javascript复制
public  class Test {
  // 常量s指向的对象不会因为 a指向的对象被回收而回收
  public  static  final Test s = new Test();
        public static void main(String[] args) {
      Test a = new Test();
      a = null;
        }
}

4、Java虚拟机内部的引用,如基本数据类型对应的class对象,一些异常对象(比如NPE,OOM)等,还有类加载器。

5、所有被synchronized锁住的对象引用。

6、本地方法栈中 JNI(Native 方法)引用的对象。

对象可回收,就一定会被回收吗?

对象的 finalize 方法给了对象一次垂死挣扎的机会,当对象不可达(可回收)时,当发生GC时,会先判断对象是否执行了 finalize 方法,如果未执行,则会先执行 finalize 方法,我们可以在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收!

注意: finalize 方法只会被执行一次,如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收!这一点切记!

对象引用与垃圾回收的关系

强引用

  • 最常见的对象:通过new关键字创建,通过GC Root能找到的对象。
  • 当所有的GC Root都不通过【强引用】引用该对象时,对象才能被垃圾回收

软引用

  • 仅有【软引用】引用该对象时,在垃圾回收后,内存仍不足时会再次发起垃圾回收,回收软引用对象
代码语言:javascript复制
创建一个软引用:SoftReference ref = new SoftReference<>(new Object());
  • 软引用被回收后,仍然还保留一个null,如将软引用加入集合,回收后遍历集合仍然还存在一个null
  • 解决:使用引用队列,软引用关联的对象被回收时,软引用自身会被加入到引用队列中,通过queue.poll()取得对象进行删除。
代码语言:javascript复制
创建一个而引用队列:ReferenceQueue queue = new ReferenceQueue<>();

创建加入了引用队列的软引用:SoftReference ref = new SoftReference<>(new Object(),queue);

弱引用

  • 仅有【弱引用】引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象。
  • 可以配合引用队列来释放弱引用自身。引用队列使用同软引用。
代码语言:javascript复制
创建一个弱引用:WeakReference ref = new WeakReference<>(new Object());

虚引用

必须配合引用队列使用,主要配合ByteBuffer使用,被引用对象回收时,会将【虚引用】入队,由Reference Hanler线程调用虚引用相关方法释放【直接内存】(unsafe类中方法)

终结器引用

无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用队列入队(引用对象暂未回收),再由Finalizer线程通过终结器引用找到被引用对象并调用他的finalize方法,第二次gc时回收被引用对象

垃圾回收主要方法

标记清除算法

步骤:

  • 先根据可达性算法标记出相应的可回收对象(图中黄色部分)
  • 对可回收的对象进行回收

优点:处理速度快。

缺点:造成空间不连续,产生内存碎片。

复制算法

步骤:

  • 分配同等大小的内存空间
  • 标记被GC Root引用的对象
  • 将引用的对象连续复制到另一块内存空间
  • 清除原来的内存空间

优点:空间连续,没有内存碎片。

缺点:存在空间浪费,有一半空间未使用。

标记整理法

  • 标记没有被GC Root引用的对象
  • 整理被引用的对象
  • 将所有的存活对象都往一端移动,紧邻排列
  • 再清理掉另一端的所有区域

优点:空间连续,没有内存碎片,空间利用率高。

缺点:整理导致效率较低。

分代收集算法

为什么要分代

1、分代回收可以对堆中对象采用不同的gc策略。

大部分的对象都很短命,都在很短的时间内都被回收了(98% 的对象都是朝生夕死的,经过一次 Minor GC 后就会被回收。

分代收集算法根据对象存活周期的不同将堆分成新生代和老生代,默认比例为 1 : 2,新生代又分为 Eden 区, from Survivor 区(简称S0),to Survivor 区(简称 S1),三者的比例为 8: 1 : 1。

根据新老生代的特点选择最合适的垃圾回收算法,我们把新生代发生的 GC 称为 Young GC(也叫 Minor GC),老年代发生的 GC 称为 Old GC(也称为 Full GC或者Major GC)。

2、分代以后,gc时进行可达性分析的范围能大大降低。

在分代回收中,新生代的规模比老年代小,回收频率也要高,显然新生代gc的时候不能去遍历老年代。

这时候只要把非收集部分指向收集部分的引用保存下来,加入gc roots,就可以避免在新生代gc时去对老年代进行可达性分析(再次注意老年代对象大量存活),能节省大量时间。

而如果不进行分代,由于老年代对象长期存活,所以总的gc频率应该和分代以后的young gc差不多,但是每次gc都需要从gc roots进行完整的堆遍历,无疑大大增加了开销。

分代垃圾回收流程

对象在新生代的分配与回收

对象首先分配在Eden伊甸园区域,大部分对象在很短的时间内都会被回收。

当 Eden 区将满时,触发 Young GC(也叫 Minor GC)。Minor GC会引发Stop the world(STW)现象,暂停其他用户的线程。垃圾回收结束后,用户线程才恢复运行。

存活对象使用复制算法移到 S0 区(from Survivor 区),同时对象年龄加一,再把 Eden 区对象全部清理以释放出空间。

当触发下一次 Minor GC 时,会把 Eden 区的存活对象和 S0(from Survivor 区) 中的存活对象复制到 S1(to Survivor 区),同时存活对象年龄 1,并清空 Eden 和 S0 的空间,再交换S1区和S0区。

新生代对象晋升老年代

1、当对象寿命超过阈值时,会晋升至老年代(默认15岁2^4,CMS收集器默认6岁),可以通过参数-XX:MaxTenuringThreshold来设置。

2、大对象会直接晋升到老年代。分配在 Eden 区,复制慢,占空间。JVM参数-XX:PretenureSizeThreshold可以设置大对象的大小(单位字节),如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。

3、动态对象年龄判断。大于设置的动态年龄阈值的对象都会进入老年代,从1岁 2岁 … n岁对象大小累加,大于survior区50%,以n岁作为阈值,大于等于这个年龄的对象都会进入老年代。通过参数设置:-XX:TargetSurvivorRatio=50

空间分配担保

是否设置空间分配担保的JVM参数:-XX:HandlePromotionFailure

在发生 MinorGC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果老年代最大可用连续空间大于新生代总对象空间,那么Minor GC 可以确保是安全的。
  • 如果不大于,那么虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。
  • 如果允许担保失败,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小。
  • 如果老年代最大可用连续空间大于历代晋升对象,则进行 Minor GC,否则进行一次 Full GC。

Full GC && Stop The World

当存放大对象新生代和老年代都放不下时,抛出OOM异常。

当老年代空间不足,会先尝试触发Minor GC,如果之后空间仍不足,会触发Full GC。

Full GC 会同时回收新生代和老年代(老年代使用标签清除或标记整理算法),它会导致 Stop The World(简称 STW)。

什么是 STW ?所谓的 STW, 即在 GC(minor GC 或 Full GC)期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。

GC会选在 Safe Point进行,一般在循环的末尾、方法返回前、调用方法的 call 之后、抛出异常的位置。

Eden, S0,S1设置为8:1:1,新生代与老年代默认设置成 1:2 ,都是为了尽可能地避免对象过早地进入老年代,尽可能晚地触发 Full GC。

垃圾收集器

  • 在新生代工作的垃圾回收器:Serial, ParNew, ParallelScavenge
  • 在老年代工作的垃圾回收器:CMS,Serial Old, Parallel Old
  • 同时在新老生代工作的垃圾回收器:G1

存在连线的垃圾收集器,代表它们之间可以配合使用。

新生代收集器

Serial 串行收集器

  • 命令:-XX: UseSerialGC
  • 特点:复制算法,Client模式下默认的年轻代收集器。

Serial串行收集器是单线程的垃圾收集器,它在进行垃圾收集时,其他用户线程会暂停,直到垃圾收集结束。

在Client 模式下,它简单有效。对于单CPU 环境来说,Serial 单线程模式无需与其他线程交互,内存不大时,STW 时间可以控制在毫秒级别。因此对于Client 模式下的虚拟机,Serial 收集器是新生代的默认收集器。

ParNew并行收集器(CMS)

  • 命令:-XX:UseParNewGC,设定并行垃圾收集的线程数量(默认开启的线程数等于cpu数)-XX:ParalletGCThreads
  • 特点:Serial 的多线程版,使用复制算法。并行收集,尽可能缩短单次GC的停顿时间。

ParNew(Parallel New) 收集器是 Serial 收集器的多线程版本,多核环境较Serial效率高,除了使用多线程,其他的收集算法、对象分配规则、回收策略与 Serial 收集器完成一样。

ParNew 主要工作在 Server 模式,多线程能让垃圾回收得更快,减少了 STW 时间,也是许多运行在 Server 模式下的虚拟机的首选新生代收集器。

除了 Serial 收集器,只有ParNew能与 CMS 收集器配合工作,他们底层共用一套代码框架。CMS 是真正意义上的并发收集器,它第一次实现了垃圾收集线程与用户线程(基本上)同时工作。

Parallel Scavenge 自适应并行收集器

  • 命令:-XX: UseParallelGC
  • 特点:复制算法,自适应策略控制吞吐量,Server模式下默认的年轻代垃圾回收器。

自适应策略是 Parallel Scavenge 与 ParNew 的重要区别。

Parallel Scavenge 收集器提供了两个参数来精确控制吞吐量:

  • 最大垃圾收集时间(单位毫秒)-XX:MaxGCPauseMillis
  • 吞吐量占比(默认99%)-XX:GCTimeRatio。GC最大花费时间的比率=1/(1 99)=1%,程序每运行100分钟,允许GC停顿共1分钟,其吞吐量=1-GC最大花费时间比率=99%
  • 开启-XX:UseAdaptiveSizePolicy,就不需要设置-Xmn、-XX:SurvivorRatio,虚拟机就会根据系统运行情况动态调整参数,以达到设定的垃圾收集时间或吞吐量指标。

它跟ParNew的关注点不同,CMS 、ParNew垃圾收集器关注的是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 目标是达到可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间 垃圾收集时间))。也就是说 ParNew 等垃圾收集器更适合用到与用户交互的程序,因为停顿时间越短,用户体验越好,而 Parallel Scavenge 收集器关注的是吞吐量,所以更适合做后台运算等不需要太多用户交互的任务。

老年代收集器

Serial Old 串行收集器

  • 命令:-XX: UseSerialOldGC
  • 特点:标记-整理算法,Client模式下默认的老年代收集器。

上文我们知道, Serial 收集器是工作于新生代的单线程收集器,与之相对地,Serial Old 是工作于老年代的单线程收集器,此收集器的主要意义在于给 Client 模式下的虚拟机使用。

如果在 Server 模式下,则它还有两大用途:

  • 一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge 配合使用。
  • 另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Serial Old与 Serial 收集器配合使用示意图如下

Parallel Old 并行收集器(吞吐量)

  • 命令:-XX: UseParallelOldGC
  • 特点:使用标记整理算法,多线程并行执行,配合Parallel Scavenge实现吞吐量优先。

Parallel Old 是相对于 Parallel Scavenge 收集器的老年代版本,使用多线程和标记整理法,两者的组合由于都是多线程收集器,真正实现了「吞吐量优先」的目标。

CMS并发标记清除收集器(停顿时间)

  • 命令:-XX: UseConcMarkSweepGC
  • 老年代占用%空间后回收-XX:CMSInitiatingOccupancyFraction
  • 开启碎片整理XX: UseCMSCompactAtFullCollection。多少次full gc之后整理-XX:CMSFullGCsBeforeCompation
  • 特点:标记清除算法,与用户线程并发交替执行,响应时间优先,目标实现最短STW停顿时间。

并发与并⾏的概念

  • 并⾏(Parallel):指多条垃圾收集线程并⾏⼯作,但此时⽤户线程仍然处于等待状态,如Parallel Old 收集器。
  • 并发(Concurrent):指⽤户线程与垃圾收集线程同时执⾏(但不⼀定是并⾏的,可能 会交替执⾏),⽤户程序在继续运⾏,⽽垃圾收集程序运⾏于另⼀个 CPU 上,如CMS收集器。

CMS (Concurrent Mark Sweep) 收集器是以实现最短 STW 时间为目标的收集器,采用的是标记清除法,主要有以下四个步骤:

  • 初始标记(STW):仅标记 GC Roots 能关联的对象,速度很快。
  • 并发标记:进行 GC Roots Tracing 的过程,跟用户线程并发执行。
  • 重新标记(STW):修正并发标记期间,用户线程运行导致的标记变动。
  • 并发清除:清除垃圾对象,不会阻塞用户线程。

初始标记和重新标记两个阶段会发生 STW(造成用户线程挂起)。重新标记一般比初始标记耗时长,但远比并发标记时间短。

整个过程中耗时最长的是并发标记和标记清理,不过这两个阶段用户线程都可工作,所以不影响应用的正常使用。

但是官方一直没有把 CMS 设为默认收集器,甚至在JDK14以后废弃使用,主要有以下三个缺点:

1、CMS 收集器对 CPU 资源敏感,垃圾回收占用的线程会导致吞吐量下降。

CMS 默认启动的回收线程数是 (CPU数量 3)/ 4。如果有 10 个用户线程处理请求,GC时就需要分出 3 个作为回收线程,吞吐量下降了30%。如果只有一两个线程,那吞吐量将直接下降 50%。

2、CMS 无法处理并发清理阶段产生的浮动垃圾(Floating Garbage)。并发清理阶段空间不足可能出现 「Concurrent Mode Failure」而导致另一次 Full GC 的产生。

由于在并发清理阶段用户线程还在运行,所以清理的同时新的垃圾也在不断出现,这部分垃圾只能在下一次 GC 时再清理掉(即浮动垃圾)。

同时在垃圾收集阶段用户线程也要继续运行,就需要预留足够多的空间要确保用户线程正常执行,这就意味着 CMS 收集器不能像其他收集器一样等老年代满了再使用。

可以通过 -XX:CMSInitiatingOccupancyFraction 来设置老年代使用了多少%空间后进行垃圾回收。但是如果设置地太高很容易导致在 CMS 运行期间预留的内存无法满足程序要求,会导致 Concurrent Mode Failure 失败,这时会启用 Serial Old 单线程收集器来重新进行老年代的收集。

3、CMS 采用的是标记清除法,会产生大量的内存碎片,给大内存分配带来麻烦。

如果无法找到足够大的连续空间来分配对象,将会触发 Full GC,这会影响应用的性能。当然我们可以开启-XX: UseCMSCompactAtFullCollection(默认是开启的),用于在 CMS 收集器进行 FullGC 时开启内存碎片的合并整理过程。还可以配合另一个参数-XX:CMSFullGCsBeforeCompation用来设置执行多少次full gc之后进行空间整理,默认是0次即每次都整理。

G1(Garbage First) 收集器

特点
  • 命令:-XX: UseG1GC
  • 指定期望停顿时间-XX:MaxGCPauseMillis
  • 特点:将Java堆划分为多个大小相等的不连续区域(Region)。整体上是标记整理,区域之间是复制算法,注重吞吐量和低延迟。

G1 收集器是面向服务端的垃圾收集器, Java8中Parallel GC是默认的垃圾收集器,在Java 9已经将G1作为默认的垃圾收集器,G1主要有以下几个特点:

  • 并行:与CMS 一样,能与应用程序线程并发执行。
  • 跟CMS相比:避免牺牲大量的吞吐性能,不需要更大的 Java Heap。
  • 空间整理没有内存碎片:G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,收集后提供规整的可用内存,有利于程序的长时间运行。
  • 可预测的停顿:在 STW 上建立了可预测的停顿时间模型,通过参数-XX:MaxGCPauseMillis指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。
空间分配

为什么G1能建立可预测的停顿模型呢,主要原因在于 G1 对堆空间的分配与传统的垃圾收集器不一器,传统的内存分配是连续的,分成新生代,老年代,新生代又分 Eden、S0、S1。而 G1 各代的存储地址不是连续的,每一代都使用了 n 个不连续的大小相同的 Region,每个Region占有一块连续的虚拟内存地址。

Region还多了一个H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象,这样超大对象就直接分配到了老年代,防止了反复拷贝移动。

传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 之后,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。

G1将Java堆划分为多个大小相等的不连续区域(Region),JVM目标是不超过2048个Region(JVM源码里TARGET_REGION_NUMBER 定义),实际可以超过该值,但是不推荐。

一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数-XX:G1HeapRegionSize

步骤

G1 收集器的整体过程与 CMS 收集器非常类似,筛选阶段会根据各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划。

工作步骤如下:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

老年代内存不足,达到阈值时进入并发标记和混合收集阶段

  • 如果回收速度>新产生垃圾的速度 :并发垃圾收集
  • 如果回收速度<新产生垃圾的速度:串行的full GC

JVM参数

  • -Xms堆内存最小值(超过初始值会扩容到最大值),minimum memory size for pile and heap
  • -Xmx堆内存最大值(通常初始值和最大值一样,因为扩容会导致内存抖动,影响程序运行稳定性),maximum memory size for pile and heap
  • -Xmn堆新生代的大小,memory size for new generation heap
  • -XX:NewRatio指定堆中的老年代和新生代的大小比例, 不过使用CMS收集器的时候这个参数会失效。
  • -XX:SurvivorRatio指定Eden 与 Survivor 比例,默认8,即8:1:1。

  • -Xss设置线程栈的大小(影响并发线程数大小),memory size for stack

元空间

  • -XX:MeatspaceSize-XX:MaxMetaspaceSize,设置方法区的初始大小和最大值,替代了JDK8之前的参数-XX:PermSize-XX:MaxPermSize

测试

栈溢出StackOverflowError

设置启动是栈大小,递归调用模拟频繁出入栈。

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

设置3岁后进入老年代,JVM参数设置如下: -Xmx300m -Xms300m -Xmn100m -XX: PrintGCDetails -XX: UseSerialGC -XX:MaxTenuringThreshold=3 -XX: PrintGCDateStamps

第一次进行Ygc,age0->1,新生代的部分对象被放到from survivor区,此时老年代used 为0K。

第二次GC完,age1->2。

第三次GC完,age2->3。

第四次GC的时候,检测到from区的对象age到达了3,搬到老年代。

对象动态年龄判断

超过了Survivor区域的30%会进入老年代,JVM参数设置:-Xmx300m -Xms300m -Xmn100m -XX: PrintGCDetails -XX: UseSerialGC -XX:MaxTenuringThreshold=3 -XX: PrintGCDateStamps -XX:TargetSurvivorRatio=30

在eden区GC移动到from区的对象,在两次GC之后 ,s区的容纳了30%,触发了动态年龄判断,直接存入老年代,会导致old区更快触发Full GC

大对象直接进入老年代

设置JVM参数:-Xmx300m -Xms300m -Xmn100m -XX: PrintGCDetails -XX:PretenureSizeThreshold=1000000 -XX: UseSerialGC

下图左边没有加-XX:PretenureSizeThreshold=1000000(单位是字节) 这个参数的表现。

G1,CMS及ParallelOld对比

JVM配置-Xms256m -Xmx768m -XX:MaxPermSize=256m,总的运行时间是30分钟。使用了三种不同的GC算法:-XX: UseParallelOldGC-XX: UseConcMarkSweepGC-XX: UseG1GC。根据远离强行模拟的结果如下:

Parallel Old

CMS

G1

Total GC pauses(总耗时/吞吐量)

14 870

32 000

20 930

Max GC pause(单次耗时/停顿时间)

721

50

64

首先来看Parallel Old (-XX: UseParallelOldGC)。测试中GC总耗时15秒,最长的延迟时间721毫秒。总的运行时间来看,GC周期减少了0.8%的吞吐量。

下一个CMS(-XX: UseConcMarkSweepGC)。测试中GC总耗时32秒,相比并行模式更长,吞吐量下降了1.7%,不过最长延迟时间只有50毫秒。

最后G1(-XX: UseG1GC)。测试结果的吞吐量减少了1.1%,最长的延迟时间64ms。相比之下是一种兼顾吞吐量和停顿时间的 GC 实现。

参考:

看完这篇垃圾回收,和面试官扯皮没问题了:https://mp.weixin.qq.com/s/UwrSOx4enEX9iNmD4q_dXg

JVM内存结构和Java内存模型别再傻傻分不清了:https://blog.csdn.net/qq_41170102/article/details/104650162

gc roots有哪些呢?:https://www.zhihu.com/question/50381439

java的gc为什么要分代?:https://www.zhihu.com/question/53613423

G1,CMS及PARALLEL GC的比较:http://it.deepinmind.com/gc/2014/05/01/g1-vs-cms-vs-parallel-gc.html

0 人点赞