GC (Allocation Failure) 那些事
平常写Spark程序,经常看到 GC(Allocation Failure) 这个日志,大概查了查意思是是jvm在执行垃圾回收,一般情况下不影响程序运行,只会拖慢程序运行时间。但是经常遇到这个日志,今天就盘一下!GC(Allocation Failure)造成的垃圾回收为young gc 又称 minor gc。
Demo代码
下面这几行spark代码是经常引起 GC(Allocation Failure) 日志的代码写法,基本上就是短时间内产生多个短生命周期的对象,用完之后就不用了,基本没有临时变量,多见于一些数据统计和分析的任务。
val sc = new SparkContext(conf)
val count = sc.textFile(input).map(line => {
try {
val info = JSON.parseObject(line)
val x = info.getString("x")
val y = info.getString("y")
val z = x + "\t" + y
z
} catch {
case e: Exception => {
"hello_world"
}
}
}).count()
println("total: " + count)
GC(Allocation Failure) - 日志
去测试环境跑一下代码demo,看下一GC日志
GC日志分析
Stdout日志中共有4个 GC(Allocation Failure) 日志(后续统一简称垃圾回收),我申请的excutor是2个,cores是2个,说明我的任务在4个核上各进行了一次垃圾回收,接下来看下第一句日志:
[GC (Allocation Failure) 2020-06-30T10:48:50.053+0800: 12.669: [ParNew: 841354K->35058K(943744K), 0.1366613 secs] 916462K->110166K(1949312K), 0.1368574 secs] [Times: user=0.94 sys=0.13, real=0.14 secs]
ParNew:
ParNew收集器是JAVA虚拟机中垃圾收集器的一种,它是Serial收集器的多线程版本。这里ParNew表示年轻代使用的是ParNew收集器,DefNew 表示新生代使用的是Serial收集器。
841354K->35058K(943744K), 0.1366613 secs:
ParNew是年轻代,这里标识了年轻代内存的变化,经过一次垃圾回收,内存由841354k减少为35058k,这里总内存大小为943744k,总内存就是Eden + 2 * Survivor的大小,此次垃圾回收共耗时0.1366613s,共减少内存 841354 - 35058 = 806296k
916462K->110166K(1949312K), 0.1368574 secs
这里标识一次垃圾回收后JVM堆区的内存变化,JVM堆主要由老年代与年轻代构成,比例为2:1,常见的Heap说的就是他们。经过一次垃圾回收后,堆区的内存由916462k变为110166k,其中堆区总内存1949312k,共耗时0.1368574s,共减少内存916462k-110166k = 806296k
Times: user=0.94 sys=0.13, real=0.14 secs
user:代表cpu在用户态(目态)花费的时间,在垃圾回收的情况下,表示 GC 线程执行所使用的 CPU 总时间。
sys:代表cpu花费在内核态(管态)的时间,即在内核执行系统调用或等待系统事件所使用的 CPU 时间。
real:代表clock time,相当于我们现实世界走过的时间。
user + sys 是程序执行实际使用的 CPU 时间。一般在执行垃圾回收时,一般都是多线程并发执行GC,所以多数情况下real的值是小于user + sys的大小的,这里用户态耗时0.94s,内核态耗时0.13s,实际时间为0.14s。如果 real 偏大,则可能是因为磁盘IO负载过重或者是CPU资源不足所导致的,网络IO例如网络访问,磁盘读写等,CPU的话则是进程过多,GC任务没有获得过多的CPU时间或者单纯的任务重人手少,CPU数量不足。
年轻代与Heap分析:
年此次垃圾回收中,年轻代减少806296k,堆区减少806296k,两个值相等,说明年轻代减少的就是堆区减少的(咋感觉像是废话),也说明此次垃圾回收过程中,没有对象从年轻代转入老年代,如果堆区减少的量小于年轻代减少的量,则代表有一部分对象从年轻代溜到了老年代。结合我们的Demo代码,这里我们map每次解析字符串,变量x,y,z都是运行时产生的短生命周期对象,而这里执行了垃圾回收,是因为我们逻辑简单且采用了map结构,短时生成的对象太多所导致的。
GC(Allocation Failure) - 年轻代
年轻代是Java Heap堆的组成部分,java虚拟机主要由老年代,年轻代构成,1.7版本的话还有永久代的概念,现在1.8的话主要就是MetaData了,也叫元空间。这个在上面的原始日志里可以看到,有一列MetaSpace的日志。
日志:
Heap
par new generation total 943744K, used 239751K [0x0000000080000000, 0x00000000c0000000, 0x00000000c0000000)
eden space 838912K, 26% used [0x0000000080000000, 0x000000008d58f0f8, 0x00000000b3340000)
from space 104832K, 20% used [0x00000000b3340000, 0x00000000b47d2e60, 0x00000000b99a0000)
to space 104832K, 0% used [0x00000000b99a0000, 0x00000000b99a0000, 0x00000000c0000000)
concurrent mark-sweep generation total 1005568K, used 75107K [0x00000000c0000000, 0x00000000fd600000, 0x0000000100000000)
Metaspace used 43871K, capacity 44414K, committed 44548K, reserved 1087488K
class space used 5712K, capacity 5868K, committed 5892K, reserved 1048576K
JVM构成:
通过对应我们可以看到JVM分为年轻代,老年代,元空间,针对GC(Allocation Failure),这里着重说一下年轻代。
这里年轻代具体分为: Eden,From Space,To Space,比例一般为8:1:1。
如何触发Minor GC
所有新建的对象都会在内存的堆中进行分配,新建的对象首先会放到年轻代的Eden里,如果年轻代填满或者说新建的对象所申请的内存大小年轻代无法满足,则会进行一次minor gc。这时Eden中没有被清除的对象就幸存下来,会被放入From Sapce中,此时To Space为空,接下来程序继承new 对象,然后年轻代又放不下,此时又会进行一次gc,此时gc会同时清除From Sapce和To Space中不用的垃圾。这时,Eden中存活下来的就会放到From Space中,但是这次的From Space其实是之前的To Space,这里gc会通过复制算法,将之前From中存活下来的对象与本次Eden存活的对象放入刚才空的那个To Spcae中,此时之前的From Space清空变成To Space,上次的To Space存储复制来的内容,变成From Space,如此下去循环往复。每当一个变量被复制一次时,都会有一个计数器为其+1,当一个对象在From-To Space之间复制达到一定次数时,jvm会认为他是一个常用的对象,从而从年轻代转移到老年代,如果这样的对象过多,最后将老年代撑爆,则会相对应进行一次Major GC或者Full GC,Major GC清楚的是老年代的内存,FULL GC则是清除老年代和年轻代一起的内存。上面这段话比较绕,下面简单总结一下:
1.新建对象,放入Eden
2.Eden放不下,垃圾回收并放入Survior
3.From Space与To Space相互交换身份互相复制,存活时间较长则放入老年代
4.老年代放不下,可能执行Major GC清理老年代内存,也可能Full GC 老年代年轻代一起清理
GC(Allocation Failure) - Spark调优
了解了GC的大致过程,就看一下Spark下该如何调整
Spark 2.0 内存分布:
Spark源码:
// Validate memory fractions
val deprecatedMemoryKeys = Seq(
"spark.storage.memoryFraction",
"spark.shuffle.memoryFraction",
"spark.shuffle.safetyFraction",
"spark.storage.unrollFraction",
"spark.storage.safetyFraction")
val memoryKeys = Seq(
"spark.memory.fraction",
"spark.memory.storageFraction") ++
deprecatedMemoryKeys
for (key <- memoryKeys) {
val value = getDouble(key, 0.5)
if (value > 1 || value < 0) {
throw new IllegalArgumentException(s"$key should be between 0 and 1 (was '$value').")
}
}
spark.memory.fraction:
spark执行内存,默认值0.5,用于存储和执行,这个值越小,发生溢写和垃圾回收的频率就越高。此配置的目的是为内部元数据、用户数据结构和不精确的大小估计预留内存,以防出现稀疏、异常大的记录。
spark.memory.storageFraction:
spark缓存比例,默认也是0.5,属于spark.memory.fraction的一部分。这个值越大,算子工作的内存就越小,越容易发生溢写磁盘。
上面还有5个key我们翻译一下他们的队列名deprecatedMemoryKeys 不推荐的配置参数,这几个参数是老版本里静态内存管理的配置参数,不够灵活,2.x之后都采用了统一内存管理,可以在存储和算子计算之间协调内存的使用,并引入写入磁盘的机制,可以更专注于写代码而不是内存管理上。
针对本文Demo代码以及对应的 Young GC 情况,我们可以适当将spark.memory.storageFractio比例调小,这样我们的执行算子就有更多的内存使用,相对应的就拥有更大的年轻代,就可以减少minor gc的频率。
GC(Allocation Failure) - JVM调优
上面我们介绍了Spark的内存分配管理,一块Storgae内存,如果没有设定可以存储在磁盘上的话,则读取的RDD均放在这块内存里,这一块的内存管理归Spark自己管理,因此不用关心这里的垃圾回收,只需要关注内存足够大可以放下你读取的文件,不报OOM即可。另一块内存就是真正的执行算子内存,这一块内存的管理是托管给JVM,新生成的对象也都放在这块内存下,基于上面分析的Young GC和我们的Demo代码,我们在老年代基本没有变量,都是年轻代用完就清理掉了,因此我们可以适当调大年轻代在内存中的占比,正常情况下两者比例2:1。
-Xmx:设置应用程序最大使用内存,这个调大,内存就大了,对应spark就是加大Excutor内存
-Xmn1g: 设置年轻代使用内存大小为1G,上面总内存增加了,这边把年轻代的也调上去
-XX:NewRatio=3,则年轻代与老年代的比例为1:3,占整个堆的1/4,加大比例,注意如果加大比例的话要减小这个Ratio的值
-XX:SurvivorRatio=4 ,设置Eden与Survior的比值,此时比例为2:4,一个Survior占年轻代的1/6,因为有两个Survior。