开篇:为什么学习多线程
实事求是地讲,对于绝大多数研发人员,平时用到多线程的场景并不多。但多线程在我们的日常开发中却无处不在,只不过很多时候,框架已经帮你实现了。比如 web 开发,容器已经帮你实现了多线程;再比如大数据开发,框架也已帮你实现了多线程,甚至分布式计算。那促使你学习多线程的原因是什么呢?我想很大可能你是为了面试打基础、做准备。没错,这真的很现实!
面试官考查多线程的原因
1. 考察你的工作技术深度。
多线程虽然很少用到。但是如果你做底层开发,或者负责基础设施(例如消息队列)研发,肯定会用到多线程。通过面试多线程,可以考察你的工作在技术方面的深度。
2 考察你的学习、理解能力。
面试大概率会考多线程问题,这已经是公开的秘密了。这其实是一个开卷考试,对所有候选人是公平的。比拼的是候选人的学习能力、理解能力、做事的态度。你可以没用过,但你要有快速掌握的能力,和稳扎稳打的学习态度
我认为第二点是主要原因。求职者都知道面试官会考查多线程,但为什么还是有的人答非所问,有的人却对答如流,有的人甚至可以深入底层原理?这无外乎两个原因:
①对面试的准备和态度。明知道要考察多线程,候选人却不认真准备,这种态度带到工作中是何其的可怕?
②学习的能力。短时间内掌握平时不常用到的多线程并不容易。彻底理解多线程,还需要 JVM 的知识。这除了自身的学习能力外,如果配合一本好的教材、几篇好的博客,能够大大加快你的学习速度、提升你的学习深度。
软件世界即现实世界
虽然可能绝大多数学习者是抱着提升自身实力,为面试做准备的初衷来学习多线程。但我想告诉大家,多线程真的很强大,有很多使用场景,能帮你解决很多问题。在学习完多线程后,你手中便多了一样武器,你解决问题的思路也更为宽广。在你以后漫漫的编程生涯中,从此多了一种选择。所以学习多线程,绝对不是仅仅为了面试。
其实多线程并不复杂,其实和现实世界中多人协作是一样的。编程初学者,会觉得软件是无形的,看不见、摸不到,只有冰冷冷的逻辑,学习起来晦涩难懂。其实从面向对象出现开始,软件已经成为现实世界的对等映射。这不光体现在语言本身,其实在软件领域无处不在,例如:
1. 设计模式
23 种经典设计模式,没有哪一种不是从现实世界得来的灵感。如果你看过设计模式的文章,你一定对设计模式中生动有趣的例子所吸引。
2. 软件设计
绝大多数软件的设计,都参考了工业设计或者参考了生活中解决问题的方式,汲取其中的设计思想。其实不管软件还是硬件或者生活中遇到的难题,在解决问题的思路上是一致的。无形的软件设计,可以借助有形世界里的案例来帮助你思考。例如 kafka 的源代码,其中 producer 的设计思想和快递公司发快递的过程很类似。还有 Java NIO,也是类似的原理。可以说软件设计的思想都发源于现实世界。
3. 软件架构
我做个类比,软件架构可以看作现实世界工厂里的机器设计和布置。我们需要考虑很多,比如需要哪些机器,不同机器如何配比、不同工序之间如何衔接、机器出问题如何应对、机器操作日志如何记录、安全如何保障。工厂里遇到的问题在软件架构上也都会遇到。
以上举例,足以说明软件和现实世界之间的相似程度。软件其实就是现实世界的映射。我们在学习软件的过程中,要善于找到生活中常见的例子类比,这样理解起来就没有困难了,而且便于记忆。
进程与线程
进程
程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程
进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)
线程
一个进程之内可以分为一到多个线程
一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器
并发与并行
单核 cpu 下,线程实际还是 串行执行 的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是 同时运行的 。总结为一句话就是: 微观串行,宏观并行 ,一般会将这种 线程轮流使用 CPU 的做法称为并发, concurrent
CPU | 时间片1 | 时间片2 | 时间片3 | 时间片4 |
core | 线程1 | 线程2 | 线程3 | 线程4 |
多核 cpu下,每个 核(core) 都可以调度运行线程,这时候线程可以是并行的。
CPU | 时间片1 | 时间片2 | 时间片3 | 时间片4 |
Core1 | 线程1 | 线程1 | 线程3 | 线程3 |
Core2 | 线程2 | 线程4 | 线程2 | 线程4 |
并发
并发(concurrent)是同一时间应对(dealing with)多件事情的能力。
例如:家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
并行
并行(parallel)是同一时间动手做(doing)多件事情的能力
例如:雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
线程安全
线程安全指的是内存的安全,在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。
所以线程安全指的是,在堆内存中的数据由于可以被任何线程访问到,在没有限制的情况下存在被意外修改的风险。即堆内存空间在没有保护机制的情况下,对多线程来说是不安全的地方,因为你放进去的数据,可能被别的线程“破坏”。
管程
Java中,每个对象其实都一个Monitor,Java中提供的synchronized关键字及wait()、notify()、notifyAll()方法,都是Monitor的一部分。
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
管程是一种概念,任何语言都可以通用。
在java中,每个加锁的对象都绑定着一个管程(监视器)
线程访问加锁对象,就是去拥有一个监视器的过程。
总结起来就是,管程就是一个对象监视器。任何线程想要访问该资源,就要排队进入监控范围。进入之后,接受检查,不符合条件,则要继续等待,直到被通知,然后继续进入监视器。
管程是定义了一个数据结构和能为并发所执行的一组操作,这组操作能够进行同步和改变管程中的数据。这相当于对临界资源的同步操作都集中进行管理,凡是要访问临界资源的进程或线程,都必须先通过管程,由管程的这套机制来实现多进程或线程对同一个临界资源的互斥访问和使用。管程的同步主要通过condition类型的变量(条件变量),条件变量可执行操作wait()和signal()。管程一般是由语言编译器进行封装,体现出OOP中的封装思想,管程模型和面向对象高度契合的。 管程只是一种解决并发问题的模型而已。
线程同步
即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态,实现线程同步的方法有很多。
线程分类
1、用户线程
在前台运行的线程都是用户线程,例如主线程。
2、守护线程
守护线程一般为后台运行的线程,守护线程是用来为用户线程服务的。例如JVM中的线程都为守护线程,典型的有GC线程。
通过在start()方法前调用 thread.setDaemon(true)可以把一个用户线程变成一个守护线程。
程序必须要确保用户线程执行完毕,才可以关闭。同时不用等待守护线程执行完毕,如后台记录操作日志,监控内存,垃圾回收等
线程状态
操作系统分为五种状态:
【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
【运行状态】指获取了 CPU 时间片运行中的状态
当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换
【阻塞状态】
如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入【阻塞状态】
等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态
java根据 Thread.State 枚举分为六种状态
初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。 线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
阻塞(BLOCKED):表示线程阻塞于锁。
等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
终止(TERMINATED):表示该线程已经执行完毕。
创建线程
1、继承Thread类
// 创建线程对象 Thread t = new Thread() { public void run() { // 要执行的任务 } }; // 启动线程 t.start();
2、实现Runnable接口
Runnable runnable = new Runnable() { public void run() { // 要执行的任务 } }; // 创建线程对象 Thread t = new Thread(runnable); // 启动线程 t.start();
3、实现Callable接口
// 创建任务对象 FutureTask<Integer> task3 = new FutureTask<>(() -> { return 100; }); // 参数1 是任务对象; 参数2 是线程名字,推荐 new Thread(task3, "t3").start(); // 主线程阻塞,同步等待 task 执行完毕的结果 Integer result = task3.get();
4、线程池
阻塞队列
从名字可以看出,他也是队列的一种,那么他肯定是一个先进先出(FIFO)的数据结构。与普通队列不同的是,他支持两个附加操作,即阻塞添加和阻塞删除方法。
- 阻塞添加:当阻塞队列是满时,往队列里添加元素的操作将被阻塞。
- **阻塞移除:**当阻塞队列是空时,从队列中获取元素/删除元素的操作将被阻塞。
举个例子:
现有三个角色:顾客,休息区,银行办理窗口。(Thread1为顾客,BlockingQueue为休息区,Thread2为银行办理窗口)。 1、正常情况下,一个银行办理窗口同一时间只能对接一个顾客; 2、恰巧今天办理的顾客有3个人,另外2个顾客怎么办,你总不至于给人家说不办了,快回家吧; 3、而正确的做法是你可以让这两个人在休息区等候,等银行窗口空闲了,然后去办理。
其实上面的情况面临的问题是:当一个线程占有资源的时候,你后面线程请求不得不阻塞,但这也不一定是缺点,反而更像是一件好事,因为他并不暴力的解决问题。
我们再来看一下关于阻塞的定义:在多线程中,阻塞的意思是,在某些情况下会挂起线程,一旦条件成熟,被阻塞的线程就会被自动唤醒。
也就是说,线程的wait和notify机制是需要我们自己去手动控制,但是我们自己认为的控制是很容易出现问题的,比如死锁,逻辑判断等…
但是有了阻塞队列,一切的问题就迎刃而解了。
**阻塞队列的好处:**阻塞队列不用手动控制什么时候该被阻塞,什么时候该被唤醒,简化了操作。
BlockingQueue的主要方法
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DXUXggLX-1679290195407)(E:\学习目录\java系列\并发编程\自己整理的笔记\image-20230320115130404.png)]
抛出异常是指当队列满时,再次插入会抛出异常(如果队列未满,插入返回值未true);
返回布尔是指当队列满时,再次插入会返回false;
阻塞是指当队列满时,再次插入会被阻塞,直到队列取出一个元素,才能插入。
超时是指当一个时限过后,才会插入或者取出。
BlockingQueue的实现类
ArrayBlockingQueue 由数组构成的有界阻塞队列
LinkedBlockingQueue 由链表构成的有界阻塞队列
PriorityBlockingQueue 支持优先级排序的无界阻塞队列
DelayQueue 支持优先级的延迟无界阻塞队列
SynchronousQueue 单个元素的阻塞队列
LinkedTransferQueue 由链表构成的无界阻塞队列
LinkedBlockingDeque 由链表构成的双向阻塞队列
粗体标记的三个用得比较多,许多消息中间件底层就是用它们实现的,也是我们下面着重说明的。
SynchronousQueue: 队列只有一个元素,如果想插入多个,必须等队列元素取出后,才能插入,只能有一个“坑位”,用一个插一个。
线程池优点
1.线程池做的工作主要是控制运行的线程的数量,处理过程中将任务加入队列,然后在线程创建后启动这些任务,如果显示超过了最大数量,超出的数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行
2.它的主要特点为:线程复用 | 控制最大并发数 | 管理线程.
七大参数
①.corePoolSize:线程池中的常驻核心线程数
在创建了线程池后,当有请求任务来之后,就会安排池中的线程去执行请求任务,近似理解为今日当值线程
当线程池中的线程数目达到corePoolSize后,就会把到达的任务放入到缓存队列当中.
②. maximumPoolSize:线程池能够容纳同时执行的最大线程数,此值大于等于1
③. keepAliveTime:多余的空闲线程存活时间,当空间时间达到keepAliveTime值时,多余的线程会被销毁直到只剩下corePoolSize个线程为止(非核心线程)
④. unit:keepAliveTime的单位
⑤. workQueue:任务队列,被提交但尚未被执行的任务(候客区),也就是阻塞队列实现类
⑥. threadFactory:表示生成线程池中工作线程的线程工厂,用户创建新线程,一般用默认即可(银行网站的logo | 工作人员的制服 | 胸卡等)
⑦. handler:拒绝策略,表示当线程队列满了并且工作线程大于等于线程池的最大显示 数(maxnumPoolSize)时如何来拒绝
jdk提供了四种默认的拒绝策略
AbortPolicy(默认):直接抛出RejectedException异常阻止系统正常运行
CallerRunsPolicy:"调用者运行"一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是返回给调用者进行处理
DiscardOldestPolicy:将最早进入队列的任务删除,之后再尝试加入队列
DiscardPolicy:直接丢弃任务,不予任何处理也不抛出异常.如果允许任务丢失,这是最好的拒绝策略
工作原理
如何合理设置参数
对于一个线程池创建多少线程合适?
1、过小会导致程序不能充分地利用系统资源、容易导致饥饿
2、过大会导致更多的线程上下文切换,占用更多内存
CPU 密集型运算
通常采用 cpu 核数 + 1 能够实现最优的 CPU 利用率,+1 是保证当线程由于页缺失故障(操作系统)或其它原因
导致暂停时,额外的这个线程就能顶上去,保证 CPU 时钟周期不被浪费
I/O 密集型运算
CPU 不总是处于繁忙状态,例如,当你执行业务计算时,这时候会使用 CPU 资源,但当你执行 I/O 操作时、远程
RPC 调用时,包括进行数据库操作时,这时候 CPU 就闲下来了,你可以利用多线程提高它的利用率
线程数 = 核数 * 期望 CPU 利用率 * 总时间(CPU计算时间+等待时间) / CPU 计算时间
例如 4 核 CPU 计算时间是 50% ,其它等待时间是 50%,期望 cpu 被 100% 利用,套用公式
4 * 100% * 100% / 50% = 8
例如 4 核 CPU 计算时间是 10% ,其它等待时间是 90%,期望 cpu 被 100% 利用,套用公式
4 * 100% * 100% / 10% = 40