《性能优化》并发与并行

简介: 性能优化系列第一篇主要给大家科普了一些性能相关的数字,为大家建立性能的初步概念。第二篇给大家介绍了支撑淘宝双十一这种达到百万QPS项目所需的相关核心技术。本文带来的是性能优化中的第一利器:并发与并行。

前言


性能优化系列第一篇主要给大家科普了一些性能相关的数字,为大家建立性能的初步概念。第二篇给大家介绍了支撑淘宝双十一这种达到百万QPS项目所需的相关核心技术。

本文带来的是性能优化中的第一利器:并发与并行。


除了核心原理介绍外,我将结合我自身的过去的实战经验,给出一些自己在使用上的建议,希望对大家有帮助。

不多废话,直接开怼。


正文


1、并发和并行?

并发和并行最关键的区别是:并行是同时执行,而并发不是同时。

这边使用Joe Armstrong 排队使用咖啡机的例子来看并行和并发的区别,如下图所示:

image.png

上半部分为并发:两个队伍交替使用咖啡机

下半部分为并行:两个队伍同时使用咖啡机

从和我们更相关的CPU的角度来看两者的区别。

并发是这样的:同一时刻只有一个任务执行。

image.png

并行是这样的:同一时刻有多个任务执行。

image.png

并发和并行结合起来是这样的:

image.png


2、并发一定能提升性能吗?


并行能提升性能大家不会有太多的疑问,但是并发是否一定能提升性能,估计还是有不少同学会有疑问。


答案是否定的,并发不一定能提升性能,但是在绝大多数场景都能提升性能。

什么场景下并发不能提升性能?


我们举个简单的例子:假设我们的服务器配置为单核CPU,要执行10个任务,10个任务都是CPU计算密集型任务,此时单线程执行效率理论上要比开10个线程执行要快。

在执行的整个过程中,基本都是CPU在运行,但是开十个线程会涉及到线程上下文切换,需要花费一些时间,导致反而更慢。


再举个更形象的例子:囧辉上语文开小差,被老师罚抄10篇课文,此时囧辉脑子里想到了两种方法。


方法1:先抄完第一篇,再抄第二篇,再抄第三篇,直到抄完第十篇。

方法2:先抄第一篇的第一段,再抄第二篇的第一段,...,再抄第十篇的第一段,再抄第一篇的第二段,直到抄完全部。


方法1为串行执行,方法2为并发执行,相信大家都能很容易看出方法二反而会更慢,因为我们在从切换不同文章时,需要先放好原来的文章,然后找新文章抄到哪个位置了,这个过程需要花费一些时间,这个过程就类似于线程上下文切换。

那什么场景下并发会提升性能了?


再举个例子:囧辉要烧10壶水,一壶水烧开的时间为1分钟。

串行执行:囧辉先烧第一壶,第一壶烧开了后接着烧第二壶,直到烧完第十壶,这个方法烧完十壶水大概需要10分钟。


并发执行:囧辉先烧第一壶,没等第一壶烧开,接着烧第二壶,就这样,囧辉一下子将十壶水都放到灶台上同时烧,这个方法烧完十壶水大概需要1分钟。


在这个场景里,并发执行就体现了很大的优化,性能提升了接近10倍。

在我们实际项目中,大部分应用场景都是第二类,因此并发大多时候能提升性能,而哪些动作是烧开水了,这个其实在性能优化第一篇里提到了,最常见的烧开水操作就是I/O操作,最常见的如:调用其他服务的RPC接口查询数据、查询MySQL数据库获取

数据等等。


3、实现方式

并发/并行的实现方式通常有两种,如下。

1)开线程直接怼,每循环一次都会新建一个线程来执行,例如下面代码,

public static void test() throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(10);
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            // 烧水
            boilingWater();
            countDownLatch.countDown();
        }).start();
    }
    // 等待处理结束
    countDownLatch.await();
}

2)使用线程池,例如下面代码。

public static void test() {
    List<Future> futureList = new ArrayList<>();
    for (int i = 0; i < 10; i++) {
        futureList.add(THREAD_POOL_EXECUTOR.submit(() -> {
            // 烧开水
            boilingWater();
        }));
    }
    for (Future future : futureList) {
        try {
            // 等待处理结束
            future.get();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

1是反例,实际项目中不要使用,就算只开1个线程,也要用线程池,因为每次创建和回收线程都是需要开销的。

下面用一个简单的demo来模拟烧开水的例子

public class BoilingWaterTest {
    /**
     * CPU的核数
     */
    private static final int NCPUS = Runtime.getRuntime().availableProcessors();
    /**
     * 创建线程池
     */
    private static final ThreadPoolExecutor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(
            NCPUS,
            NCPUS * 2,
            30,
            TimeUnit.MINUTES,
            new LinkedBlockingDeque<>(1000),
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy());
    public static void main(String[] args) throws Exception {
        serial();
        concurrent();
    }
    public static void serial() {
        // 串行执行
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10; i++) {
            boilingWater();
        }
        System.out.println("serial cost:" + (System.currentTimeMillis() - start));
    }
    public static void concurrent() throws InterruptedException {
        // 并发执行
        CountDownLatch countDownLatch = new CountDownLatch(10);
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10; i++) {
            THREAD_POOL_EXECUTOR.execute(() -> {
                boilingWater();
                countDownLatch.countDown();
            });
        }
        // 等待任务全部执行完毕
        countDownLatch.await();
        System.out.println("concurrent cost:" + (System.currentTimeMillis() - start));
    }
    public static void boilingWater() {
        try {
            // 烧开一壶水需要1秒
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

执行该方法输出如下,符合我们的预期。

serial cost:10091
concurrent cost:1048

此时并发执行的流程就如下图,从一个task拆出多个task,然后由每个CPU负责处理1个,因此处理时间接近于1个任务的处理时间。

image.png


4、线程池的参数设置

1)线程数

之前的线程池面试文章里有介绍过线程数的设置,这边直接复制过来:

要想合理的配置线程池大小,首先我们需要区分任务是计算密集型还是I/O密集型。

对于计算密集型,设置线程数 = CPU + 1,通常能实现最优的利用率。

对于I/O密集型,网上常见的说法是设置线程数 = CPU * 2 ,这个做法是可以的,但个人觉得不是最优的。


在我们日常的开发中,我们的任务几乎是离不开I/O的,常见的网络I/ORPC调用)、磁盘I/O(数据库操作),并且I/O的等待时间通常会占整个任务处理时间的很大一部分,在这种情况下,开启更多的线程可以让 CPU 得到更充分的使用,一个较合理的计算公式如下:


线程数= CPU * CPU利用率 * (任务等待时间/ 任务计算时间 + 1)

例如我们有个定时任务,部署在4核的服务器上,该任务有100ms在计算,900msI/O等待,则线程数约为:4 * 1 * (1 + 900 / 100) = 40个。

当然,具体我们还要结合实际的使用场景来考虑。如果要求比较精确,可以通过压测来获取一个合理的值。


上述是比较理想的线程数计算方式,在实际项目使用中,如果无法很准确的计算,那么可以先用我上面的线程池配置,也就是:


corePoolSize = CPU核数

maximumPoolSize = CPU核数 * 2

这个参数设置可能不是最理想的,但在大多数情况下都是一个还不错的选择,比较合适。


2keepAliveTimeTimeUnit

这两个参数一起决定了非核心线程空闲后的存活时间。

这两个参数说实话并不是非常重要,实际使用过程中不要设置太离谱的值一般问题不大,我个人一般使用5分钟或30分钟。


3workQueue

工作队列,当核心线程处理不过来时,任务会堆积在队列里。

常见的队列有ArrayBlockingQueue LinkedBlockingQueue,两者的主要区别在于 ArrayBlockingQueue 占用空间会更小,而 LinkedBlockingQueue 在生产者和消费者使用了不同的锁性能会好一点。


通常情况下,两者的区别微乎其微,除非你要处理的任务量非常非常大,此时你需要仔细考虑使用哪个更合适,否则通常情况下两个随便选都可以。


常见的坑:使用LinkedBlockingQueue 时没设置队列大小,也就是使用了无界队列(Integer.MAX_VALUE),任务处理不过来,不断积压在队列里,最终造成内存溢出。

线程池使用不当导致内存溢出的case我已经见过很多次了,这个经验大家一定要铭记在心:使用LinkedBlockingQueue 一定要设置队列大小。


另外,这边给大家介绍下另一个我常用的工作队列:SynchronousQueue

SynchronousQueue 不是一个真正的队列,而是一种在线程之间移交的机制。要将一个元素放入SynchronousQueue 中,必须有另一个线程正在等待接受这个元素。如果没有线程等待,并且线程池的当前大小小于 maximumPoolSize,那么线程池将创建一个线程,否则根据拒绝策略,这个任务将被拒绝。使用直接移交将更高效,因为任务会直接移交给执行它的线程,而不是被放在队列中,然后由工作线程从队列中提取任务。只有当线程池是无界的或者可以拒绝任务时,该队列才有实际价值,

Executors.newCachedThreadPool使用了该队列。


上述内容里提到了:当线程池是无界的或者可以拒绝任务时,该队列才有实际价值。

使用无界的线程池说实话挺危险的,我强烈建议不要使用,特别是经验不太丰富的新人。因此我们在使用 SynchronousQueue 的时候可以理解为一定会出现任务被拒绝的情况,因此要选择好合适的拒绝策略。


SynchronousQueue 我一般会搭配 CallerRunsPolicy 使用,个人觉得这2个是个绝佳组合,这个组合起到的效果是:当线程池处理不过来时,直接交由调用者线程(往线程池里添加任务的主线程)来执行,此时任务不会被积压在队列里,同时调用者线程无法继续提交任务。


简单来说:任务处理非常高效,没有任务积压的概念不会有内存溢出的风险,同时在线程池处理不过来时具有控制任务提交速度的效果。


4ThreadFactory

线程工厂,这个没啥好说的,通常使用默认的就行。

常见的改动场景是:给线程设置个自定义的名字,方便区分。

这种场景下,可以使用一些工具类提供的现有方法,也可以将 DefaultThreadFactory 拷贝出来自己修改一下。


5RejectedExecutionHandler

拒绝策略,线程池处理不过来时的策略。默认有4种策略,其中3种我个人比较常用到。

AbortPolicy:默认的策略,直接抛出异常,没有特殊需求直接使用该策略即可。

CallerRunsPolicy:调用者线程执行策略,该策略上面提到了,我一般是配合SynchronousQueue 使用,起到一个控制任务提交速度的效果。

DiscardPolicy:抛弃策略,直接丢掉要提交的任务,这个策略一般在线程池执行的是不太重要的任务时使用。


5、并发并行适用于哪种场景

典型的适合使用并发并行的场景通常有以下特点:

1)存在I/O操作,并且I/O操作有多次,最典型的就是RPC调用和查询数据库

2I/O操作比较耗时,越耗时越有优化价值

3)多次I/O操作之间没有依赖关系,可以同时调用


总结



并发和并行是性能优化中非常常用的手段,使用起来非常简单,并且带来的性能提升通常非常明显,很容易就有几倍几倍的提升,快在自己的项目中用起来吧。


最后


我是囧辉,一个坚持分享原创技术干货的程序员,如果觉得本文对你有帮助,欢迎一键三连。


推荐阅读



Java 基础高频面试题(2021年最新版)

Java 集合框架高频面试题(2021年最新版)

面试必问的 Spring,你懂了吗?

面试必问的 MySQL,你懂了吗?

相关文章
|
4月前
并发与并行的区别(详细介绍)
并发与并行的区别(详细介绍)
649 0
|
6月前
并发和并行以及他们的区别
并发:         并发指的是多个任务交替执行的能力,这些任务可能不是同时执行,而是通过快速切换在不同任务之间来实现“同时执行”的效果。在多核处理器上,多个线程可以真正同时执行,而在单核处理器上,线程之间通过时间片轮转实现并发。         所以当谈论并发的时候一定要加个单位时间,也就是说单位时间内并发量是多少?离开了单位时间其实是没有意义的。 并行:         并行指的是多个任务同时执行的能力,每个任务都在独立的CPU上执行。并行通常用于同时处理独立任务,这些任务可以同时执行,而不需要相互等待或协同工作。 两者区别:         关键区别在于并发强调任务在时间上交替执行
40 0
|
1月前
|
Java
优化并发程序性能:锁的调优技巧
优化并发程序性能:锁的调优技巧
18 0
|
1月前
|
调度 数据库 计算机视觉
并行和并发的区别(详细)
并行和并发的区别(详细)
|
5月前
|
存储 缓存 Linux
高效利用CPU缓存一致性:优化技巧与策略分析
高效利用CPU缓存一致性:优化技巧与策略分析
|
7月前
|
机器学习/深度学习 缓存 Linux
很底层的性能优化:让CPU更快地执行你的代码
很底层的性能优化:让CPU更快地执行你的代码
|
9月前
|
存储 并行计算 安全
并发和并行的区别
并发和并行的区别
|
11月前
串行、并行和并发的区别
串行、并行和并发的区别
|
canal 存储 缓存
大厂都是如何对高并发系统做性能优化的?(上)
高并发系统的奥义:高性能、高可用、可扩展。 性能反应了系统的使用体验 都是上万QPS的系统,一个响应时间毫秒级,一个秒级,用户体验明显不同 可用性则表示系统可以正常服务用户的时间 上万QPS的系统,一个可全年不停机且无异常,一个隔三差五就宕机 可扩展性 流量可分为平时流量、峰值流量。峰值流量可能会是平时流量的几倍至几十倍,在应对峰值流量时,通常需在架构方案上做更多准备。易于扩展的系统能在短期内迅速扩容,更加平稳分摊峰值流量。
295 0
大厂都是如何对高并发系统做性能优化的?(上)
|
存储 编译器 调度
并发和并行有什么区别
并发和并行有什么区别
132 0

热门文章

最新文章

相关实验场景

更多