性能优化:软件代码性能优化

用户头像
NORTH
关注
发布于: 2020 年 07 月 29 日
性能优化:软件代码性能优化

广义上讲,性能优化可分为前端优化和后端优化,前端优化主要关注加载速度和交互流畅度,而后端优化则更关注响应时间、并发数以及系统的稳定性。在前端可以通过浏览器缓存、使用页面压缩、合理布局页面、减少Cookie传输、CDN等手段改善性能。而在后端则可以通过本地或分布式缓存、多线程、异步等手段提高系统整体处理能力,优化后端性能。



从前到后,给这些优化手段做个简单分类:

  • 架构性能优化手段:缓存、异步、集群

  • 前端性能优化手段:加载优化、执行优化、渲染优化、样式优化、脚本优化

  • 应用性能优化手段:性能优化模式、并发编程、资源复用、数据结构与算法、JVM调优、参数调优等

  • 存储性能优化手段:数据库优化 (索引、缓存、SQL等)、I/O优化、分布式文件系统等

  • 其他性能优化手段:操作系统参数调优、提升硬件等



其中,架构优化三板斧前面已经说过很多,最下层的操作系统参数调优以及提升硬件等手段可酌情考虑,它们都不在本文论述范围内。



前端性能优化手段中,像减少HTTP请求、缓存资源、压缩代码、压缩图片、减少Cookie等都属于加载优化;CSS写在头部、JS写在尾部、避免img等标签的src属性为空等都属于执行优化;减少DOM节点、动画优化、高频事件优化等属于渲染优化;避免在HTML中写style、避免CSS表达式、不滥用web字体、标准化各种浏览器前缀等都属于样式优化;避免不必要的DOM操作、避免document.write的使用、缓存DOM选择与计算等属于脚本优化。更多的前端性能优化指南,可以阅读参考资料。



接下来,我们从应用端性能优化中最最基础的部分说起,即如何在代码层面优化系统性能。

合理使用数据结构

在项目初期,虽然不必过于在意性能优化,但仍有很多手段来保证代码质量,提高系统性能。但使用这些手段之前,请务必保证:不要使用任何你不知道背后原理的优化技巧。下面会总结几个可能与大家直觉相左的例子来说明这一点有多重要。



就拿集合来说,大家都知道,ArrayList是基于数组实现,而LinkedList是基于双向链表。对于它们,有一个常见的说法是“使用 ArrayList 做新增删除操作会影响效率”,但真的如此吗?



这种说法的依据是,ArrayList在添加或删除元素之前,会先检查容量大小,如果容量不够大,会按照原来数组的1.5倍大小进行扩容,在扩容后还需要将原来的数组复制到新分配的内存地址。



但扩容的前提是容量不够大,如果容量足够的话,就不会涉及到扩容。如果在初始化时,就指定了足够大的数组容量,且只是在数组末尾添加元素 ( 如果不指定插入位置,都是这样 ),那么ArrayList在大量新增元素的场景下,性能并不会变差,反而比 LinkedList 的性能要好。因此,在大多数不涉及删除的情况下,可以直接使用ArrayList,并且在初始化时,指定数组容量。



再比如,Java8引入的Stream API,可以通过Lambda表达式对集合进行各种非常便利、高效的聚合操作。它不仅可以通过串行的方式实现数据操作,还可以通过并行的方式处理大批量的集合数据,来提升处理效率。



但 Stream 的整个处理流程是很复杂的,如果只是简单的遍历,且数据量很小的话,常规的迭代方式性能反而会更好;在单核 CPU 服务器配置环境中,也是常规迭代方式更有优势;而在大数据循环迭代中,如果服务器是多核 CPU 的情况下,Stream 的并行迭代才更有优势。

多线程性能优化

在并发编程中,如果有多个线程对同一共享资源进行访问,一般我们会使用锁的保证数据的安全性。只要有锁,就会存在竞争,就会导致上下文切换,带来额外的系统开销。因此,多线程性能优化,都是围绕锁来展开的。



先从简单的说起,最简单的优化手段即减少锁的持有时间,比如使用同步代码块来代替同步方法,将不必要的逻辑处理移出同步代码块:



public synchronized void doSomething() {
businesscode();
mutex();
businesscode();
}



优化成:



public void doSomething() {
businesscode();
synchronized (this) {
mutex();
}
businesscode();
}



第二种有效的优化手段是降低锁的粒度。像 Synchronized 和 ReentrantLock 都是独占锁,也就是不管请求是读还是写,都会被阻塞。但大多数业务场景中,多个线程来读共享资源,是没有必要加锁的。这种情况下,可以使用读写锁来代替独占锁。



针对读多写少的场景,Java提供 ReentrantReadWriteLock,它允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问。ReentrantReadWriteLock 的使用模板大致如下:



public class RWL {
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 读锁
private Lock readLock = lock.readLock();
// 写锁
private Lock writeLock = lock.writeLock();
public E read() {
readLock.lock();
try {
// do something
} finally {
readLock.unlock();
}
}
public void write() {
writeLock.lock();
try {
// do something
} finally {
writeLock.unlock();
}
}
}



ReentrantReadWriteLock 能够很好的解决读大于写的并发场景,但它也不是没有缺点,当读线程很多的情况下,会导致写线程迟迟无法竞争到锁而一直处于等待状态。在 JDK1.8 中,Java 提供了 StampedLock 类解决了这个问题。



这就是第三种优化手段:用乐观锁代替竞争锁



StampedLock 相比于读写锁,改进之处在于:读的过程中也允许写线程获得锁后写入数据,但这可能导致我们读的数据不一致,所以,这里需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。



乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。



通过一个官方的例子来了解下 StampedLock 是如何使用的:



public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}



和读写锁相比,写入的加锁是完全一样的,不同的是读取。注意到首先我们通过 tryOptimisticRead() 获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过 validate() 去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。



StampedLock 还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在if-then-update的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。



StampedLock 把读锁细分为乐观读和悲观读,能进一步提升并发效率,但 StampedLock 并没有被广泛运用到实际场景中,最大的原因是它不支持可重入,不能在一个线程中反复获取同一个锁。



总结起来,上述三种优化手段的核心思路都是:降低锁竞争。在 Synchronized 同步锁中,通过减少锁的占用时间来降低竞争;而读写锁在同步锁的基础上,降低锁粒度,通过读写锁来优化读大于写的场景;而 StampedLock 实现了乐观读锁,在大多数情况下可以代替悲观读锁,进一步降低锁竞争,促使系统的并发性能达到最佳。



最后还有一点需要注意,常用的很多数据结构,比如 HashMap、ArrayList 等都不是线程安全的,在并发编程中,在合适的场景,尽量使用并发容器来存储或读取对象。常用的有:ConcurrentHashMap、ConcurrentSkipListMap、CopyOnWriteArrayList等,它们的适用场景可自行搜索。

性能优化模式

谈到模式时,脑袋中马上跳出设计模式,设计模式通常用来来解决设计僵化、系统脆弱以及可维护性等问题,其实,性能优化也存在一些很常用的解决方案,称之为性能优化模式。比如,用分布式消息队列来实现瞬时高并发的流量削峰,就是一种常见的性能优化模式。



接下来介绍几种常见的性能优化模式。



首先是请求分割模式,即将整个请求流程切分成多个相互依赖的stage,每个stage中包含相互独立的多种业务逻辑处理。切分完成后,stage内部可以并行处理,多个stage串行处理,这样,一次请求的总耗时,就等于每个stage耗时的总和,而每个stage耗时则约等于其内部最长的业务处理时间。当然,如果每个stage能够串行处理肯定更好了,但实际业务场景中,这样的情况还是比较少。



使用水平分割模式,能够有效降低请求耗时,但缺点是需要对业务间的依赖关系进行分析和梳理,复杂的系统进行这项工作,真的是挺令人纠结的;除此之外,并行处理引入的多线程问题,又让代码变得更加复杂和脆弱,因此,请谨慎使用此模式。



其次是Thread-Per-Message模式,翻译过来就是每个消息一个线程的意思,最常见的例子就是用线程池来处理 Socket 请求,即一个线程监听I/O事件,每当这个线程监听到一个I/O事件,则交给另外一个处理线程执行I/O操作。虽然,在网络编程中已经很少使用这种模式,但这并不妨碍我们使用它。比如,业务处理完成后,需要通知第三方系统时,可以创建任务提交到线程池来处理这部分逻辑;还有些时候,只需要处理某些业务逻辑,并不需要返回结果,也可以采用这种模式。



还想介绍一个模式叫上下文模式,这里的上下文指的是贯穿整个请求生命周期的一些全局对象。我们熟知的 Spring 中的 ApplicationContext 类,它在整个系统的生命周期中保存了配置信息、用户信息以及注册的 bean 等上下文信息。



有时候,在整个请求的生命周期内,也需要保存一些全局信息,这些信息,除了常见的用户信息,还可以是权限数据、业务配置、数据库配置等。有这样一个上下文对象,在整个请求链条上都可以随时获得自己想要的数据,甚至可以通过把它放到 HTTP 请求的 header 中,传递到其他服务中去。



上下文模式的最大好处,还是解耦。比如,想把相互依赖的代码分割成多个 stage,就可以创建上下文,在前面的 stage 处理完成后,把结果扔到 context 中,后面的 stage 直接从 context 中取值即可。这样所有stage方法的参数只有一个 context 对象,可以很方便的把一个很长的大方法拆分成多个逻辑独立的小方法。



典型的上下文定义如下:



public class RequestContext {
// 全局数据
private RequestContextDTO mongoContextDTO;
// 使用 TransmittableThreadLocal 可以把上下文数据传递给子线程
private static TransmittableThreadLocal<MongoContext> HOLDER = new TransmittableThreadLocal<>() {
@Override
protected RequestContext initialValue() {
return new RequestContext();
}
};
public static RequestContext get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}



透过这3种模式可以看出,性能优化模式相对于设计模式来说,应用范围更窄一下。更多的情况是,企业内部,根据自己的业务场景总结出一套适合自己的性能优化模式,它们可以很好的解决自身的问题,但并不一定适合其他企业。因此,在使用前人总结的性能优化模式时,需要根据实际的业务情况做相应的调整。

最后

未完待续……



封面图:Markus Winkler

参考资料

前端性能优化指南

使用StampedLock

性能优化模式

Java性能调优实战

发布于: 2020 年 07 月 29 日 阅读数: 153
用户头像

NORTH

关注

Because, I love. 2017.10.16 加入

这里本来应该有简介的,但我还没想好 ( 另外,所有文章会同步更新到公众号:时光虚度指南,欢迎关注 ) 。

评论

发布
暂无评论
性能优化:软件代码性能优化