把学习当成一种习惯
选择往往大于努力,越努力越幸运

  • 由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现"新生代"、"老年代"、"Eden空间"、"From Survivor空间"、"To Survivor空间"等名词;堆内存的这些区域划分仅仅是一部分垃圾收集器(例如Serial、Serial Old、ParNew、CMS、Parallel Scavenge、Parallel Old等)的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局.
  • Serial、Serial Old、ParNew、CMS、Parallel Scavenge、Parallel Old等是基于"经典分代"来设计的不同区域的垃圾收集器,需要在新生代、老年代不同区域收集器搭配才能工作,例如 Serial + Serial Old 、PraNew + CMS 、Parallel Scavenge + Parallel Old 这三大经典组合.
  • 新生代收集器 : Serial、ParNew、Parallel Scavenge
  • 老年代收集器 : Serial Old、CMS、Parallel Old

前言

  JVM虚拟机所管理的最大的一块内存就是堆内存,且该内存区域是被共享的,唯一的作用就是存放实例对象,也是垃圾收集器主要管理的地方,故又称GC堆.

  • 【PS:Java世界里所有的对象实例"都"在这里分配内存,但随着Java语言的发展,Java对象实例都分配在堆上也渐渐变得不是那么绝对了】
  • 【PS:如今的内存动态分配与内存回收技术已经相当成熟,为什么我们还要去了解垃圾收集和内存分配 : 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对JVM提供的参数实施必要的监控和调节,保证系统的稳定性、可用性.】

堆内存

新生代和老年代

  • 基于"经典分代"的设计理论,堆内存划分为新生代和老年代两大区域 :
    • 新生代 : 绝大多数的对象都是朝生夕死,即每次GC后会存在大量垃圾对象,少量存活对象;
    • 老年代 : 熬过越多次垃圾收集过程的对象就越难以消亡,即每次GC后会存在大量存活对象,少量垃圾对象;
  • 新生代和老年代由于对象的存活率不同而采用了不同的回收算法(不同的收集器):
    • 新生代的内存区域的垃圾收集器是基于 标记-复制 算法的基础上进行了内存划分的优化,为了就是减少大部分内存浪费的缺点,即拆分为 Eden区、From Survivor、To Survivor三个区域,默认分配的占比为 Eden:S1:S2 = 8:1:1,即参数-XX:SurvivorRatio=8.
    • 老年代的内存区域的垃圾收集器是基于 标记-清除 或者 标记-整理 算法,具体的实现有Serial Old、CMS、Parallel Old等老年代的垃圾收集器.

标记-复制算法

  新生代大部分的对象都是垃圾对象,少部分是存活对象,如果有一种算法可以只针对少部分存活对象进行整理,那么可以提升垃圾回收效率,而标记-复制算法这种垃圾收集算法就可以做到,它将可用内存容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉,复制算法的优势就是实现简单、高效、没有内存碎片,但是当内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,还有一个明显的缺点,就是这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点,大大的减少了可用空间容量.
  新生代收集器 : Serial、ParNew、Parallel Scavenge等都是使用的标记-复制算法,并且通过设置两个Survivor区来解决空间浪费问题,即只有一个Survivor区来存放存活对象.每次分配内存只使用Eden和其中一块Survivor.发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间,这样即解决了空间浪费问题,也没有出现内存碎片问题.
  这里还存在一个问题 : 当Survivor空间不足以容纳一次Minor GC之后存活的对象时,如何处理:这些存活对象是直接进入老年代了.所以这个问题也是我们需要在实际中考虑、避免的.

标记-清除算法、标记-整理算法

  标记-清除算法分为"标记"和"清除"两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来;
  标记-清除算法有两个明显的缺点 : ①执行不稳定,即如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;②内存碎片,标记、清除之后会产生大量不连续的内存碎片,可能会导致一些大对象无法找到足够的连续存储空间而进行再一次的垃圾收集操作,甚至OOM.

  老年代大部分的对象都是存活对象,所以不是使用标记-复制算法来进行垃圾回收,而是使用标记-清除算法 或者 标记-整理算法来进行垃圾收集,标记-整理是在标记-清除算法的基础上进行优化的一种算法.
  标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,即回收后没进行整理,进而导致内存碎片,会影响创建对象时内存的分配和内存的访问效率,而后者是移动式的回收算法,即回收后会对存活对象进行整理,但是由于整理这些存活对象将会是一种延长"Stop The World"时间的操作.

  标记-清除算法需要考虑的是内存碎片导致的内存分配和访问耗时的增加,标记-整理算法需要考虑的是内存回收时会更加复杂;即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的.

  • Parallel Old收集器是比较关注吞吐量的,所以它采用的是标记-整理算法.
  • CMS是比较关注延迟的,所以它是采用的标记-清除算法,当然CMS可以暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再进行内存空间整理,以获得规整的内存空间,这种方式需要配置相关参数 :
    • -XX:+UseCMSCompactAtFullCollection: 默认打开,设置CMS收集器每次完成Full GC后会 "Stop The World",进行一次内存碎片整理;
    • -XX:CMSFulllGCsBeforeCompaction: 设置CMS收集器执行多少次Full GC会 "Stop The World",进行一次内存碎片整理,默认为0,即每次Full GC后都会进行一次内存碎片整理;
    • 这两个参数都必须开启CMS收集器才生效.

小结

  • 堆内存分为新生代和老年代;
  • 新生代的垃圾收集器是基于标记-复制算法的基础上进行了区域划分的优化,拆分为 Eden区、From Survivor、To Survivor 三大区域,相关参数-XX:Survivor-Ratio=8;
  • 新生代需要避免Minor GC后的存活对象的空间大小不够存入Survivor区进而导致直接进入老年代区域;
  • 老年代的垃圾收集器是基于标记-清除 或者 标记-整理 算法进行的垃圾回收;
  • 相关参数 :
    • -Xms1.5GB : 设置堆内存的最小值 , -Xmx1.5GB : 设置堆内存的最大值 ; 这两个参数设置为一样即可,避免堆内存的自动扩展;
    • -XX:+HeapDumpOnOutOf-MemoryError : 虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析;
    • -Xmn1GB : 分配1GB的内存给新生代,剩下的512MB分配给老年代;
    • -XX:+PrintGCDetails : 收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况.

内存分配和GC策略

优先Eden区分配内存

对象的创建在Eden区进行的分配内存,当Eden区不够内存时,将发生一次Minor GC.

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

  虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头的Mark Word,存活对象每次经过一次Minor GC后,年龄就会+1,当年龄增加到设置的阈值后就会晋升到老年代,相对应的参数为 : -XX:MaxTenuringThreshold , 默认15.

【PS: 长期存活的对象进入老年代也是实际中需要考虑的一个问题:减少大量的存活对象提前进入老年代,或者存活对象占用新生代,可提前进入老年代】

动态对象年龄判断

  动态对象年龄判断规则是基于Survivor区的,即当Survivor区的使用率达到了50%(默认值),那么就会触发动态对象年龄判断,即不要求Survivor的存活对象必须等到-XX:MaxTenuringThreshold才晋升到老年代,而是Survivor的存活对象 从 年龄1 + 年龄2 + 年龄2 + 年龄n 递增相加到总的占用空间大于等于 50%时, 那么 年龄n以上的对象将进入老年代,相关参数 : -XX:TargetSurvivorRatio,默认50%.

【PS: 动态对象年龄判断也是实际中需要考虑的一个问题:减少大量的存活对象进入老年代】

大对象直接进入老年代

  -XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作.

【PS:-XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效】

空间分配担保

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

  • 如果大于,那么此次的Minor GC是没有"风险",可以放心的进行Minor GC,因为我们假设最极端情况下Minor GC后全部的对象都是存活对象,也可以直接进入老年代(Survivor区域不够存);
  • 如果小于,那么此次的Minor GC是可能有"风险"的,那么需要判断有没有开启参数 -XX:HandlePromotionFailure (是否允许担保失败) :
  • 如果有开启,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小 :
    • 如果大于,那么会冒险尝试进行一次Minor GC,但是这次的Minor GC还是有风险的,假设Minor GC 后的存活对象占用空间大于Survivor,那么将直接进入老年代,如果老年代不够存,那么还是会触发一次Full GC,腾出更多的空间,Full GC后还是不够的话,那么就直接OOM了;
    • 如果小于,那么会先进行一次Full GC,腾出更多的空间,然后再进行Minor GC,Minor GC后的存活对象占用空间大于Survivor,那么将直接进入老年代,如果老年代不够存,那么直接OOM了;
  • 如果没有开启,那么会进行一次Full GC,腾出更多的空间,然后再进行Minor GC,Minor GC后的存活对象占用空间大于Survivor,那么将直接进入老年代,如果老年代不够存,那么直接OOM了.
  • 【PS: "风险": Minor GC后的存活对象空间大于Survivor空间而直接进入老年代,所以老年代会进行空间分配担保,决定是否进行Full GC来让老年代腾出更多空间】
  • JDK 6 Update 24之后,-XX:HandlePromotionFailure参数失效,即默认都是开启状态.

小结

  • 什么时候新生代的存活对象会进入老年代 :
    • MaxTenuringThreshold : 年龄达到 15岁(默认);
    • TargetSurvivorRatio : 动态对象年龄判断 : Survivor区的存活对象的年龄1 + 年龄2 + 年龄3 + ... 年龄n 占用空间大于等于 50%(默认)时,年龄n以上的对象会提前进入老年代,无需等到15岁;
    • SurvivorRatio : Minor GC后,存活对象大于Survivor,直接进入老年代;
    • PretenureSizeThreshold : 大对象直接进入老年代

老年代CMS的相关参数

较为经典的老年代垃圾收集器有 Serial Old、Parallel Old、CMS :

  • Serial Old : 是一个单线程收集器,使用的标记-整理算法,主要供客户端模式下使用,服务端模式下使用 Parallel Old 或者 CMS;
  • Parallel Old : 是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,提高收集效率,基于标记-整理算法; Parallel Scavenge + Parallel Old 是一组 "吞吐量优先"的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合下可优先考虑.
  • CMS : 是一种以获取最短回收停顿时间为目标的收集器,即减少系统的"Stop The World"的操作时间来降低延迟;支持多线程并发收集,基于标记-清除算法;CMS收集器就非常符合如B/S的服务端模式的系统,垃圾回收运行的过程更复杂,分为四个阶段 :
    • 初始标记(Stop The World) : 仅仅只是标记一下GCRoots能直接关联到的对象,速度很快;
    • 并发标记 : 从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
    • 重新标记(Stop The World) : 为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录;
    • 并发清理 : 清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的;
  • CMS的缺陷 :
    • 对CPU线程数资源比较敏感,在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低总吞吐量;CMS默认启动的回收线程数是(处理器核心数量+3)/4,当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大;
    • "浮动垃圾" : 在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,这些垃圾并不能被这次统一回收,只能下次垃圾收集时在清理,这部分垃圾就是"浮动垃圾";还有一种情况,由于回收线程与用户线程是并发执行的,所以需要预留一部分的空间给并发收集时的用户线程使用,参数-XX:CMSInitiatingOccu-pancyFraction的默认值就是92%(JDK6),即CMS并发收集时,预留了8%的内存空间供用户线程使用;如果CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次"并发失败"(Concurrent Mode Failure),那么CMS将启动预备的方案进行,即强行进入 "Stop The World",临时开启Serial Old收集器来重新进行老年代的垃圾收集;
    • 内存碎片 : CMS是基于标记-清除算法去进行垃圾收集的,那么就会导致内存碎片的问题,空间碎片过多,可能就会导致没有连续空间分配导致进行Full GC;CMS收集器提供了2个参数 :
      • -XX:+UseCMS-CompactAtFullCollection (默认开启,JDK9废除),即每次Full GC时都会进行一次内存碎片整理,该过程会进入"Stop The World"
      • -XX:CMSFullGCsBeforeCompaction (JDK9废除),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)后再执行一次内存碎片整理,默认为0,即每次进入Full GC时都进行碎片整理;

小结

  • 什么时候老年代会触发Full GC :
    • HandlePromotionFailure : 触发Minor GC前,基于老年代空间担保,如果老年代最大可用的连续空间小于历次晋升到老年代对象的平均大小,则触发Full GC;
    • 触发Minor GC 后,存活对象大于老年代的最大可用连续空间时;
    • CMSInitiatingOccu-pancyFraction : 使用CMS收集器时,当使用空间高于92%时,将会触发Full GC;

总结

  掌握好堆内存中新生代和老年代的区域划分、不同垃圾收集器使用的回收算法,什么时候触发Minor GC、Minor GC后什么样的触发条件会使新生代的存活对象进入老年代、什么时候老年代会触发Full GC等各种垃圾收集器的特点以及运作原理等,基于这些虚拟机内存知识,再根据自己系统的实际运行模型选择最优的垃圾收集器以及调节最优的参数来获得最好的性能.
  JVM调优没有固定的参数、垃圾收集器以及没有最优的调优方法,只有根据系统实际的运行模型去进行调优,一切脱离系统的实际运行模型去进行调优的说法都是耍流氓.

结束语

  • 原创不易
  • 希望看完这篇文章的你有所收获!

相关参考资料

  • 周志明先生著-《深入理解Java虚拟机》【书籍】

目录