如果UGC内容体系激励策略单一,用户为获得京豆而应付式晒单,就会造成内容质量欠佳。为了促进用户生产高质量的晒单内容,展现商品的核心价值,以提升用户参与度和UGC内容质量。从2020年3月起我们团队开发了一款围绕生活场景的有奖话题晒单种草平台:啊哈时刻。
它的总体架构是这样的:
随着产品开发节奏逐渐平稳,在工作中也逐渐的开始关注服务器性能问题。在此之前,处理性能问题时通常直接采用服务器扩容的方式来解决。但这种方式会导致服务器采购成本的直线提升。所以这一次我想从程序代码、JVM配置等方面入手来对服务器的性能进行一个调优,以此提升服务器的性能。
本章我将分享一些在性能优化的过程中所学习到的知识和经验。借此与其他同样关注服务器性能调优的同学做一个交流。
在做性能调优时采用的是对单台服务器进行压测的方法,在CPU使用率达到60%左右时观察接口的TP99和QPM值。通过不断的优化代码、调整JVM参数来观察优化效果。
本次做性能调优是SOA这一层,从架构图中就能够看出SOA承担了整个系统中比较重要的工作。其负责将多个上游所输出的原始数据,根据不同平台客户端的需求对数据进行拼装 。SOA在一定程度上会成为整个系统的性能瓶颈。本次性能优化主要关注了线程池、内存分配和JVM即时编译三个方面。
在线程池优化这一块,我是直接使用的java原生的线程池实现。最初想法是用户量并不大,不需要开启太多线程。所以最初使用的是由ExecutorService.newCachedThreadPool()方法构造的线程池,通过压测发现使用该类型线程池接口的QPM只能达到几百次调用,这样成绩是肯定不能接受的。利用jstack工具打印线程快照发现服务器开启了非常多的线程,而绝大多数的线程都处于等待状态。这势必将导致CPU在线程调度上有很大的开销,虽然说I/O密集型的系统可以适当多开线程,但是当线程数达到数百甚至上千时CPU也是吃不消的。
通过阅读上图的JAVA源码片段可以看出,ExecutorService.newCachedThreadPool()方法构造的线程池。其实是一个核心线程数为0、最大线程数为2的31次方减1个、线程空闲等待时间是60秒且使用SynchronousQueue队列的线程池,SynchronousQueue队列是一个不会保存任何任务,它会直接把新加入的任务交给线程池来执行。而此时线程池会在无可复用线程时一直会创建新的线程,直到线程数量达到最大线程数。
随后我打算使用具有一定数量核心线程并配合用一个可以保存任务且长度受限的任务队列的线程池来改善系统性能。但有一个核心线程开多少合适的问题。核心线程如果开的太多但实际任务没那么多时会造成一些线程一直处于等待状态,如果开的太少又无法充分发挥系统性能,因为在使用长度受限的任务队列的线程池时,如果任务队列没有充满时是不会开更多的线程来执行任务的。
通过阅读源码发现,在配置线程池时指定的任务队列最终会由ThreadPoolExecutor类中的私有成员workQueue引用,虽说是私有成员但通过ThreadPoolExecutor类中的一个公有方法getQueue可以实现在ThreadPoolExecutor子类中获取他持有的任务队列实例。同时ThreadPoolExecutor类中有一个被protected修饰的抽象方法beforeExecute。这个方法是可以在新任务加入到线程池开始执行前被调用的一个方法。至此头脑中有了一个想法,是不是可以实现一个ThreadPoolExecutor子类然后实现beforeExecute方法,在每次任务添加到线程池中时都通过getQueue方法拿到线程池的任务队列,然后检查当时队列的长度。看当压测时最多会被加入多少个任务。
上图是我实现的ThreadPoolExecutor的一个子类通过实现beforeExecute方法并加入相关代码,实现了在压测时能够输出任务队列中任务数。经过不断的尝试,最终将线程池的核心线程数设定在了90个线程。并在应用初始化后主动调用线程池的预热方法启动所有核心线程数。通过这两项优化,彻底解决了异步线程数量不可控和核心线程第一次启动时的启动耗时问题。在QPM的表现上,优化后QPM提升了将近40%。
在内存使用效率这块,首先想到的检查JVM堆中的对象是否存在可复用的情况。可以通过使用jmap工具抓取jvm堆中对象的个数统计信息。
上图是我对分析日志的一个截取,这个名为MenuItemVo的类是每个内容数据的扩展菜单项。通过对业务的场景的分析发现其实只需要实例化几个固定的MenuItemVo对象持续的复用就可以,根本不需要创建更多的对象。创建的越多后续对垃圾回收造成的压力一定会越大,间接的影响了系统的性能。
随后关注点移至内存的分代管理。系统是基于Oracle JRE 1.8运行的,采用G1垃圾回收器。虽然说G1垃圾回收器对内存的分代有了重新的定义和实现,但其根本还是遵循着内存分代管理的思路。在对项目的代码分析后实现发现,每个接口执行过程中创建的对象实例,都是被局部变量所引用。一个方法或一个接口请求过程结束后这些对象在完成序列化后就再无用处,很有可能就会被下一次垃圾回收所销毁掉。我想到通过检查一下JVM的JMAP日志,来验证我的发现是否正确。
上面两幅图是我截取的在优化之前JMAP的日志。可以看出在默认情况下新老生代比例是2(图1中第16行)在整个JVM堆内存设置为5G时(图1中第12行)最大的新生代大小为3G(图1中第14行)。从实际使用情况来看大部分对象的生存期都非常的短,容量将近2G的老生代却只使用了12%(图2中44-48行),而新生代的Eden区的使用比达到了73%以上(图2中32-36行)。通过对日志的分析可以发现,如果我们使用默认的新老生代配置将会有将近1个G以上的内存空间是浪费的。所以我打算抛弃G1的自动调整策略,通过参数-Xnm来手动指定新生代的大小。通过不断的尝试,最终在整个堆大小设置为5G的情况下,把新生的大小设置为了4G进一步压缩了老年代的空间占用。通过此项优化,GC的执行频率下降了将近10%,QPM提升了将近5%。
我们都知道Oracle JRE中默认的JVM虚拟机是HotSpot,其最主要的特点之一是其JIT技术。但JIT技术有一个绕不过去的痛点是其在运行过程中对程序热点的分析所引入的系统性能损耗。而Oracle JRE 1.8支持通过参数-Xcomp来使HotSpot虚拟机运行于编译模式。在增加这个参数后发现系统的启动时间直接延长了2倍以上,最初觉得如果能通过延长启动时间来换取系统运行期间的性能提升也是一件非常值得去做的事。但压测发现,在开启了JVM编译模式后性能反而下降了10%至15%。这让我非常的不理解,为什么将java字节码编译成本地代码后反而会下降呢。按说在系统未完全启动时进行字节码的编译,应该会比运行时编译应用的优化技术更多更复杂才对,为什么性能会变差呢。后来通过调研得知当通过-Xcomp使HotSpot虚拟机运行于编译模式时其实并没有启动全部的优化策略,所以并不是说编译模式就一定会比混合模式好。最后我们通过使用-XX:CompileThreshold参数来降低JIT编译的阈值来使即时编译提前介入。
通过上述的一系列操作,最终使系统的性能提升了将近50%。但系统的性能调优是一个持续的过程,所以后续在性能调优这条道路上还是有很多的思路和想法值得我们去尝试的。