Java 9中的GC调优基础
《Java 9中的GC调优基础》要点: 在经过了几次跳票之后,Java 9终于在原计划日期的整整一年之后发布了正式版.Java 9引入了很多新的特性,除了闪瞎眼的Module System和REPL,最重要的变化我认为是默认GC(Garbage Collector)修改为新一代更复杂、更全面、性能更好的G1(Garbage-First).JDK的维护者在GC选择上一直是比较保守的,G1从JDK 1.6时代就开始进入开发者的视野,直到本日正式成为Hotspot的默认GC,也是走了很长的路. 本文将主要讲解GC调优需要知道的一些基础知识,会涉及到一些GC的实现细节,但不会对实现细节做很全面的阐述,如果你看完本文之后,能对GC有一个大致的认识,那本文的写作目的也就达到了.由于在这次写作过程中,恰逢Java 9正式版发布,之前都是依赖Java 8的文档写的,如果有不正确的地方还望指正.本文将包括以下内容:
一、GC的作用范围 要谈GC的作用范围,首先要谈JVM的内存结构,JVM内存中主要有以下几个区域:堆、办法区(JVM规范中的叫法,Hotspot大致对应的是Metaspace)、栈、本地办法栈、PC等,其中GC主要作用在堆上,如下图所示: JVM内存布局 其中堆和办法区是所有线程共享的,其他则为线程独有,HotSpot JVM使用基于分代的垃圾回收机制,所以在堆上又分为几个不同的区域(在G1中,各年龄代不再是连续的一整片内存,为了描述方便,这里还使用传统的表示办法),具体如下图所示: JVM堆中的分区 二、GC负责的事情 GC的发展是随着JDK(Standard Edition)的发展一步步发展起来的,垃圾回收(Garbage Collection)可以说是JDK里最影响性能的行为了.GC做的事情,说白了便是「通过对内存进行管理,以保障在内存足够的时候,程序可以正常的使用内存」.具体而言,GC通常做的事情有以下3个: 1. 分配对象和对象的年龄管理 通常而言,GC需要管理「在上图中的年轻代(Young)分配对象,然后通过一系列的年龄管理,将之销毁或晋升到老年代(Tenured)中去」的过程.这个过程会随同着若干次的Minor GC. 对于普通的对象而言,分配内存是一件很简单并且快速的事情.在对象还未创建时,其所占内存大小通过类的元数据就可以确定,而Eden区域的内存可以认为是连续的,所以给对象分配内存要做的只是在上图中Eden区域中把指针移动相应的长度,并将地址返回给对象的引用即可.当然实际的过程比这个复杂,在下文中会提到. 不外,有时候一个对象会直接在老年代中创建,这个点也会在后边提到. 2. 在老年代中进行标志 老年代的GC算法可以大致是认为是一个标记-整理(Mark-Compact,其实是混合了标记-清理,标记-复制和标记-整理)算法,所以老年代的垃圾清理首先要做的就是在老年代对存活的对象(可达性分析,关于不同的可达性可以参考JDK解构 - Java中的引用和动态代理的实现)进行标记,对于寻求大吞吐量的服务器应用来说,这个过程往往必要是并发的. 标志的过程发生在Major GC被触发之后,不同的GC对于MajorGC的触发条件和标志过程的实现也不尽相同. 3. 在老年代中进行压缩 在上一条的基础上,将还存活的对象进行压缩(CMS和G1的行为与此有些不同之处),压缩的过程就是将存活的对象从老年代的起点进行挨个复制,使得老年代维持在一片连续的内存中,消除内存碎片,对于内存分配速度的提升会有很大的赞助. 三、GC的种类 Hotspot会根据宿主机的硬件特性和操作系统类型,将之分为客户端型(client-class)或者服务器型(server-class),如果是服务器型主机,Java 9之前默认使用Parallel GC,Java 9中默认使用G1.对于服务器型主机的选择尺度是「CPU核心数大于1,内存大于2GB」,所以现在大部分的主机都可以认为是服务器型主机. 这里讨论的所有GC都是基于分代垃圾回收算法的. 1. Serail Serail是最早的一款GC,它只使用一个线程来做所有的Minor和Major垃圾回收.它在运行时,其他所有的事情都会暂停.其工作方式十分简单,在需要GC的平安点,它会停止所有其他线程(Stop-The-World),对年轻代进行标记-复制,或对老年代进行标记-整理. 可以使用JVM参数 看起来Serial古老而简陋,但在宿主机资源紧张或者JVM堆很小的情况下(好比堆内存大小只有不到100M),Serial反而可以达到更好的效果,因为其他并发或并行GC都是基于多线程的,会带来额外的线程切换和线程间通信的开销. 2. Parallel/Throughput Parallel在Java 9之前是服务器型宿主机中JVM的默认GC,其垃圾回收的算法和Serial基原形同,不同之处在与它使用多线程来执行.由于使用了多线程,可以享受多核CPU带来的优势,可以通过参数-XX:+UseParallelGC -XX:+UseParallelOldGC显示指定. 3. CMS CMS和G1都属于「Mostly Concurrent Mark and Sweep Garbage Collector」,可以使用-XX:+UseConcMarkSweepGC参数打开.CMS的年轻代垃圾回收使用的是 老年代使用CMS,CMS的回收和Parallel也基本类似,不同点在与,CMS使用的更复杂的可达性分析步骤,并且不是每次都做压缩的动作,这样达到的效果便是,Stop-The-World的时长会降低,JVM运行中断的时间减少,适合在对延迟敏感的场景下使用. CMS在Java 9中已经被废弃,但了解CMS的行为对理解G1会有一些赞助,所以这里还是会简单的叙述一下.CMS的步骤大致如下:
CMS的设计比拟复杂,所以也带来了一些问题,比如浮动垃圾(Floating Garbage,指的是在第一步标记可达,但在第二步执行的同时已经不可达的对象),由于不做老年代压缩,导致老年代会出现较多的内存碎片. 4. G1 由于「引入了并发标记」和「不做老年代压缩」,CMS可以带来更好的响应时延表现,但同时也带来了一些问题.G1自己就是作为CMS的替代品出现的,在它的使用场景里,堆不再是连续的被分为上文所说的各种代,整个堆会被分为一个个区域(Region),每个区域可以是任何代.如下图所示: 使用G1的JVM某时刻的堆内存 其中有红色方框的为年轻代(标S的为Survivor区域,其他为Eden),其他蓝色底的区域为老年代(标H的为大对象区域,用以存储大对象). 四、G1的一些细节 G1与以上3种GC相同,也是基于分代的垃圾回收器.它的垃圾回收步调可以分为年轻代回收(Young-only phase,类似于Minor GC)和混合垃圾回收阶段(Space-reclamation phase).下图是Oracle文档中对于此两个阶段的示意图: jsgct_dt_001_grbgcltncyl.png G1设计目标和适用对象 G1的设计目标是让大型的JVM可以动态的控制GC的行为以满足用户配置的性能目标.G1会在平衡吞吐和响应时延的基础上,尽可能的满足用户的需求.它适用的JVM往往有以下特征:
对G1的行为进行测试 如果想要看垃圾回收的具体执行过程,可以使用虚拟机参数 static int TOTAL_SIZE = 1024 * 5;static Object[] floatingObjs= new Object[TOTAL_SIZE];static LinkedList<Object> immortalObjs = new LinkedList<Object>();//释放浮动垃圾synchronized static void renewFloatingObjs() { System.err.println("存活对象满========================================"); if (floatingSize + 5 >= TOTAL_SIZE) { floatingObjs= new Object[TOTAL_SIZE]; floatingSize = 0; }}//添加浮动垃圾synchronized static void addObjToFloating(Object obj) { if (floatingSize++ < TOTAL_SIZE) { floatingObjs[floatingSize] = obj; if (immortalSize++ < TOTAL_SIZE) { immortalObjs.add(obj); } else { immortalObjs.remove(new Random().nextInt(TOTAL_SIZE)); immortalObjs.add(obj); } }}public static void main(String[] args) { for (int i = 0; i < 10; i++) { new Thread(() -> { while (true) { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } Byte[] garbage = new Byte[1024 * (1 + new Random().nextInt(20))]; if (new Random().nextInt(20) < 2) { if (floatingSize + 5 >= TOTAL_SIZE) { renewFloatingObjs(); } addObjToFloating(garbage); } } }).start(); }} 在这段代码中,模拟了常规程序的使用情况.赓续的生成新的大小不等的对象,这些对象中会有大约10%的机会进入浮动垃圾 从上边的测试可以得到如下GC日志1,这是一次完整的年轻代GC,从中可以看到,默认的区域大小为1M,同时将开始一次 //日志1[0.014s][info][gc,heap] Heap region size: 1M//一次完整的年轻代垃圾回收,随同着一次暂停[12.059s][info ][gc,start ] GC(18) Pause Young (G1 Evacuation Pause) [12.059s][info ][gc,task ] GC(18) Using 8 workers of 8 for evacuation [12.078s][info ][gc,phases ] GC(18) Pre Evacuate Collection Set: 0.0ms [12.078s][info ][gc,phases ] GC(18) Evacuate Collection Set: 18.6ms [12.079s][info ][gc,phases ] GC(18) Post Evacuate Collection Set: 0.3ms [12.079s][info ][gc,phases ] GC(18) Other: 0.3ms [12.079s][info ][gc,heap ] GC(18) Eden regions: 342->0(315) [12.079s][info ][gc,heap ] GC(18) Survivor regions: 38->35(48) [12.079s][info ][gc,heap ] GC(18) Old regions: 425->463 [12.079s][info ][gc,heap ] GC(18) Humongous regions: 0->0 [12.078s][debug][gc,ergo,ihop ] GC(18) Request concurrent cycle initiation (occupancy higher than threshold) occupancy: 485490688B allocation request: 0B threshold: 472331059B (45.00) source: end of GC[12.078s][debug][gc,ihop ] GC(18) Basic information (value update),threshold: 472331059B (45.00),target occupancy: 1049624576B,current occupancy: 521069456B,recent allocation size: 20640B,recent allocation duration: 817.38ms,recent old gen allocation rate: 25251.50B/s,recent marking phase length: 0.00ms[12.078s][debug][gc,ihop ] GC(18) Adaptive IHOP information (value update),threshold: 472331059B (47.37),internal target occupancy: 997143347B,occupancy: 521069456B,additional buffer size: 367001600B,predicted old gen allocation rate: 318128.08B/s,predicted marking phase length: 0.00ms,prediction active: false[12.078s][debug][gc,refine ] GC(18) Updated Refinement Zones: green: 15,yellow: 45,red: 75[12.079s][info ][gc,heap ] GC(18) Eden regions: 342->0(315)[12.079s][info ][gc,heap ] GC(18) Survivor regions: 38->35(48)[12.079s][info ][gc,heap ] GC(18) Old regions: 425->463[12.079s][info ][gc,heap ] GC(18) Humongous regions: 0->0[12.079s][info ][gc,metaspace ] GC(18) Metaspace: 5172K->5172K(1056768K)[12.079s][debug][gc,heap ] GC(18) Heap after GC invocations=19 (full 0): [12.079s][info ][gc ] GC(18) Pause Young (G1 Evacuation Pause) 803M->496M(1001M) 19.391ms [12.079s][info ][gc,cpu ] GC(18) User=0.05s Sys=0.00s Real=0.02s 年轻代回收(Young-only) 对于纯粹的年轻代回收,其算法很简单,与Parallel和CMS的年轻代十分类似,这是一个多线程并行执行的过程,同样必要Stop-The-World(对应上边日志中的 老年代回收(concurrent cycle) G1的老年代回收是在老年代空间触及一个阈值(Initiating Heap Occupancy Percent)之后,这个回收随同着年轻代的回收工作,但与上边所说的回收有些不同.
从对G1的GC日志的分析,可以看到G1的垃圾回收行为是基于一个可预测的模型:GC会不断的主动触发垃圾回收,在这个过程中不断地进行信息统计和系统GC参数的设置,然后将上边这些步骤支配在这些垃圾回收过程中. 大对象的分配 正常情况下,一个对象会在年轻代的Eden中创建,然后通过垃圾回收和年龄管理之后,晋升到老年代.但对于某些比拟大的对象,可能会直接分配到老年代去. 对于G1,对象大多数情况都会在Eden上分配,如果JVM判断一个对象为大对象(其阈值可以通过-XX:G1HeapRegionSize来设置),则会直接分配如老年代的大对象区域中. 对于其他的内存区域连续的GC,下面是从StackOverflow上搬运过来的对象在堆上的分配过程:
一些简单的GC调优办法 1. 使用分歧的索引对象 引用的类型会直接影响其所引用对象的GC行为,当要做一些内存敏感的应用时,可以参考使用合适的引用类型.具体可以参考JDK解构 - Java中的引用和动态代理的实现. 2. 使用Parallel 从上文中可知,Java 8默认的GC是Parallel,它也叫Throughput,所以它的目的是尽可能的增加系统的吞吐量.在Parallel里,可以通过参数调节最大停止时间(-XX:MaxGCPauseMillis,默认无设置)和吞吐量(-XX:GCTimeRatio,默认值是99,即最大使用1%的时间来做垃圾回收)来调优GC的行为.其中设置最大停止时间可能会导致GC调节各年龄代分区的尺寸(通过增量来实现). 3. 使用G1 从Java 9开始G1酿成了默认的GC,G1中有一些细节的概念在上文中没有叙述,这里先介绍一下:
G1提供了丰富的基于分歧目的的可调优的参数,列表如下: 参数 | 描述 | |