首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

从实际调优经验谈GC优化

技术干货,第一时间推送

背景

最近同事W一直反馈有个服务一到高峰期超时比较严重,因为该服务是个计数服务,主要是以操作redis为准,当时想着扩一波机器缓解下,但是查看高峰期的内存和CPU,发现并没有到瓶颈,但是查看GC的时候发现高峰期的YGC占用了100ms,平时YGC在20ms左右,该服务提供给外部调用的最大超时时间是50ms,因此如果高峰期YGC占用了100ms,那在这100ms内所有的请求都属于超时异常的,所以就想着适当调整GC来控制超时的量。

GC知识点回顾

垃圾回收(Garbage Collection)是Java虚拟机(JVM)垃圾回收器提供的一种用于在空闲时间不定时回收无任何对象引用的对象占据的内存空间的一种机制。

注意:垃圾回收回收的是无任何引用的对象占据的内存空间而不是对象本身。换言之,垃圾回收只会负责释放那些对象占有的内存。对象是个抽象的词,包括引用和其占据的内存空间。当对象没有任何引用时其占据的内存空间随即被收回备用,此时对象也就被销毁。但不能说是回收对象,可以理解为一种文字游戏。

引用

如果Reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。引用又分为强引用,软引用,弱引用,虚引用。

(1)强引用(Strong Reference):如“Object obj = new Object()”,这类引用是Java程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。

(2)软引用(Soft Reference):它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2之后提供了SoftReference类来实现软引用。

(3)弱引用(Weak Reference):它也是用来描述非须对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。

(4)虚引用(Phantom Reference):最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2之后提供了PhantomReference类来实现虚引用。

判断对象是否是垃圾的算法

Java语言规范没有明确地说明JVM使用哪种垃圾回收算法,但是任何一种垃圾回收算法一般要做2件基本的事情:

(1)找到所有存活对象;

(2)回收被无用对象占用的内存空间,使该空间可被程序再次使用。

通常有以下几种算法来判断对象是否已死:

引用计数算法(Reference Counting Collector)

堆中每个对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1(a = b, b被引用,则b引用的对象计数+1)。当引用失效时(一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时),计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。

优点:引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利(OC的内存管理使用该算法)。

缺点:难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。

早期的JVM使用引用计数,现在大多数JVM采用对象引用遍历(根搜索算法)。

根搜索算法(Tracing Collector)

? ? ?首先了解一个概念:*根集(Root Set)*

? ? ?所谓根集(Root Set)就是正在执行的Java程序可以访问的引用变量(注意:不是对象)的集合(包括局 部变量、参数、类变量),程序可以使用引用变量访问对象的属性和调用对象的方法。

这种算法的基本思路:(1)通过一系列名为“GC Roots”的对象作为起始点,寻找对应的引用节点。(2)找到这些引用节点后,从这些节点开始向下继续寻找它们的引用节点。(3)重复(2)。(4)搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,就证明此对象是不可用的。? ?

标记可达对象:

JVM中用到的所有现代GC算法在回收前都会先找出所有仍存活的对象。根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图。下图所展示的JVM中的内存布局可以用来很好地阐释这一概念:

首先,垃圾回收器将某些特殊的对象定义为GC根对象。所谓的GC根对象包括:

(1)虚拟机栈中引用的对象(栈帧中的本地变量表);

(2)方法区中的常量引用的对象;

(3)方法区中的类静态属性引用的对象;

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

(5)活跃线程。

接下来,垃圾回收器会对内存中的整个对象图进行遍历,它先从GC根对象开始,然后是根对象引用的其它对象,比如实例变量。回收器将访问到的所有对象都标记为存活。

存活对象在上图中被标记为蓝色。当标记阶段完成了之后,所有的存活对象都已经被标记完了。其它的那些(上图中灰色的那些)也就是GC根对象不可达的对象,也就是说你的应用不会再用到它们了。这些就是垃圾对象,回收器将会在接下来的阶段中清除它们。

关于标记阶段有几个关键点是值得注意的:

(1)开始进行标记前,需要先暂停应用线程,否则如果对象图一直在变化的话是无法真正去遍历它的。暂停应用线程以便JVM可以尽情地收拾家务的这种情况又被称之为安全点(Safe Point),这会触发一次Stop The World(STW)暂停。触发安全点的原因有许多,但最常见的应该就是垃圾回收了。

(2)暂停时间的长短并不取决于堆内对象的多少也不是堆的大小,而是存活对象的多少。因此,调高堆的大小并不会影响到标记阶段的时间长短。

(3)在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:

1.如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize()方法。当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。

2.如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的finalize()方法最多只会被系统自动调用一次),稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。

1. JVM内存结构

整个JVM内存结构图如下表示:

Java程序在运行时,需要在内存中的分配空间。为了提高运算效率,就对数据进行了不同空间的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。

具体划分为如下5个内存空间:(非常重要)

. 栈:存放局部变量. 堆:存放所有new出来的东西. 方法区:被虚拟机加载的类信息、常量、静态常量等。. 程序计数器(和系统相关): 每个线程拥有一个PC寄存器,在线程创建时创建,指向下一条指令的地址,执行本. 地方法时,PC的值为undefined. 本地方法栈

因为本次GC优化的点是调整堆,所以我们重点讲解下堆的组成成分。

堆是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。

堆在JVM启动的时候就被创建,堆中储存了各种对象,这些对象被自动管理内存系统(Automatic Storage Management System),也就是常说的“Garbage Collector(垃圾回收器)”管理。这些对象无需、也无法显示地被销毁。

JVM将Heap分为两块:新生代New Generation和老年代Old Generation

堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,导致new对象的开销比较大。

Sun Hotspot JVM为了提升对象内存分配的效率,对于所有创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样的,但如果对象过大的话则仍然要直接使用堆空间分配。

TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

所有新创建的Object都将会存储在新生代Young Generation中。如果Young Generation的数据在一次或多次GC后存活下来,那么将被转移到OldGeneration。新的Object总是创建在Eden Space。

2. GC算法

1) 标记-清除算法:

标记-清除算法是现代垃圾回收算法的思想基础。标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象;然后,在清除阶段,清除所有未被标记的对象。

它的做法是当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被成为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

标记:标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。

也就是说,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行

标记-清除算法的缺点

(1)首先,它的缺点就是效率比较低(递归与全堆对象遍历),导致stop the world的时间比较长,尤其对于交互式的应用程序来说简直是无法接受。试想一下,如果你玩一个网站,这个网站一个小时就挂五分钟,你还玩吗?

(2)第二点主要的缺点,则是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。

2) 复制算法:(新生代的GC)

将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

. 与标记-清除算法相比,复制算法是一种相对高效的回收方法. 不适用于存活对象较多的场合,如老年代(复制算法适合做新生代的GC)

复制算法的最大的问题是:空间的浪费

复制算法使得每次都只对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,这个太要命了。

所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。

现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块比较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1:1,也就是说,每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的空间会被浪费。

当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖于老年代进行分配担保,所以大对象直接进入老年代。

3)标记-整理算法:(老年代的GC)

?如果在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选中这种算法。

标记-压缩算法适合用于存活对象较多的场合,如老年代。它在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-压缩算法也首先需要从根节点开始,对所有可达对象做一次标记;但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端;之后,清理边界外所有的空间。

标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。上图中可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。

但是,标记/整理算法唯一的缺点就是效率也不高。不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。

标记-清除算法、复制算法、标记整理算法的总结:

三个算法都基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是语法中变量作用域的相关内容。因此,要想防止内存泄露,最根本的办法就是掌握好变量作用域,而不应该使用C/C++式内存管理方式。

在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stop the world)。

它们的区别如下:(>表示前者要优于后者,=表示两者效果一样)

(1)效率:复制算法>标记/整理算法>标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。

(2)内存整齐度:复制算法=标记/整理算法>标记/清除算法。

(3)内存利用率:标记/整理算法=标记/清除算法>复制算法。

注1:可以看到标记/清除算法是比较落后的算法了,但是后两种算法却是在此基础上建立的。

注2:时间与空间不可兼得。

4) 分代收集算法:(新生代的GC+老年代的GC)

当前商业虚拟机的GC都是采用的“分代收集算法”,这并不是什么新的思想,只是根据对象的存活周期的不同将内存划分为几块儿。一般是把Java堆分为新生代和老年代:短命对象归为新生代,长命对象归为老年代。

少量对象存活,适合复制算法:在新生代中,每次GC时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成GC。大量对象存活,适合用标记-清理/标记-整理:在老年代中,因为对象存活率高、没有额外空间对他进行分配担保,就必须使用“标记-清理”/“标记-整理”算法进行GC。注:老年代的对象中,有一小部分是因为在新生代回收时,老年代做担保,进来的对象;绝大部分对象是因为很多次GC都没有被回收掉而进入老年代。

3. GC 日志

要查看GC日志,需要设置一下jvm的参数。关于输出GC日志的参数有以下几种:

-XX:+PrintGC 输出GC日志

-XX:+PrintGCDetails 输出GC的详细日志

-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)

-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)

-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息

-Xloggc:../logs/gc.log 日志文件的输出路径

通常GC的日志大概长这样:

[GC (System.gc()) [PSYoungGen: 3686K->664K(38400K)] 3686K->672K(125952K), 0.0016607 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

[Full GC (System.gc()) [PSYoungGen: 664K->0K(38400K)] [ParOldGen: 8K->537K(87552K)] 672K->537K(125952K), [Metaspace: 2754K->2754K(1056768K)], 0.0059024 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

Heap

PSYoungGen ? ? ?total 38400K, used 333K [0x00000000d5c00000, 0x00000000d8680000, 0x0000000100000000)

eden space 33280K, 1% used [0x00000000d5c00000,0x00000000d5c534a8,0x00000000d7c80000)

from space 5120K, 0% used [0x00000000d7c80000,0x00000000d7c80000,0x00000000d8180000)

to ? space 5120K, 0% used [0x00000000d8180000,0x00000000d8180000,0x00000000d8680000)

ParOldGen ? ? ? total 87552K, used 537K [0x0000000081400000, 0x0000000086980000, 0x00000000d5c00000)

object space 87552K, 0% used [0x0000000081400000,0x00000000814864a0,0x0000000086980000)

Metaspace ? ? ? used 2761K, capacity 4486K, committed 4864K, reserved 1056768K

class space ? ?used 299K, capacity 386K, committed 512K, reserved 1048576K

GC日志开头的”[GC”和”[Full GC”说明了这次垃圾收集的停顿类型,如果有”Full”,说明这次GC发生了”Stop-The-World”。因为是调用了System.gc()方法触发的收集,所以会显示”[Full GC (System.gc())”,不然是没有后面的(System.gc())的。

“[PSYoungGen”和”[ParOldGen”是指GC发生的区域,分别代表使用Parallel Scavenge垃圾收集器的新生代和使用Parallel old垃圾收集器的老生代。为什么是这两个垃圾收集器组合呢?因为我的jvm开启的模式是Server,而Server模式的默认垃圾收集器组合便是这个,在命令行输入java -version就可以看到自己的jvm默认开启模式。还有一种是client模式,默认组合是Serial收集器和Serial Old收集器组合。

在方括号中”PSYoungGen:”后面的”3686K->664K(38400K)”代表的是”GC前该内存区域已使用的容量->GC后该内存区域已使用的容量(该内存区域总容量)”

在方括号之外的”3686K->672K(125952K)”代表的是”GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”

再往后的”0.0016607 sec”代表该内存区域GC所占用的时间,单位是秒。

再后面的”[Times: user=0.00 sys=0.00, real=0.00 secs]”,user代表进程在用户态消耗的CPU时间,sys代表代表进程在内核态消耗的CPU时间、real代表程序从开始到结束所用的时钟时间。这个时间包括其他进程使用的时间片和进程阻塞的时间(比如等待 I/O 完成)。

至于后面的”eden”代表的是Eden空间,还有”from”和”to”代表的是Survivor空间。

线上GC实际案例分析

此次线上调优的系统是一个计数器服务,暂且称其为F吧。系统F是一个具有滑动时间窗口的计数服务,每天有几十亿次的访问量,系统内部主要是小对象比较多,当时每台机器容量分配的是 8C16G,所以当时GC的调整参数设置成了 -xmx 12g -xms 12g -xmn 4g,咋一看这样设置没啥毛病,也是jvm建议设置的参数(年轻代占总堆的1/3)。

? 我们看下高峰期该服务的GC相应性能图:

高峰期系统F的GC耗时在100ms左右,每分钟GC count是2,如果是一般的系统,这样的GC性能还是很不错,完全达不到调优的必要,但是系统F是一个统计服务,在高峰期QPS 达到9万,对外提供单词请求最大的耗时是50ms,如果每次GC耗时108ms,意味着在这段时间内的请求将全部超时(大约有9720个请求超时), ?这是不能容忍的。

通常来说GC调优的目标有以下三个:

高可用,可用性达到几个9。

低延迟,请求必须多少毫秒内完成响应。

高吞吐,每秒完成多少次事务。

明确系统需求之所以重要,是因为上述性能指标间可能冲突。比如通常情况下,缩小延迟的代价是降低吞吐量或者消耗更多的内存或者两者同时发生。

系统F主要关注的指标是高可用和低延迟,因此调整主要是以该两项指标为主。

此次优化目标:高峰期GC耗时降低至20ms左右,每分钟GC count为1~2个

我们查看了下优化前的系统参数:

-Xms12g -Xmx12g -Xmn4g -XX:ParallelGCThreads=4

-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+UseCompressedClassPointers

-XX:+UseConcMarkSweepGC -XX:+UseParNewGC

gc 日志:

[GC (Allocation Failure) ?[ParNew: 2520325K->3721K(2831168K), 0.1117350 secs] 2955342K->438993K(5976896K), 0.0120977 secs] [Times: user=0.00 sys=0.04, real=0.11 secs]

[GC (Allocation Failure) ?[ParNew: 2520329K->3852K(2831168K), 0.1117688 secs] 2955601K->439390K(5976896K), 0.0121465 secs] [Times: user=0.01 sys=0.04, real=0.11 secs]

GC调整前的日志情况如下:

高峰期每分钟GC count 2~3个

GC平均耗时在100ms

YGC阶段回收效率超90%(说明年轻代中的对象都是朝生夕灭的,大部分都被回收了)

几乎没有full gc

因为几乎没有出现full gc 说明老年大设置的有点大,可以适当调整小点,那是不是意味着在总堆不变的情况下,年轻代就可以设置大些呢?如果年轻代设置过大,单位时间内YGC count数量会降低,但是YGC扫描的时间就会增大,从而单次GC的耗时就会增多。所以折中之后,我们调整了GC了以下参数作为对比:

1. -xmx8g -xms8g -xmn4g

2. -xmx8g -xms8g -xmn3g

3. -xmx8g -xms8g -xmn5g

4. -xmx6g -xms6g -xmn3g

通过观察几天这几组GC参数对比,最终第四组gc 参数表现最好,而且几乎不出现full gc,高峰期效果图如下:

最终我们选择了 -xmx6g -xms6g -xmn3g 作为我们GC的系统参数,省下的机器配置还可以继续水平扩展(线上我们用的是docker),通过此次调整我们发现系统堆不是设置的越大越好,适当的调整效果反而会更棒(当然这里也出现了小插曲,当时还设置了一组非常激进的参数 -xmx8g -xms8g -xmn6g,高峰期YGC表现更好,但是在下午16:00左右 系统不同程度出现了full gc ,单次full gc 耗时了几秒,在这几秒内的请求全部超时,所以参数设置的时候需要考虑full gc的情况,老年代不能设置的过低,需要合理设置,否则一旦出现full gc,对于整个系统将是一场灾难。)

-深入原理-

?知其然并知其所以然

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20200909A0ASYU00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券
http://www.vxiaotou.com