Java多线程¶
约 5583 个字 799 行代码 5 张图片 预计阅读时间 29 分钟
Note
本章中的概念部分都只是为了后面的程序执行更好理解,更深层的概念移步到Linux进程部分
线程与进程基本介绍¶
进程:在内存中运行的程序实例,一般一个程序代表一个进程
线程:进程中最小的执行单元,一般线程负责进程中程序的运行,一个线程至少存在一个线程,也可以有多个线程,当存在多个线程时,一般称为多线程程序
Note
可以简单理解为:当一个程序加载到内存中后就会开启一个进程,当程序需要执行某一个功能时就会开辟一个线程,该线程就是程序与CPU交流的通道,一个功能对应着一个线程,一个线程对应着一个通道
并发和并行基本介绍¶
并行:在同一个时刻,多个CPU(多核CPU)同时执行指令任务
并发:在同一个时刻,一个CPU执行多个指令任务
Note
在CPU是单核时,CPU看似在同一时刻执行多个任务,实际上是CPU在执行任务中进行的高速切换,因为速度快所以人很难感知到任务执行的先后顺序
现在CPU基本上都是多核,可以理解为多个CPU,所以可以同一时间处理多个任务,每一个CPU管一个任务,但是依旧存在着高速切换,只是频率相对于单核CPU会变小,所以现在的CPU在执行指令时一般都是并行和并发同时存在
CPU调度基本介绍¶
CPU调用一般分为两种:
- 分时调度:让所有线程轮流获取到CPU的调度权,并且相对平均分配每个线程占用的CPU时间片
- 抢占式调度:多个线程轮流抢占CPU的使用权(哪个线程抢到了CPU的使用权,哪个线程先执行),一般都是优先级高的线程抢到的概率大,但不代表使用权一定属于优先级高的线程
Note
Java程序都是抢占式调用
主线程基本介绍¶
主线程:CPU和内存之间专门为Java中的main
函数服务开辟的线程
创建线程对象与相关方法¶
在Java中,创建线程对象常见的有两种方式:
- 普通类继承
Thread
类,重写Thread
中的run
方法 - 普通类实现
Runnable
接口,重写接口中的run
方法
(一)继承Thread
类创建线程对象¶
继承Thread
类后重写Thread
中的run
方法,该方法用于线程中执行的任务,例如循环等。创建完自定义线程类后就可以通过自定义类创建一个线程对象,使用该对象调用start()
方法启动线程,例如:
Java | |
---|---|
1 2 3 4 5 6 7 8 |
|
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
因为Java程序都是抢占式调用,所以会出现交替执行的情况,也会出现主线程先执行完,再执行自定义线程的任务
Note
需要注意,不要对同一个线程对象多次调用start
方法,也不要显式调用run
方法,直接调用run
方法就不会被认为是线程启动执行任务
Thread
类中常用的方法¶
void start()
方法:启动进程,JVM会自动调用对应线程的run
方法void run()
方法:设置线程中的任务,该方法是Thread
类实现了Runnable
接口后重写的方法String getName()
方法:获取调用对象的线程名称,默认情况下线程名称组成为:Thread+编号
void setName(String name)
方法:设置调用对象的线程名称static Thread currentThread()
:获取当前已经获取到CPU使用权的线程static void sleep(long millis)
:设置线程睡眠,参数表示睡眠毫秒数
Note
需要注意,Thread
中的sleep
方法会抛出异常,如果在自定义线程类中使用sleep
方法时,不可以使用throws
处理异常,只能使用try...catch
,但是如果在主线程则可以直接使用
基本使用实例:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
Thread
类中关于线程优先级的方法¶
void setPriority(int newPriority)
:设置调用对象的线程优先级,线程优先级越高,抢到CPU使用权的概率越大,但是概率大不代表一定可以抢到。Java中线程优先级有10个等级,其中1表示最小优先级,10表示最大优先级,默认优先级为5int getPriority()
:获取调用对象的线程优先级
基本使用示例:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
守护线程与Thread
类中关于守护线程的方法¶
守护进程:守护线程表示当前线程的任务会随着所有非守护线程结束而结束,但是在非守护线程结束时,守护线程一般不会是立即结束,因为在非守护线程结束时需要与守护线程进行结束信号的通信,这段时间中守护线程依旧在执行
Note
需要注意,当出现一个守护线程,多个非守护线程时,守护线程会等到所有非守护线程结束才会结束
在Java中,可以使用void setDaemon(boolean on)
将调用对象所在的线程设置为守护线程或者取消设置守护线程,参数取值只有两种:true
(开启守护线程)和false
(关闭守护线程)
基本使用实例:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
礼让线程与Thread
类中关于礼让线程的方法¶
礼让线程:默认情况下Java的线程对CPU使用权是抢占式,而礼让线程是为了让正在抢夺使用权的线程尽可能相对平衡(不是绝对平衡),从而达到二者交替执行
在Java中,设置礼让线程的方法为:static void yield()
基本使用实例:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
插入线程与Thread
类中关于插入线程的方法¶
插入线程:让调用对象所在线程尽可能优先执行完,再执行其他进程
在Java中对应插入线程的方法为:void join()
基本使用实例:
Java | |
---|---|
1 2 3 4 5 6 7 8 |
|
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
(二)实现Runnable
接口创建线程对象¶
本方法创建线程对象与继承Thread
方式类似,但因为Runnable
是接口,所以必须重写对应的run
方法,使用实现类创建对象(目前不是线程对象),将该对象使用Thread
中的构造方法:Thread(Runnable target)
创建线程对象
Java | |
---|---|
1 2 3 4 5 6 7 8 |
|
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
如果想为线程设置名字,可以使用void setName(String name)
方法,也可以使用构造函数,例如:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
使用匿名内部类创建线程对象¶
基本使用方式如下:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
使用继承还是实现Runnable
接口创建线程对象¶
如果当前自定义线程类已经继承了其他类,则选择通过实现Runnable
接口创建线程对象,否则使用继承创建线程对象,因为Java不支持多继承
如果需要多个线程对象使用共享同一个资源时,可以考虑使用实现Runnable
接口的方式创建线程对象
(三)实现Callable
接口创建线程对象¶
Callable<T>
接口类似于Runnable
接口,都可以用于创建线程对象。
在该接口中,有一个call()
方法,与Runnable
接口中的run()
类似,但是call()
方法存在返回值,该返回值有Callable<T>
接口的泛型<T>
决定,并且call()
方法在接口Callable<T>
中抛出了异常,则实现类重写的call()
方法也可以抛异常
Note
需要注意,Java中的泛型只能写引用类型,具体会在Java中的泛型章节介绍
当需要接收call()
方法的返回值时,需要使用到FutureTask<T>
(实现Future<T>
接口)中的get()
方法(重写Future<T>
接口中的get()
方法),该方法返回值也是泛型<T>
创建线程时,使用Thread
中的Thread(Runnable target)
,因为FutureTask<T>
也是Runnable
的实现类
基本使用如下:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
(四)使用线程池创建线程对象¶
线程池引入¶
之所以需要线程池,是因为前面每一次使用线程时就需要创建一次线程对象,多次创建对象会有时间和空间的消耗,为了尽量减少这种消耗,可以提前创建好线程对象,使用时在其中获取即可,而创建好的线程对象所在位置就称为线程池
使用线程池中的对象时遵循特点:当线程池有足够的线程对象使用时,可以正常获取到线程对象使用,使用完后归还给线程池,而当线程池中没有线程对象可用,则新线程进入等待,直到有新线程对象在线程池中并处于空闲状态
创建线程池¶
使用工具类Executors中的静态方法:static ExecutorService newFixedThreadPool(int nThreads)
获取线程池对象,返回值ExecutorService
就是管理线程池的对象,参数代表线程池中的线程对象个数
执行线程池任务¶
使用ExecutorService
中的两个方法可以提交线程任务,使用ExecutorService
对象调用:
- 提交
Runnable
线程任务:Future<?> submit(Runnable task)
- 提交
Callable
线程任务:Future<T> submit(Callable<T> task)
上面的两个submit
方法只有「提交Callable
线程任务」的方法有返回值,该返回值由FutureTask<T>
类对象接收
使用FutureTask<T>
中的get
方法可以接收「提交Callable
线程任务」的方法的返回值
关闭线程池¶
使用ExecutorService
中的void shutdown()
方法,可以依次关闭线程池,如果有任务执行,会等待所有任务执行完毕后关闭线程池,不再接收任何线程任务
基本使用实例¶
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
线程安全¶
线程安全引入¶
当同一个数据被多个线程获取到时,就会出现线程安全问题
例如,在买票的过程中,一共有三个人一起买票,如果至少两个人同时拿到同一张票就代表出现了线程不安全
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
例如下面的情况:
解决线程安全¶
在Java中,解决线程安全的方式就是给有线程不安全的代码加锁,并且必须是同一把锁,否则该锁无效。给线程加锁的方式有两种:
-
使用同步代码块,使用格式如下:
Java 1 2 3
synchronized (唯一任意对象){ // 出现线程不安全的代码 }
-
同步方法:包括静态同步方法和非静态同步方法,使用格式如下:
Java 1 2 3 4 5 6 7 8 9
// 静态方法 权限修饰符 static synchronized 返回值类型 方法名 { // 方法体 } // 非静态方法 权限修饰符 synchronized 返回值类型 方法名 { // 方法体 }
给线程不安全的代码加锁后,当一个线程进入后就会「加锁」,此时其他线程无法再进入对应的代码,当前面的线程执行完毕后离开,该锁就会「解锁」,此时其他线程就会进入重复上面的过程,在此过程中,哪一个线程先执行取决于哪一个线程先抢到CPU的使用权
同步代码块解决线程不安全¶
以前面的买票为例,解决方案如下:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
|
修改后的代码就可以解决线程不安全的问题
上面的代码也可以通过实现Runnable
类的方式创建线程对象实现,例如下面的代码:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
使用接口实现与继承的不同的是,锁对象和票成员不需要使用static
修饰,因为此时三个线程共用一个tickets
和obj
成员,示意图如下:
同步方法解决线程不安全¶
- 静态同步方法
Note
以继承+同步方法为例
对于静态同步方法来说,其默认锁是对象类
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
- 非静态同步方法
Note
非静态同步方法只能使用接口的方式创建线程对象,因为使用继承无法保证this
只指向一个对象
对于非静态同步方法,其默认锁是this
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
使用lock
锁解决线程不安全¶
前面使用synchronized
同步代码块和使用synchronized
修饰方法的方式都有一个比较明显的缺点:不够灵活
- 对于同步代码块来说,只有在执行完同步代码块后才会释放锁对象
- 对于方法来说,调用该方法执行完才会释放锁
为了解决这个问题,引入了lock
锁
在标准中,lock
是一个接口,对应有一个实现类ReentrantLock
,在该实现类中有两个方法,通过这两个方法控制同步代码块:
- 无参构造:
lock()
- 无参方法释放锁:
unlock()
使用时,使用lock()
放在出现线程不安全问题的代码块开始处加锁,执行完线程不安全问题的代码块后在最后一句代码后方添加unlock()
方法释放锁
Note
使用需要导包util.concurrent.locks.Lock
和util.concurrent.locks.ReentrantLock
使用示例:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
Note
使用细节:如果出现了try...catch
,可以考虑将unlock()
方法放入finally
中
死锁¶
前面解决线程安全时涉及到加锁,但是如果出现锁嵌套,就容易出现死锁问题,例如下图:
代码实现:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
|
线程状态¶
在Java中,并不是所有进程都在开始运行之后直接进入运行状态,常见的状态有6种,见下面表格:
线程状态 | 导致状态发生条件 |
---|---|
NEW (新建) | 线程刚被创建,但是并未启动。还没调用start 方法。 |
Runnable (可运行) | 线程可以在Java虚拟机中运行的状态,可能正在运行自己代码,也可能没有,这取决于操作系统处理器。 |
Blocked (锁阻塞) | 当一个线程试图获取一个对象锁,而该对象锁被其他的线程持有,则该线程进入Blocked 状态;当该线程持有锁时,该线程将变成Runnable 状态。 |
Waiting (无限等待) | 一个线程在等待另一个线程执行一个(唤醒)动作时,该线程进入Waiting 状态。进入这个状态后是不能自动唤醒的,必须等待另一个线程调用notify 或者notifyAll 方法才能够唤醒。 |
Timed Waiting (计时等待) | 同waiting 状态,有几个方法有超时参数,调用他们将进入Timed Waiting 状态。这一状态将一直保持到超时期满或者接收到唤醒通知。带有超时参数的常用方法有Thread.sleep 、Object.wait 。 |
Terminated (被终止) | 因为run 方法正常退出而死亡,或者因为没有捕获的异常终止了run 方法而死亡,也可以调用过时方法stop() |
对应状态图如下:
线程状态常用方法¶
- 无参线程释放锁等待:
void wait()
,注意,本方法会抛异常 - 随机唤醒一个正在等待的线程:
void notify()
- 唤醒所有正在等待的线程:
void notifyAll()
- 有参线程释放锁等待:
void wait(long timeout)
,参数为等待毫秒值
Note
上面的方法所在类都是Object
,但是因为所有类都继承自Object
,所以所有子类都可以使用上面的方法
线程状态常用方法使用实例(线程间的通信)¶
在前面的线程程序中,如果涉及到多个线程,就会根据「哪一个线程先抢到CPU使用权哪一个线程先执行」的原则,而不是线程之间相互交替执行。当需要线程之间的相互交替执行,可以使用wait()
方法和notify()
方法相互协调控制线程的执行,而多个线程之间协调控制线程的执行则称为线程间的通信
实例实现(使用同步代码块)¶
例如下面的使用实例:
在一个程序中生产和消费产品,两个行为分别对应着两个线程,一个线程负责生产,一个线程负责消费,并且消费模式为:生产一个产品紧接着消费一个产品,不可以产生同时生产和同时消费,使用代码实现对应的效果
首先分析本题的要求:
- 因为要两个线程分别生产产品和消费产品,所以需要两个线程对象,但是这两个线程对象均访问一个产品资源
-
接着考虑产品中的属性,首先是产品当前的数量,定义为
count
,接着是标记生产或者消费,定义为flag
(假设flag
为true
时代表当前存在产品,不需要生产,可以进行消费;否则不存在为false
,不可以消费,需要生产)Note
需要注意,数量和消费成员可以不需要使用
static
-
考虑生产产品和消费产品的逻辑,为了保证两个线程不会出现同一时刻访问到同一个数据或者一个线程进行中,因为切换导致另一个线程执行,需要使用锁对象,此时需要使用到同步代码块:
- 对于生产产品线程来说:当
flag
为true
的时候证明当前存在产品,本线程不可以再执行,需要等待;当flag
为false
时,count
加1,并将flag
标记为true
。最后唤醒正在等待的消费线程,生产产品线程退出同步代码块 - 对于消费产品线程来说:当
flag
为true
的时候证明当前存在产品,本线程需要执行,直接打印当前count
的值(表示消费第几个产品),并将flag
标记为false
,唤醒正在等待的消费线程,生产产品线程退出同步代码块;当flag
为false
时,说明不存在产品,不可以执行消费,本线程需要等待。
Note
因为需要确保两个线程使用同一个锁对象,所以可以在创建线程对象时传递同一个锁对象,此时需要为两个自定义线程类提供对象成员以及构造函数 此处需要注意,与前面线程共享资源不完全相同,这里通过同一个锁对象实现两个线程访问同一个锁对象中的资源,而前面的共享资源是通过同一个类对象创建三个线程,所以每个线程都是访问同一个类对象的空间
- 对于生产产品线程来说:当
-
为了便于观察,可以通过
sleep
方法降低运行速度
示例代码:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
|
效果如下:
但是,上面的代码只实现了两个线程之间的通信,如果涉及到多个生产线程和消费线程,此时就打破了前面实现的消费模式平衡,考虑出现问题的原因:
-
原因1:在前面只有两个线程进行通信时,只会出现一个线程运行一个线程等待交替进行,根据
notify
方法的特性「每一次随机唤醒一个等待的进程」可以确保一定唤醒另一个正在等待的线程;但是如果出现多个生产线程和消费线程,则此时notify
方法的特性中的「随机」就会导致此处出现问题「多次生产或者多次消费」,因为notify
无法保证下一次唤醒的一定是正在等待的生产线程(消费运行)或者正在等待的消费线程(生产运行)。假设开始一个生产线程运行,当该线程运行完毕释放锁后,下一次还是生产线程时,就会产生多次生产,同样对于消费线程也是如此Info
针对原因1,提出解决方案:当一个线程结束后,唤醒其他所有线程一起抢锁,此时需要使用
notifyAll()
方法 -
原因2:原因2出现在原因1之后,尽管使用了
notifyAll()
方法依旧没有解决问题,假设开始运行的线程为生产线程,其他线程均处于等待状态,如果使用notifyAll()
唤醒了所有的线程,此时所有线程开始抢锁,若抢到锁的依旧是生产线程,上面的代码就会因为是if
语句,而走完if
语句之后就继续向下走,走到生产产品部分导致出现连续生产Info
针对原因2,提出解决方案:将if语句换成
while
语句,此时对于原因2中的情况来说,走完while
内部的语句后会因为是while继续判断是否执行while
内部的语句,而因为第一次线程生产产品已经将flag
设置为true
,所以此时flag
为true
,while
判断为真,第二个抢到锁的生产线程就会继续进入while
内部执行等待
根据前面的两个解决方案修改代码如下:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
|
实例实现(使用同步方法)¶
基本逻辑与前面一致,基于非静态同步方法修改,需要注意,同步方法不需要使用锁对象,对于非静态同步方法来说,锁对象默认是this
Note
注意,不可以使用静态同步方法,因为如果是静态同步方法,就需要在静态同步方法中使用静态的wait
和notify
,但是二者并没有对应的静态版本
- 非静态同步方法
Note
为了保证this
指向同一个对象,依旧需要使用构造函数将产品对象传入,确保锁唯一
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
|
定时器(了解)¶
使用定时器可以规定间隔多长时间执行一次线程任务
创建定时器可以使用Timer()
构造方法
在Timer
中存在一个方法void schedule(TimerTask task, Date firstTime, long period)
可以对给定的线程进行执行时间设置,第一个参数为线程任务,TimeTask
是一个抽象类,该类实现自Runnable
接口,所以也存在一个抽象方法run()
,实现了TimerTask
的类需要重写run()
重写,第二个参数表示开始时间,第三个参数表示间隔时间,单位为毫秒
使用实例:
Java | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 |
|