多线程相关知识

多线程

引言

从2020.12.3-2020.12.5,大概花了三天时间学完了多线程的基础知识,碍于本人的记忆力不好,所以想通过写博客的方式记录一下自己学到的知识。(当做笔记,自己康康,用来复习的,质量一般)

文章中的 “教材” 均指湖南科技大学分发的 Java 教材

2023/3/13 第二次编辑该部分内容

一、线程概述

1.1 程序、进程和线程

1.1.1 程序
可以看做一个静态的指令集合。

1.1.2 进程
一个进程就是CPU执行的单个任务的过程,是程序在执行过程当中CPU资源分配的最小单位,并且进程都有自己的地址空间,包含了创建态、就绪态、运行态、阻塞态、终止态五个状态。进程中有方法区和堆。

1.1.3 线程
线程是CPU调度的最小单位,它可以和属于同一个进程的其他线程共享这个进程的全部资源。每条线程都有独立的虚拟机栈、本地方法栈和程序计数器。

1.1.4 进程和线程之间的关系
一个进程包含多个线程,一个线程只能在一个进程之中。每一个进程最少包含一个线程。

1.1.5 进程和线程之间的区别
(1)进程是CPU资源分配的最小单位,线程是CPU调度的最小单位。
(2)进程之间的切换开销比较大,但是线程之间的切换开销比较小。
(3)CPU会把资源分配给进程,但是线程几乎不拥有任何的系统资源。因为线程之间是共享同一个进程的,所以线程之间的通信几乎不需要系统的干扰。

1.2 并发与并行

内容参考来源

可以简单的理解为,并发是一个人同时吃三个馒头,而并行是三个人同时吃三个馒头。

1.2.1 并发
指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
这就好像两个人用同一把铁锨,轮流挖坑,一小时后,两个人各挖一个小一点的坑,要想挖两个大一点得坑,一定会用两个小时。
在这里插入图片描述

1.2.2 并行
指在同一时刻,有多条指令在多个处理器上同时执行。
就好像两个人各拿一把铁锨在挖坑,一小时后,每人一个大坑。所以无论从微观还是从宏观来看,二者都是一起执行的。
在这里插入图片描述

1.3 多线程的三大特性
(1)原子性:一个操作或者多个操作要么全部执行要么就都不执行。
(2)可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
(3)有序性:程序执行的顺序按照代码的先后顺序执行。

1.4 使用多线程的优点:
(1)减少程序响应时间;
(2)提高CPU利用率;
(3)创建和切换开销小;
(4)简化程序结构。
(5)数据共享效率高。

二、线程的创建

内容参考来源

2.1 通过继承Thread类创建线程
(1)定义Thread类的子类,并重写该类的run方法,该方法的方法体就是线程需要执行的任务,因此run()方法也被称为线程执行体。
(2)创建Thread子类的实例,也就是创建了线程对象。
(3)启动线程,即调用线程的start()方法。
在这里插入图片描述

2.2 通过实现Runnable接口创建线程
(1)定义Runnable接口的实现类,一样要重写run()方法,和第一种方式一样,这里的run()方法也是线程的执行体。
(2)创建Runnable实现类的实例,并用这个实例作为Thread的target来创建Thread对象,这个Thread类才是真正的线程对象。
(3)依然是通过调用线程对象的start方法来启动线程。
实现Runnable接口

2.3 使用Callable和Future来创建线程
和Runnable接口不一样,Callable接口提供了一个call()方法来作为线程的执行体,call()方法比run()方法功能要更加强大,call()方法可以有返回值,call()方法可以声明抛出异常(前两种如果要抛异常只能通过try,catch来实现)。
(1)创建Callable接口的实现类,并实现call()方法,然后创建该类的实例。
(2)使用Future Task类来包装Callable对象。该FutureTask对象封装了Callable对象的call()方法的返回值。
(3)使用FutureTask对象作为Thread对象的target创建并启动线程(因为FutureTask实现了Runnable接口并在重写的run方法中执行call方法)。
(4)调用FutureTask对象的get方法来获取线程执行结束后的返回值。
在这里插入图片描述

2.4 通过线程池来创建线程
ExecutorService es = Executors.newFixedThreadPool(30);
ExecutorService es = Executors.newCachedThreadPool();
FixedThreadPool创建的线程池-》用户可以指定线程池大小,但指定了就不可变
CachedThreadPool创建的线程池-》线程池大小可变
在这里插入图片描述

2.5 几种创建线程方式的对比
实现Runnable和实现Callable接口方式基本相同,不过是后者执行call方法并且有返回值,而run方法无任何返回值,因此可以把这两种方式归为一种方式与继承Thread类的方式进行对比,差别如下(以实现接口方式为主):
(1)线程只是实现Runnable接口或Callable接口,还可以继承其他类(有点像接口和抽象类的区别,java是单继承的,但可以实现多个接口)
(2)实现接口的方式多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形
(3)如果需要访问当前线程,必须调用Thread.currentThread方法
(4)继承Thread类的线程类不能再继承其他父类(java单继承决定)
因此,一般推荐采用实现接口的方式来创建线程。

2.6 线程中的常用方法
(1)start()
(2)run()
(3)currentThread():静态方法,返回当前执行的线程,可以通过类名Thread.currentThread()调用,灰常方便~
(4)getName() :获取当前线程的名称
(5)setName() :使用的时候注意构造器里面是否可以传参数,例如,Thread实现多线程的时候需要“super(name)”
(6)yield():释放当前线程CPU的执行权
(7)join():在线程a中调用join(),此时线程a进入阻塞状态,执行其它线程(其它线程执行完了,a不一定获得CPU执行权,一样要抢)
(8)sleep():让线程进入阻塞状态,时间一到自动唤醒线程。注意要捕获异常(InterruptedException)
(9)wait():让线程进入阻塞状态,不过要借助notify()方法才可以结束线程的阻塞状态。同样要注意捕获异常(InterruptedException),wait()只能在同步方法和同步代码块里面使用。
(10)notify()、notifyAll():释放被因调用wait()而被“挂起”的线程
(11)stop():结束当前线程(现在不怎么用了,为什么?)
(12)isAlive():判断当前线程是否存活

三、线程的生命周期以及状态转换

内容参考来源

3.1 线程的生命周期

在这里插入图片描述
在我们程序编码中如果想要确定线程当前的状态,可以通过getState()方法来获取,同时我们需要注意任何线程在任何时刻都只能是处于一种状态。

(1)New(新创建)
图片左侧上方是 NEW 状态,这是创建新线程的状态,相当于我们 new Thread() 的过程。

New 表示线程被创建但尚未启动的状态:当我们用 new Thread() 新建一个线程时,如果线程没有开始运行 start() 方法,那么线程也就没有开始执行 run() 方法里面的代码,那么此时它的状态就是 New。而一旦线程调用了 start(),它的状态就会从 New 变成 Runnable,进入到图中绿色的方框。

(2)Runnable(可运行)
Java 中的 Runable 状态对应操作系统线程状态中的两种状态,分别是 Running 和 Ready,也就是说,Java 中处于 Runnable 状态的线程有可能正在执行,也有可能没有正在执行,正在等待被分配 CPU 资源。

所以,如果一个正在运行的线程是 Runnable 状态,当它运行到任务的一半时,执行该线程的 CPU 被调度去做其他事情,导致该线程暂时不运行,它的状态依然不变,还是 Runnable,因为它有可能随时被调度回来继续执行任务。

(3)Blocked(被阻塞)
从 Runnable 状态进入到 Blocked 状态只有一种途径,那么就是当进入到 synchronized 代码块中时未能获得相应的 monitor 锁(关于 monitor 锁我们在之后专门来介绍,这里我们知道 synchronized 的实现都是基于 monitor 锁的)

在右侧我们可以看到,有连接线从 Blocked 状态指向了 Runnable ,也只有一种情况,那么就是当线程获得 monitor 锁,此时线程就会进入 Runnable 状体中参与 CPU 资源的抢夺

(4)Waiting(等待)
对于 Waiting 状态的进入有三种情况,如图中所示,分别为:
①当线程中调用了没有设置 Timeout 参数的 Object.wait() 方法
②当线程调用了没有设置 Timeout 参数的 Thread.join() 方法
③当线程调用了 LockSupport.park() 方法

关于 LockSupport.park() 方法,这里说一下,我们通过上面知道 Blocked 是针对 synchronized monitor 锁的,但是在 Java 中实际是有很多其他锁的,比如 ReentrantLock 等,在这些锁中,如果线程没有获取到锁则会直接进入 Waiting 状态,其实这种本质上它就是执行了 LockSupport.park() 方法进入了Waiting 状态

Blocked 是在等待其他线程释放 monitor 锁;
Waiting 则是在等待某个条件,比如 join 的线程执行完毕,或者是 notify()/notifyAll() 。

(5)Timed Waiting(计时等待)
Timed Waiting 状态与 Waiting 状态非常相似,其中的区别只在于是否有时间的限制,在 Timed Waiting 状态时会等待超时,之后由系统唤醒,或者也可以提前被通知唤醒如 notify。

通过上述图我们可以看到在以下情况会让线程进入 Timed Waiting 状态:
①线程执行了设置了时间参数的 Thread.sleep(long millis) 方法;
②线程执行了设置了时间参数的 Object.wait(long timeout) 方法;
③线程执行了设置了时间参数的 Thread.join(long millis) 方法;
④线程执行了设置了时间参数的 LockSupport.parkNanos(long nanos) 方法和 LockSupport.parkUntil(long deadline) 方法。

通过这个我们可以进一步看到它与 waiting 状态的相同。

(6)Terminated(被终止)
Terminated 终止状态,要想进入这个状态有两种可能:
①run() 方法执行完毕,线程正常退出。
②出现一个没有捕获的异常,终止了 run() 方法,最终导致意外终止。

3.2 线程状态间转换
上面我们讲了各自状态的特点和运行状态进入相应状态的情况,那么接下来我们将来分析各自状态之间的转换,其实主要就是Blocked、waiting、Timed Waiting 三种状态的转换,以及他们是如何进入下一状态最终进入 Runnable。

(1)Blocked 进入 Runnable
想要从 Blocked 状态进入 Runnable 状态,我们上面说过必须要线程获得 monitor 锁,但是如果想进入其他状态那么就相对比较特殊,因为它是没有超时机制的,也就是不会主动进入。

在这里插入图片描述

(2)Waiting 进入 Runnable
只有当执行了 LockSupport.unpark(),或者 join 的线程运行结束,或者被中断时才可以进入 Runnable 状态。

在这里插入图片描述

如果通过其他线程调用 notify() 或 notifyAll()来唤醒它,则它会直接进入 Blocked 状态,这里大家可能会有疑问,不是应该直接进入 Runnable 吗?这里需要注意一点 ,因为唤醒 Waiting 线程的线程如果调用 notify() 或 notifyAll(),要求必须首先持有该 monitor 锁,这也就是我们说的 wait()、notify 必须在 synchronized 代码块中。

所以处于 Waiting 状态的线程被唤醒时拿不到该锁,就会进入 Blocked 状态,直到执行了 notify()/notifyAll() 的唤醒它的线程执行完毕并释放 monitor 锁,才可能轮到它去抢夺这把锁,如果它能抢到,就会从 Blocked 状态回到 Runnable 状态。

其中的一些方法及其作用:
①wait():使一个线程处于等待状态,并且释放所持有的对象的lock。
②sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要捕捉InterruptedException异常。
③notify():唤醒一个处于等待状态的线程,注意的是在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由JVM确定唤醒哪个线程,而且不是按优先级。
④Allnotity():唤醒所有处入等待状态的线程,注意并不是给所有唤醒线程一个对象的锁,而是让它们竞争。

(3)Timed Waiting进入Runnable
同样在 Timed Waiting 中执行 notify() 和 notifyAll() 也是一样的道理,它们会先进入 Blocked 状态,然后抢夺锁成功后,再回到 Runnable 状态。

在这里插入图片描述

但是对于 Timed Waiting 而言,它存在超时机制,也就是说如果超时时间到了那么就会系统自动直接拿到锁,或者当 join 的线程执行结束/调用了LockSupport.unpark()/被中断等情况都会直接进入 Runnable 状态,而不会经历 Blocked 状态。

在这里插入图片描述
拓展:关于join()方法

3.3 总结
(1)线程的状态是按照箭头方向来走的,比如线程从 New 状态是不可以直接进入 Blocked 状态的,它需要先经历 Runnable 状态。
(2)线程生命周期不可逆:一旦进入 Runnable 状态就不能回到 New 状态;一旦被终止就不可能再有任何状态的变化。
(3)所以一个线程只能有一次 New 和 Terminated 状态,只有处于中间状态才可以相互转换。也就是这两个状态不会参与相互转化。

四、时间的调度

4.1 线程的优先级
优先级越高的线程获得CPU执行的机会越大,反之优先级越小的线程获得CPU执行的机会越小。

4.2 Thread类的静态常量
线程的优先级用1~10之间的整数来表示,数字越大优先级越高:
static int MAX_PRIORITY = 10
static int MIN_PRIORITY = 5
static int NORM_PRIORITY = 1

4.3 线程休眠
利用sleep(long millis)方法人为地将正在执行的线程暂停(进入休眠状态或让线程阻塞),将CPU使用权让给其他线程
PS:新手注意:
1)使用sleep()要捕获异常(catch(InterruptedException e))
2)线程类提供了两种线程休眠方法:sleep(long millis)和sleep(long millis, int nanos),这两种方法都带有休眠时间参数,当其他线程都终止后不代表当前线程会立即执行,而是必须当休眠时间结束后,线程才会转换到就绪状态。

4.4 线程让步
通过yield()方法来实现,区别于sleep(long millis),yield()不会阻塞该线程,它只是将线程转换为就绪状态,让系统的调度器重新调度一次。当某个线程调用yield()方法之后,与当前线程优先级相同或者更高的线程可以获得执行的机会。(线程低的抢不过)
Tips:通过yield()方法可以实现线程让步,让当前正在运行的线程失去CPU使用权,让系统的调度器重新调度一次,由于Java虚拟机默认采用抢占式调度模型,所有线程都会再次抢占CPU资源使用权,所以在执行线程让步后并不能保证立即执行其他程序(就是说让步之后,大家都在统一起跑线,做出让步的线程还是有可能抢到CPU执行权的)

4.5 线程插队
在Thread()类中提供了join()来实现“插队”功能。当在某个线程中调用其他线程的join()方法时,调用的线程将被阻塞,直到被join()方法加入的线程执行完成后,原来的线程才会进入就绪状态,重新去争夺CPU的执行权。
注:详细代码参见课本P368
Tips:Thread类中还提供了一个带有时间参数的线程插队方法join(long millis)。当带有时间参数的join(long millis)进行线程插队时,必须等待插入的线程指定时间过后才会执行其他线程。

五、多线程同步

多线程安全问题内容参考
线程同步内容参考

5.1 多线程情况下,为什么会出现线程安全问题?
线程安全问题一般是发生再多线程环境,当多个线程同时共享一个全局变量或静态变量做写的操作时候,可能会发生数据冲突问题,也就是线程安全问题,在读的操作不会发生数据冲突问题。

可以采用线程同步的方式来解决线程的安全问题。

5.2 线程同步方式
该部分内容,强烈推荐看这篇文章→点击跳转

(1)同步方法
即有synchronized关键字修饰的方法。 由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。

(2)同步代码块
即有synchronized关键字修饰的语句块。被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。

(3)使用特殊域变量(volatile)实现线程同步
①volatile关键字为域变量的访问提供了一种免锁机制
②使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新
③因此每次使用该域就要重新计算,而不是使用寄存器中的值
④volatile不会提供任何原子操作,它也不能用来修饰final类型的变量

(4)使用重入锁实现线程同步
在JavaSE5.0中新增了一个 java.util.concurrent 包来支持同步。ReentrantLock 类是可重入、互斥、实现了Lock接口的锁, 它与使用 synchronized 方法和块具有相同的基本行为和语义,并且扩展了其能力。

ReenreantLock类的常用方法:
①ReentrantLock() : 创建一个ReentrantLock实例
②lock() : 获得锁
③unlock() : 释放锁
注:ReentrantLock()还有一个可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,不推荐使用。

(5)使用局部变量实现线程同步
使用ThreadLocal管理变量,则每一个使用该变量的线程都获得该变量的副本,副本之间相互独立,这样每一个线程都可以随意修改自己的变量副本,而不会对其他线程产生影响。

ThreadLocal 类的常用方法:
①ThreadLocal() : 创建一个线程本地变量
②get() : 返回此线程局部变量的当前线程副本中的值
③initialValue() : 返回此线程局部变量的当前线程的"初始值"
④set(T value) : 将此线程局部变量的当前线程副本中的值设置为value

在这里插入图片描述

Tips:ThreadLocal与同步机制
① ThreadLocal 与同步机制都是为了解决多线程中相同变量的访问冲突问题
②前者采用以"空间换时间"的方法,后者采用以"时间换空间"的方式

六、死锁问题

内容来源
————————————————
版权声明:本文为CSDN博主「wdy00000」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/wdy00000/article/details/124398496

6.1 死锁定义
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。 此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

6.2 产生死锁的原因
6.2.1 产生死锁的必要条件
(1)互斥条件: 进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
(2)请求和保持条件: 当进程因请求资源而阻塞时,对已获得的资源保持不放。
(3)不剥夺条件: 进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
(4)环路等待条件: 在发生死锁时,必然存在一个进程——资源的环形链。

6.2.2 产生原因
(1)竞争资源
系统中的资源可以分为两类:
①可剥夺资源,是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU和主存均属于可剥夺性资源;
②另一类资源是不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。

产生死锁中的竞争资源之一指的是竞争不可剥夺资源(例如:系统中只有一台打印机,可供进程P1使用,假定P1已占用了打印机,若P2继续要求打印机打印将阻塞)
产生死锁中的竞争资源另外一种资源指的是竞争临时资源(临时资源包括硬件中断、信号、消息、缓冲区内的消息等),通常消息通信顺序进行不当,则会产生死锁

(2)进程间推进顺序非法
若P1保持了资源R1,P2保持了资源R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁
例如,当P1运行到P1:Request(R2)时,将因R2已被P2占用而阻塞;当P2运行到P2:Request(R1)时,也将因R1已被P1占用而阻塞,于是发生进程死锁

6.3 解决死锁的基本方法
6.3.1 死锁预防
(1)资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
(2)只要有一个资9源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
(3)可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
(4)资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)

6.3.2 解决方法
(1)以确定的顺序获得锁
如果必须获取多个锁,那么在设计的时候需要充分考虑不同线程之前获得锁的顺序。

那么死锁就永远不会发生。 针对两个特定的锁,开发者可以尝试按照锁对象的hashCode值大小的顺序,分别获得两个锁,这样锁总是会以特定的顺序获得锁,那么死锁也不会发生。问题变得更加复杂一些,如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“环路等待”),可能就无法满足要求了,这个时候开发者可以使用银行家算法,所有的锁都按照特定的顺序获取,同样可以防止死锁的发生,该算法在这里就不再赘述了,有兴趣的可以自行了解一下。

(2)超时放弃
当使用synchronized关键词提供的内置锁时,只要线程没有获得锁,那么就会永远等待下去,然而Lock接口提供了boolean tryLock(long time, TimeUnit unit) throws InterruptedException方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁。通过这种方式,也可以很有效地避免死锁。 还是按照之前的例子,过程如下:
①避免死锁
预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得 较满意的系统性能。由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全的状态,则将资源分配给进程;否则,进程等待。其中最具有代表性的避免死锁算法是银行家算法。

银行家算法:首先需要定义状态和安全状态的概念。系统的状态是当前给进程分配的资源情况。因此,状态包含两个向量Resource(系统中每种资源的总量)和Available(未分配给进程的每种资源的总量)及两个矩阵Claim(表示进程对资源的需求)和Allocation(表示当前分配给进程的资源)。安全状态是指至少有一个资源分配序列不会导致死锁。当进程请求一组资源时,假设同意该请求,从而改变了系统的状态,然后确定其结果是否还处于安全状态。如果是,同意这个请求;如果不是,阻塞该进程知道同意该请求后系统状态仍然是安全的。

②检测死锁
首先为每个进程和每个资源指定一个唯一的号码;然后建立资源分配表和进程等待表。

死锁检测
a、Jstack命令
jstack是java虚拟机自带的一种堆栈跟踪工具。jstack用于打印出给定的java进程ID或core file或远程调试服务的Java堆栈信息。 Jstack工具可以用于生成java虚拟机当前时刻的线程快照。线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。

b、JConsole工具
Jconsole是JDK自带的监控工具,在JDK/bin目录下可以找到。它用于连接正在运行的本地或者远程的JVM,对运行在Java应用程序的资源消耗和性能进行监控,并画出大量的图表,提供强大的可视化界面。而且本身占用的服务器内存很小,甚至可以说几乎不消耗。

③解除死锁
当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有:
剥夺资源:从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态;
撤消进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态消除为止;所谓代价是指优先级、运行代价、进程的重要性和价值等。

七、多线程通信

多线程通信内容来源
https://zhuanlan.zhihu.com/p/138689342

7.1 多线程通信概述
线程是操作系统调度的最小单位,有自己的栈空间,可以按照既定的代码逐步的执行,但是如果每个线程间都孤立的运行,那就会造资源浪费。在现实中,我们需要这些线程间可以按照指定的规则共同完成一件任务,所以这些线程之间就需要互相协调,这个过程被称为线程的通信。

线程的通信可以被定义为:

线程通信就是当多个线程共同操作共享的资源时,互相告知自己的状态以避免资源争夺。

7.2 线程通信的方式
线程通信主要可以分为三种方式,分别为共享内存、消息传递和管道流

(1)共享内存:线程之间共享程序的公共状态,线程之间通过读-写内存中的公共状态来隐式通信。

在学习Volatile之前,我们先了解下Java的内存模型

在这里插入图片描述

在java中,所有堆内存中的所有的数据(实例域、静态域和数组元素)存放在主内存中可以在线程之间共享,一些局部变量、方法中定义的参数存放在本地内存中不会在线程间共享。线程之间的共享变量存储在主内存中,本地内存存储了共享变量的副本。如果线程A要和线程B通信,则需要经过以下步骤:
①线程A把本地内存A更新过的共享变量刷新到主内存中
②线程B到主内存中去读取线程A之前已更新过的共享变量。

这保证了线程间的通信必须经过主内存,下面引出我们要学习的关键字 volatile

volatile 有一个关键的特性:保证内存可见性,即多个线程访问内存中的同一个被 volatile 关键字修饰的变量时,当某一个线程修改完该变量后,需要先将这个最新修改的值写回到主内存,从而保证下一个读取该变量的线程取得的就是主内存中该数据的最新值,这样就保证线程之间的透明性,便于线程通信。

代码实现

/**
 * @Author: Simon Lang
 * @Date: 2020/5/5 15:13
 */
public class TestVolatile {
    private static volatile boolean flag=true;
    public static void main(String[] args){
        new Thread(new Runnable() {
            public void run() {
                while (true){
                    if(flag){
                        System.out.println("线程A");
                        flag=false;
                    }
                }
            }
        }).start();
​
​
        new Thread(new Runnable() {
            public void run() {
                while (true){
                    if(!flag){
                        System.out.println("线程B");
                        flag=true;
                    }
                }
            }
        }).start();
    }
}

测试结果
线程A和线程B交替执行。
在这里插入图片描述

(2)消息传递:线程之间没有公共的状态,线程之间必须通过明确的发送信息来显示的进行通信。

①wait/notify等待通知方式
从字面上理解,等待通知机制就是将处于等待状态的线程将由其它线程发出通知后重新获取CPU资源,继续执行之前没有执行完的任务。最典型的例子生产者–消费者模式:
在这里插入图片描述
有一个产品队列,生产者想要在队列中添加产品,消费者需要从队列中取出产品,如果队列为空,消费者应该等待生产者添加产品后才进行消费,队列为满时,生产者需要等待消费者消费一部分产品后才能继续生产。队列可以认为是java模型里的临界资源,生产者和消费者认为是不同的线程,它们需要交替的占用临界资源来进行各自方法的执行,所以就需要线程间通信。

生产者–消费者模型主要为了方便复用和解耦,java语言实现线程之间的通信协作的方式是等待/通知机制,
等待/通知机制提供了三个方法用于线程间的通信,分别为wait()、notify()和notifyAll()。

a、wait():当前线程释放锁并进入等待(阻塞)状态
b、notify():唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后继续竞争锁
c、notifyAll():唤醒所有正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后继续竞争锁

等待/通知机制是指一个线程A调用了对象Object的wait()方法进入等待状态,而另一线程B调用了对象Object的notify()或者notifyAll()方法,当线程A收到通知后就可以从对象Object的wait()方法返回,进而执行后序的操作。线程间的通信需要对象Object来完成,对象中的wait()、notify()、notifyAll()方法就如同开关信号,用来完成等待方和通知方的交互。

测试代码

 public class WaitNotify {
    static boolean flag=true;
    static Object lock=new Object();public static void main(String[] args) throws InterruptedException {
        Thread waitThread=new Thread(new WaitThread(),"WaitThread");
        waitThread.start();
        TimeUnit.SECONDS.sleep(1);
        Thread notifyThread=new Thread(new NotifyThread(),"NotifyThread");
        notifyThread.start();
    }
    //等待线程
    static class WaitThread implements Runnable{
        public void run() {
            //加锁
            synchronized (lock){
                //条件不满足时,继续等待,同时释放lock锁
                while (flag){
                    System.out.println("flag为true,不满足条件,继续等待");
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                //条件满足
                System.out.println("flag为false,我要从wait状态返回继续执行了");}}
    }
    //通知线程
    static class NotifyThread implements Runnable{public void run() {
            //加锁
            synchronized (lock){
                //获取lock锁,然后进行通知,但不会立即释放lock锁,需要该线程执行完毕
                lock.notifyAll();
                System.out.println("设置flag为false,我发出通知了,但是我不会立马释放锁");
                flag=false;
            }
        }
    }
 }

测试结果
在这里插入图片描述

Tips:使用wait()、notify()和notifyAll()需要注意以下细节
(1)使用wait()、notify()和notifyAll()需要先调用对象加锁
(2)调用wait()方法后,线程状态由Running变成Waiting,并将当前线程放置到对象的等待队列
(3)notify()和notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()和notifyAll()的线程释放锁之后等待线程才有机会从wait()返回
(4)notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部转移到同步队列,被移到的线程状态由Waiting变为Blocked。
(5)从wait()方法返回的前提是获得调用对象的锁

②join方式
在很多应用场景中存在这样一种情况,主线程创建并启动子线程后,如果子线程要进行很耗时的计算,那么主线程将比子线程先结束,但是主线程需要子线程的计算的结果来进行自己下一步的计算,这时主线程就需要等待子线程,java中提供可join()方法解决这个问题。

join()方法的作用是:在当前线程A调用线程B的join()方法后,会让当前线程A阻塞,直到线程B的逻辑执行完成,A线程才会解除阻塞,然后继续执行自己的业务逻辑,这样做可以节省计算机中资源。

测试代码

public class TestJoin {
    public static void main(String[] args){
        Thread thread=new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程0开始执行了");
            }
        });
        thread.start();
        for (int i=0;i<10;i++){
            JoinThread jt=new JoinThread(thread,i);
            jt.start();
            thread=jt;
        }}static class JoinThread extends Thread{
        private Thread thread;
        private int i;public JoinThread(Thread thread,int i){
            this.thread=thread;
            this.i=i;
        }@Override
        public void run() {
            try {
                thread.join();
                System.out.println("线程"+(i+1)+"执行了");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

测试结果
在这里插入图片描述

Tips:每个线程的终止的前提是前驱线程的终止,每个线程等待前驱线程终止后,才从join方法返回,实际上,这里涉及了等待/通知机制,即下一个线程的执行需要接受前驱线程结束的通知。

(3)管道输入/输出流
管道流是是一种使用比较少的线程间通信方式,管道输入/输出流普通文件输入/输出流 或者 网络输出/输出流 不同之处在于,它主要用于线程之间的数据传输,传输的媒介为管道。

管道输入/输出流主要包括4种具体的实现:PipedOutputStrean、PipedInputStrean、PipedReader和PipedWriter,前两种面向字节,后两种面向字符。

java的管道的输入和输出实际上使用的是一个循环缓冲数组来实现的,默认为1024,输入流从这个数组中读取数据,输出流从这个数组中写入数据,当这个缓冲数组已满的时候,输出流所在的线程就会被阻塞,当向这个缓冲数组为空时,输入流所在的线程就会被阻塞。

在这里插入图片描述

buffer:缓冲数组,默认为1024
out:从缓冲数组中读数据
in:从缓冲数组中写数据

测试代码

public class TestPip {
    public static void main(String[] args) throws IOException {
        PipedWriter writer  = new PipedWriter();
        PipedReader reader = new PipedReader();
        //使用connect方法将输入流和输出流连接起来
        writer.connect(reader);
        Thread printThread = new Thread(new Print(reader) , "PrintThread");
        //启动线程printThread
        printThread.start();
        int receive = 0;
        try{
            //读取输入的内容
            while((receive = System.in.read()) != -1){
                writer.write(receive);
            }
        }finally {
            writer.close();
        }
    }private static class Print implements Runnable {
        private PipedReader reader;public Print(PipedReader reader) {
            this.reader = reader;
        }@Override
        public void run() {
            int receive = 0;
            try{
                while ((receive = reader.read()) != -1){
                    //字符转换
                    System.out.print((char) receive);
                }
            }catch (IOException e) {
                System.out.print(e);
            }
        }
    }
}

测试结果
在这里插入图片描述

Tips:对于Piped类型的流,必须先进性绑定,也就是调用connect()方法,如果没有将输入/输出流绑定起来,对于该流的访问将抛出异常。

八、线程池

本部分内容推荐查阅
(1)CSDN博客
https://blog.csdn.net/u013541140/article/details/95225769
https://blog.csdn.net/qq_40093255/article/details/116990431

(2)B站讲解视频
https://www.bilibili.com/video/BV1dt4y1i7Gt/?spm_id_from=333.337.search-card.all.click&vd_source=4d858b82adb94dccd6d1414e266ceddf

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值