目录
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、单线程编程与多线程编程
举一个例子来说明单线程编程与多线程编程的区别:
//假设用户在一个电商网站购物支付后,电商网站后台将记录本次购买记录的日志,然后入库相关数据,最后发送一封购买成功的提示给用户,即:
//1.记录日志 saveLog()
//2.入库操作 saveData()
//3.发送邮件 sendMail()
//对应的伪代码如下:
public void buySomething(){
saveLog();
saveData();
sendMail();
}
假设saveLog方法执行时间为500ms,saveData方法执行时间为200ms,sendMail方法执行时间为600ms,且三个方法串行执行,那么buySomething总体的执行时间为:500ms+200ms+600ms = 1300ms。
根据上述的业务逻辑,其实我们发现saveLog,saveData,sendMail三个方法之间并没有什么依赖关系,这意味着我们在保存日志的时候,cpu完全可以去操作数据库或发送邮件,而不必等待日志保存完毕才去执行后续的操作。我们就可以将这代码改造为多线程版本:
public void buySomething(){
//构造线程对象
Thread saveLogThread = new SaveLogThread();
Thread saveDataThread =new SaveDataThread();
Thread sendMailThread =new SendMailThread();
//开启线程
saveLogThread.start();
saveDataThread.start();
sendMailThread.start();
}
此时saveLog,saveData,sendMail三个方法由独立的3个线程来执行,如果电商网站的服务器是一台多核计算机的话(这其实是废话,做电商的难道还会用单核的服务器么。。。),这三个方法将变为并行执行,即saveLog,saveData,sendMail方法不再需要相互等待。
如果不考虑线程创建、线程间切换以及GC等操作所耗费的时间的话,那么buySomething总体的执行时间由耗时最长的sendMail()的决定,即600ms。
这意味着对于这个例子来讲,并行编程的效率比串行编程的效率提升了将近一倍。
就算该电商网站的服务器是单核的,采用多线程编程也能提升这buySomething方法的执行效率,因为cpu能够将等待IO操作的时间节省出来去执行其他线程。
4、IO密集型程序与CPU密集型程序
提到多线程编程还需要理解一个重要的概念,我们需要知道我们的程序到底属于IO密集型程序还是CPU密集型。
IO密集型程序:这类程序大多数时间都在进行IO操作,比如硬盘或内存的读写,比如网络间的通信等。
CPU密集型程序:这类程序的特点是大部份时间都在执行计算、逻辑判断等CPU动作。例如一个计算圆周率至小数点一千位以下的程序,在执行的过程当中绝大部份时间用在三角函数和开根号的计算,这种就是属于CPU密集型的。
之所以要理解这两个概念,是因为他们能更好的帮助我们决定是否在自己的程序中采用多线程。
在考虑是否适用多线程编程时,需要明白以下几点:
- 使用多线程不会增加 CPU 的能力。但是如果使用JVM的本地线程实现,则不同的线程可以在不同的处理器上同时运行(在多CPU的机器中),从而使多 CPU 机器得到充分利用。
-
如果应用程序是CPU密集型的,并受 CPU 功能的制约,则只有多核CPU计算机能够从更多的线程中受益,如果是单核的计算机,采用多线程编程反而会额外引入线程间切换的成本。
-
当应用程序必须等待缓慢的资源(如网络连接或数据库连接)时,即IO密集型程序,多线程通常是有利的。
-
基于网络请求的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)
package calculate;
public abstract class AbstractCalcSum {
//cpu核数,用于控制多线程实现对象中开启的线程个数
public static int CPU_CORE = 8;
//标志位,用于判断当前实现类是否为多线程模式,默认不是多线程
private boolean isConcurrent = false;
public boolean isConcurrent() {
return isConcurrent;
}
//执行计算的抽象方法,需要其他实现类进行重载实现
public abstract long doCalc(int targetNum) throws Exception;
}
单线程版本实现 CalcSum
package calculate.impl;
import calculate.AbstractCalcSum;
public class CalcSum extends AbstractCalcSum {
//模式为单线程
private boolean isConcurrent = false;
@Override
public boolean isConcurrent() {
return isConcurrent;
}
@Override
public long doCalc(int targetNum) {
long sum = 0;
//简单的循环累加
for (int i = 1; i <= targetNum; ++i) {
sum += i;
}
return sum;
}
}
(第一种多线程实现方式)基于Thread类实现的计算线程类 CalcSumThread
package calculate.impl.concurrence;
public class CalcSumThread extends Thread {
//线程名称
public String name;
//计算范围开始值
public long start;
//计算范围结束值
public long end;
//计算结果
public long result;
public CalcSumThread() {}
public CalcSumThread(String name, int start, int end) {
this.name = name;
this.start = start;
this.end = end;
}
@Override
public void run() {
result = 0;
for (long i = start; i <= end; ++i) {
result += i;
}
System.out.println("\t" + name + "执行完毕,结果为:" + result);
}
}
(第一种多线程实现方式)基于Thread类实现的计算类 CalcSumWithThread
(该类利用了 splitTask 方法对计算任务进行子任务拆分)
package calculate.impl;
import calculate.AbstractCalcSum;
import calculate.impl.concurrence.CalcSumThread;
public class CalcSumWithThread extends AbstractCalcSum {
//模式为多线程
private boolean isConcurrent = true;
@Override
public boolean isConcurrent() {
return isConcurrent;
}
@Override
public long doCalc(int targetNum) throws InterruptedException {
return doCalcWithThread(targetNum);
}
private static long doCalcWithThread(int targetNum) throws InterruptedException {
//划分线程子任务
CalcSumThread[] tasks = splitTask(targetNum);
//开始所有线程
for (CalcSumThread t : tasks) {
t.start();
}
//调用线程的join方法,使主线程阻塞并等待所有子线程完成(注意:如果把下面的for循环注释掉,那么最后将得到未知的total值)
for (CalcSumThread t : tasks) {
t.join();
}
//此时所有子线程都执行完毕,可以直接获取每个子线程的计算结果
long total = 0;
for (CalcSumThread t : tasks) {
total += t.result;
}
return total;
}
//根据目标数target与任务划分指标splitTo对计算任务进行多线程子任务划分
public static CalcSumThread[] splitTask(int targetNum) {
CalcSumThread[] tasks = new CalcSumThread[CPU_CORE];
int splitNum = targetNum / CPU_CORE;
for (int i = 0; i < CPU_CORE; ++i) {
int start = i * splitNum + 1;
int end = splitNum * (i + 1);
//由于target不一定能被splitTo除尽,此时最后一个区间直接取target的值
if (i == CPU_CORE - 1 && end < targetNum) {
end = targetNum;
}
String name = start + "~" + end + "计算任务";
tasks[i] = new CalcSumThread(name, start, end);
}
return tasks;
}
}
(第二种多线程实现方式)基于Callable类实现的计算线程类 CalcSumCallable
package calculate.impl.concurrence;
import java.util.concurrent.*;
public class CalcSumCallable implements Callable<Long> {
//线程名称
public String name;
//计算范围开始值
public long start;
//计算范围结束值
public long end;
//计算结果
public long result;
public CalcSumCallable() {}
public CalcSumCallable(String name, int start, int end) {
this.name = name;
this.start = start;
this.end = end;
}
@Override
public Long call() {
result = 0;
for (long i = start; i <= end; ++i) {
result += i;
}
System.out.println("\t" + name + "执行完毕,结果为:" + result);
return result;
}
}
(第二种多线程实现方式)基于Callable类,Future类以及线程池ExecutorService类实现的计算类 CalcSumWithCallable
package calculate.impl;
import calculate.AbstractCalcSum;
import calculate.impl.concurrence.CalcSumCallable;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class CalcSumWithCallable extends AbstractCalcSum {
//初始化线程池,默认线程数为配置的CPU核数
private static ExecutorService POOL = Executors.newFixedThreadPool(CPU_CORE);
//模式为多线程
private boolean isConcurrent = true;
@Override
public boolean isConcurrent() {
return isConcurrent;
}
@Override
public long doCalc(int targetNum) throws ExecutionException, InterruptedException {
return doCalcWithPool(targetNum);
}
public static long doCalcWithPool(int targetNum) throws InterruptedException, ExecutionException {
//划分线程子任务
CalcSumCallable[] tasks = splitTask(targetNum);
//使用线程池执行所有callable线程
List<Future<Long>> results = POOL.invokeAll(Arrays.asList(tasks));
//遍历Future集合,获取所有子线程的计算结果
long total = 0;
for (Future<Long> result : results) {
//执行get方法时,如果子线程还未执行完,那么会阻塞主线程等待结果
total += result.get();
}
return total;
}
//根据目标数target与任务划分指标splitTo对计算任务进行多线程子任务划分
private static CalcSumCallable[] splitTask(int targetNum) {
CalcSumCallable[] tasks = new CalcSumCallable[CPU_CORE];
int splitNum = targetNum / CPU_CORE;
for (int i = 0; i < CPU_CORE; ++i) {
int start = i * splitNum + 1;
int end = splitNum * (i + 1);
//由于target不一定能被splitTo除尽,此时最后一个区间直接取target的值
if (i == CPU_CORE - 1 && end < targetNum) {
end = targetNum;
}
String name = start + "~" + end + "计算任务";
tasks[i] = new CalcSumCallable(name, start, end);
}
return tasks;
}
}
用于测试的类 Test
package calculate;
import calculate.impl.CalcSum;
import calculate.impl.CalcSumWithCallable;
import calculate.impl.CalcSumWithThread;
public class Test {
//用于计算的目标数字,可修改该值来比较不同输入时的测试结果
private static int TARGET_NUM = 1000000000;
public static void test(AbstractCalcSum calc) throws Exception {
System.out.println("目标数字:" + TARGET_NUM);
System.out.println("测试类:" + calc.getClass().getName() + " 模式:" + (calc.isConcurrent() ? "多线程" + " 开启线程数:" + AbstractCalcSum.CPU_CORE : "单线程"));
long startTime = System.currentTimeMillis();
long sum = calc.doCalc(TARGET_NUM);
long endTime = System.currentTimeMillis();
System.out.println("耗时: " + (endTime - startTime) + " ms " + "结果: " + sum);
System.out.println();
}
public static void main(String[] args) throws Exception {
//测试单线程版本
test(new CalcSum());
//测试利用Thread实现的多线程版本
test(new CalcSumWithThread());
//测试利用Callable,Future以及线程池ExecutorService实现的多线程版本
test(new CalcSumWithCallable());
}
}
由于代码逻辑很简单,注释也写得比较详细,本文就不再赘述了,接下来看看测试结果。
(4) 测试结果
测试用例1:
入参:targetNum = 1000000000(10亿)
结果:
目标数字:1000000000
测试类:calculate.impl.CalcSum 模式:单线程
耗时: 1116 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithThread 模式:多线程 开启线程数:8
750000001~875000000计算任务执行完毕,结果为:101562500062500000
250000001~375000000计算任务执行完毕,结果为:39062500062500000
125000001~250000000计算任务执行完毕,结果为:23437500062500000
875000001~1000000000计算任务执行完毕,结果为:117187500062500000
1~125000000计算任务执行完毕,结果为:7812500062500000
625000001~750000000计算任务执行完毕,结果为:85937500062500000
500000001~625000000计算任务执行完毕,结果为:70312500062500000
375000001~500000000计算任务执行完毕,结果为:54687500062500000
耗时: 642 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithCallable 模式:多线程 开启线程数:8
125000001~250000000计算任务执行完毕,结果为:23437500062500000
625000001~750000000计算任务执行完毕,结果为:85937500062500000
250000001~375000000计算任务执行完毕,结果为:39062500062500000
750000001~875000000计算任务执行完毕,结果为:101562500062500000
875000001~1000000000计算任务执行完毕,结果为:117187500062500000
375000001~500000000计算任务执行完毕,结果为:54687500062500000
500000001~625000000计算任务执行完毕,结果为:70312500062500000
1~125000000计算任务执行完毕,结果为:7812500062500000
耗时: 681 ms 结果: 500000000500000000
分析:可以看到,多线程版本的执行效率接近单线程版本的两倍。
但是多线程也不是适用于所有情况,我们来看测试用例2。
测试用例2:
入参:targetNum = 100000(10万)
结果:
目标数字:100000
测试类:calculate.impl.CalcSum 模式:单线程
耗时: 3 ms 结果: 5000050000
目标数字:100000
测试类:calculate.impl.CalcSumWithThread 模式:多线程 开启线程数:8
25001~37500计算任务执行完毕,结果为:390631250
12501~25000计算任务执行完毕,结果为:234381250
37501~50000计算任务执行完毕,结果为:546881250
1~12500计算任务执行完毕,结果为:78131250
50001~62500计算任务执行完毕,结果为:703131250
75001~87500计算任务执行完毕,结果为:1015631250
62501~75000计算任务执行完毕,结果为:859381250
87501~100000计算任务执行完毕,结果为:1171881250
耗时: 5 ms 结果: 5000050000
目标数字:100000
测试类:calculate.impl.CalcSumWithCallable 模式:多线程 开启线程数:8
1~12500计算任务执行完毕,结果为:78131250
25001~37500计算任务执行完毕,结果为:390631250
37501~50000计算任务执行完毕,结果为:546881250
12501~25000计算任务执行完毕,结果为:234381250
50001~62500计算任务执行完毕,结果为:703131250
62501~75000计算任务执行完毕,结果为:859381250
75001~87500计算任务执行完毕,结果为:1015631250
87501~100000计算任务执行完毕,结果为:1171881250
耗时: 73 ms 结果: 5000050000
分析:可以看到在计算量比较小的时候,多线程版本的计算效率反而低于单线程版本,尤其是线程池版本的效率远远低于单线程版本。
这是因为多线程意外着线程、线程池的创建以及线程间切换的成本,当计算任务的体量比较小时,这些额外的成本就体现出来了。
目前所使用的线程任务划分数都是以我的cpu核数8来进行子任务的拆分的,接下来利用如下几个用例观察线程开启的数目对于程序执行效率的影响。
用例:(我的计算机是intel i7 8核的cpu,另外由于线程划分数过多时输出的内容也会变多,因此我注释掉了每个线程执行完成后的输出内容)
入参:targetNum = 1000000000(10亿) 线程划分数:4
结果:
目标数字:1000000000
测试类:calculate.impl.CalcSum 模式:单线程
耗时: 1136 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithThread 模式:多线程 开启线程数:4
耗时: 934 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithCallable 模式:多线程 开启线程数:4
耗时: 930 ms 结果: 500000000500000000
入参:targetNum = 1000000000(10亿) 线程划分数:8
结果:
目标数字:1000000000
测试类:calculate.impl.CalcSum 模式:单线程
耗时: 1107 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithThread 模式:多线程 开启线程数:8
耗时: 671 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithCallable 模式:多线程 开启线程数:8
耗时: 620 ms 结果: 500000000500000000
入参:targetNum = 1000000000(10亿) 线程划分数:16
结果:
目标数字:1000000000
测试类:calculate.impl.CalcSum 模式:单线程
耗时: 1152 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithThread 模式:多线程 开启线程数:16
耗时: 550 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithCallable 模式:多线程 开启线程数:16
耗时: 658 ms 结果: 500000000500000000
入参:targetNum = 1000000000(10亿) 线程划分数:32
结果:
目标数字:1000000000
测试类:calculate.impl.CalcSum 模式:单线程
耗时: 1118 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithThread 模式:多线程 开启线程数:32
耗时: 463 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithCallable 模式:多线程 开启线程数:32
耗时: 592 ms 结果: 500000000500000000
入参:targetNum = 1000000000(10亿) 线程划分数:64
结果:
目标数字:1000000000
测试类:calculate.impl.CalcSum 模式:单线程
耗时: 1136 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithThread 模式:多线程 开启线程数:64
耗时: 475 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithCallable 模式:多线程 开启线程数:64
耗时: 580 ms 结果: 500000000500000000
入参:targetNum = 1000000000(10亿) 线程划分数:128
结果:
目标数字:1000000000
测试类:calculate.impl.CalcSum 模式:单线程
耗时: 1156 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithThread 模式:多线程 开启线程数:128
耗时: 622 ms 结果: 500000000500000000
目标数字:1000000000
测试类:calculate.impl.CalcSumWithCallable 模式:多线程 开启线程数:128
耗时: 505 ms 结果: 500000000500000000
分析:当线程划分数量小于当前cpu的核数时,由于无法最大化利用多核cpu的计算效率,因此性能明显比更多的线程数划分数量的版本低。
而线程划分的数量也不是越高就越好,从上面的用例结果显示,从线程数量增加到16之后,线程数量的增加其实对于程序性能的提升并没有明显的帮助,甚至有降低的趋势,这是因为线程数量增加后,生成线程对象以及线程间切换的成本也同时在增加的原因。