本文关键字:
线程
,线程池
,单线程
,多线程
,线程池的好处
,线程回收
,创建方式
,核心参数
,底层机制
,拒绝策略
,参数设置
,动态监控
,线程隔离
线程和线程池相关的知识,是Java学习或者面试中一定会遇到的知识点,本篇我们会从线程和进程,并行与并发,单线程和多线程等,一直讲解到线程池,线程池的好处,创建方式,重要的核心参数,几个重要的方法,底层实现,拒绝策略,参数设置,动态调整,线程隔离等等。主要的大纲如下(本文只涉及线程部分,线程池下篇讲):
进程和线程
从线程到进程
要说线程池,就不得不先讲讲线程,什么是线程?
线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。
那么问题来了,进程又是什么?
进程是操作系统中进行保护和资源分配的基本单位。
是不是有点懵,进程摸得着看得见么?具体怎么表现?打开Windows
的任务管理器或者Mac
的活动监视器,就可以看到,基本每一个打开的App
就是一个进程,但是并不是一定的,一个应用程序可能存在多个进程。
比如下面的Typora
就显示了两个进程,每个进程后面有一个PID
是唯一的标识,也是由系统分配的。除此之外,每个进程都可以看到有多少个线程在执行,比如微信有32
个线程在执行。重要的一句话:一个程序运行之后至少有一个进程,一个进程可以包含多个线程。
<img src="https://markdownpicture.oss-cn-qingdao.aliyuncs.com/blog/image-20210508225417275.png" alt="image-20210508225417275" style="zoom:50%;" />
为什么需要进程?
程序,就是指令的集合,指令的集合说白了就是文件,让程序跑起来,在执行的程序,才是进程。程序是静态的描述文本,而进程是程序的一次执行活动,是动态的。进程是拥有计算机分配的资源的运行程序。
我们不可能一个计算机只有一个进程,就跟我们全国不可能只有一个市或者一个部门,计算机是一个庞然大物,里面的运转需要有条理,就需要按照功能划分出比较独立的单位,分开管理。每个进程有自己的职责,也有自己的独立内存空间,不可能混着使用,要是所有的程序共用一个进程就会乱套。
每个进程,都有各自独立的内存,进程之间内存地址隔离,进程的资源,比如:代码段,数据集,堆等等,还可能包括一些打开的文件或者信号量,这都是每个进程自己的数据。同时,由于进程的隔离性,即使有一个程序的进程出现问题了,一般不会影响到其他的进程的使用。
进程在Linux系统中,进程有一个比较重要的东西,叫进程控制块(PCB
),仅做了解:
PCB
是进程的唯一标识,由链表实现,是为了动态的插入以及删除,创建进程的时候,生成一个PCB
,进程结束的时候,回收这个PCB
。PCB
主要包括以下的信息:
- 进程状态
- 进程标识信息
- 定时器
- 用户可见的寄存器,控制状态寄存区,栈指针等等。
进程怎么切换的呢?
先明白计算机里面的一个事实:CPU运转得超级无敌快,快到其他的只有寄存器差不多能匹配它的速度,但是很多时候我们需要从磁盘或者内存读或者写数据,这些设备的速度太慢了,与之相差太远。(如果不特殊说明,默认是单核的CPU)
假设一个程序/进程的任务执行一段时间,要写磁盘,写磁盘不需要CUP
进行计算,那CPU
就空出来了,但是其他的程序也不能用,CPU
就干等着,等到写完磁盘再接着执行。这多浪费,CPU
又不是这个程序一家的,其他的应用也要使用。CPU
你不用的时候,总有别人需要用。
所以CPU
资源需要调度,程序A
不用的时候,可以切出来,让程序B
去使用,但是程序A
切回来的时候怎么保证它能够接着之前的位置继续执行呢?这时候不得不提上下文的事。
当程序A
(假设为单进程)放弃CPU
的时候,需要保存当前的上下文,何为上下文?也就是除了CPU
之外,寄存器或者其他的状态,就跟犯罪现场一样,需要拍个照,要不到时候别的程序执行完之后,怎么知道接下来怎么执行程序A
,之前执行到哪一步了。总结一句话:保存当前程序的执行状态。
上下文切换一般还涉及缓存的开销,也就是缓存会失效,一般执行的时候,CPU会缓存一些数据方便下次更快的执行,一旦进行上下文切换,原来的缓存就失效了,需要重新缓存。
调度一般有两种(一般是按照线程维度来调度),CPU
的时间被分为特别小的时间片:
- 分时调度:每个线程或者进程轮流的使用
CPU
,平均时间分配到每个线程或者进程。 - 抢占式调度:优先级高的线程/进程立即抢占下一个时间片,如果优先级相同,那么随机选择一个进程。
时间片超级短,CPU超级快,给我们无比丝滑的感觉,就像是多个任务在同时进行
我们现在操作系统或者其他的系统,基本都是抢占式调度,为什么?
因为如果使用分时调度,很难做到实时响应,当后台的聊天程序在进行网络传输的时候,分配予它的时间片还没有使用完,那我点击浏览器,是没有办法实时响应的。除此之外,如果前面的进程挂了,但是一直占有CPU
,那么后面的任务将永远得不到执行。
由于CPU
的处理能力超级快,就算是单核的CPU
,运行着多个程序,多个进程,经过抢占式的调度,每一个程序使用的时候都像是独享了CPU
一样顺滑。进程有效的提高了CPU
的使用率,但是进程在上下文切换的时候是存在着一定的成本的。
线程和进程什么关系?
前面说了进程,那有了进程,为啥还要线程,多个应用程序,假设我们每个应用程序要做n
件事,就用n
个进程不行么?
可以,但是没必要。
进程一般由程序,数据集合和进程控制块组成,同一个应用程序一般是需要使用同一个数据空间的,要是一个应用程序搞很多个进程,就算有能力做到数据空间共享,进程的上下文切换都会消耗很多资源。(一般一个应用程序不会有很多进程,大多数一个,少数有几个)
进程的颗粒度比较大,每次执行都需要上下文切换,如果同一个程序里面的代码段A
,B
,C
,做不一样的东西,如果分给多个进程去处理,那么每次执行都有切换进程上下文。这太惨了。一个应用程序的任务是一家人,住在同一个屋子下(同一个内存空间),有必要每个房间都当成每一户,去派出所登记成一个户口么?
进程缺点:
- 信息共享难,空间独立
- 切换需要
fork()
,切换上下文,开销大 - 只能在一个时间点做一件事
- 如果进程阻塞了,要等待网络传过来数据,那么其他不依赖这个数据的任务也做不了
但是有人会说,那我一个应用程序有很多事情要做,总不能只用一个进程,所有事情都等着它来处理啊?那不是会阻塞住么?
确实啊,单独一个进程处理不了问题,那么我们把进程分得更小,里面分成很多线程,一家人,每个人都有自己的事情做,那我们每个人就是一个线程,一家人就是一个进程,这样岂不是更好么?
进程是描述CPU时间片调度的时间片段,但是线程是更细小的时间片段,两者的颗粒度不一样。线程可以称为轻量级的进程。其实,线程也不是一开始就有的概念,而是随着计算机发展,对多个任务上下文切换要求越来越高,随之抽象出来的概念。
$$进程时间段 = CPU加载程序上下文的时间 + CPU执行时间 + CPU保存程序上下文的时间$$
$$ 线程时间段 = CPU加载线程上下文的时间 + CPU执行时间 + CPU保存线程上下文的时间$$ **最重要的是,进程切换上下文的时间远比线程切换上下文的时间成本要高**,如果是同一个进程的不同线程之间抢占到`CPU`,切换成本会比较低,因为他们**共享了进程的地址空间**,线程间的通信容易很多,通过共享进程级全局变量即可实现。 况且,现在多核的处理器,让不同进程在不同核上跑,进程内的线程在同个核上做切换,尽量减少(不可以避免)进程的上下文切换,或者让不同线程跑在不同的处理器上,进一步提高效率。 进程和线程的模型如下: ![image-20210509163642149](https://markdownpicture.oss-cn-qingdao.aliyuncs.com/blog/image-20210509163642149.png) ### 线程和进程的区别或者优点 - 线程是程序执行的最小单位,进程是操作系统分配资源的最小单位。 - 一个应用可能多个进程,一个进程由一个或者多个线程组成 - 进程相互独立,通信或者沟通成本高,在同一个进程下的线程共享进程的内存等,相互之间沟通或者协作成本低。 - 线程切换上下文比进程切换上下文要快得多。 ## 线程有哪些状态 现在我们所说的是`Java`中的线程`Thread`,一个线程在一个给定的时间点,只能处于一种状态,这些状态都是虚拟机的状态,不能反映任何操作系统的线程状态,一共有六种/七种状态: - `NEW`:创建了线程对象,但是还没有调用`Start()`方法,还没有启动的线程处于这种状态。 - `Running`:运行状态,其实包含了两种状态,但是`Java`线程将就绪和运行中统称为可运行 - `Runnable`:就绪状态:创建对象后,调用了`start()`方法,该状态的线程还位于可运行线程池中,等待调度,获取`CPU`的使用权 - 只是有资格执行,不一定会执行 - `start()`之后进入就绪状态,`sleep()`结束或者`join()`结束,线程获得对象锁等都会进入该状态。 - `CPU`时间片结束或者主动调用`yield()`方法,也会进入该状态 - `Running` :获取到`CPU`的使用权(获得CPU时间片),变成运行中 - `BLOCKED` :阻塞,线程阻塞于锁,等待监视器锁,一般是`Synchronize`关键字修饰的方法或者代码块 - `WAITING` :进入该状态,需要等待其他线程通知(`notify`)或者中断,一个线程无限期地等待另一个线程。 - `TIMED_WAITING` :超时等待,在指定时间后自动唤醒,返回,不会一直等待 - `TERMINATED` :线程执行完毕,已经退出。如果已终止再调用start(),将会抛出`java.lang.IllegalThreadStateException`异常。 ![image-20210509224848865](https://markdownpicture.oss-cn-qingdao.aliyuncs.com/blog/image-20210509224848865.png) 可以看到`Thread.java`里面有一个`State`枚举类,枚举了线程的各种状态(`Java`线程将**就绪**和**运行中**统称为**可运行**): ```Java public enum State { /** * 尚未启动的线程的线程状态。 */ NEW, /** * 可运行线程的线程状态,一个处于可运行状态的线程正在Java虚拟机中执行,但它可能正在等待来自操作系统(如处理器)的其他资源。 */ RUNNABLE, /** * 等待监视器锁而阻塞的线程的线程状态。 * 处于阻塞状态的线程正在等待一个监视器锁进入一个同步的块/方法,或者在调用Oject.wait()方法之后重新进入一个同步代码块 */ BLOCKED, /** * 等待线程的线程状态,线程由于调用其中一个线程而处于等待状态 */ WAITING, /** * 具有指定等待时间的等待线程的线程状态,线程由于调用其中一个线程而处于定时等待状态。 */ TIMED_WAITING, /** * 终止线程的线程状态,线程已经完成执行。 */ TERMINATED; } ``` 除此之外,Thread类还有一些属性是和线程对象有关的: - long tid:线程序号 - char name[]:线程名称 - int priority:线程优先级 - boolean daemon:是否守护线程 - Runnable target:线程需要执行的方法 介绍一下上面图中讲解到线程的几个重要方法,它们都会导致线程的状态发生一些变化: - `Thread.sleep(long)`:调用之后,线程进入`TIMED_WAITING`状态,但是不会释放对象锁,到时间苏醒后进入`Runnable`就绪状态 - `Thread.yield()`:线程调用该方法,表示放弃获取的`CPU`时间片,但是不会释放锁资源,同样变成就绪状态,等待重新调度,不会阻塞,但是也不能保证一定会让出`CPU`,很可能又被重新选中。 - `thread.join(long)`:当前线程调用其他线程`thread`的`join()`方法,当前线程不会释放锁,会进入`WAITING`或者`TIMED_WAITING`状态,等待thread执行完毕或者时间到,当前线程进入就绪状态。 - `object.wait(long)`:当前线程调用对象的`wait()`方法,当前线程会释放获得的对象锁,进入等待队列,`WAITING`,等到时间到或者被唤醒。 - `object.notify()`:唤醒在该对象监视器上等待的线程,随机挑一个 - `object.notifyAll()`:唤醒在该对象监视器上等待的所有线程 ## 单线程和多线程 单线程,就是只有一条线程在执行任务,串行的执行,而多线程,则是多条线程同时执行任务,所谓同时,并不是一定真的同时,如果在单核的机器上,就是假同时,只是看起来同时,实际上是轮流占据CPU时间片。 下面的每一个格子是一个时间片(每一个时间片实际上超级无敌短),不同的线程其实可以抢占不同的时间片,获得执行权。**时间片分配的单位是线程,而不是进程,进程只是容器** ![image-20210511002923132](https://markdownpicture.oss-cn-qingdao.aliyuncs.com/blog/image-20210511002923132.png) ### 如何启动一个线程 其实`Java`的`main()`方法本质上就启动了一个线程,但是**本质上不是只有一个线程**,看结果的 5 就大致知道,其实一共有 5 个线程,主线程是第 5 个,大多是**后台线程**: ```java public class Test { public static void main(String[] args) { System.out.println(Thread.currentThread().toString()); } } ``` 执行结果: ```txt Thread[main,5,main] ``` 可以看出上面的线程是`main`线程,但是要想创建出有别于`main`线程的方式,有四种: - 自定义类去实现`Runnable`接口 - 继承`Thread`类,重写`run()`方法 - 通过`Callable`和`FutureTask`创建线程 - 线程池直接启动(本质上不算是) #### 实现Runnable接口 ```java class MyThread implements Runnable{ @Override public void run(){ System.out.println("Hello world"); } } public class Test { public static void main(String[] args) { Thread thread = new Thread(new MyThread()); thread.start(); System.out.println("Main Thread"); } } ``` 运行结果: ```txt Main Thread Hello world ``` 如果看底层就可以看到,构造函数的时候,我们将`Runnable`的实现类对象传递进入,会将`Runnable`实现类对象保存下来: ```java public Thread(Runnable target) { this(null, target, "Thread-" + nextThreadNum(), 0); } ``` 然后再调用`start()`方法的时候,会调用原生的`start0()`方法,原生方法是由`c`或者`c++`写的,这里看不到具体的实现: ```java public synchronized void start() { if (threadStatus != 0) throw new IllegalThreadStateException(); group.add(this); boolean started = false; try { // 正式的调用native原生方法 start0(); started = true; } finally { try { if (!started) { group.threadStartFailed(this); } } catch (Throwable ignore) { } } } ``` `Start0()`在底层确实调用了`run()`方法,并且不是直接调用的,而是启用了另外一个线程进行调用的,这一点在代码注释里面写得比较清楚,在这里我们就不展开讲,我们将关注点放到`run()`方法上,调用的就是刚刚那个`Runnable`实现类的对象的`run()`方法: ```java @Override public void run() { if (target != null) { target.run(); } } ``` #### 继承Thread类 由于`Thread`类本身就实现了`Runnable`接口,所以我们只要继承它就可以了: ```java class Thread implements Runnable { } ``` 继承之后重写run()方法即可: ```java class MyThread extends Thread{ @Override public void run(){ System.out.println("Hello world"); } } public class Test { public static void main(String[] args) { Thread thread = new Thread(new MyThread()); thread.start(); System.out.println("Main Thread"); } } ``` 执行结果和上面的一样,其实两种方式本质上都是一样的,一个是实现了`Runnable`接口,另外一个是继承了实现了`Runnable`接口的`Thread`类。两种都没有返回值,因为`run()`方法的返回值是`void`。 #### Callable和FutureTask创建线程 要使用该方式,按照以下步骤: - 创建`Callable`接口的实现类,实现`call()`方法 - 创建`Callable`实现类的对象实例,用`FutureTask`包装Callable的实现类实例,包装成`FutureTask`的实例,`FutureTask`的实例封装了`Callable`对象的`Call()`方法的返回值 - 使用`FutureTask`对象作为`Thread`对象的`target`创建并启动线程,`FutureTask`实现了`RunnableFuture`,`RunnableFuture`继承了`Runnable` - 调用`FutureTask`对象的`get()`来获取子线程执行结束的返回值 ```java import java.util.concurrent.Callable; import java.util.concurrent.FutureTask; public class CallableTest { public static void main(String[] args) throws Exception{ Callable<String> callable = new MyCallable<String>(); FutureTask<String> task = new FutureTask<String>(callable); Thread thread = new Thread(task); thread.start(); System.out.println(Thread.currentThread().getName()); System.out.println(task.get()); } } class MyCallable<String> implements Callable<String> { @Override public String call() throws Exception { System.out.println( Thread.currentThread().getName() + " Callable Thread"); return (String) "Hello"; } } ``` 执行结果: ```txt main Thread-0 Callable Thread Hello ``` 其实这种方式本质上也是`Runnable`接口来实现的,只不过做了一系列的封装,但是不同的是,它可以实现返回值,如果我们期待一件事情可以通过另外一个线程来获取结果,但是可能需要消耗一些时间,比如异步网络请求,其实可以考虑这种方式。 `Callable`和`FutureTask`是后面才加入的功能,是为了适应多种并发场景,`Callable`和`Runnable`的区别如下: - `Callable` 定义方法是`call()`,`Runnable`定义的方法是`run()` - `Callable`的`call()`方法有返回值,`Runnable`的`run()`方法没有返回值 - `Callable`的`call()`方法可以抛出异常,`Runnable`的`run()`方法不能抛出异常 #### 线程池启动线程 本质上也是通过实现`Runnable`接口,然后放到线程池中进行执行: ```java import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; class MyThread extends Thread { @Override public void run() { System.out.println(Thread.currentThread().getName() + " : hello world"); } } public class Test { public static void main(String[] args) throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(5); for (int i = 0; i < 10; i++) { MyThread thread = new MyThread(); executorService.execute(thread); } executorService.shutdown(); } } ``` 执行结果如下,可以看到五个核心线程一直在执行,没有规律,循环十次,但是并没有创建出十个线程,这和线程池的设计以及参数有关,后面会讲解: ```txt pool-1-thread-5 : hello world pool-1-thread-4 : hello world pool-1-thread-5 : hello world pool-1-thread-3 : hello world pool-1-thread-2 : hello world pool-1-thread-1 : hello world pool-1-thread-2 : hello world pool-1-thread-3 : hello world pool-1-thread-5 : hello world pool-1-thread-4 : hello world ``` 总结一下,启动一个线程,其实本质上都离不开`Runnable`接口,不管是继承还是实现接口。 ### 多线程可能带来的问题 - 消耗资源:上下文切换,或者创建以及销毁线程,都是比较消耗资源的。 - 竞态条件:多线程访问或者修改同一个对象,假设自增操作`num++`,操作分为三步,读取`num`,`num`加1,写回`num`,并非原子操作,那么多个线程之间交叉运行,就会产生不如预期的结果。 - 内存的可见性:每个线程都有自己的内存(缓存),一般修改的值都放在自己线程的缓存上,到刷新至主内存有一定的时间,所以可能一个线程更新了,但是另外一个线程获取到的还是久的值,这就是不可见的问题。 - 执行顺序难预知:线程先`start()`不一定先执行,是由系统决定的,会导致共享的变量或者执行结果错乱 ## 并发与并行 并发是指两个或多个事件在同一时间间隔发生,比如在同`1s`中内计算机不仅计算`数据1`,同时也计算了`数据2`。但是两件事情可能在某一个时刻,不是真的同时进行,很可能是抢占时间片就执行,抢不到就别人执行,但是由于时间片很短,所以在1s中内,看似是同时执行完成了。当然前面说的是单核的机器,并发不是真的同时执行,但是多核的机器上,并发也可能是真的在同时执行,只是有可能,这个时候的并发也叫做并行。 ![image-20210511012516227](https://markdownpicture.oss-cn-qingdao.aliyuncs.com/blog/image-20210511012516227.png) 并行是指在同一时刻,有多条指令在多个处理器上同时执行,真正的在同时执行。 ![image-20210511012723433](https://markdownpicture.oss-cn-qingdao.aliyuncs.com/blog/image-20210511012723433.png) 如果是单核的机器,最多只能并发,不可能并行处理,只能把CPU运行时间分片,分配给各个线程执行,执行不同的线程任务的时候需要上下文切换。而多核机器,可以做到真的并行,同时在多个核上计算,运行。**并行操作一定是并发的,但是并发的操作不一定是并行的。** ### 关于作者 秦怀,公众号【**秦怀杂货店**】作者,技术之路不在一时,山高水长,纵使缓慢,驰而不息。个人写作方向:Java源码解析,JDBC,Mybatis,Spring,redis,分布式,剑指Offer,LeetCode等,认真写好每一篇文章,不喜欢标题党,不喜欢花里胡哨,大多写系列文章,不能保证我写的都完全正确,但是我保证所写的均经过实践或者查找资料。遗漏或者错误之处,还望指正。 [2020年我写了什么?](http://aphysia.cn/archives/2020) [开源编程笔记](https://damaer.github.io/Coding/#/) [150页的剑指Offer PDF领取](https://mp.weixin.qq.com/s?__biz=MzA3NTUwNzk0Mw==&tempkey=MTExNF9zZ2FPelJtWkNCdlZ6dTRuVThBSDdNc01JNFZuSTBrVlZWU0dCRk45dzlLVmx3SWx3NXlHVE5DWkRTSFBnNWVhRFV6RkNKOURjSmhUTExZeVp4QndwbEZ4Q2NfWUlzMzI2bDQzSm51TVJ4SE14QVhsUFIxSWJkcWtGQVhhLVVwZGRPZ0cwRHFDaGJvZ2pPeDM3NXdzcGF5N3A5bFdRaE9JU1Rpbi1Rfn4%3D&chksm=383018090f47911fd2458fe7c2ee89cbde7a7875dcba06d9f2e4daca191c7c0ab6409777f14d#rd)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。