深入详解 Java 线程
前言
什么是线程?线程,有时被称为轻量进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程 ID,当前指令指针 (PC),寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。
一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。线程也有就绪、阻塞和运行三种基本状态。就绪状态是指线程具备运行的所有条件,逻辑上可以运行,在等待处理机;运行状态是指线程占有处理机正在运行;阻塞状态是指线程在等待一个事件(如某个信号量),逻辑上不可执行。每一个程序都至少有一个线程,若程序只有一个线程,那就是程序本身。
进程 VS 线程
进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由线程实现,还可以混合多进程+多线程。
和多线程相比,多进程的缺点是:
-
创建进程比创建线程开销大很多,尤其是在 Windows 上
-
进程间通信比线程要慢,因为线程见通信就是读写同一个变量,速度很快
多进程的优点:
- 多进程稳定性比多线程高,因为在多进程情况下,一个进程的崩溃不会影响其他进程,任何一个线程崩溃会导致整个进程崩溃。
多线程的应用场景
-
程序中出现需要等待的操作,比如网络操作、文件 IO 等,可以利用多线程充分使用处理器资源,而不会阻塞程序中其他任务的执行
-
程序中出现可分解的大任务,比如耗时较长的计算任务,可以利用多线程来共同完成任务,缩短运算时间
-
程序中出现需要后台运行的任务,比如一些监测任务、定时任务,可以利用多线程来完成
生命周期及五种基本状态
首先,看一下 Thread 类中给出的关于线程状态的说明:
public enum State { //还没有调用start()开启的线程实例所处的状态 NEW, //正在虚拟机中执行或者等待被执行的线程所处的状态,但是这种状态也包含线程正在等待处理器资源这种情况 RUNNABLE, // 等待在监视器锁上的线程所处的状态,比如进入synchronized同步代码块或同步方法失败 BLOCKED, // 等待其它线程执行特定操作的线程所处的状态;比如线程执行了以下方法: Object.wait with no timeout、Thread.join with no timeout、 LockSupport.park WAITING, // 等待其它线程执行超时操作的线程所处的状态;比如线程执行了以下方法: Thread.sleep、Object.wait with timeout //Thread.join with timeout、LockSupport.parkNanos、LockSupport.parkUntil TIMED_WAITING, //退出的线程所处的状态 TERMINATED; }
接下来在看一下 Java 中线程的生命周期较为经典的图:
上图中基本上囊括了 Java 中多线程各重要知识点。掌握了上图中的各知识点,Java 中的多线程也就基本上掌握了。主要包括:
Java 线程具有五中基本状态
-
新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();
-
就绪状态(Runnable):当调用线程对象的 start() 方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待 CPU 调度执行,并不是说执行了 t.start() 此线程立即就会执行;
-
运行状态(Running):当 CPU 开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
-
阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对 CPU 的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被 CPU 调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:
-
等待阻塞:运行状态中的线程执行 wait() 方法,使本线程进入到等待阻塞状态;
-
同步阻塞 -- 线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
-
其他阻塞 -- 通过调用线程的 sleep() 或 join() 或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep() 状态超时、join() 等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。
-
死亡状态(Dead):线程执行完了或者因异常退出了 run() 方法,该线程结束生命周期。
举个通俗一点的例子来解释上面五种状态,比如上厕所:
你平时去商城上厕所,准备去上厕所就是新建状态(new),上厕所要排队,排队就是就绪状态(Runnable),有坑位了,轮到你了,拉屎就是运行状态(Running),你拉完屎发现没有手纸,要等待别人给你送纸过来,这个状态就是阻塞(Blocked),等你上完厕所出来,上厕所这件事情结束了就是死亡状态了。
注意:便秘也是阻塞状态,你便秘太久了,别人等不及了,把你赶走,这个就是挂起,还有一种情况,你便秘了,别人等不及了,跟你说你先出去酝酿一下,5分钟后再过来拉屎,这就是睡眠。
自定义线程的实现
处于实用的角度出发,想要使用多线程,那么第一步就是需要知道如何实现自定义线程,因为实际开发中,需要线程完成的任务是不同的,所以我们需要根据线程任务来自定义线程,JDK 为我们的开发人员提供了三种自定义线程的方式,供实际开发中使用,来开发出符合需求的多线程程序!
以下是线程的三种实现方式,以及对每种实现的优缺点进行分析,最后是对这三种实现方式进行总结;
方式一:继承Thread类
// 通过继承Thread类实现自定义线程类 public class MyThread extends Thread { // 线程体 @Override public void run() { System.out.println("Hello, I am the defined thread created by extends Thread"); } public static void main(String[] args){ // 实例化自定义线程类实例 Thread thread = new MyThread(); // 调用start()实例方法启动线程 thread.start(); } }
优点:实现简单,只需实例化继承类的实例,即可使用线程
缺点:扩展性不足,Java是单继承的语言,如果一个类已经继承了其他类,就无法通过这种方式实现自定义线程
方式二:实现 Runnable 接口
public class MyRunnable implements Runnable { // 线程体 @Override public void run() { System.out.println("Hello, I am the defined thread created by implements Runnable"); } public static void main(String[] args){ // 线程的执行目标对象 MyRunnable myRunnable = new MyRunnable(); // 实际的线程对象 Thread thread = new Thread(myRunnable); // 启动线程 thread.start(); } }
优点:
- 扩展性好,可以在此基础上继承其他类,实现其他必需的功能
- 对于多线程共享资源的场景,具有天然的支持,适用于多线程处理一份资源的场景
缺点:构造线程实例的过程相对繁琐一点
方式三:实现Callable接口
package com.thread; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; public class MyCallable implements Callable<String> { @Override public String call() throws Exception { return "Hello, I am the defined thread created by implements Callable"; } public static void main(String[] args){ // 线程执行目标 MyCallable myCallable = new MyCallable(); // 包装线程执行目标,因为Thread的构造函数只能接受Runnable接口的实现类,而FutureTask类实现了Runnable接口 FutureTask<String> futureTask = new FutureTask<>(myCallable); // 传入线程执行目标,实例化线程对象 Thread thread = new Thread(futureTask); // 启动线程 thread.start(); String result = null; try { // 获取线程执行结果 result = futureTask.get(); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } System.out.println(result); } }
优点:
- 扩展性好
- 支持多线程处理同一份资源
- 具备返回值以及可以抛出受检查异常
缺点:
- 相较于实现Runnable接口的方式,较为繁琐
线程常用方法简单介绍
sleep()
是一个静态方法。使当前线程(即调用该方法的线程)暂停执行一段时间,让其他线程有机会继续执行,但它并不释放对象锁,当到达指定的睡眠时间后会返回,线程处于就绪状态,然后参与CPU调度。也就是如果有 Synchronized 同步块,其他线程仍然不同访问共享数据。注意该方法要捕获异常
比如有两个线程同时执行(没有 Synchronized ),一个线程优先级为 MAX_PRIORITY,另一个为 MIN_PRIORITY,如果没有 sleep() 方法,只有高优先级的线程执行完成后,低优先级的线程才能执行,但当高优先级的线程 sleep(5000) 后,低优先级就有机会执行了。
总之,sleep() 可以使低优先级的线程得到执行的机会,当然也可以让同优先级、高优先级的线程有执行的机会。
join()
Thread 类中有一个 join() 方法,非静态方法。此方法表示,在当前线程中 a 中,b 线程调用 join() 方法,那么,a 线程就会释放资源,让给 b 线程先执行。注意该方法也要捕获异常。
当然可以多个线程使用的。也就是b,c 线程都在a线程里面调用 join 方法。
// 主线程 public class Father extends Thread { public void run() { Son s = new Son(); s.start(); s.join(); ... } } // 子线程 public class Son extends Thread { public void run() { ... } }
这里的示例,为啥 father 线程为啥会被暂停。这个是因为其实主线程持有了 son,同时可以看到 join 方法中有个 synchronized ,然后代码块中调用了 wait 方法,我的理解应该是 father 持有这个锁的对象, 所以当调用 wait 的时候,wait 的生效也是在持有该对象的线程上生效的。不要关注是谁在哪里调用了 wait , 应该看到谁持有了这个对象,记住 wait 是在锁对象上调用的。
对于 sleep,则是在哪个线程的 run 里面调用的,就是 sleep 哪个睡眠。
yield()
是一个静态方法,与 sleep() 类似,只是不能由用户指定暂停多长时间,调用该方法会让当前线程让出CPU使用权,然后处于就绪状态,线程调度会从就绪队列里面获取一个优先级最高的线程,也可能会调度到刚刚让出CPU的那个线程继续获取CPU执行权。
wait() 和notify()、notifyAll()
这三个方法用于协调多个线程对共享数据的存取,所以必须在 Synchronized 语句块内使用这三个方法,否则会抛出错 IllegalMonitorStateException。前面说过 Synchronized 这个关键字用于保护共享数据,阻止其他线程对共享数据的存取。但是这样程序的流程就很不灵活了,如何才能在当前线程还没退出 Synchronized 数据块时让其他线程也有机会访问共享数据呢?此时就用这三个方法来灵活控制。
wait() 方法使当前线程被阻塞挂起暂停执行并释放对象锁标志,让其他线程可以进入 Synchronized 数据块,当前线程被放入对象等待池中。当调用共享对象的 notify() 或者 notifyAll() 方法才会返回,此时返回的线程会加入到锁标志等待池中,只有锁标志等待池中的线程能够获取锁标志,如果锁标志等待池中没有线程,则 notify() 不起作用。
notifyAll() 则从对象等待池中移走所有等待那个对象的线程并放到锁标志等待池中。
需要注意的是,当线程调用共享对象的 wait() 方法时,当前线程只会释放当前共享对象的锁,当前线程持有的其他共享对象的监视器锁并不会释放。
线程中断
为啥需要中断呢?下面简单的举例情况:
-
比如我们会启动多个线程做同一件事,比如抢 12306 的火车票,我们可能开启多个线程从多个渠道买火车票,只要有一个渠道买到了,我们会通知取消其他渠道。这个时候需要关闭其他线程;
-
很多线程的运行模式是死循环,比如在生产者/消费者模式中,消费者主体就是一个死循环,它不停的从队列中接受任务,执行任务,在停止程序时,我们需要一种”优雅”的方法以关闭该线程;
-
在一些场景中,比如从第三方服务器查询一个结果,我们希望在限定的时间内得到结果,如果得不到,我们会希望取消该任务;
上面这几个例子线程已经在运行了,并不好去干涉,但是可以通过中断,告诉这个线程,你应该中断了。比如上面的例子中的线程再收到中断后,可以通过中断标志来结束线程的运行。当然,你也可以收到后,不做任何处理,这也是可以的。
在 Java 中,停止一个线程的主要机制是中断,中断并不是强迫终止一个线程,它是一种协作机制,是给线程传递一个取消信号,但是由线程来决定如何以及何时退出。
需要注意的是:在停止线程的时候,不要调用 stop 方法,该方法已经被废弃了,并且会带来不可预测的影响。
线程对中断的反应
-
RUNNABLE:线程在运行或具备运行条件只是在等待操作系统调度
-
WAITING/TIMED_WAITING:线程在等待某个条件或超时
-
BLOCKED:线程在等待锁,试图进入同步块
-
NEW/TERMINATED:线程还未启动或已结束
线程中断常用的方法
-
interrupt() :中断线程,将会设置该线程的中断状态位,即设置为true,中断的结果线程是死亡、还是等待新的任务或是继续运行至下一步,就取决于这个程序本身。线程会不时地检测这个中断标示位,以判断线程是否应该被中断(中断标示值是否为true)。它并不像stop方法那样会中断一个正在运行的线程。
-
interrupted():第一次使用返回true,并清除中断标志位,在此之后查询中断状态isInterrupt()都会返回false,刚刚第一个例子也看到了,利用 第一次返回的true可以跳出循环。第二次以及以后都是返回false。
-
isInterrupted():仅仅查询中断标志位来判断是否发生中断并返回true或者false。
RUNNABLE 状态
如果线程在运行中,interrupt() 只是会设置线程的中断标志位,没有任何其它作用。线程应该在运行过程中合适的位置检查中断标志位,比如说,如果主体代码是一个循环,可以在循环开始处进行检查,如下所示:
public class InterruptRunnableDemo extends Thread { @Override public void run() { while (!Thread.currentThread().isInterrupted()) { // 也可以使用 !Thread.currentThread().interrupted() 来判断有没有中断 // ... 单次循环代码 } System.out.println("done "); } public static void main(String[] args) throws InterruptedException { Thread thread = new InterruptRunnableDemo(); thread.start(); Thread.sleep(1000); thread.interrupt(); } }
WAITING/TIMED_WAITING
线程执行如下方法会进入WAITING状态:
public final void join() throws InterruptedException public final void wait() throws InterruptedException
执行如下方法会进入TIMED_WAITING状态:
public final native void wait(long timeout) throws InterruptedException; public static native void sleep(long millis) throws InterruptedException; public final synchronized void join(long millis) throws InterruptedException
在这些状态时,对线程对象调用 interrupt() 会使得该线程抛出 InterruptedException,需要注意的是,抛出异常后,中断标志位会被清空(线程的中断标志位会由 true 重置为false,因为线程为了处理异常已经重新处于就绪状态),而不是被设置。比如说,执行如下代码:
Thread t = new Thread (){ @Override public void run() { try { Thread.sleep(1000); } catch (InterruptedException e) { //exception被捕获,但是为输出为false 因为标志位会被清空 System.out.println(isInterrupted()); } } }; t.start(); try { Thread.sleep(100); } catch (InterruptedException e) { } t.interrupt();//置为true
InterruptedException 是一个受检异常,线程必须进行处理。我们在异常处理中介绍过,处理异常的基本思路是,如果你知道怎么处理,就进行处理,如果不知道,就应该向上传递,通常情况下,你不应该做的是,捕获异常然后忽略。
捕获到 InterruptedException,通常表示希望结束该线程,线程大概有两种处理方式:
-
向上传递该异常,这使得该方法也变成了一个可中断的方法,需要调用者进行处理
-
有些情况,不能向上传递异常,比如Thread的run方法,它的声明是固定的,不能抛出任何受检异常,这时,应该捕获异常,进行合适的清理操作,清理后,一般应该调用Thread的interrupt方法设置中断标志位,使得其他代码有办法知道它发生了中断
第一种方式的示例代码如下:
//抛出中断异常,由调用者捕获 public void interruptibleMethod() throws InterruptedException{ // ... 包含wait, join 或 sleep 方法 Thread.sleep(1000); }
第二种方式的示例代码如下:
public class InterruptWaitingDemo extends Thread { @Override public void run() { while (!Thread.currentThread().isInterrupted()) { try { // 模拟任务代码 Thread.sleep(2000); } catch (InterruptedException e) { // ... 清理操作 System.out.println(isInterrupted());//false // 重设中断标志位为true Thread.currentThread().interrupt(); } } System.out.println(isInterrupted());//true } public static void main(String[] args) { InterruptWaitingDemo thread = new InterruptWaitingDemo(); thread.start(); try { Thread.sleep(100); } catch (InterruptedException e) { } thread.interrupt(); } }
BLOCKED
如果线程在等待锁,对线程对象调用interrupt()只是会设置线程的中断标志位,线程依然会处于BLOCKED状态,也就是说,interrupt()并不能使一个在等待锁的线程真正”中断”。我们看段代码:
public class InterruptWaitingDemo extends Thread { @Override public void run() { while (!Thread.currentThread().isInterrupted()) { try { // 模拟任务代码 Thread.sleep(2000); } catch (InterruptedException e) { // ... 清理操作 // 重设中断标志位 Thread.currentThread().interrupt(); } } System.out.println(isInterrupted()); } public static void main(String[] args) { InterruptWaitingDemo thread = new InterruptWaitingDemo(); thread.start(); try { Thread.sleep(100); } catch (InterruptedException e) { } thread.interrupt(); } }
BLOCKED 如果线程在等待锁,对线程对象调用 interrupt() 只是会设置线程的中断标志位,线程依然会处于 BLOCKED 状态,也就是说,interrupt() 并不能使一个在等待锁的线程真正”中断”。我们看段代码:
public class InterruptSynchronizedDemo { private static Object lock = new Object();//monitor private static class A extends Thread { @Override public void run() { //等待lock锁 synchronized (lock) { //等待标志位被置为true while (!Thread.currentThread().isInterrupted()) { } } System.out.println("exit"); } } public static void test() throws InterruptedException { synchronized (lock) {//获取锁 A a = new A(); a.start(); Thread.sleep(1000); //a在等待lock锁,interrupt 无法中断 a.interrupt(); //a线程加入当前线程,等待执行完毕 a.join(); } } public static void main(String[] args) throws InterruptedException { test(); } }
test 方法在持有锁 lock 的情况下启动线程 a,而线程 a 也去尝试获得锁 lock,所以会进入锁等待队列,随后 test 调用线程 a 的 interrupt 方法并等待线程线程 a 结束,线程 a 会结束吗?不会,interrupt 方法只会设置线程的中断标志,而并不会使它从锁等待队列中出来。线程a 会一直尝试获取锁,但是主线程也在等待 a 结束才会释放锁,所以相互之间互为等待,不能结束。
我们稍微修改下代码,去掉 test方法中的最后一行 a.join(),即变为:
public static void test() throws InterruptedException { synchronized (lock) { A a = new A(); a.start(); Thread.sleep(1000); a.interrupt(); } //lock锁释放后 A线程重队列中出来 }
这时,程序就会退出。为什么呢?因为主线程不再等待线程 a 结束,释放锁 lock 后,线程 a 会获得锁,然后检测到发生了中断,所以会退出。
在使用 synchronized 关键字获取锁的过程中不响应中断请求,这是 synchronized 的局限性。如果这对程序是一个问题,应该使用显式锁,java 中的 Lock 接口,它支持以响应中断的方式获取锁。对于 Lock.lock(),可以改用 Lock.lockInterruptibly(),可被中断的加锁操作,它可以抛出中断异常。等同于等待时间无限长的 Lock.tryLock(long time, TimeUnit unit)。
NEW/TERMINATE
如果线程尚未启动 (NEW),或者已经结束 (TERMINATED),则调用 interrupt() 对它没有任何效果,中断标志位也不会被设置。比如说,以下代码的输出都是 false。
public class InterruptNotAliveDemo { private static class A extends Thread { @Override public void run() { } } public static void test() throws InterruptedException { A a = new A(); a.interrupt(); System.out.println(a.isInterrupted()); a.start(); Thread.sleep(100); a.interrupt(); System.out.println(a.isInterrupted()); } public static void main(String[] args) throws InterruptedException { test(); } }
IO操作
如果线程在等待 IO 操作,尤其是网络 IO,则会有一些特殊的处理,我们没有介绍过网络,这里只是简单介绍下。
-
实现此 InterruptibleChannel 接口的通道是可中断的:如果某个线程在可中断通道上因调用某个阻塞的 I/O 操作(常见的操作一般有这些:serverSocketChannel. accept()、socketChannel.connect、socketChannel.open、socketChannel.read、socketChannel.write、fileChannel.read、fileChannel.write)而进入阻塞状态,而另一个线程又调用了该阻塞线程的 interrupt 方法,这将导致该通道被关闭,并且已阻塞线程接将会收到 ClosedByInterruptException,并且设置已阻塞线程的中断状态。另外,如果已设置某个线程的中断状态并且它在通道上调用某个阻塞的 I/O 操作,则该通道将关闭并且该线程立即接收到 ClosedByInterruptException;并仍然设置其中断状态。
-
如果线程阻塞于 Selector 调用,则线程的中断标志位会被设置,同时,阻塞的调用会立即返回。
我们重点介绍另一种情况,InputStream 的 read 调用,该操作是不可中断的,如果流中没有数据,read 会阻塞 (但线程状态依然是 RUNNABLE ),且不响应 interrupt(),与 synchronized 类似,调用 interrupt() 只会设置线程的中断标志,而不会真正”中断”它,我们看段代码
public class InterruptReadDemo { private static class A extends Thread { @Override public void run() { while(!Thread.currentThread().isInterrupted()){ try { System.out.println(System.in.read())//wait input } catch (IOException e) { e.printStackTrace(); } } System.out.println("exit"); } } public static void main(String[] args) throws InterruptedException { A t = new A(); t.start(); Thread.sleep(100); t.interrupt(); } }
线程t启动后调用 System.in.read() 从标准输入读入一个字符,不要输入任何字符,我们会看到,调用 interrupt() 不会中断 read(),线程会一直运行。
不过,有一个办法可以中断 read() 调用,那就是调用流的 close 方法,我们将代码改为:
public class InterruptReadDemo { private static class A extends Thread { @Override public void run() { while (!Thread.currentThread().isInterrupted()) { try { System.out.println(System.in.read()); } catch (IOException e) { e.printStackTrace(); } } System.out.println("exit"); } public void cancel() { try { System.in.close(); } catch (IOException e) { } interrupt(); } } public static void main(String[] args) throws InterruptedException { A t = new A(); t.start(); Thread.sleep(100); t.cancel(); } }
我们给线程定义了一个 cancel 方法,在该方法中,调用了流的 close 方法,同时调用了 interrupt 方法,这次,程序会输出:
-1
exit
也就是说,调用close方法后,read方法会返回,返回值为-1,表示流结束。
如何正确地取消/关闭线程
1. 以上,我们可以看出,interrupt 方法不一定会真正”中断”线程,它只是一种协作机制,如果 不明白线程在做什么,不应该贸然的调用线程的 interrupt 方法,以为这样就能取消线程。
2. 对于以线程提供服务的程序模块而言,它应该封装取消/关闭操作,提供单独的取消/关闭方法给调用者,类似于 InterruptReadDemo 中演示的 cancel 方法,外部调用者应该调用这些方法而不是直接调用 interrupt。
3. Java并发库的一些代码就提供了单独的取消/关闭方法,比如说,Future接口提供了如下方法以取消任务:boolean cancel(boolean mayInterruptIfRunning);
4. 再比如,ExecutorService提供了如下两个关闭方法:
void shutdown(); List<Runnable> shutdownNow();
5. Future 和 ExecutorService 的 API 文档对这些方法都进行了详细说明,这是我们应该学习的方式。
什么是中断异常
现在一个首要的问题来了,什么是中断异常,InterruptedException到底意味着什么意思呢?下面是笔者通过阅读IBM官网上面对于此的定义:
When a method throws InterruptedException, it is telling you several things in addition to the fact that it can throw a particular checked exception. It is telling you that it is a blocking method and that it will make an attempt to unblock and return early
大致意思如下:InterruptedException实质上是一个检测异常,它表明有一个阻塞的方法被中断了,它尝试进行解除阻塞操作并返回地更早一些。中断阻塞方法的操作线程并不是自身线程干的,而是其它线程。而中断操作发生之后,随后会抛出一个InterruptedException,伴随着这个异常抛出的同时,当前线程的中断状态重新被置为false。
创建多少线程合适?
创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的,由于 I/O 设备的速度相对于 CPU 来说都很慢,所以大部分情况下,I/O 操作执行的时间相对于 CPU 计算来说都非常长,这种场景我们一般都称为 I/O 密集型计算;和 I/O 密集型计算相对的就是 CPU 密集型计算了,CPU 密集型计算大部分场景下都是纯 CPU 计算。I/O 密集型程序和 CPU 密集型程序,计算最佳线程数的方法是不同的。
下面我们对这两个场景分别说明。
对于 CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率,所以对于一个 4 核的 CPU,每个核一个线程,理论上创建 4 个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
对于 I/O 密集型的计算场景,比如前面我们的例子中,如果 CPU 计算和 I/O 操作的耗时是 1:1,那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2,那多少个线程合适呢?是 3 个线程,如下图所示:CPU 在 A、B、C 三个线程之间切换,对于线程 A,当 CPU 从 B、C 切换回来时,线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。
通过上面这个例子,我们会发现,对于 I/O 密集型计算场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,我们可以总结出这样一个公式:
最佳线程数 =1 +(I/O 耗时 / CPU 耗时)
我们令 R=I/O 耗时 / CPU 耗时,综合上图,可以这样理解:当线程 A 执行 IO 操作时,另外 R 个线程正好执行完各自的 CPU 计算。这样 CPU 的利用率就达到了 100%。
不过上面这个公式是针对单核 CPU 的,至于多核 CPU,也很简单,只需要等比扩大就可以了,计算公式如下:
最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]