多进程多线程概述
多线程多进程概述
进程与线程
进程英文单词 process,有运行的意思,顾名思义,他必须是运行着的才能称之为进程;
线程英文单词 thread,有丝线的意思,就是颗粒很细,力度很小,因此他要依附于进程,
所以我们可以姑且这样认为,没有进程肯定谈不上有线程;
没有真正意义上的多线程
了解 CPU(单核)的人都知道,CPU 在同一个时刻只能给一个程序分配资源,也就是赋
予一个程序运行权,那么我们看到一次能运行好几个程序其实是 CPU 来回切换执行权,所
以让别人以为是并发运行,只是切换的速度很快(取决于 CPU 的主频)所以没有真正意义
上的并发;
纠结的思考
刚才的程序我们看到的已经有两个线程的存在了,我之前说过,一个进程至少有一
个执行单元,可以理解为至少有一个线程在运行,那么 main 函数应该就是一个线程,因为
它是程序的入口,然后我们又写了一个匿名类它显示的实现了 Runnable 接口,并且构造了
一个 Thread 类,因此它也是一个线程,因此有两个线程在同时运行问题往往没有那么简单,这也是本小节名字“纠结的思考”的来源,他真的是两个线程
么?难道 JVM 不做些什么吗?最起码我们应该联想到它应该有一个后台线程负责管理堆栈
信息管理垃圾的清理工作啊,因此上述的代码远远不止于一个线程,但是往往这样的钻牛角
尖会让我们学习 Thread API 产生很多顾虑,因此我们可以暂且不用去管 JVM 在有多少个线
程在支撑着我们的程序,但是我们最起码应该有这样的意识,这样也不至于在学习的路上浅
尝辄止。
总结
- Main 函数本身其实就是一个线程,我们称他为主线程;
- 实现多线程我们可以继承 Thread 类,也可以继承 Runnable 接口;
- 没有严格意义上的并发;
- JVM 自身有很多后台线程在运行;
多线程详解
多线程的创建
继承Thread创建线程
实现一个线程的一种方式为成为 Thread
的子类,也就是继承 Thread,然后重写 run 方法,其中 run 方法是线程的执行代码片段;
可以总结创建并运行一个线程有三个步骤;
- 继承 Thread 类;
- 重写 run 方法;
- 调用线程的 start 方法(启动线程,调用 run 方法)
首先我们继承了 Thread 类,我们也重写了 run 方法,我们也在 main 方法中构造了一个
Thread 类然后调用了 start 方法;因此线程被创建并且被运行了
思考
为什么我重写的是 run 方法,却要调用 start 方法来启动它,我们如果直接调用线程实
例的 run 方法不行么?可以,当然可以因为它是成员函数,调用当然是无可厚非的事情了,
但是为什么他不代表启动了线程呢?
父类实现算法,子类实现细节
在程序的设计中我们经常会将算法进行抽象,因为它有很多种运算的可能,所以我们为
了更好地扩展,我们将算法进行了抽象,并且统一交给父类进行实现,子类只需要知道某个
单元模块的功能即可,具体是如何穿插起来的子类不用去关心,
为什么实现模块的抽象方法都是 protected 的呢?
因为我们不想让调用者关注到我们实现的细节,这也是面向对象思想封装的一个体现;
为什么 display 方法是不可继承的呢?
因为算法一旦确定就不允许更改,更改也只允许算法的所有者也就是他的主人更改,如
果调用者都可通过继承进行修改,那么算法将没有严谨性可言;
Thread中的Template Design
打开代码发现 start 代码中调用了 JNI 函数 start0,他就用到了模板模式;读者可以自己
查看源码看看;
线程的状态
线程的初始化:
线程的初始化状态就是我们所说的创建了一个线程,也就是说实例化了一个 Thread 的
子类,就等着被 start,初始化状态应该是很容易理解的状态;
线程的运行状态
线程的运行状态就是我们当创建完线程之后,显式的调用了 start 方法,此时线程就处
于运行状态,可是实际是这样的么?这就要看 CPU 的脸色了,因此我刚才的说法只能说对
了一半,但是不够严谨,线程被 start 之后并不一定会马上运行,因此还有一个中间状态叫
做临时状态我之所以没有在上图中画,是因为我觉得这个状态可以不用太多的关注,所谓临
时状态就是指,在 CPU 的执行队列当中,等待 CPU 轮询进行执行,说白了就是在等待获取
执行权;
线程的冻结状态
所谓线程的冻结状态就是,线程被调用了 sleep 方法或者调用了 wait 方法之后,放弃了CPU 的执行权,根据上图的箭头可以看到这个时候的线程能够继续回到运行状态,也就是说重新获取了 CPU 的执行权,当然它也可以直接到死亡状态,比如被中断,或者出现异常;
线程的死亡状态
线程在什么情况下能够到死亡状态呢?第一种是出现了致命的异常导致线程被死亡,另
外一种是线程的执行逻辑执行完毕,线程也就正常死亡;死亡后的线程不可能再回到任何一
个状态;
思考
线程被 start 了为什么不能严格认为是运行状态呢?
因为 CPU 有一个执行权的问题,也就是说线程被 start 之后只具备运行资格,但未必获取到了执行权,因此不能严格认定他为运行状态;
线程冻结之后为什么还能够回到运行状态呢?
因为线程冻结之后其实他并没有死亡,他只是放弃了运行权,并且他已经没有运行资格
了,只有在解冻之后他才有可能获取运行资格,然后获取执行权;
线程的这几种状态是如何切换的呢?
1、 初始化状态只能到运行状态;
2、 运行状态能到冻结状态也能到死亡状态;
3、 冻结状态能到运行状态也能到死亡状态;
4、 死亡状态只能接受死亡的事实;
实现Runnable接口创建线程
Runnable 只是一个任务的接口,他并不是一个线程,他的出现是为了将线
程和业务执行逻辑分离
Runnable和Thread的区别
Runnable 就是一个可执行任务的标识而已,仅此而已;而 Thread 才是线程所有 API 的体现;
继承了 Thread 父类就没有办法去继承其他类,而实现了 Runnable 接口也可以继承其他类并且实现其他接口,这个区别也是很多书中千篇一律提到的,其实 Java 中的对象即使继承了其他类,也可以通过再构造一个父类的方式继承很多个类,或者通过内部类的方式继承很多个类,因此这个区别个人觉得不痛不痒;
将任务执行单元和线程的执行控制区分开来,这才是引入 Runnable 最主要的目的,
Thread 你就是一个线程的操作者,或者独裁者,你有 Thread 的所有方法,而 Runnable只是一个任务的标识,只有实现了它才能称之为一个任务,这也符合面向对象接口的逻辑,接口其实就是行为的规范和标识;
线程中的策略模式
我们可以姑且认为 Thread 是骨架,是提供功能的,而 Runnable 只是其中某个业务逻辑的一种实现罢了,为什么说只是一种实现呢?因为业务逻辑会是很复杂,也会是千变万化的,因此我们需要对它进行高度的抽象,这样才能将具体业务逻辑与抽象分离,程序的可扩展性才能够强,该模式也是本人在编码的时候非常喜欢的一种设计思想;
相信大家就会明白为什么需要有 Runnable 的出现,也能体会到我为什么说将 Thread 和Runnable 来进行比较本身就是一个不合适的提议,因为他们关注的东西就不是一个事情,一个负责线程本身的功能,另外一个则专注于业务逻辑的实现
线程名字
线程名字的默认编号
线程的名字默认是这样命名的 thread-n(其中 n 是从 0 开始的数字)当然你也可以通过显
式的方式进行设定,比如他有 setName 方法,并且有 Thread(String name)这样的构造函数传递名字,并且有 getName()方法获取名字等
线程名字的获取方式
如何获取当前运行的线程名字呢?我们知道 main 函数并没有继承 Thread 也就是说我们
不能通过 getName 这样的 API 获取名字,那么我们应该如何获取呢,其实 Thread 类提
供了一个静态方法 Thread.currentThread()就可以获取当前运行的线程,如果获取了线程
那么获取他的名字应该是易如反掌的事情了;
线程的同步
那个叫号小程序,如果您多运行几次,或者将程序中的 max_value 修
改大之后,您可能会发现有这样的问题,为什么有些号码没有显示出来,相反有些号码则被
显示了几次,这到底是真么回事呢?可以看到不仅有重复的号码出现,并且有超过 500 的情况出现,这到底是由于什么原因引起的呢?
假设三个线程现在同时执行到了(1)这个位置,判断条件都满足也就是说都小于 500,好的我们假定一个数字此时刚好是 499,接着往下分析,假设此时 1 号线程执行到了(2)这个位置,此时 CPU 恰恰将它的执行权切换到了 2 号线程,那么由于 2 号线程在 1 这个位置上进行过判断条件满足,因此它直接输出 500,这个应该不难理解,2 号线程执行完毕之后继续回到了(1)的位置,但是条件不成立,它自己退出了,因此 CPU 又将执行权转到了1 号线程,一号线程起来之后就执行输出语句,因此变成了 501,回去(1)位置之后判断发现条件不符合退出,现在只剩下 3 号线程,3 号线程也不用再进行判断了,直接到(2)号位置执行输出语句,因此输出了 502,至于重复输出的根据这样的逻辑也是能够解释通过的,读者可以自己进行解释;
为什么会出现这样的问题呢?因为我们需要访问的数据没有被保护,这就是多线程最最
令人头疼的地方,线程安全,多线程之间的数据共享引发的安全问题
同步代码块
给共享数据加锁
可以看到我们的代码中增加了一个 synchronized 这样的关键字,他的意思就是线程的同步,立即的通俗来讲就是给代码或者业务逻辑加锁,何为加锁呢?就是我们将部分数据保护起来,每次只能有一个线程进行访问,举个最简单的例子,假设有一个单行道,不管后面的人是老老实实排队的,还是从中间翻越过来的,还吃打架打赢挤过来的,但是单行道的出口总是只能有一个人才能通过!通过单行道的那个门口就是我们所说的锁,那程序加了锁之后是如何运行的呢?
同步代码块的有效范围
看到这里有些人就要问了,既然加了锁,我们的代码就只能被一个线程调用,这样岂不
是降低了效率,在同步代码的部分并没有多线程并发的情况出现呀?如果你能想到这一点,
就说明你对锁的机制了解的差不多了,的确,情况的确如此,因为我们要尽量的缩小同步锁
的范围,有什么原则么?其实如果程序写多了,就会想到我们同步代码块最小的粒度应该放
在共享数据的上下文,或者说共享数据被操作的上下文中,
####如何定义一个锁
所谓加锁,就是为了防止多个线程同时操作一份数据,如果多个线程操作的数据都是各自的,那么就没有加锁的必要
共享数据的锁对于访问他们的线程来说必须是同一份,否则锁只能私有的锁,各锁个的,起不到保护共享数据的目的,试想一下将 Object lock 的定义放到 run 方法里面,每次都会实例化一个 lock,每个线程获取的锁都是不一样的,也就没有争抢可言,说的在通俗一点甲楼有一个门上了锁,A 要进门,乙楼有一个门上了锁 B 要进门,A 和 B 抢的不是一个门,因此不存在数据保护或者共享;
锁的定义可以是任意的一个对象,该对象可以不参与任何运算,只要保证在访问的多个线程看来他是唯一的即可;
####同步方法
其实方法的同步和代码块的公布大相径庭就是在方法名前面加上 synchronized 关键字,具体的格式如何呢?我这里简单的写一下
Private|default|protected|public [static] synchronized void|return type methodName(Parameters)
同步 run 方法
Run 方法是否可以加 synchronized 关键字,当然是可以的,这个在任何时候都符合语法规范,但是为什么不能将 run 方法同步?如果您仔细阅读了我们对 CPU 执行权那部分的分析之后,这个问题也许您自己都已经找到答案了,当第一个线程获取到了 CPU 的执行权之后,进入 run 方法,一定是执行完毕所有的逻辑才会退出,因为 run 方法加了锁,其他线程只有等待的份,地一个线程执行完毕退出,其他线程获取到了锁,想要执行,一看判断已经不符合则自动退出;因此 run 方法加锁,真实情况是会有多个线程运行,但是只有一个线程执行业务逻辑,其他线程都等于阻塞状态,如果不相信,可以尝试一下,打印出来的信息一定只是一个线程相关的;
####同步总结
不管是同步代码块或者同步方法,我们需要事先确定的是:“当同一份的数据被多个线
程操作的时候才考虑同步”,否则将会产生效率的问题
同一份数据,如果不同的线程访问的不是同一份数据,就没有必要加锁保持同步
多个线程访问,多个线程访问的时候采取考虑同步,如果一份数据只是被一个线程访问,就没有必要进行同步;
多个线程同步的代码块必须是同一个锁
this 锁与static锁
####this锁
同步函数其实用到的锁就是 this 锁,为什么他用到的是 this 锁呢?
分别启动了两个线程,分别用来执行 ClassA 中的两个方法 A 和 B,两个方法都是加了锁的,也就是说某个线程尽到方法 A 中其他线程就不能进入 A,但是另一个线程应该能进入 B,但是我们等了半天方法 B 仍然没有输出,因此我们得出一个结论,他们的锁是同一个,至于是哪一个锁呢?答案就是 this 锁;
####static 锁
静态锁,锁是类的字节码信息,因此如果一个类的函数为静态方法,那么我们需要通过
该类的 class 信息进行加锁;
线程的休眠
单例模式的详解
了解单例设计模式的人都知道,单例中涉及的类他在内存之中始终是独一份存在的,如果存在两份则将出现问题,并且单例模式有两种相对比较有特点的形式,那就是饿汉式与懒
汉式单例模式,
饿汉式单例模式
所谓饿汉式单例设计模式,就是将类的静态实例作为该类的一个成员变量,也就是说在
JVM 加载它的时候就已经创建了该类的实例,因此它不会存在多线程的安全问题
可以看到上述代码中的单例不存在线程安全的问题,但是他有一个性能上面的问题,那
就是提前对实例进行了初始化或者说构造,假设构造该类需要很多的性能消耗,如果代码写
成这个样子将会提前完成构造,又假设我们在系统运行过程中压根就没有对该实例进行使用,那岂不是很浪费系统的资源呢?
懒汉式单例模式
所谓懒汉式单例模式的意思就是,实例虽然作为该类的一个实例变量,但是他不主动进
行创建,如果你不使用它那么他将会永远不被创建,只有你在第一次使用它的时候才会被创
建,并且得到保持;
上述的代码就是我们所说的懒汉式单例模式,但是根据上文中的关于线程安全问题的分
析我们不难发出现,instance 有可能会被创建两次
那么我们应该如何避免多线程引起的问题呢,看到这里您可能想到了用 synchronized 这个关键字来解决问题,
但是该方法的效率将是相当低下的,因为每一次调用都要获取锁,判断锁的状态,因此
就会出现解决了安全问题,带来了效率问题,当然安全问题和效率问题本来就是两个很不可
调和的矛盾,但是我们也不应该就此妥协,需要尽我们的智慧既解决了安全问题又带来了最
小的效率影响;我们将程序写成如下的样子
上述代码带来了哪些改变又如何将效率的损耗降到了最低
通过上述代码的分析,我们不难发现,锁的等待或者争抢最多发生两次,也就是同步代
码块中的代码最多被执行两次,如此一来,安全问题解决了,效率问题也被解决掉了。
死锁
多线程同步锁总是以牺牲系统性能为代价的,但是比牺牲性能代价更加严重的将是死锁,程序一旦出现死锁的状况,将会挂死而并不是退出,有时候死锁的问题是很难排查的,尤其是在较大的项目中多人协作的项目中,死锁是一个很头疼的问题,所以我们应该在编写程序的时候规避掉死锁,为了规避死锁,我们首先需要写出一个死锁程序,这样会很清楚什么是死锁,然后又如何避免死锁;
什么是死锁
假设有两个线程 A 和 B,其中 A 持有 B 想要的锁,而 B 持有 A 想要的锁,两个都在等
待各自释放所需要的锁,这样的情况很容易引起死锁现象的发生,
1 | package org.cy.thread; |
可以看到程序运行到一定地步就阻塞住了,然后没有任何输出,但是程序并没有停止,
而是出现了挂死;
用jstack查看:
如何避免死锁
从刚才的代码中可以看出我们实现死锁的方式是同步代码块中的同步,因此在日常的开
发过程中应该避免使用这样的情况,如果有这样的情况出现也要认真的推演,反复地琢磨,
万不得已的请看下才考虑同步代码块中有同步代码块;
线程间的通讯
生产者消费者
一个线程实现让 x++(模拟我们在创建 X 值)而另外一个线程
则是不断的消费 X 值(也就是上述代码中打印而已)
真正意义上的生产者消费者应该是这样的,生产一个消费一个,如果没有生产那就没有
消费,没有被消费完毕就不应该进行生产,因此我们将推出两个比较重要的方法,那就是
wait 和 notify,只启动了一个生产线程和一个消费线程;系统运行并没有什么异样。但是当多个生产者和消费者时,就会出现死锁的问题或者生产多次消费一次,或者生产一次消费多次,为什么会出现上述现象呢?
改成notifyAll就行了。
1 | package org.cy.thread; |
wait
wait 方法和之前的 sleep 一样就是放弃 CPU 执行权,但是他和 sleep 不一样的地方是需要等待另外一个持有相同锁的线程对其进行唤醒操作,并且 wait 方法必须有一个同步锁,否则会抛出一个异常 java.lang.IllegalMonitorStateException: current thread not owner
notify 详解
notify 方法就是将之前处在临时状态的线程唤醒,并且获取执行权,等待 CPU 的再次调度,但是有一点需要注意的是必须和之前的 wait 方法用到的锁是同一个;
###notifyAll 详解
notify 方法是唤醒一个正处在阻塞状态的线程,那他到底唤醒的是谁呢?其实在 JVM 中也存在一个线程队列或者线程池的概念,我们看看下图中的表示,关于 wait 和 notify 中的线程两者均使用的是一把锁,否则将没有可以探讨的必要;
从该图中可以看出,notify 方法将严格按照 FIFO(先进先出)的方式唤醒在线程队列中的与自己持有同样一把锁的线程;通过上图读者应该很清楚 notify 的作用,那么 notifyAll的作用是什么呢?请看下图
通过上图的描述,我们可以看出 notifyAll 方法是将所有 wait 中的线程都进行唤醒,当
然前提就是唤醒的线程持有和自己一样的锁,否则将不能被唤醒;
守护线程与线程的优先级
守护线程
什么叫做守护线程,你可以简单的将其理解为一个后台线程,他的特点主要是,主线程
一旦运行结束,它就会随之结束,不管运行没运行完毕,都会随之结束,(应该是所有非后台线程运行结束,它也随之结束)
守护线程的设置也是相当简单,只需要将线程的 Daemon设置为 true 即可
线程的 yield
线程的 yield 方法就是短暂放弃 CPU 执行权,但是它刹那点就和其他线程争抢 CPU 执行权;
Thread.yield()方法作用是:暂停当前正在执行的线程对象,并执行其他线程。
yield()应该做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。
结论:yield()从未导致线程转到等待/睡眠/阻塞状态。在大多数情况下,yield()将导致线程从运行状态转到可运行状态,但有可能没有效果。
暂停当前正在执行的线程对象,并执行其他线程。
线程的停止
线程的停止,之前调用 stop 方法可以停止,但是该方法目前也被过期掉了,因为它存
在线程安全问题,但是我们也有自己的方法让线程停止,我们一般在线程的 run 方法中会是一个死循环,因此线程的停止一般有两种方式
Run 方法中的业务逻辑执行完毕;
死循环退出;
线程的优先级
线程的优先级别为 5,没一个线程但是我们可以通过设置提高或者降低线程的优先级别,所谓线程的优先级别高就是获得 CPU 执行权的几率高,但是企图通过线程优先级设置来进行业务的控制这个是不可行的 void setPriority(int newPriority)
线程 Join
通过字面意思就可以理解到,线程的 Join 方法就是临时加入一个线程,等到该线程执
行结束之后才能运行主线程
线程的 interrupt
Interrupt 方法的作用就是将处在阻塞中的线程打断,也就是线程将从阻塞状态转换到临时状态或者其他状态,执行该方法会抛出一个异常,也就是 wait 方法或者 sleep 方法中我们经常需要捕获的异常 InterruptedException
#线程池的实现
线程的创建和销毁是比较消耗系统性能的,所以如何将已创建线程再次复用就可以避免线程创建和销毁带来的消耗
线程组
线程组顾名思义就是一组线程的意思,将一组线程存放在一个组里面,方便管理,方便
监控,相比 Thread,ThreadGroup 的使用并不是那么频繁,说实话我在日常的工作中也几乎很少用到 ThreadGroup,
1 | package org.cy.thread.threadpool; |
另外一种创建方式:
1 | ThreadGroup tg2 = new ThreadGroup(tg,"tg2"); |
可以看到线程组的创建可以将另外一个线程组作为参数传递进去,该线程组就称之为父
线程组,在该线程组中可以通过 getParent()方法获取父线程组,当然也可以查看到父线程组中线程的状态等;
enumerate 方法其实就是线程引用的拷贝,并不是深入克隆
线程池雏形
线程池应该最起码具备的就是如下几个特点,在接下来的文字中我们将会围绕着这几点
然后一一进行实现
- 任务队列;
- 线程管理者;
- 最大线程活跃数;
- 线程最小数;
- 线程最大数;
###最小线程数
既然是线程池,里面的线程应该不止一个,因此它有若干个,也就是线程池初始化的时
候需要创建的线程最小数
###最大线程数
虽然线程中有很多个线程,但是也是有个极限的吧,因此最大线程就是限制线程池中最
大的线程数,那么当线程池中的线程已经不能满足任务时,这个时候需要采取哪些策略呢?
当然方式有很多种,等待,抛出异常告知调用者,放入任务队列中等
###最大活跃线程数
当线程池的线程需要超过最小线程数时,他需要增加到一个不超过最大线程数的值,这个时候他就重新动态的开辟一个线程,当线程的需求量不是太大的时候,线程池就有义务负责销毁线程释放 CPU,内存等资源,那么应该如何释放这些线程呢?那就释放到活跃线程数的这个值;
###属性之间的关系
最小线程数<=最大活跃线程数<=最大线程数
###任务队列属性
我们的任务都是存放在队列之中,但是严格来说,一个任务队列应该还有如下的一些最基本特点;
- 任务队列最大任务数
- 超过最大任务数该如何处理;
- 任务队列中的任务状态监控
###线程状态的监控
所谓线程的状态监控就是指通过回调或者监听的手段,得知当前运行线程运行的状况,
启动,运行中,正常结束,异常结束等状况
可以看到,扮演这个比较重要角色的接口是 Thread.UncaughtExceptionHandler,运行之后我们发现,线程意外死亡被我们很好的捕获到了