美文网首页
由Finalizer和SocksSocketImpl引起的Ful

由Finalizer和SocksSocketImpl引起的Ful

作者: SDEls | 来源:发表于2019-10-26 19:02 被阅读0次

    AIQ - 全国最专业的人工智能大数据技术社区
    csdn博客: 由Finalizer和SocksSocketImpl引起的Fullgc问题盘点

    问题描述

    • 问题1: 我们的网关服务在发布刚启动的时候,总是会报几次fullgc,并且会引起少量请求超时。

    • 问题2.:同时服务在某些时间点会报出较多的超时异常,在cat监控上观察到超时异常和fullgc时间点吻合,fullgc耗时在600ms左右,那么至少fullgc停顿时间是造成短时间内大量超时的因素。并且观察到old区内存非常缓慢的线性增长,在达到old区内存92%左右时,触发fullgc,old内存开始占用很小。并且从eden区young gc稳定,每次young gc后eden内存基本都可以回收, 所以当时初步判断进入eden区的对象应该是由于每次younggc少量对象因gc年龄太大而晋升。

    • 问题3: fullgc时间600ms左右,时间过长

    JVM运行参数:

    -XX:InitialHeapSize=4244635648 -XX:MaxHeapSize=4244635648 -XX:MaxNewSize=1414529024 -XX:MinHeapDeltaBytes=524288 -XX:NewSize=1414529024 -XX:OldSize=2830106624 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC

    老年代回收默认使用了ParallelOld收集器

    排查

    调整JVM参数,打印gc详细日志,其它JVM参数没有变化。在线下环境进行压测,观察到服务刚启动时,下图红框显示了fullgc的原因是Metadata GC Threshold

    f6282ee714fa490f9d6f0fadc290522a-image.png

    因为使用ParallelOld收集器即使加上参数也无法打印survivor区的对象年龄分布,只能显示desired survivor size。所以启用CMS收集器进行压测成功打印了age分布信息

    46148fdd168a48a5a29cad5660213a60-image.png

    登上线上机器查看当前进程里对象占用内存的前20排名

    
    [bbb@aaa bin]$ ./jmap -histo:live 6258 | head -20
    
     num  #instances  #bytes class name
    
    ----------------------------------------------
    
     1:  26715  25520840 [I
    
     2: 215586  24046568 [C
    
     3:  95269  18935792 [B
    
     4:  69325 7764400 java.net.SocksSocketImpl
    
     5: 108223 6844216 [Ljava.lang.Object;
    
     6: 395157 6322512 java.lang.Object
    
     7: 213313 5119512 java.lang.String
    
     8: 125955 4030560 java.util.concurrent.ConcurrentHashMap$Node
    
     9:  69319 3327312 java.net.SocketInputStream
    
     10:  69319 3327312 java.net.SocketOutputStream
    
     11:  36674 3227312 java.lang.reflect.Method
    
     12:  98415 3149280 java.util.HashMap$Node
    
     13:  69833 2793320 java.lang.ref.Finalizer
    
     14: 106620 2558880 java.net.InetAddress$InetAddressHolder
    
     15: 106614 2558736 java.net.Inet4Address
    
     16:  31185 2536648 [Ljava.util.HashMap$Node;
    
     17:  59156 2366240 java.util.LinkedHashMap$Entry
    
    

    dump内存后进行分析,在dominator_tree中可以看到大对象Finalizer的Retained Heap列是指该对象GC之后所能回收到内存的总和,可以看出由Finalizer关联的引用所占的空间最多。

    f5e6d59b300e4d6eb085c6070543fdd7-image.png

    在histogram视图中可以看到占用内存前几高的对象都是和socket相关的

    9d4ff80f75af4532b8b1cb619bfbae50-image.png

    查看dump内存中的不可达对象中,org.apache.commons.pool.impl.CursorableLinkedList$Listable对象非常多。

    8827d303fc424fe2a7aabeda003246eb-image.png

    在支配树中查看该对象的引用关系发现Listable中存的value是GenericKeyedObjectPool的内部类ObjectTimestampPair,ObjectTimestampPair中存的value指向的是thrift通信所用的TSocket, TSocket中封装着jdk的java.net.Socket。Socket中使用的SocketImpl的实现是SocksSocketImpl,在SocksSocketImpl的父类AbstractPlainSocketImpl中,重写了finalize()方法,从注释可以看出来,该方法的作用是:为了防止用户忘记关闭资源,当SocksSocketImpl被回收时,finalize被调用执行清理工作,SocksSocketImpl的close()方法体中也是直接调用AbstractPlainSocketImpl的close()。

    c307f3e44c124cff9b681d63c8c662ea-image.png

    原因

    启动时fullgc

    MetaspaceSize初始值过小

    线上设置-XX:MetaspaceSize初始值过小,metaspace会在-XX:MaxMetaspaceSize范围内动态扩容,在启动过程中,每次fullgc后也观察到了commit 的 metaspace空间变大了。(在这里当时观察到fullgc后,整个新生代对象全部清空了,老年代大了非常多,难道本该进入survivor区的都进入了老年代???)

    其它可能原因

    -XX:TargetSurvivorRatio为单个survivor区的目标存活率

    Desired survivor size = (survivor_capacity _TargetSurvivorRatio) / 100 _sizeof(a pointer):survivor_capacity(一个survivor space的大小)乘以TargetSurvivorRatio

    3acd5dd73b7544a9a905b7f1a8edb701-image.png

    正常默认desired survivor size 是一个survivor space的50%,线上默认没有启用-XX: +UseAdaptiveSizePolicy,参数意味着eden区和survivor区的比例是动态调整的,从gc日志也能观察到某时刻survivor区可能非常小,很容易导致survivor区溢出,survivor之所以动态调整是因为希望系统尽可能的满足系统吞吐量。

    如果所有age的survivor space对象的大小如果超过Desired survivor size,则重新计算threshold,以age和MaxTenuringThreshold的最小值为准,否则以MaxTenuringThreshold为准,即为了满足设定的survivor区的目标存活率,JVM会自动调整MaxTenuringThreshold。比如年龄从1-7的对象总和已经>Desired survivor size,那么TenuringThreshold 可能降低为6,生怕survivor区溢出。那么把survivor区适当调大,TenuringThreshold值就可能到达15,长期存活对象就越有可能在新生代被回收。

    老年代缓慢增长

    NettyIO和ThriftIO 的连接池

    由于tcp连接频繁创建代价非常大,所以有了长连接和连接池技术。我们目前线上使用的原生的thriftIO(TNonblockingSocket),使用上面提到的apache的对象池GenericKeyedObjectPool 的实现来缓存建立的连接,看了下我们连接池的参数配置,

    minIdle=1,
    MaxIdle=5,
    maxActive=300,
    连接池队列使用FIFO管理池对象

    minEvictableIdleTimeMillis为30分钟,默认30分钟后,连接从池中销毁

    意味着

    1. 在qps较低的时候(夜间)大量请求都会使用池的头部链接,后部连接会因为到达evict时间而被销毁

    2. 在qps较高的时候,池对象无空闲,500ms后在小于maxActive情况下创建新的连接,并使用完后立刻销毁无法复用

    3. 查看我们younggc每分钟频次,在30分钟内对象年龄分布中超过15完全有可能,即很可能出现SocksSocketImpl对象频繁晋升老年代

    4. Finalizer的Retain Heap之所以那么大,也是因为内存中存在大量SocksSocketImpl对象

    4. socket对象内部的byte[]也会随着进入老年代

    ThriftIO情况下,每个请求独占一个socket连接,当基于该连接的请求在服务端处理时,该连接空闲率增加。

    NettyIO情况下,多个请求同一时刻可以复用同一个channel来传输数据,意味着同样qps下,NettyIO会创建较少的连接数

    ThriftIO的池化依赖的apache common pool,使用TNonblockingSocket作为TTransport

    NettyIO的池化由公司自研,看了下其实现Netty连接池的原理,大体原理是:

    1. 设定池大小的最小最大配置

    2. 将池对象Channel放到数组或List中,每个请求都从池中随机选择一个Channel(很可能选择的是同一个)

    3. 两个ConcurrentHashMap中,一个保存写出的消息ID和Callback,另一个包含消息ID和 LinkedBlockingQueue,这样解决了多线程操作channel响应结果到底是哪个线程的问题

    3. 业务线程使用Netty的Channel作为thrift的TTransport层进行writeAndFlush发送消息

    4. 业务线程blcok在从LinkedBlockingQueue中获取结果

    5. Netty的某ClientHandler解码后进行callback调用,并添加结果到LinkedBlockingQueue

    6. 阻塞的业务线程返回 进行处理

    Finalizer原理

    e0f7dc29deb34ee68fe06664ae7a9189-image.png

    因为SocksSocketImpl 对象实现了finalize方法,JVM在java 对象创建过程中识别出其实现了finalize方法,会将其封装成Finalizer对象,Finalizer是一个双向链表,并添加到Finalizer链表中,这样有Finalizer引用存在,SocksSocketImpl 对象即使已经无用也不会被回收。

    c652ca3a742b471db5a23735d4d9f575-image.png

    Finalizer的祖父类Refrence有一个ReferenceHandler线程,来完成将Finalizer新添加的对象加入到RefrenceQueue中,该线程具体执行时机是在pending字段被设置的时候,即会在GC线程进行第一次标记的时候,接着RefrenceQueue在enquery方法中通过notifyAll方法唤醒FinalizerThread线程执行后续逻辑,FinalizerThread是在Finalizer类的静态代码块中会创建一个FinalizerThread类型的守护线程,但是这个线程的优先级比较低,意味着在cpu吃紧的时候可能会抢占不到资源执行。实现如下

    c749a6785f7a4d7d9d3b970541f1d1e8-image.png

    FinalizerThread线程干的事情就是执行对象实现的finalize方法,然后将Finalizer对象从Finalizer链表中删除

    0cd02f3724ae48969ba1709b37048427-image.png

    意味着如果在执行finalize方法时,对象没有再次赋给强引用,现在也没有了Finalizer引用,那么在下一次GC时,便会被真正的回收,即实现finalize方法的对象的回收至少需要两次gc,而FinalizerThread 执行优先级非常低,

    SocksSocketImpl的父类重写了finalize方法,这么做主要是为了确保在用户忘记手动关闭socket连接的情况下,在该对象被回收时能够自动关闭socket来释放一些资源,但是在开发过程中,真的忘记手动调用了close方法,那么这些socket对象可能会因为FinalizeThread线程迟迟没有执行到这些对象的finalize方法,而导致一直占用某些资源,造成内存泄露。

    fullgc耗时较长

    线上没有采用 更关注系统响应时间CMS收集器,同时有可能标记大量Finalizer对象的处理耗时较多,毕竟Finalizer对象数量很大。

    如果采用CMS收集器,那么CMS FinalMarking(并发重新标记,STW过程)进行如下的处理:

    1. 遍历新生代对象,重新标记

    2. 根据GC Roots,重新标记

    3. 遍历老年代的Dirty Card,重新标记,这里的Dirty Card大部分已经在clean阶段处理过

    在第一步骤中,需要遍历新生代的全部对象,如果新生代的使用率很高,需要遍历处理的对象也很多,这对于这个阶段的总耗时来说,是个灾难(因为可能大量的对象是暂时存活的,而且这些对象也可能引用大量的老年代对象,造成很多应该回收的老年代对象而没有被回收,遍历递归的次数也增加不少)

    解决

    NettyIO

    推动各端的客户端使用NettyIO

    目标

    • 解决服务启动过程中会出现几次fullgc问题
    • 降低频繁创建连接
    • 降低fullgc STW时间
    • 降低高龄成员缓慢晋升导致fullgc次数

    JVM参数

    1. 打印gc详细信息及gc耗时
    2. 减少堆内存 减少gc耗时
    3. 打印survivor区年龄分布
    4. 启用CMS,启动remark阶段并行&remark前YGC,减少remark阶段耗时
    5. 增大metaspace,解决服务启动时的fullgc
    6. 并行处理Reference Finalizer队列 加快Finalizer引用对象(好多socket相关)的快速回收
    7. 提升SurvivorRatio目标存活率,减少分配担保晋升
    -Xmx4g
    -Xms4g
    -Xmn1.5g
    -Xloggc:gc.log
    -XX:MetaspaceSize=200m
    -XX:MaxMetaspaceSize=1g
    -XX:+PrintHeapAtGC
    -XX:+PrintFlagsFinal
    -XX:+PrintGCDetails
    -XX:+PrintGCDateStamps
    -XX:+PrintTenuringDistribution
    -XX:+PrintGCTimeStamps
    -XX:+PrintGCApplicationStoppedTime
    -XX:+ParallelRefProcEnabled
    -XX:+UseParNewGC
    -XX:+UseConcMarkSweepGC
    -XX:+CMSScavengeBeforeRemark
    -XX:TargetSurvivorRatio=70
    -XX:SurvivorRatio=8
    -XX:+HeapDumpOnOutOfMemoryError
    

    优化后老年代增长比其它未优化机器缓慢,且启动时未再出现fullgc。


    0adb52c45e5a4f7d8817ddbe850bf07b-image.png

    优化后,CMS最耗时的remark阶段花费160ms,remark阶段STW总耗时降低到200ms以内,降低至优化前1/3耗时,且高峰低峰STW耗时平稳。


    ca31811d60d84821a56c39f9dca35951-image.png 29e287d3dc7c4960bb2bbde4b69c7054-image.png

    附录

    GC ROOTS

    1. System Class:由bootstrap classloader加载的类,例如rt.jar,里面的类的包名都是java.util.*开头的。

    2. JNI Local:native代码中的局部变量,例如用户编写的JNI代码或JVM内部代码。

    3. JNI Global:native代码中的全局变量,例如用户编写的JNI代码或JVM内部代码。

    4. Thread Block:被当前活跃的线程锁引用的对象。

    5. Thread:正在存活的线程

    6. Busy Monitor:调用了wait()、notify()或synchronized关键字修饰的代码——例如synchronized(object)synchronized方法。

    7. Java Local:局部变量。例如函数的输入参数、正在运行的线程栈里创建的对象。

    8. Native Stack:native代码的输入或输出参数,例如用户定义的JNI代码或JVM的内部代码。在文件/网络IO方法或反射方法的参数。

    9. Finalizable:在finalize队列中等待它的finalizer对象运行的对象。

    10. Unfinalized:重载了finalize方法,但是还没有进入finalize队列中的对象。

    11. Unreachable:从任何gc roots节点都不可达的对象,在MAT中将这些对象视为root节点,如果不这么做,就不能对这些对象进行分析。

    12. Java Stack Frame:Java栈帧,用于存放局部变量。只在dump文件被解析的时候会将java stack frame视为对象。

    13. Unknown:没有root类型的对象。有些dump文件(例如IBM的Portable Heap Dump)没有root信息。

    相关文章

      网友评论

          本文标题:由Finalizer和SocksSocketImpl引起的Ful

          本文链接:https://www.haomeiwen.com/subject/ndjuvctx.html