Java中常见性能优化策略的总结 - Java技术债务

文章目录


在软件开发中,性能优化是非常重要的一环。以下是常见的性能优化策略

1. 代码优化

之所以把代码放到第一位,是因为这一点最容易引起技术人员的忽视。很多技术人员拿到一个性能优化的需求以后,言必称缓存、异步、JVM等。实际上,第一步就应该是分析相关的代码,找出相应的瓶颈,再来考虑具体的优化策略。有一些性能问题,完全是由于代码写的不合理,通过直接修改一下代码就能解决问题的,比如:

  • 减少循环次数:循环是消耗时间的操作,应该尽可能减少循环次数。
  • 相同逻辑重复多次:重复计算会消耗很多时间,因此应该尽可能避免。
  • 使用缓存:缓存可以避免重复计算,提高性能。
  • 使用高效的算法和数据结构:高效的算法和数据结构可以显著提高程序性能。

2. 数据库层面优化

SQL调优

这是最常用、每一个技术人员都应该掌握基本的SQL调优手段(包括方法、工具、辅助系统等)。以MySQL为例,最常见的方式是,由自带的慢查询日志或者开源的慢查询系统定位到具体的出问题的SQL,然后使用explain、profile等工具来逐步调优,最后经过测试达到效果后上线。优化方式比如:索引优化:合理使用索引可以提高查询效率。避免全表扫描:全表扫描会消耗很多时间,应该尽可能避免。

架构层面的调优

这一类调优包括读写分离、水平和垂直分库分表等方面,一般需要的改动较大,但是频率没有SQL调优高,而且一般需要DBA来配合参与。我们可以通过内部监控报警系统(比如Zabbix),定期跟踪一些指标数据是否达到瓶颈,一旦达到瓶颈或者警戒值,就需要考虑这些事情。通常,DBA也会定期监控这些指标值。

  • 分库分表:数据量大的情况下,可以采用分库分表的方式提高查询效率。
  • **读写分离:**实现方法一是通过分析sql语句是insert/select/update/delete中的哪一种,从而对应选择主从,二是通过拦截方法名称的方式来决定主从

连接池调优

我们的应用为了实现数据库连接的高效获取、对数据库连接的限流等目的,通常会采用连接池类的方案,即每一个应用节点都管理了一个到各个数据库的连接池。随着业务访问量或者数据量的增长,原有的连接池参数可能不能很好地满足需求,这个时候就需要结合当前使用连接池的原理、具体的连接池监控数据和当前的业务量作一个综合的判断,通过反复的几次调试得到最终的调优参数。

3. 网络优化

减少网络请求次数:网络请求是消耗时间的操作,应该尽可能减少请求次数。

4. 缓存

缓存分类

  • 本地缓存:HashMap、ConcurrentHashMap、Ehcache、Guava Cache等
  • 缓存服务:Redis、Tair、Memcache

使用场景

什么情况适合用缓存?考虑以下两种场景:

  • 短时间内相同数据重复查询多次且数据更新不频繁,这个时候可以选择先从缓存查询,查询不到再从数据库加载并回设到缓存的方式。此种场景较适合用单机缓存。
  • 高并发查询热点数据,后端数据库不堪重负,可以用缓存来扛。

选型考虑

  • 如果数据量小,并且不会频繁地增长又清空(这会导致频繁地垃圾回收),那么可以选择本地缓存。
    • 如果需要一些策略的支持(比如缓存满的逐出策略),可以考虑Ehcache
    • 如不需要一些策略的支持,可以考虑HashMap
    • 如需要考虑多线程并发的场景,可以考虑ConcurentHashMap
  • 其他情况,可以考虑缓存服务。目前从资源的投入度、可运维性、是否能动态扩容以及配套设施来考虑,我们优先考虑Tair。目前Tair还不能支持的场合(比如分布式锁、Hash类型的value),我们考虑用Redis。

什么时候更新缓存?如何保障更新的可靠性和实时性?

更新缓存的策略,具体的细节可以看一下这个文章:redis缓存如何保证数据一致性

缓存是否会满,缓存满了怎么办?

对于一个缓存服务,理论上来说,随着缓存数据的日益增多,在容量有限的情况下,缓存肯定有一天会满的。

  • 给缓存服务选择合适的缓存逐出算法,比如最常见的LRU。可以看一下:redis内存的淘汰机制
  • 针对当前设置的容量,设置适当的警戒值,比如10G的缓存,当缓存数据达到8G的时候,就开始发出报警,提前排查问题或者扩容。
  • 给一些没有必要长期保存的key,尽量设置过期时间。

缓存是否允许丢失?丢失了怎么办?

根据业务场景判断,是否允许丢失。如果不允许,就需要带持久化功能的缓存服务来支持,比如Redis或者Tair。更细节的话,可以根据业务对丢失时间的容忍度,还可以选择更具体的持久化策略,比如Redis的RDB或者AOF

缓存被“击穿”问题

对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑另外一个问题:缓存被“击穿”的问题。

具体细节可以看:redis的缓存穿透、缓存雪崩、缓存击穿怎么搞?

5. 异步

使用场景

针对某些客户端的请求,在服务端可能需要针对这些请求做一些附属的事情,这些事情其实用户并不关心或者用户不需要立即拿到这些事情的处理结果,这种情况就比较适合用异步的方式处理这些事情。

作用

  • 缩短接口响应时间,使用户的请求快速返回,用户体验更好。
  • 避免线程长时间处于运行状态,这样会引起服务线程池的可用线程长时间不够用,进而引起线程池任务队列长度增大,从而阻塞更多请求任务,使得更多请求得不到技术处理。
  • 线程长时间处于运行状态,可能还会引起系统Load、CPU使用率、机器整体性能下降等一系列问题,甚至引发雪崩。异步的思路可以在不增加机器数和CPU数的情况下,有效解决这个问题。

常见做法

  • 额外开辟线程,这里可以采用额外开辟一个线程或者使用线程池的做法,在IO线程(处理请求响应)之外的线程来处理相应的任务,在IO线程中让response先返回。

    如果异步线程处理的任务设计的数据量非常巨大,那么可以引入阻塞队列BlockingQueue作进一步的优化。具体做法是让一批异步线程不断地往阻塞队列里扔数据,然后额外起一个处理线程,循环批量从队列里拿预设大小的一批数据,来进行批处理(比如发一个批量的远程服务请求),这样进一步提高了性能。

  • 使用消息队列(MQ)中间件服务,MQ天生就是异步的。一些额外的任务,可能不需要我这个系统来处理,但是需要其他系统来处理。这个时候可以先把它封装成一个消息,扔到消息队列里面,通过消息中间件的可靠性保证把消息投递到关心它的系统,然后让这个系统来做相应的处理。

    比如C端在完成一个提单动作以后,可能需要其它端做一系列的事情,但是这些事情的结果不会立刻对C端用户产生影响,那么就可以先把C端下单的请求响应先返回给用户,返回之前往MQ中发一个消息即可。而且这些事情理应不是C端的负责范围,所以这个时候用MQ的方式,来解决这个问题最合适。

6. JVM调优

什么时候调?

通过监控系统(如没有现成的系统,自己做一个简单的上报监控的系统也很容易)上对一些机器关键指标(gc time、gc count、各个分代的内存大小变化、机器的Load值与CPU使用率、JVM的线程数等)的监控报警,也可以看gc log和jstat等命令的输出,再结合线上JVM进程服务的一些关键接口的性能数据和请求体验,基本上就能定位出当前的JVM是否有问题,以及是否需要调优。

怎么调?

  1. 如果发现高峰期CPU使用率与Load值偏大,这个时候可以观察一些JVM的thread count以及gc count(可能主要是young gc count),如果这两个值都比以往偏大(也可以和一个历史经验值作对比),基本上可以定位是young gc频率过高导致,这个时候可以通过适当增大young区大小或者占比的方式来解决。
  2. 如果发现关键接口响应时间很慢,可以结合gc time以及gc log中的stop the world的时间,看一下整个应用的stop the world的时间是不是比较多。如果是,可能需要减少总的gc time,具体可以从减小gc的次数和减小单次gc的时间这两个维度来考虑,一般来说,这两个因素是一对互斥因素,我们需要根据实际的监控数据来调整相应的参数(比如新生代与老生代比值、eden与survivor比值、MTT值、触发cms回收的old区比率阈值等)来达到一个最优值。
  3. 如果发生full gc或者old cms gc非常频繁,通常这种情况会诱发STW的时间相应加长,从而也会导致接口响应时间变慢。这种情况,大概率是出现了“内存泄露”,Java里的内存泄露指的是一些应该释放的对象没有被释放掉(还有引用拉着它)。那么这些对象是如何产生的呢?为啥不会释放呢?对应的代码是不是出问题了?问题的关键是搞明白这个,找到相应的代码,然后对症下药。所以问题的关键是转化成寻找这些对象。怎么找?综合使用jmap和MAT,基本就能定位到具体的代码。

7. 多线程与分布式

适当地利用,可达到加速的效果。

使用场景

  • 离线任务
  • 异步任务
  • 大数据任务
  • 耗时较长任务的运行

注意:线上对响应时间要求较高的场合,尽量少用多线程,尤其是服务线程需要等待任务线程的场合(很多重大事故就是和这个息息相关),如果一定要用,可以对服务线程设置一个最大等待时间。

常见做法

如果单机的处理能力可以满足实际业务的需求,那么尽可能地使用单机多线程的处理方式,减少复杂性;反之,则需要使用多机多线程的方式。

对于单机多线程,可以引入线程池的机制,作用有二:

  • 提高性能,节省线程创建和销毁的开销
  • 限流,给线程池一个固定的容量,达到这个容量值后再有任务进来,就进入队列进行排队,保障机器极限压力下的稳定处理能力在使用JDK自带的线程池时,一定要仔细理解构造方法的各个参数的含义,如core pool size、max pool size、keepAliveTime、worker queue等,在理解的基础上通过不断地测试调整这些参数值达到最优效果。

可以看一下文章:线程池的理解以及使用Java创建线程池的方式

如果单机的处理能力不能满足需求,这个时候需要使用多机多线程的方式。这个时候就需要一些分布式系统的知识了。首先就必须引入一个单独的节点,作为调度器,其他的机器节点都作为执行器节点。调度器来负责拆分任务,和分发任务到合适的执行器节点;执行器节点按照多线程的方式(也可能是单线程)来执行任务。这个时候,我们整个任务系统就由单击演变成一个集群的系统,而且不同的机器节点有不同的角色,各司其职,各个节点之间还有交互。这个时候除了有多线程、线程池等机制,像RPC、心跳等网络通信调用的机制也不可少。后续我会出一个简单的分布式调度运行的框架。

   登录后才可以发表评论呦...

专注分享Java技术干货,包括
但不仅限于多线程、JVM、Spring Boot
Spring Cloud、 Redis、微服务、
消息队列、Git、面试题 最新动态等。

想交个朋友吗
那就快扫下面吧


微信

Java技术债务

你还可以关注我的公众号

会分享一些干货或者好文章

Java技术债务