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

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

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

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

前端性能优化手段中,像减少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性能调优实战

Comments

Post a Message

人生在世,错别字在所难免,无需纠正。

提交评论