目录

1. 前言
2. 串行、并发与并行
3. 单线程编程与多线程编程
4. IO密集型程序与CPU密集型程序
5. 实例:利用多线程提升累加程序的性能
    (1)功能描述
    (2)多线程优化思路
    (3)代码实现
    (4)测试结果


1、前言

最近在巩固java多线程编程相关的知识,并且做了一些实验。

今天就用这一篇文章来总结下最近所学到的关于多线程编程方面的知识点,并且用一个实例来具体说明问题。


2、串行、并行与并发

在介绍实验之前,首先还是回顾一下多线程编程相关的知识点,先从串行、并发与并行开始说起。

对于一个编程的新手来说,可能难以分清什么是串行,什么是并行,什么又是并发的,看到这几个词就觉得一头雾水。

其实在掌握了相关的概念之后,这三个词代表的知识点并不难理解。

串行:英文翻译为Serial,就是one by one的意思。程序指令按照顺序一个接一个的执行,我们平常开发的代码一般都是串行的,因为我们写的程序默认就是单线程的。串行很好理解,因为和我们阅读代码的方式一样,拿到一份代码,从上读到下,你就知道这份代码都做了些什么了。

串行的优点:所有代码都在一个线程中执行,不存在资源竞争,因此不存在线程安全问题,代码的逻辑也容易被理解。
串行的缺点:串行的编程概念十分简单,但也正因如此,串行编程无法很好地利用cpu的运算优势,尤其是现代的多核cpu。

并行:英文翻译为Parallel,并行代表多个事件同时发生,对应到编程来说就是同一时间cpu执行多个线程。并行的概念是在串行之后提出的,由于硬件技术的迅速发展,电脑由单核cpu变成了多核cpu,这使得串行编程无法很好地利用硬件的优势,因此提出了并行编程。

并行的优点:由于多个线程同时执行,因此程序的执行效率高,CPU利用率高。
并行的缺点:存在线程不安全情况,也就是存在临界区的问题会出现数据不准确,不安全,脏数据。

(并发和并行这两个名词令人迷惑,曾经我也感觉十分迷茫,不是都有并行了吗,为什么又冒出来一个并发?)

并发:英文翻译为Concurrent,并发和并行不同,并发指的是在同一时间间隔内多个事件同时发生(并行是同一时间点,并发是一段时间间隔),对应到编程中去,则是指cpu在一段时间间隔内同时执行多个线程。

为什么出现并发的概念呢?并发编程是对于单核cpu的性能做提升用的,思考如下场景,对于一个单核cpu的计算机,如果是串行编程,那么当线程在执行一个比较耗时的IO操作时,高速的cpu不得不陪着线程一起等待缓慢的IO操作完成之后才能继续执行,这大大影响了cpu的运行效率。

并发编程能很好的解决这个问题,当某个线程在等待IO操作时,cpu转而去执行其他的线程,当IO操作结束后,cpu再重新回来执行这个线程,这样就能很好的提升cpu的运转效率了。

又比如我们现在使用的一些编辑器软件,在编辑页面上打字的同时,编辑器还会帮我们自动保存我们当前写好的内容,如果编辑器时单线程运行的,那么编辑器在自动保存时,我们就必须卡在编辑页面,直到编辑器保存完毕后才能继续打字,这非常影响使用体验,而改成并发运行就可以避免。

并发的优点:提高了cpu的利用率,提高线程执行效率从而提升用户体验.
并发的缺点:并发编程的难度比单线程编程高很多,多线程的创建将消耗系统资源,也带来了线程间切换的成本。


3、单线程编程与多线程编程

举一个例子来说明单线程编程与多线程编程的区别:

假设saveLog方法执行时间为500ms,saveData方法执行时间为200ms,sendMail方法执行时间为600ms,且三个方法串行执行,那么buySomething总体的执行时间为:500ms+200ms+600ms = 1300ms。

根据上述的业务逻辑,其实我们发现saveLog,saveData,sendMail三个方法之间并没有什么依赖关系,这意味着我们在保存日志的时候,cpu完全可以去操作数据库或发送邮件,而不必等待日志保存完毕才去执行后续的操作。我们就可以将这代码改造为多线程版本:

此时saveLog,saveData,sendMail三个方法由独立的3个线程来执行,如果电商网站的服务器是一台多核计算机的话(这其实是废话,做电商的难道还会用单核的服务器么。。。),这三个方法将变为并行执行,即saveLog,saveData,sendMail方法不再需要相互等待。

如果不考虑线程创建、线程间切换以及GC等操作所耗费的时间的话,那么buySomething总体的执行时间由耗时最长的sendMail()的决定,即600ms。

这意味着对于这个例子来讲,并行编程的效率比串行编程的效率提升了将近一倍。

就算该电商网站的服务器是单核的,采用多线程编程也能提升这buySomething方法的执行效率,因为cpu能够将等待IO操作的时间节省出来去执行其他线程。


4、IO密集型程序与CPU密集型程序

提到多线程编程还需要理解一个重要的概念,我们需要知道我们的程序到底属于IO密集型程序还是CPU密集型。

IO密集型程序:这类程序大多数时间都在进行IO操作,比如硬盘或内存的读写,比如网络间的通信等。

CPU密集型程序:这类程序的特点是大部份时间都在执行计算、逻辑判断等CPU动作。例如一个计算圆周率至小数点一千位以下的程序,在执行的过程当中绝大部份时间用在三角函数和开根号的计算,这种就是属于CPU密集型的。

之所以要理解这两个概念,是因为他们能更好的帮助我们决定是否在自己的程序中采用多线程。

在考虑是否适用多线程编程时,需要明白以下几点:

  1. 使用多线程不会增加 CPU 的能力。但是如果使用JVM的本地线程实现,则不同的线程可以在不同的处理器上同时运行(在多CPU的机器中),从而使多 CPU 机器得到充分利用。

  2. 如果应用程序是CPU密集型的,并受 CPU 功能的制约,则只有多核CPU计算机能够从更多的线程中受益,如果是单核的计算机,采用多线程编程反而会额外引入线程间切换的成本。

  3. 当应用程序必须等待缓慢的资源(如网络连接或数据库连接)时,即IO密集型程序,多线程通常是有利的。

  4. 基于网络请求的web系统或者交互式的桌面软件有必要是多线程的;否则,用户将感觉应用程序反映迟钝。


5、 实例:利用多线程提升累加程序的性能

光说不练假把式,接下来用一个完整的多线程编程实例来介绍如何通过多线程优化CPU密集型程序的性能。

(1) 功能描述

累加程序描述:
该程序接受一个整数targetNum,计算从1累加到tatgetNum的结果并返回。
举例:
输入targetNum:5 计算结果:1+2+3+4+5 = 15

(2) 多线程优化

从targetNum为5的计算过程我们可以看出,如果我们的程序是单线程的,那么我们需要从1逐一累加到targetNum才能获得最终结果。

然而通过思考不难发现,在当前线程计算1+2的同时,完全可以开启另外一个线程计算3+4+5,最后将两个线程的结果进行一次累加就能得到最终结果,这就是所谓的多线程编程思维。

当我们的计算机是多核cpu时,开启多个线程执行计算意味着我们能够进行高效的并行计算,从而提升程序的执行效率。

(3) 代码实现

我为了体现单线程编程与多线程编程的差别,抽象出了一个计算超类,然后分别使用单线程和多线程的实现该类的抽象计算方法,然后利用了Java中的不同API(Thread,Callable,ExecutorService,Future等)来实现两种多线程编程,从而比较它们的区别。

具体的代码实现如下:

抽象计算类 AbstractCalcSum
(由于我的机器的cpu核数是8核,因此我给CPU_CORE赋的初值是8)

单线程版本实现 CalcSum

(第一种多线程实现方式)基于Thread类实现的计算线程类 CalcSumThread

(第一种多线程实现方式)基于Thread类实现的计算类 CalcSumWithThread

(该类利用了 splitTask 方法对计算任务进行子任务拆分)

(第二种多线程实现方式)基于Callable类实现的计算线程类 CalcSumCallable

(第二种多线程实现方式)基于Callable类,Future类以及线程池ExecutorService类实现的计算类 CalcSumWithCallable

用于测试的类 Test

由于代码逻辑很简单,注释也写得比较详细,本文就不再赘述了,接下来看看测试结果。

(4) 测试结果

测试用例1:
入参:targetNum = 1000000000(10亿)
结果:

分析:可以看到,多线程版本的执行效率接近单线程版本的两倍。


但是多线程也不是适用于所有情况,我们来看测试用例2。

测试用例2:
入参:targetNum = 100000(10万)
结果:

分析:可以看到在计算量比较小的时候,多线程版本的计算效率反而低于单线程版本,尤其是线程池版本的效率远远低于单线程版本。
这是因为多线程意外着线程、线程池的创建以及线程间切换的成本,当计算任务的体量比较小时,这些额外的成本就体现出来了。


目前所使用的线程任务划分数都是以我的cpu核数8来进行子任务的拆分的,接下来利用如下几个用例观察线程开启的数目对于程序执行效率的影响。

用例:(我的计算机是intel i7 8核的cpu,另外由于线程划分数过多时输出的内容也会变多,因此我注释掉了每个线程执行完成后的输出内容)
入参:targetNum = 1000000000(10亿) 线程划分数:4
结果:

入参:targetNum = 1000000000(10亿) 线程划分数:8
结果:

入参:targetNum = 1000000000(10亿) 线程划分数:16
结果:

入参:targetNum = 1000000000(10亿) 线程划分数:32
结果:

入参:targetNum = 1000000000(10亿) 线程划分数:64
结果:

入参:targetNum = 1000000000(10亿) 线程划分数:128
结果:

分析:当线程划分数量小于当前cpu的核数时,由于无法最大化利用多核cpu的计算效率,因此性能明显比更多的线程数划分数量的版本低。
而线程划分的数量也不是越高就越好,从上面的用例结果显示,从线程数量增加到16之后,线程数量的增加其实对于程序性能的提升并没有明显的帮助,甚至有降低的趋势,这是因为线程数量增加后,生成线程对象以及线程间切换的成本也同时在增加的原因。

发表评论

电子邮件地址不会被公开。 必填项已用*标注