当前位置: 首页 > 后端技术 > Java

高性能Java计算服务的性能调优

时间:2023-04-01 19:34:35 Java

作者:vivo互联网服务器团队-陈东兴、李浩轩、陈金霞随着业务越来越复杂,性能优化已经成为每个技术人的必修课。性能优化从何入手?如何从问题表象中定位性能瓶颈?我如何验证优化是否有效?本文将介绍和分享vivo推送推荐项目中的性能调优实践,希望能为大家提供一些借鉴和参考。1.背景介绍在Push推荐中,在线服务从Kafka接收需要触及用户的事件,然后选择最适合这些目标用户的文章进行推送。该服务是用Java开发的,是CPU密集型的。随着业务的不断发展,请求并发量和模型计算量越来越大,导致项目出现性能瓶颈,Kafka消费积压严重,无法及时完成目标用户的分发,需求无法满足业务增长。因此,迫切需要专门的性能优化。2.优化指标和思路我们的性能指标是吞吐量TPS,由经典公式TPS=并发数/平均响应时间RT可知。如果需要提高TPS,有两种方式:增加并发数,比如增加单机的并行线程数,或者水平扩展的机器数;减少平均响应时间RT,包括应用线程(业务逻辑)执行时间,以及JVM本身的GC时间消耗。现实中,我们机器的CPU使用率已经很高了,达到80%以上,单机增加并发的预期收益有限,所以主要精力放在降低RT上。下面将从热点代码和JVMGC两个方面详细讲解,如何分析和定位性能瓶颈点,并用3个技巧将吞吐量提升100%。3.热点代码优化如何快速找到应用中最耗时的热点代码?借助阿里巴巴开源的arthas工具,我们得到了在线服务的CPU火焰图。火焰图说明:火焰图是根据perf结果生成的SVG图像,用于展示CPU的调用栈。y轴代表调用栈,每一层都是一个函数。调用堆栈越深,火焰越高,执行函数在顶部,其父函数在下面。x轴表示样本数。如果一个函数在x轴上占据的宽度越宽,说明它被采样的次数越多,也就是执行时间越长。请注意,x轴不代表时间,而是所有调用堆栈合并并按字母顺序排列。火焰图就是看顶层哪个函数占用的宽度最大。只要出现“平台期”,就表明该函数可能存在性能问题。颜色没有特殊意义,因为火焰图表示CPU的繁忙程度,所以一般选择暖色。3.1优化一:尽量避免使用原生的String.split方法3.1.1性能瓶颈分析从火焰图中我们首先发现有13%的CPU时间花在了java.lang.String.split方法上。熟悉性能优化的同学都会知道,原生的split方法是性能杀手,效率比较低,调用频繁会消耗大量资源。但是业务特征处理确实需要频繁的拆分。如何优化呢?通过分析split的源码和项目的使用场景,我们发现三个优化点:(1)业务中没有使用正则表达式,原生split在分隔符为2及以上时默认使用正则表达式字符方式;众所周知,正则表达式效率低下。(2)当分隔符为单个字符(且不是正则表达式字符)时,原生的String.split进行了性能优化处理,但是中间的一些内部转换处理在我们实际业务场景中是多余的,比较消耗性能。它的具体实现是:通过String.indexOf和String.substring方法实现切分处理,将切分结果存储在ArrayList中,最后将ArrayList转换为string[]输出。在我们的业务中,其实经常需要list类型的结果,list和string[]之间的转换多了2次。(3)业务中最常调用split的地方其实只需要split后的第一个结果即可;原生split方法或其他工具类有重载优化方法,可以指定limit参数,满足limit个数后提前返回;但是在业务代码中,使用str.split(delim)[0]并不是最好的性能。3.1.2优化方案针对业务场景,我们定制实现了split的性能优化版本。导入java.util.ArrayList;导入java.util.List;导入org.apache.commons.lang3.StringUtils;/***自定义拆分工具*/publicclassSplitUtils{/***自定义拆分函数,返回第一个A**@paramstr待拆分的字符串*@paramdelim分隔符*@return拆分后的第一个字符串*/publicstaticStringsplitFirst(finalStringstr,finalStringdelim){if(null==str||StringUtils.isEmpty(delim)){返回str;}intindex=str.indexOf(delim);如果(索引<0){返回海峡;}if(index==0){//在起始字符处定界,返回空字符串return"";}返回str.substring(0,索引);}/***自定义拆分函数,全部返回**@paramstr待拆分字符串*@paramdelim分隔符*@return拆分返回结果*/publicstaticListsplit(Stringstr,finalStringdelim){if(null==str){返回新的ArrayList<>(0);}如果(StringUtils.isEmpty(delim)){List结果=newArrayList<>(1);结果。添加(海峡);返回结果;}finalListstringList=newArrayList<>();while(true){intindex=str.indexOf(delim);如果(索引<0){stringList.add(str);休息;}stringList.add(str.substring(0,index));str=str.substring(index+delim.length());}返回字符串列表;}}与原来的String.split相比,有几个变化:放弃了对正则表达式的支持,只支持分隔符分割;输出参数直接返回列表拆分处理的实现,类似于原生实现中对单个字符的处理,使用string.indexOf和string.substring方法,将拆分结果放入列表,输出参数为直接返回列表,减少数据转换处理;提供了splitFirst方法,业务场景只需要在分隔符前为第一个字符串,进一步提升性能。3.1.3微基准测试如何验证我们的优化效果?首先选择jmh作为微基准测试工具,与原生的String.split和apache的StringUtils.split方法进行对比,测试结果如下:使用单个字符作为分隔符,可以看出性能native的实现与apache的工具类似,而custom的性能提升了50%左右。选择多个字符作为分隔符。当分隔符使用2个字符长度时,原实现的性能大大降低,只有单个char的1/3;而apache的实现也减少到原来的2/3,而自定义实现基本和原来一样。选择单个字符作为分隔符,只需要返回第一个分词结果。当使用单个字符作为分隔符且只需要第一个分词结果时,自定义实现的性能是原生实现的两倍,与原生实现相同。结果的5倍。3.1.4通过微基准测试验证端到端优化效果后,我们将优化部署到线上服务,验证端到端整体性能收益;重新使用arthas收集火焰图,split方法的耗时降低到2%左右;整体端到端耗时下降31.77%,吞吐量提升45.24%。性能提升尤为明显。3.2优化二:加快map的查表效率3.2.1性能瓶颈分析从火焰图中我们发现HashMap.getOrDefault方法占用的时间比例非常大,达到了20%,主要在查询权重map。这是因为:业务中确实需要高频调用,特征交叉处理后数量扩大,单机并发调用达到1000wops/s左右。权重图本身也非常大,存储超过1000万条,占用内存大;同时hash碰撞的概率也增加了,碰撞时的查询效率从O(1)降低到O(n)(链表)或者O(logn)(红黑树)。Hashmap本身就是一个非常高效的map实现。起初,我们尝试调整加载因子loadFactor或者切换到其他的map实现,但是并没有获得明显的收益。我们如何提高get方法的性能?3.2.2在分析优化方案的过程中,我们发现querymap的key(交叉处理后的特征key)是string类型,平均长度超过20;我们知道string的equals方法其实就是遍历比较char[]字符,key越长,比较效率越低。publicbooleanequals(ObjectanObject){if(this==anObject){返回真;}if(anObjectinstanceofString){StringanotherString=(String)anObject;intn=值.长度;if(n==anotherString.value.length){charv1[]=value;charv2[]=anotherString.value;诠释我=0;while(n--!=0){if(v1[i]!=v2[i])返回false;我++;}返回真;}}返回假;是否可以缩短密钥的长度,甚至将其更改为数值?通过一个简单的微基准测试,我们发现这个想法应该是可行的。于是和算法同学交流了一下。巧合的是,算法同学恰好也有同样的诉求。在换用新的训练框架的过程中,他们发现string的效率特别低,需要用numerical来代替features。一拍即合,很快确定了方案:算法同学将featurekey映射为longvalue,映射方式实现为自定义hash,尽量减少hash碰撞的概率;算法同学训练输出新模型的权重图,可以保留更多条目。平衡基线模型的效果指标;将baseline模型的效果指标调平后,上线服务器上的灰度新模型,将weightmap的key改为long类型,验证性能指标。3.2.3优化效果特征条目数量增加30%(模型效果超过baseline),工程性能也取得了显着的收益;整体端到端耗时下降20.67%,吞吐量提升26.09%;此外,在内存使用方面也取得了不错的收益,权重图的内存大小下降了30%。4.JVMGC优化Java设计中自动垃圾回收的目的是将应用程序开发人员从手动动态内存管理中解放出来。开发者不需要关心内存的分配和回收,也不需要关心分配的动态内存的生命周期。这以一些运行时开销为代价完全消除了一些与内存管理相关的错误。在小型系统上开发时,GC的性能开销可以忽略不计,但是当扩展到大型系统(尤其是那些数据量大、线程多、事务率高的应用程序)时,GC的开销就不可忽略,甚至可能成为重要的性能瓶颈。上图模拟了一个理想的系统,除了垃圾收集之外,它是完全可扩展的。红线表示在单处理器系统上仅花费1%的时间进行垃圾回收的应用程序。这意味着在具有32个处理器的系统上吞吐量损失超过20%。洋红色线表明,对于垃圾收集时间为10%的应用程序(这在单处理器应用程序中并不太长),当扩展到32个处理器时吞吐量损失超过75%。因此,JVMGC也是一个非常重要的性能优化措施。我们的推荐服务使用高端计算资源(64核256G),GC影响因素相当可观;通过对线上服务GC数据的采集和监控,我们发现我们服务的GC情况比较糟糕,每分钟YGC累计时间在10s左右。为什么GC开销这么大,如何减少GC耗时?4.1优化三:使用堆外缓存代替堆内缓存4.1.1性能瓶颈分析我们dump了服务存活的堆对象,使用mat工具进行内存分析,发现有2个对象特别巨大,占占总存活堆内存百分比的76.8%。其中:第一大对象是本地缓存,细粒度存储普通数据,单机数据量上千万;使用caffine缓存组件,缓存自动刷新周期设置为1小时;目的是尽量减少IO查询的次数;第二大对象是模型权重图本身,常驻内存,不会更新。新模型加载后,将作为旧模型卸载。4.1.2优化方案如何在避免GC压力过大的情况下尽可能多的缓存数据?我们想到将缓存对象移出堆,这样就不受堆内存大小的限制;并且堆外内存不受JVMGC控制,避免了缓存过大对GC的影响。经过研究,我们决定采用成熟的开源堆外缓存组件OHC。(1)OHC简介OHC的全称是off-heap-cache,即堆外缓存。是2015年为ApacheCassandra开发的缓存框架,后来从Cassandra项目中独立出来,成为一个单独的类库。项目地址为https://github.com/snazy/ohc。特征数据存放在堆外,堆中只存放少量元数据,不影响GC。支持为每个缓存项设置过期时间。支持配置LRU和W_TinyLFU驱逐策略。可以维护大量缓存项。支持异步加载。缓存读写速度以微秒为单位。Level(2)OHC使用快速入门:OHCacheohCache=OHCacheBuilder.newBuilder().keySerializer(yourKeySerializer).valueSerializer(yourValueSerializer).build();可选配置项:在我们的服务中,设置capacity为12G,segmentCount段数为1024,序列化协议使用kryo。4.1.3优化效果切换到堆外缓存后,服务YGC降低到每分钟800ms,整体端到端吞吐量提升了20%左右。4.2思考题在JavaGC优化中,我们将本地缓存对象从Java堆中移到了堆外,取得了很好的性能提升。还记得上面提到的其他巨型物体模型权重图吗?模型权重图是否也可以从Java堆中移除?答案是肯定的。我们使用C++重写了模型推理和计算部分,包括权重图的存储和检索、排序分数计算等逻辑;然后将C++代码输出为so库文件,以原生方式调用Java程序,将权重图移出Jvm堆,性能提升良好。五、结语通过上面介绍的三个措施,我们从热代码优化和JvmGC两个方面提升了服务负载和性能,整体吞吐量翻了一番,达到了阶段性目标。然而,性能调优是无止境的,每个业务场景、每个系统的实际情况也千差万别。很难用一篇文章涵盖和介绍所有的优化场景。希望本文介绍的一些实用的调优经验,比如如何确定优化方向,如何入手分析,如何验证收益,能够给大家一些借鉴和参考。