频繁操作本地缓存导致YGC耗时过长

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"某天,某位群友在JVM讨论群里发来一张GC log的图片。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其中主要的问题是YGC过长,每次耗时约为200ms。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/cb/cbf95fe264bb7a20233f2240a455a33a.png","alt":"image.png","title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用的JVM参数如下:"}]},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"-Xmn2048m -Xms4096m -Xmx4096m -XX:+PrintGC -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:MetaspaceSize=128M -XX:MaxMetaspaceSize=128M\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"指定年轻代内存为2g,初始JVM内存为4g,最大JVM内存为4g。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这个问题引起了群友们的关注。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"从GC log和JVM参数可以看出,GC算法使用默认的Parallel Scanvenge。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"可以看到Eden区大小为1536M,两个Survivor区大小为256M。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"得出"},{"type":"codeinline","content":[{"type":"text","text":"-XX:SurvivorRatio = 6"}]},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"此外可以看到在GC时,"},{"type":"codeinline","content":[{"type":"text","text":"desired survivor size 268435456 bytes = 256M"}]},{"type":"text","text":",得出"},{"type":"codeinline","content":[{"type":"text","text":"-XX:TargetSurvivorRatio = 100"}]},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"默认情况下,"},{"type":"codeinline","content":[{"type":"text","text":"-XX:SurvivorRatio = 8,-XX:SurvivorRatio = 50"}]},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"然而并未设置这两个参数,一直怀疑是配置没有生效。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"一时没有想到办法,有群友建议试着调整下 MaxGCPauseMills 或者 GCTimeRatio 参数,然后效果都不好。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"之后的某天,尝试使用jmap -heap pid打印应用的堆栈信息。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/46/460dd5605c4333706c27f792d4df75e0.png","alt":"image.png","title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"发现虽然写着SurvivorRatio = 8,但是E:S0:S1的比例并非是8:1:1。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"于是开始寻找原因。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"找到来自R大的回答:"}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"HotSpot VM里,ParallelScavenge系的GC(UseParallelGC / UseParallelOldGC)默认行为是SurvivorRatio如果不显式设置就没啥用。显式设置到跟默认值一样的值则会有效果。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因为ParallelScavenge系的GC最初设计就是默认打开AdaptiveSizePolicy的,它会自动、自适应的调整各种参数。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"于是推荐群友尝试使用CMS,让这些配置固定下来,不做自适应调整。但设置之后,发现YGC效果依旧不好。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/d3/d33c494cac97bedc5152ab7e67893721.png","alt":"image.png","title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"显示发生4次YGC,耗时1.145s,平均耗时约286ms,情况反而更糟,回头再次分析GC log。发现日志中有这么一行:"},{"type":"codeinline","content":[{"type":"text","text":"new threshold 7 (max 15)"}]},{"type":"text","text":"JVM中有个参数为晋升年龄阈值("},{"type":"codeinline","content":[{"type":"text","text":"-XX:MaxTenuringThreshold"}]},{"type":"text","text":"),默认值为15。意思为在YGC时,超过该年龄的对象会被晋升到老年代。但GC log中显示该阈值被修改成了7。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在年轻代对象晋升时,有一个判断条件如下:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"动态年龄判断,大于等于某个年龄的对象超过了survivor空间一半,大于等于某个年龄的对象直接进入老年代。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"得出,在某次YGC时,Survivor区中,年龄超过7的对象占用了Survivor空间一半以上。而正常情况下,年轻代对象朝生夕死。网络服务处理请求为毫秒级,YGC几秒甚至十几秒才发生一次,多数年轻对象活不过1代。于是,猜测该群友使用了本地缓存。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在得到肯定的回复后,详细询问了群友使用本地缓存的方法。自行实现了一个本地缓存,类似于HashMap。别的服务会每一分钟推送缓存数据用于同步。在同步的时候不做diff操作,直接put。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"举例:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"缓存中保存Person类"},{"type":"text","text":"。"}]},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"1@Data\n2class Person {\n3\n4 private String name;\n5\n6 private Integer age;\n7}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"缓存内容可能为:"}]},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"1{\n2 \"jjs\":{\n3 \"age\":27,\n4 \"name\":\"jjs\"\n5 }\n6}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"缓存同步涉及两种操作:新增和覆盖。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"两种操作均直接使用put操作实现,无论当前缓存key是否已经存在。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这样的操作方法在业务上完全没有问题。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"但对于GC而言,每次缓存同步需要new很多新的对象,并且这些对象都将一直存活,直到被覆盖,或者晋升到老年代。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这些缓存对象首先会被分配到年轻代,在YGC时候,这些对象都会被标记为存活。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"得到YGC耗时过长原因一:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"年轻代中有太多存活的对象,增加了标记时间。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"此外,HashMap是数组加链表的结构,使用Node结构用于保存key、value。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"HashMap的Node结构如下:"}]},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"1static class Node implements Map.Entry {\n2 final int hash;\n3 final K key;\n4 V value;\n5 Node next;\n6}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"每次put生成的node节点,很可能(hash冲突)被挂在已有node节点的next域上。已有node为缓存,长期存活,很有可能位于老年代。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"那么,就形成了老年代对象对年轻代对象的引用。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在YGC中,需要扫描Card Table中的dirty区域来识别被老年代对象引用的年轻代对象。正常情况下,这种情形并不多。但在本文例子中,会大量存在。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"得到YGC耗时过长原因二:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"YGC又需要花费大量的时间在扫描Card Table上,总结原因是操作本地缓存太频繁导致了YGC耗时过长。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"回顾YGC的大致过程:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/63/63b888c87a7ac471c96ef689dc106836.png","alt":"image.png","title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"从根节点开始扫描年轻代对象,直到扫描到下个引用为非年轻代对象。(可以避免YGC扫描整个堆。)"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"扫描老年代dirty区域,即可扫描到被老年代对象引用的年轻代对象。(老年代被分为不同的块,Card Table字节数组中每个字节表示老年代中的一块。新分配对象时,触发写屏障,存在有老年代对象引用年轻代对象时,将对应的卡表设置为dirty。)"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"将Eden和From区中的对象复制到To区。如果To区已满,则直接复制到老年代。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"YGC耗时过长问题的排查还是应该从两个点出发:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"YGC时存活的年轻代对象太多。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"老年代对象引用年轻代对象的情况太多。"}]}]}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"解决方案"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"修改代码需要一定的时间,群友采用了一种短期的办法。"},{"type":"text","marks":[{"type":"strong"}],"text":"修改了推送的周期。原来每一分钟推送一次"},{"type":"text","text":"。YGC下降到18-25ms,但在缓存推送时,YGC时间仍然达到200ms。两次缓存推送之间的对象都符合朝生夕死的弱分代假设,YGC时间正常。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"后续修改思考/建议:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"分批推送缓存,并且在接到推送的缓存时做diff操作,尽量修改已有对象而非新建对象。此举可减少长寿对象生成。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"即使使用分批推送,在应用启动时,还是需要全量加载缓存。仍旧会面临应用刚启动时,YGC耗时过长的问题。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"重新规划应用。因为经常变化的数据并不适合放在缓存中。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用Redis缓存。Redis的响应时间为毫秒级,甚至只需几毫秒,并且无需考虑分布式下缓存同步问题。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用CMS垃圾回收算法。因为默认和Parallel Scanvenge算法配合的老年代回收算法是Serial Old。该算法需要标记清理压缩,STW时间较长。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"看完三件事❤️"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"关注公众号 『 "},{"type":"text","marks":[{"type":"strong"}],"text":"java烂猪皮"},{"type":"text","text":" 』,不定期分享原创知识。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"同时可以期待后续文章ing🚀"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/85/8518f1f13ab07e46122c84420bbf39a8.png","alt":null,"title":"","style":[{"key":"width","value":"50%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"作者:阿菜"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"出处:https://club.perfma.com/article/1578279"}]}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章