我会手动创建线程,为什么让我使用线程池?

为什么要使用多线程?

防止并发编程出错最好的办法就是不写并发程序,既然多线程编程容易出错,为什么它还经久不衰呢?

并发编程适用于什么场景?

  • 并发编程在所有场景下都是快的吗?
  • 知道它很快,何为快?怎样度量?
    使用多线程就是在正确的场景下通过设置正确个数的线程来最大化程序的运行速度,翻译到硬件级别就是:充分利用CPU和I/O的利用率
    两个正确得到保证,也就能达到最大化利用 CPU 和 I/O的目的了。最关键是,如何做到两个【正确】?
    先来看看两个名词:
  • CPU密集型程序
  • I/O密集型程序

CPU密集型程序

一个完整请求,I/O操作可以在很短时间内完成, CPU还有很多运算要处理,也就是说 CPU 计算的比例占很大一部分。
比如我们要计算 1+2+....100亿 的总和,很明显,这就是一个 CPU 密集型程序。
在【单核】CPU下,我们创建4个线程来分段计算,即
1、线程1计算 [1,25亿)
2、......
3、线程4计算 [75亿,100亿]

微信图片_20200524232331.png

由于是单核 CPU,所有线程都在等待 CPU 时间片。按照理想情况来看,四个线程执行的时间总和与一个线程5独自完成是相等的,实际上我们还忽略了四个线程上下文切换的开销:所以,单核CPU处理CPU密集型程序,这种情况并不太适合使用多线程
此时如果在 4 核CPU下,同样创建四个线程来分段计算,看看会发生什么?
微信图片_20200524232537.png

每个线程都有 CPU 来运行,并不会发生等待 CPU 时间片的情况,也没有线程切换的开销。理论情况来看效率提升了 4 倍:所以,如果是多核CPU 处理 CPU 密集型程序,我们完全可以最大化的利用 CPU 核心数,应用并发编程来提高效率

I/O密集型程序

与 CPU 密集型程序相对,一个完整请求,CPU运算操作完成之后还有很多 I/O 操作要做,也就是说 I/O 操作占比很大部分。
我们都知道在进行 I/O 操作时,CPU是空闲状态,所以我们要最大化的利用 CPU,不能让其是空闲状态。
同样在单核 CPU 的情况下:

微信图片_20200524232710.png

每个线程都执行了相同长度的 CPU 耗时和 I/O 耗时,如果你将上面的图多画几个周期,CPU操作耗时固定,将 I/O 操作耗时变为 CPU 耗时的 3 倍,你会发现,CPU又有空闲了,这时你就可以新建线程 4,来继续最大化的利用 CPU。
所以我们可以得出:线程等待时间所占比例越高,需要越多线程;线程CPU时间所占比例越高,需要越少线程。

创建多少个线程合适?

从上面知道,我们有 CPU 密集型和 I/O 密集型两个场景,不同的场景当然需要的线程数也就不一样了。

CPU 密集型程序创建多少个线程合适?

有些同学早已经发现,对于 CPU 密集型来说,理论上 线程数量 = CPU 核数(逻辑) 就可以了,但是实际上,数量一般会设置为 CPU 核数(逻辑)+ 1, 为什么呢?
《Java并发编程实战》这么说:
计算(CPU)密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下CPU周期不会中断工作。
所以对于CPU密集型程序, CPU 核数(逻辑)+ 1 个线程数是比较好的经验值的原因了。

I/O密集型程序创建多少个线程合适?

上面已经让大家按照图多画几个周期(你可以动手将I/O耗时与CPU耗时比例调大,比如6倍或7倍),这样你就会得到一个结论,对于 I/O 密集型程序:
最佳线程数 = (1/CPU利用率) = 1 + (I/O耗时/CPU耗时)
这是一个CPU核心的最佳线程数,如果多个核心,那么 I/O 密集型程序的最佳线程数就是:最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (I/O耗时/CPU耗时))
说到这,有些同学可能有疑问了,要计算 I/O 密集型程序,是要知道 CPU 利用率的,如果我不知道这些,那要怎样给出一个初始值呢?
按照上面公式,假如几乎全是 I/O耗时,所以纯理论你就可以说是 2N(N=CPU核数),当然也有说 2N + 1的
谈完理论,咱们说点实际的:
1、我怎么知道具体的 I/O耗时和CPU耗时呢?
2、怎么查看CPU利用率?
其实有很多 APM (Application Performance Manager)工具可以帮我们得到准确的数据,学会使用这类工具,也就可以结合理论,在调优的过程得到更优的线程个数了:比如SkyWalking,CAT等;

面试题

一、假设要求一个系统的 TPS(Transaction Per Second 或者 Task Per Second)至少为20,然后假设每个Transaction由一个线程完成,继续假设平均每个线程处理一个Transaction的时间为4s,如何设计线程个数,使得可以在1s内处理完20个Transaction?
答:一个线程处理一个Transaction要4s,那么一个线程的TPS为0.25,20TPS就需要80个线程。
但是这没有考虑到CPU的数目,一般服务器的CPU核数为16或者32,如果有80个线程,那么肯定会带来太多不必要的线程上下文切换开销
二、计算操作需要5ms,DB操作需要 100ms,对于一台 8个CPU的服务器,怎么设置线程数呢?
答:CPU核心数(1+I/O操作时间/CPU操作时间) = 8 * (1+100/5)= 168个
那如果DB的 QPS(Query Per Second)上限是1000,此时这个线程数又该设置为多大呢?
答:一个线程的QPS=1000/(5+100),168个线程就是168
(1000/(5+1000))=1600QPS,因为DB最大QPS=1000,只有 168*(1000/1600)个线程使用,即105个,同样考虑到CPU的数目,需要调优,因为一次请求不仅仅包括 CPU 和 I/O操作,具体的调优过程还要考虑内存资源,网络等具体内容。

增加CPU核心数一定能解决问题吗?

首先看一下阿姆达尔定律:

微信图片_20200524234855.png

就是说假如我们的串行率是 5%,那么我们无论采用什么技术,最高也就只能提高 20 倍的性能。
Tips: 临界区都是串行的,非临界区都是并行的,用单线程执行临界区的时间/用单线程执行(临界区+非临界区)的时间就是串行百分比。所以我们在编程时记住:最小化临界区范围,因为临界区的大小往往就是瓶颈问题的所在

多线程不一定就比单线程高效,比如大名鼎鼎的 Redis (后面会分析),因为它是基于内存操作,这种情况下,单线程可以很高效的利用CPU。而多线程的使用场景一般时存在相当比例的I/O或网络操作。

但是在现实中,你可能听过或者被要求过:
尽量避免手动创建线程,应使用线程池统一管理线程

手动创建线程有什么缺点?

1、不受控的风险
2、频繁创建开销大

不受控的风险

系统资源有限,每个人针对不同业务都可以手动创建线程,并且创建标准不一样(比如线程没有名字)。当系统运行起来,所有线程都在疯狂抢占资源,无组织无纪律,混乱场面可想而知,而且解决起来相当困难。如果为每个请求都创建一个线程,当大量请求铺面而来的时候,这好比一个正规木马程序,内存被无情榨干耗尽。另外,过多的线程自然也会引起上下文切换的开销,总的来说,不受控风险很大。

频繁创建开销大

按照常规理解 new Thread() 创建一个线程和 new Object() 没有什么差别。Java中万物接对象,因为 Thread 的老祖宗也是 Object。
new Thread() 在操作系统层面并没有创建新的线程,这是编程语言特有的。真正转换为操作系统层面创建一个线程,还要调用操作系统内核的API,然后操作系统要为该线程分配一系列的资源。
我们做一个对比:
new Object()过程

Object obj = new Object();

当我需要【对象】时,我就会给自己 new 一个,这个过程你应该很熟悉了:
1、分配一块内存 M
2、在内存 M 上初始化该对象
3、将内存 M 的地址赋值给引用变量 obj

创建一个线程的过程
创建一个线程还要调用操作系统内核API。为了更好的理解创建并启动一个线程的开销,我们需要看看 JVM 在背后帮我们做了哪些事情:
1、它为一个线程栈分配内存,该栈为每个线程方法调用保存一个栈帧
2、每一栈帧由一个局部变量数组、返回值、操作数堆栈和常量池组成
3、一些支持本机方法的 jvm 也会分配一个本机堆栈
4、每个线程获得一个程序计数器,告诉它当前处理器执行的指令是什么
5、系统创建一个与Java线程对应的本机线程
6、将与线程相关的描述符添加到JVM内部数据结构中
7、线程共享堆和方法区域
用数据来说明创建一个线程需要多大空间呢?答案是大约 1M 左右:

java -XX:+UnlockDiagnosticVMOptions -XX:NativeMemoryTracking=summary -XX:+PrintNMTStatistics -version

相信到这里你已经明白了,对于性能要求严苛的现在,频繁手动创建/销毁线程的代价是非常巨大的,解决方案自然也是你知道的线程池了.

什么是线程池
你常见的数据库连接池,实例池,还有XX池,OO池,各种池,都是一种池化(pooling)思想,简而言之就是为了最大化收益,并最小化风险,将资源统一在一起管理的思想
Java 也提供了它自己实现的线程池模型—— ThreadPoolExecutor。Java线程池就是为了最大化高并发带来的性能提升,并最小化手动创建线程的风险,将多个线程统一在一起管理的思想。
我们来看ThreadPoolExecutor的构造方法:

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.acc = System.getSecurityManager() == null ?
                null :
                AccessController.getContext();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
}
微信图片_20200525000427.png

试想,如果有请求就新建一趟列车,请求结束就“销毁”这趟列车,频繁往复这样操作,这样的代价肯定是不能接受的。
可以看到,使用线程池不但能完成手动创建线程可以做到的工作,同时也填补了手动线程不能做到的空白。归纳起来说,线程池的作用包括:
1、利用线程池管理并使用线程,控制最大并发数(手动创建线程很难得到保证)
2、实现任务线程队列缓存策略和拒绝机制
3、实现某些与实践相关的功能,如定时执行,周期执行等
4、隔离线程环境,比如,交易服务和搜索服务在同一台服务器上,分别开启两个线程池,交易线程的资源消耗明显要大。因此,通过配置独立的线程池,将较慢的交易服务与搜索服务隔离开,避免个服务线程互相影响。

线程池的使用思想/注意事项

不能忽略的线程池拒绝策略

我们很难准确的预测未来的最大并发量,所以定制合理的拒绝策略是必不可少的步骤。默认情况, ThreadPoolExecutor 提供了四种拒绝策略:
1、AbortPolicy:默认拒绝策略,会throw RejectedExecutionException拒绝
2、CallerRunsPolicy:提交任务的线程自己去执行该任务
3、DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列
4、DiscardPolicy:直接丢弃任务,没有任何异常抛出
不同的框架(Netty,Dubbo)都有不同的拒绝策略,我们也可以通过实现 RejectedExecutionHandler 自定义的拒绝策略。
对于采用何种策略,具体要看执行的任务重要程度。如果是一些不重要任务,可以选择直接丢弃;如果是重要任务,可以采用降级(所谓降级就是在服务无法正常提供功能的情况下,采取的补救措施。具体采用何种降级手段,这也是要看具体场景)处理,例如将任务信息插入数据库或者消息队列,启用一个专门用作补偿的线程池去进行补偿。

禁止使用Executors创建线程池

Executors 大大的简化了我们创建各种类型线程池的方式,为什么还不让使用呢?
其实,只要你打开看看它的静态方法参数就会明白了,传入的workQueue 是一个边界为 Integer.MAX_VALUE 队列,我们也可以变相的称之为无界队列了,因为边界太大了,这么大的等待队列也是非常消耗内存的。
另外该 ThreadPoolExecutor方法使用的是默认拒绝策略(直接拒绝),但并不是所有业务场景都适合使用这个策略,当很重要的请求过来直接选择拒绝显然是不合适的。
总的来说,使用 Executors 创建的线程池太过于理想化,并不能满足很多现实中的业务场景,所以要求我们通过 ThreadPoolExecutor来创建,并传入合适的参数。

总结

当我们需要频繁的创建线程时,我们要考虑到通过线程池统一管理线程资源,避免不可控风险以及额外的开销.。
了解了线程池的几个核心参数概念后,我们也需要经过调优的过程来设置最佳线程参数值。
线程池弥补了手动创建线程的缺陷和空白,同时,合理的降级策略能大大增加系统的稳定性。
(未完待续)

你可能感兴趣的:(我会手动创建线程,为什么让我使用线程池?)