JVM GC学习整理

猪小花1号2018-09-05 09:35

作者:廖祥俐


Java程序员在编写程序的时候通常是不需要考虑太多的内存分配与回收问题,而是把这一内存分配与回收过程交给JVM进行处理。JVM主要管理两种类型内存:堆(Heap)和非堆(Permanent区域),其中Heap是运行时数据区域,所有类实例和数组的内存均从此处分配,JVM的GC也主要是针对这一块内存;Permanent主要是存储的是java的类信息,包括解析得到的方法、属性、字段等等,永久带基本不参与垃圾回收。

JVM分代的基本假设

JVM的GC算法,都是通过将堆内存区进行分代,通过分代回收以提高GC的回收效率,将堆内存(可分配内存)分为新生代(Young Generation,年轻代)与旧生代(Old Generation,年老代)

对于新生代的垃圾回收成为Young GC,或者叫Minor GC(在Eden区内存不足时触发,注意:不是新生代空间不足时触发),而对旧生代的回收则成为Major GC,也叫 Full GC(在旧生代空间不足时触发)。将生命期短的对象放在一起,将少数生命期长的对象放在一起,分别采用不同的回收策略。

内存分代分配与回收的基本假设是:

绝大部分对象的生命周期都非常短暂,存活时间短。

也就是说,理想情况下,Young GC应该完成了99%以上(甚至更多)的垃圾回收。


对象的分配方式:

1,绝大部分的对象直接在Eden区分配

  • 直接加锁抢占Eden区,并进行内存分配

  • bump-the-pointer策略:JVM内部维护一个指针(allocatedTail),它始终指向先前已分配对象的尾部,当新的对象分配请求到来时,只需检查代中剩余空间(从allocatedTail到代尾geneTail)是否足以容纳该对象,并在“是”的情况下更新allocatedTail指针并初始化对象

  • TLAB:对于多线程而言,将Eden区分为若干段,每个线程使用独立的一段,避免相互影响(不用加锁即可拿到内存,但是对象大小有限制)

2,对于大对象,如果Eden区不能够分配,会直接在Old generation(旧生代)中创建(应该尽量避免

关于Young GC(回收区域/回收策略)

在介绍Young GC之前,首先要介绍一下新生代(Young generation)的内存结构,如下图所示:

新生代被分为三个部分:Eden区,From区,To区。其中From与To经常进行互换,用于存放通过Young GC而没有被回收的对象,这两个区又成为Survivor区。

  • 绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;

  • 最初一次,当Eden区满的时候,执行Young GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区From(此时,To是空白的,两个Survivor总有一个是空白的);

  • 下次Eden区满了,再执行一次Young GC,将消亡的对象清理掉,将存活的对象复制到From中,然后清空Eden区;

  • 如果From区也满了,则在下次Young GC的时候将仍然存活的对象复制到To区(此时From与To角色互换),并清空From区

  • 当两个存活区切换了几次之后,仍然存活的对象,将被复制到老年代(这里会有个计数过程,超过设定计数的对象才会被复制到老年代)。


新生代的垃圾收集器主要有:

  • Serial收集器: Serial收集器是在client模式下默认的新生代收集器,其收集效率大约是100M左右的内存需要几十到100多毫秒;在client模式下,收集桌面应用的内存垃圾,基本上不影响用户体验。所以,一般的Java桌面应用中,直接使用Serial收集器(不需要配置参数,用默认即可)。

  • ParNew收集器:Serial收集器的多线程版本,这种收集器默认开通的线程数与CPU数量相同,-XX:ParallelGCThreads可以用来设置开通的线程数。 可以与CMS收集器配合使用,事实上用-XX:+UseConcMarkSweepGC选择使用CMS收集器时,默认使用的就是ParNew收集器,所以不需要额外设置-XX:+UseParNewGC,设置了也不会冲突,因为会将ParNew+Serial Old作为一个备选方案; 如果单独使用-XX:+UseParNewGC参数,则选择的是ParNew+Serial Old收集器组合收集器。 一般情况下,在server模式下,如果选择CMS收集器,则优先选择ParNew收集器。

  • Parallel Scavenge收集器:关注的是吞吐量(关于吞吐量的含义见上一篇博客),可以这么理解,关注吞吐量,意味着强调任务更快的完成,而如CMS等关注停顿时间短的收集器,强调的是用户交互体验。 在需要关注吞吐量的场合,比如数据运算服务器等,就可以使用Parallel Scavenge收集器。


关于FGC(回收区域/回收策略)

对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次Young GC后存活了下来),则会被复制到年老代,年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的GC次数也比年轻代少。当年老代内存不足时,将执行Major GC,也叫 Full GC。

年老代的垃圾收集器有:

  • Serial Old收集器:在1.5版本及以前可以与 Parallel Scavenge结合使用(事实上,也是当时Parallel Scavenge唯一能用的版本),另外就是在使用CMS收集器时的备用方案,发生 Concurrent Mode Failure时使用,如果是单独使用,Serial Old一般用在client模式中。

  • Parallel Old收集器:在1.6版本之后,与 Parallel Scavenge结合使用,以更好的贯彻吞吐量优先的思想,如果是关注吞吐量的服务器,建议使用Parallel Scavenge + Parallel Old 收集器。

  • CMS收集器:这是当前阶段使用很广的一种收集器,国内很多大的互联网公司线上服务器都使用这种垃圾收集器,CMS收集器以获取最短回收停顿时间为目标,非常适合对用户响应比较高的B/S架构服务器。

  • CMSIncrementalMode: CMS收集器变种,属增量式垃圾收集器,在并发标记和并发清理时交替运行垃圾收集器和用户线程。

  • G1 收集器:面向服务器端应用的垃圾收集器,计划未来替代CMS收集器。


垃圾收集器搭配(图来源于《深入理解Java虚拟机:JVM高级特效与最佳实现》,图中两个收集器之间有连线,说明它们可以配合使用)


GC日志

Young GC日志

[GC    [PSYoungGen: 142816K->10752K(142848K)] 246648K->243136K(375296K), 0.0935090 secs] [Times: user=0.55 sys=0.10, real=0.09 secs]

这是一次在young generation中的GC

  • 它将已使用的堆空间从246648K减少到了243136K,用时0.0935090秒。

  • 所使用的垃圾收集器(即PSYoungGen)

  • young generation的大小和使用情况(在这个例子中“PSYoungGen”垃圾收集器将young generation所使用的堆空间从142816K减少到10752K)

  • 从堆空间的变化可以看出,本次Young GC清空的内存并没有显著让堆空间变小,说明很多在Young generation的对象被移到了Old generation中

  • 详细日志的“Times”部分包含了GC所使用的CPU时间信息,分别为操作系统的用户空间和系统空间所使用的时间。

Full GC日志

[Full GC [PSYoungGen: 10752K->9707K(142848K)] [ParOldGen: 232384K->232244K(485888K)] 243136K->241951K(628736K) [PSPermGen: 3162K->3161K(21504K)], 1.5265450 secs]

这是一次在Old generation中的GC

  • 对Young generation使用的垃圾收集器(即PSYoungGen),Young generation空间从10752K到9707K

  • 对Old generation使用的垃圾收集器(即ParOldGen),Old generation空间从232384K到232244K,堆空间从243136K到241951K

  • 堆总空间 628736K = Young generation总空间(142848K)+Old generation总空间(485888K)

  • 对永久代使用的垃圾收集器(即PSPermGen)

  • Full GC持续了大约1.53秒


JVM的一些默认参数

具体参考Java 6 JVM参数选项大全(中文版)


参数及其默认值 描述
-XX:NewSize=2.125m 新生代对象生成时占用内存的默认值
-XX:MaxNewSize=size 新生成对象能占用内存的最大值
-XX:MaxPermSize=64m 方法区所能占用的最大内存(非堆内存)
-XX:PermSize=64m 方法区分配的初始内存
-XX:MaxTenuringThreshold=15 对象在新生代存活区切换的次数(坚持过MinorGC的次数,每坚持过一次,该值就增加1),大于该值会进入老年代
-XX:MaxHeapFreeRatio=70 GC后java堆中空闲量占的最大比例,大于该值,则堆内存会减少
-XX:MinHeapFreeRatio=40 GC后java堆中空闲量占的最小比例,小于该值,则堆内存会增加
-XX:NewRatio=2 新生代内存容量与老生代内存容量的比例
-XX:ReservedCodeCacheSize= 32m 保留代码占用的内存容量
-XX:ThreadStackSize=512 设置线程栈大小,若为0则使用系统默认值
-XX:LargePageSizeInBytes=4m 设置用于Java堆的大页面尺寸
-XX:PretenureSizeThreshold= size 大于该值的对象直接晋升入老年代(这种对象少用为好)
-XX:SurvivorRatio=8 Eden区域Survivor区的容量比值,如默认值为8,代表Eden:Survivor1:Survivor2=8:1:1
-XX:TargetSurvivorRatio=50 一个计算期望存活大小Desired survivor size的参数.

计算公式: (survivor_capacity TargetSurvivorRatio) / 100 sizeof(a pointer):survivor_capacity(一个survivor space的大小)乘以TargetSurvivorRatio, 表明所有age的survivor space对象的大小如果超过Desired survivor size,则重新计算threshold,以age和MaxTenuringThreshold的最小值为准,否则以MaxTenuringThreshold为准.


调优的目的

对JVM内存的系统级的调优主要的目的是减少GC的频率和Full GC的次数,过多的GC和Full GC是会占用很多的系统资源(主要是CPU),影响系统的吞吐量。特别要关注Full GC,因为它会对整个堆进行整理,所以优化的目的主要有以下两个:

  • 将转移到老年代的对象数量降低到最小;
  • 减少full GC的执行时间;


为了达到上面的目的,一般地,需要做的事情有:

  • 减少使用全局变量和大对象(大对象避免直接分配到老年代);
  • 调整新生代的大小到最合适;
  • 设置老年代的大小为最合适;
  • 选择合适的GC收集器;


这里具体说一下关于新生代、老年代大小调节可能带来的风险:

1,调大新生代风险:

  • 年老代变小,如果有较多存活对象,更容易导致Full GC
  • Young GC需要回收的区域更大 ,Young GC的时间可能更长


2,调大老年代的风险:

  • 新生代区域变小,大对象容易直接在老年代中分配
  • 一次老年代的Full GC的时间更长


编程角度需要注意的地方

1,尽可能缩小对象的作用域,即生命周期。

  • 如果可以在方法内声明的局部变量,就不要声明为实例变量。
  • 除非你的对象是单例的或不变的,否则尽可能少地声明static变量。


2,利用线程TLAB分配对象的特点,尽可能的将大对象拆分成小对象

JVM在内存新生代Eden Space中开辟了一小块线程私有的区域,称作TLAB(Thread-local allocation buffer)。默认设定为占用Eden Space的1%。在Java程序中很多对象都是小对象且用过即丢,它们不存在线程共享也适合被快速GC,所以对于小对象通常JVM会优先分配在TLAB上,并且TLAB上的分配由于是线程私有所以没有锁开销。因此在实践中分配多个小对象的效率通常比分配一个大对象的效率要高。

也就是说,Java中每个线程都会有自己的缓冲区称作TLAB(Thread-local allocation buffer),每个TLAB都只有一个线程可以操作,TLAB结合bump-the-pointer技术可以实现快速的对象分配,而不需要任何的锁进行同步,也就是说,在对象分配的时候不用锁住整个堆,而只需要在自己的缓冲区分配即可。


TLAB分配具体分析

1,JVM参数设定


参数设置 含义 默认值
XX:TLABWasteTargetPercent TLAB剩余空间占eden区的百分比 1%
-XX:+UseTLAB 开启TLAB分配 默认启动
-XX:+PrintTLAB 打印TLAB相关分配信息
-XX:TLABSize 设置TLAB大小
-XX:+ResizeTLAB 自动调整TLAB大小 默认启动


2,TLABWasteTargetPercent 默认TLAB占eden区的百分比1%的含义

  • 在gc的时候,已经分配给TLAB,还没有使用的内存,不应该超过eden的1%


3,一个线程一次拿到的分配空间是多少呢?简单的公式

一个线程分配得到的TLAB=(总共有的空间)*(每次分配的比例)/当前线程数

总共有的空间默认是Eden区的总大小,当前线程数:

hotspot在每次gc前,统计当前线程中从上次gc以来,曾经使用过TLAB的线程数,用这个线程数和过去得到的历史值进行一个加权平均,即(100.0 - weight) 历史平均值 / 100.0 + weight 当前采样值 / 100.0。公式中的weight由TLABAllocationWeight参数决定,如果不设置的话,缺省是35。

以上两个确定了,那么每次分配的比例如何确定呢?

参考这篇英文The Real Thing,从概率上说,GC可能在任何时候发生,即对于某一时刻,GC与否的概率是一半一半,即0.5。则如果对TLABWasteTargetPercent 按照默认设定,说明浪费的的内存不超过1%,那么在0.5的概率是浪费的情况下,其分配的比例是多少,才让浪费的期望值小于1%?答案是2%的比例。

默认的情况下,一个线程分配得到的TLAB=(Eden区大小)*2//当前线程数(默认35)


参考资料

面向GC的Java编程

Java系列笔记(3) - Java 内存区域和GC机制

对象都是在堆上分配的吗?

Java中的逃逸分析和TLAB以及Java对象分配



网易云大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者廖祥俐授权发布