摘要:本文学习了线程的相关知识,包括线程产生的安全问题和解决办法,以及如何进行线程通信。
环境
Windows 10 企业版 LTSC 21H2
Java 1.8
1 概述
1.1 进程
1.1.1 单指令到批处理
说起进程的由来,需要从操作系统的发展历史谈起。
在最初的时候,计算机只能接受一些特定的指令,用户输入一个指令,计算机就做一个操作。当用户在思考或者输入数据时,计算机就在等待,所以很多时候计算机处于等待用户输入的状态。
为了提高效率,将需要执行的多个指令写在磁带上,然后交由计算机去读取并逐个地执行这些指令,并将输出结果写到另一个磁带上。这样就减少了计算机的等待时间,批处理操作系统也由此诞生。
1.1.2 多个程序同时运行
虽然批处理操作系统的诞生极大地提高了任务处理的便捷性,但是仍然存在一个很大的问题:
假如有两个任务A和B,任务A在执行到一半时,需要读取大量的数据输入(I/O操作),此时CPU只能等待任务A读取完数据才能继续执行,这样就浪费了CPU资源。
此时就需要计算机在任务A读取数据时,让任务B使用CPU执行任务,当任务A读取完数据之后,让任务B暂停,让任务A使用CPU继续执行。
但是这么做需要解决两个问题:
- 单个程序运行,计算机的内存里存储的是单个程序的数据,多个程序同时运行,内存需要存储多个程序的数据,并能够加以区分。
- 多个程序轮流使用CPU资源,计算机需要记录每个程序的使用情况,保证程序在暂停后能从原位置继续执行。
1.1.3 进程出现
于是便有了进程,用进程来对应一个程序,每个进程对应一定的内存地址空间,并且只能使用它自己的内存空间,各个进程间互不干扰。并且进程保存了程序每个时刻的运行状态,这样就为进程切换提供了可能。当进程暂时时,它会保存当前进程的状态,比如进程标识、进程的使用的资源等,在下一次重新切换回来时,便根据之前保存的状态进行恢复,然后继续执行。
这就是并发,能够让操作系统从宏观上看起来同一个时间段有多个任务在执行。
换句话说,进程让操作系统的并发成为了可能。
注意,对于单核CPU来说,虽然并发从宏观上看有多个任务在执行,但是事实上,任一个具体的时刻,只有一个任务在占用CPU资源,因为只有一个CPU。
1.2 线程
1.2.1 程序内的多任务
虽然进程的出现解决了操作系统的并发问题,但一个进程在一段时间内只能执行一个操作,对于有多个子任务的进程来说,操作系统只能逐个执行这些子任务:
比如有一个音乐播放器程序,如果某一时刻程序正在播放音乐,用户此时又点击了查看歌词,那么程序就需要等待音乐播放完毕才会显示歌词,这显然无法满足用户。
此时就需要操作系统支持在程序内部同时执行多个子任务,在播放音乐的同时显示对应的歌词。
但是这么做需要解决两个问题:
- 多个子任务属于同一个程序,并且子任务之间需要共享数据,比如需要根据正在播放的音乐显示歌词。
- 每个子任务在进行暂停和执行时,需要记录各自的运行信息。
1.2.2 线程出现
于是便有了线程,用线程来对应一个程序里的一个子任务,一个进程内的多个线程共享进程的存储空间。同时每个线程又有各自的线程标识,存储各自的线程运行信息。
操作系统给每个线程分配的时间段都很短,再加上快速的线程切换,从而让用户感觉系统是同时在做多件事情的,满足了用户对实时性的要求。
换句话说,进程让操作系统的并发成为了可能,而线程让进程内部的并发成为了可能。
但是要注意,一个进程虽然包括多个线程,但是这些线程是共同享有进程占有的资源和地址空间的。所以说,进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位。
1.2.3 多线程是否一定优于单线程
不一定,要看具体的任务以及计算机的配置。
对于单核CPU。如果是CPU密集型任务,如解压文件,多线程的性能反而不如单线程性能,因为解压文件需要一直占用CPU资源,如果采用多线程,线程切换导致的开销反而会让性能下降。如果是交互类型的任务,肯定是需要使用多线程的。
对于多核CPU,多线程肯定优于单线程,因为多个线程能够更加充分利用每个核的资源。
虽然多线程能够提升程序性能,但是相对于单线程来说,它的编程要复杂地多,要考虑线程安全问题。因此,在实际编程过程中,要根据实际情况具体选择。
1.3 进程和线程的区别
1.3.1 从承担角色的角度
进程是拥有资源的基本单位,线程可以共享其隶属进程的系统资源。
线程是操作系统进行调度的基本单位。
1.3.2 从系统开销的角度
进程由程序、数据、进程控制块三部分组成。程序就是程序代码,数据存储了运算结果和变量的值,进程控制块存储了进程信息,是进程存在的唯一标志。每次创建进程系统都要分配资源,如内存、IO等,系统开销大。
线程由线程ID、当前指令寄存器、程序计数器、累加寄存器等组成。指令寄存器存储了线程在切换时执行到了哪条指令,程序计数器存储了下一条指令在主存储器中的地址,累加寄存存储了运算结果和变量的值。线程切换只需要存储和设置少量寄存器变量,系统开销小。
2 创建机制
2.1 创建
创建线程有两种方法:
- 创建继承了Thread类的对象:
java 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21public class Demo {
public static void main(String[] args) {
Thread t1 = new DemoThread("线程一");
Thread t2 = new DemoThread("线程二");
t1.start();
t2.start();
}
}
class DemoThread extends Thread {
public DemoThread(String name) {
this.setName(name);
}
public void run() {
for (int i = 0; i < 100000; i++) {
System.out.println(Thread.currentThread().getName() + " >>> " + i);
}
}
} - 创建传入一个实现了Runnable接口的类的Thread对象:
java 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class Demo {
public static void main(String[] args) {
DemoThread dt = new DemoThread();
Thread t1 = new Thread(dt, "线程一");
Thread t2 = new Thread(dt, "线程二");
t1.start();
t2.start();
}
}
class DemoThread implements Runnable {
public void run() {
for (int i = 0; i < 100000; i++) {
System.out.println(Thread.currentThread().getName() + " >>> " + i);
}
}
}
这两种方式都可以用来创建线程去执行子任务,具体选择哪一种方式要看自己的需求。直接继承Thread类可能比实现Runnable接口看起来更加简洁,但是如果自定义类需要继承其他类,则只能选择实现Runnable接口。
事实上,查看Thread类的实现源代码会发现Thread类是实现了Runnable接口的。
不论哪种方式创建线程,都必须重写run()
方法,在run()
方法中定义需要执行的任务。
创建线程对象后通过start()
方法去启动线程,在线程中执行run()
方法。如果调用run()
方法,即相当于在主线程中执行run()
方法,跟普通的方法调用没有任何区别,此时并不会在线程中执行run()
方法。
2.2 使用
属性:
- priority:线程的优先级。最低为1,最高为10,默认为5。优先级高的线程获取CPU时间片的概率高。
- threadStatus:线程的状态。常见的状态有NEW、RUNNABLE、BLOCKED、WAITING。
构造方法:
- Thread():使用这种方式创建的线程,需要重写Thread类里的run()方法。
- Thread(Runnable target):使用这种方式创建的线程,需要实现Runnable接口里的run()方法。
静态方法:
- Thread.currentThread():获取当前运行的线程。
常用方法:
- void run():线程执行任务的主要代码。如果没有实现Runnable接口里的run()方法,则需要重写Thread类里的run()方法。
- void start():启动线程的方法,虚拟机会调用线程的run()方法。只用调用了start()方法,才会启动一个新的线程执行定义的任务。
- void sleep(long millis):本地方法,让当前线程休眠指定时间。sleep()方法不会释放占用的锁。
- void join():相当于void join(0),传入0代表一直等待到结束。
- void join(long millis):执行指定的线程一段时间,直到执行时间达到传入的时间长度或者线程执行完毕。
- void interrupt():设置线程的中断状态为true,不会停止线程。
- boolean interrupted():返回当前线程的中断状态,然后将中断状态设置为false。
- void yield():让当前正在运行的线程让出它的时间片,给其他线程抢占的机会,可用于优化忙等待。
2.3 优先级
线程的优先级是为了在多线程环境中便于系统对线程的调度,优先级越高先执行机会越大,并不是一定先执行。
线程的优先级可以理解为线程抢占CPU时间片的概率,并不能保证优先级高的线程一定会先执行。
不同的系统有不同的线程优先级的取值范围,同一个优先级在不同的系统里的值可能是不同的。
一个线程的优先级设置遵从以下原则:
线程创建时,子继承父的优先级。
线程创建后,可通过调用setPriority()
方法改变优先级。
线程的优先级是1-10之间的正整数,线程优先级最高为10,最低为1,默认为5。
优先级:
- 1- MIN_PRIORITY
- 10-MAX_PRIORITY
- 5-NORM_PRIORITY
线程调度器选择优先级最高的线程运行,但是如果发生以下情况,就会终止线程的运行:
- 线程体中调用了
yield()
方法,让出了对CPU的占用权。 - 线程体中调用了
sleep()
方法,使线程进入睡眠状态。 - 线程由于I/O操作而受阻塞。
- 另一个更高优先级的线程出现。
- 在支持时间片的系统中,该线程的时间片用完。
2.4 生命周期
线程的生命周期:一个线程从创建到消亡的过程。
如下图,表示线程生命周期中的各个状态:
2.4.1 新建状态(New)
当线程对象创建后,即进入了新建状态。
2.4.2 就绪状态(Ready)
当调用线程对象的start()
方法,线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了start()
方法后此线程立即就会执行。
当调用线程的yield()
方法时,线程从运行状态转换为就绪状态,但接下来CPU调度就绪状态中的哪个线程具有一定的随机性。
2.4.3 运行状态(Running)
当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。
就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中。
2.4.4 阻塞状态(Blocked)
处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。
根据阻塞产生的原因不同,阻塞状态又可以分为三种:
- 等待阻塞:运行状态中的线程执行
wait()
方法,使本线程进入到等待阻塞状态。 - 同步阻塞:线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
- 其他阻塞:通过调用线程的
sleep()
方法或join()
方法或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、I/O处理完毕时,线程重新转入就绪状态。
2.4.5 死亡状态(Dead)
线程执行完了或者因异常退出了run()
方法,该线程结束生命周期。
2.5 状态
线程在运行的生命周期中可能处于六种不同的状态,在某一时刻线程只能处于其中的一个状态。
各个状态改变如图:
2.5.1 初始状态(New)
初始状态表示线程已经创建,但是还没有调用start()
方法。
示例:
1 | public static void main(String[] args) { |
2.5.2 运行状态(RUNNABLE)
运行状态表示线程已经获取了CPU时间片,将操作系统中就绪和运行两种状态笼统地称为运行状态。
示例:
1 | public static void main(String[] args) { |
2.5.3 阻塞状态(BLOCKED)
阻塞状态表示线程阻塞于锁,假如有两个都是同步安全的线程,当一个线程处于RUNNABLE状态时,则另一个线程处于BLOCKED状态。
示例:
1 | public static void main(String[] args) { |
2.5.4 等待状态(WAITTING)
等待状态表示程序处于等待。
调用wait()、join()、await()、lock()等方法时不传入参数,会使线程处于等待。
示例:
1 | public static void main(String[] args) { |
2.5.5 等待超时状态(TIME_WAITTING)
等待超时状态表示程序处于限时等待,不同于等待状态,可以在指定的时间后自行返回。
调用wait()、join()、await()、lock()、sleep()等方法时传入参数,会使线程处于等待超时状态。
示例:
1 | public static void main(String[] args) { |
2.5.6 终止状态(TERMINATED)
终止状态表示当前线程已经执行完毕。
2.6 中断机制
2.6.1 说明
一个线程不应该由其他线程来强制中断或停止,而是应该由线程自己自行停止,所以使用中断机制用于停止线程。
每个线程对象中都有一个标识,用于标识线程是否被中断。该标识位为true表示中断,该标识为false表示未中断,通过调用线程对象的interrupt()
方法将线程的标识位设为true。
如果线程处于正常活动状态,那么会将线程的中断标志设置位true,被设置中断标志的线程将继续正常运行,不受影响。
如果线程处于被阻塞状态(例如处于sleep、wait、join等状态),在别的线程中调用当前线程对象的interrupt()
方法,那么当前线程立即被阻塞状态,并抛出InterruptedException异常。
2.6.2 使用
在需要中断的线程中不断监听中断状态,一旦发生中断就执行中断处理业务逻辑。
三种识别中断的方式:
- 通过volatile变量实现:
java 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public static volatile boolean isStop = false;
public static void main(String[] args) {
new Thread(()->{
while (true) {
if (isStop) {
System.out.println(Thread.currentThread().getName() + "结束");
break;
}
System.out.println(Thread.currentThread().getName() + "运行");
}
}, "t1").start();
try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
new Thread(()->{
isStop = true;
}, "t2").start();
} - 通过AtomicBoolean实现:
java 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public static AtomicBoolean atomicBoolean = new AtomicBoolean(false);
public static void main(String[] args) {
new Thread(()->{
while (true) {
if (atomicBoolean.get()) {
System.out.println(Thread.currentThread().getName() + "结束");
break;
}
System.out.println(Thread.currentThread().getName() + "运行");
}
}, "t1").start();
try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
new Thread(()->{
atomicBoolean.set(true);
}, "t2").start();
} - 通过Thread类自带方法实现:
java 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (true) {
if (Thread.currentThread().isInterrupted()) {
System.out.println(Thread.currentThread().getName() + "结束");
break;
}
System.out.println(Thread.currentThread().getName() + "运行");
}
}, "t1");
t1.start();
try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
new Thread(()->{
t1.interrupt();
}, "t2").start();
}
3 安全问题
3.1 原因
多个线程操作共享的数据。
一个线程在操作共享数据时,其他线程也操作了共享数据。
3.2 复现
3.2.1 同时售票引发的安全问题
有2个售票窗口同时售卖3张车票,在这个情境中,用2个线程模拟2个售票窗口,3张车票是共享资源,可售卖的编号是1到3,从3号车票开始售卖。
如果在售票时没有考虑线程的并发问题,2个窗口都能同时修改车票资源,则很容易引发多线程的安全问题。
示例:
1 | public class Demo { |
结果:
1 | 窗口1 sale 2 |
结果显示窗口1在最后一次售卖中,卖出了编号为0的车票,实际上是不存在的。
出现这种问题的原因是当车票还剩1张的时候,2个窗口同时判断车票数量是否大于1,这时2个窗口就同时进入了售票扣减的代码,导致本来只能卖出1张的车票被2个窗口各自卖出了1张,从而产生了不存在的车票。
在程序里产生这种问题一般都是因为时间片的切换导致的,当一个线程进入操作共享资源的代码块时,时间片用完,另一个线程也通过判断进入了同一个代码块,导致第二个线程在操作共享资源时,没有重新进行判断。也就是说线程对共享资源的操作时不完整的,中间有可能被其他线程对资源进行修改。
3.2.2 单例模式引发的安全问题
3.2.2.1 懒汉式
懒汉式存在线程安全问题。
懒汉式支持延迟加载,但只能在单线程环境下使用。在多线程环境下,一个线程进入了判断语句块,还没来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例,所以在多线程环境下不可使用这种方式。
示例:
1 | public class Singleton { |
为了解决线程安全问题,可以使用synchronized关键字来修饰获取线程的公有方法,但是这么做会导致每次都要进入到同步方法里判断一下,效率太低。
示例:
1 | public class Singleton { |
为了不需要每次都进行同步,可以使用双重锁定检查(DCL,Double Check Lock),只需要在创建的时候进入同步方法,以后只要判断已经存在实例就直接返回实例,不需要再次进入同步方法。
示例:
1 | public class Singleton { |
除了使用同步机制保证线程安全之外,还可以使用静态内部类来保证线程安全。
示例:
1 | public class Singleton { |
类的静态属性只会在第一次加载类的时候初始化,JVM保证了线程的安全性,在类进行初始化时,别的线程是无法进入的。
这种方式跟饿汉式方式采用的机制类似,两者都是采用了类装载的机制来保证初始化实例时只有一个线程,但又有不同:
- 饿汉式只要Singleton类被装载就会实例化,没有延迟加载的作用。
- 静态内部类在Singleton类被装载时并不会立即实例化,而是在需要实例化时,调用getInstance()方法,才会装载SingletonInstance类,从而完成Singleton的实例化。
3.2.2.2 饿汉式
饿汉式不存在线程安全问题。
饿汉式的写法比较简单,就是在类装载的时候就完成实例化,避免了线程同步问题。但这样会导致在类加载时就进行了实例化,没有做到延迟加载,如果这个实例没有被用到,会造成内存浪费。
示例:
1 | public class Singleton { |
3.3 解决
3.3.1 使用synchronized关键字
synchronized是解决并发问题的一种最常用的方法,也是最简单的一种方法。
作用有三个:
- 确保线程互斥的访问同步代码。
- 保证共享变量的修改能够及时可见。
- 有效解决重排序问题。
从语法上讲,有三种用法:
- 修饰代码块。
- 修饰普通方法。
- 修饰静态方法。
3.3.1.1 修饰代码块
使用synchronized关键字修饰的代码块将对共享资源的操作封装起来,当有一个线程运行代码块时,其他线程只能等待,从而避免共享资源被其他线程修改。
要求多个线程同步使用的锁都必须是同一个才能保证同步,常用的是使用一个Object对象,或者使用this,或者使用类的class对象。
示例:
1 | public class Demo { |
结果:
1 | 窗口2 sale 3 |
线程在进入卖票的代码块之前,先看一下当前是否由其他线程在执行代码块,如果有其他线程在执行代码块则会等待,直到其他线程执行完之后才能进入代码块,从而保证了线程并发的安全问题。
3.3.1.2 修饰普通方法
将操作共享资源的代码封装为方法,添加synchronized关键字修饰,这个方法就是同步方法,使用的锁是this对象。
示例:
1 | public class Demo { |
结果:
1 | 窗口1 sale 3 |
在每次调用sale()
方法售票的时候,程序会将实例对象this作为锁,保证一个时间只能有一个线程在操作共享资源。
3.3.1.3 修饰静态方法
如果该方法是静态方法,因为静态方法优先于类的实例化,所以静态方法是不能持有this的,静态同步方法的琐是类的class对象。
示例:
1 | public class Demo { |
结果:
1 | 窗口1 sale 3 |
使用静态同步方法除了需要注意共享资源也要用static修饰外,其他的和普通同步方法是一样的。
3.3.2 比较synchronized关键字和volatile关键字
3.3.2.1 含义
volatile主要用在多个线程感知实例变量被更改了场合,从而使得各个线程获得最新的值。它强制线程每次从主内存中讲到变量,而不是从线程的私有内存中读取变量,从而保证了数据的可见性。
synchronized主要通过对象锁控制线程对共享数据的访问,持有相同对象锁的线程只能等其他持有同一个对象锁的线程执行完毕之后,才能持有这个对象锁访问和处理共享数据。
3.3.2.2 比较
量级比较:
- volatile轻量级,只能修饰变量。
- synchronized重量级,还可修饰方法。
原子性:
- volatile不能保证原子性,不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞。
- synchronized可以保证原子性,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞。
3.3.2.3 总结
要使用synchronized,必须要有两个以上的线程。单线程使用没有意义,还会使效率降低。
要使用synchronized,线程之间需要发生同步,不需要同步的没必要使用synchronized,例如只读数据。
使用synchronized的缺点是效率非常低,因为加锁、释放锁和释放锁后争抢CPU执行权的操作都很耗费资源。
4 死锁问题
4.1 产生原因
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放,而该资源又被其他线程锁定,从而导致每一个线程都得等其它线程释放其锁定的资源,造成了所有线程都无法正常结束。
死锁产生的四个必要条件:
- 互斥使用,即当资源被一个线程占有时,别的线程不能使用。
- 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
- 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
- 循环等待,即存在一个等待队列,P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
当上述四个条件都成立的时候,便形成死锁。在死锁的情况下如果打破上述任何一个条件,便可让死锁消失。
4.2 复现死锁
线程A拿到了资源A需要获取资源B,线程B拿到了资源B需要获取资源A,当两个线程都在等待资源时,就出现了死锁:
1 | public class Demo { |
当出现死锁时,控制台打印结果如下:
1 | 线程1 >>> has a need b |
4.2 避免死锁
通过以下几种方式避免死锁:
- 避免嵌套占用资源:这是产生死锁最主要的原因,如果已经占有一个资源,就要避免同时占有另一个资源。
- 只对需要的资源加锁:只对程序用到的资源进行加锁,避免对不需要使用的资源加锁。
- 避免无限期的等待:在等待另一个线程结束时,那最好设置一个等待的最长时间。
5 线程通信
5.1 背景原因
在多线程并发的情况下,如果都对共享资源进行操作,会导致线程安全问题,可以使用线程的同步机制来保证多线程环境下程序的安全性。但使用同步机制只能保证线程安全,并不能在两个线程或者多个线程之间自由切换,线程的切换完全受CPU的影响。
使用同步机制让两个线程交替打印10到1的数字:
1 | public class Demo { |
结果:
1 | 线程1 >>> 10 |
因为两个线程的调度完全受CPU时间片的影响,只有当一个线程运行时间结束后,另一个线程才能运行,并不能实现在线程运行的过程中进行切换。
如果想让两个线程交替打印数字,那么很显然同步机制是做不到的,这时候就需要让两个线程之间进行通信。
5.2 通信机制
要达到上面所说的两个线程交替打印,需要两个线程进行通信,当第一个线程打印了之后,把自己锁起来,唤醒第二个线程打印,当第二个线程打印之后,也把自己锁起来,唤醒第一个线程,这样就可以实现两个线程的交替打印了。
线程的协作是通过Object类里的wait()
方法,配合notify()
方法或者notifyAll()
方法实现的:
- wait():该方法会导致当前线程等待,直到其他线程调用了此线程的notify()或者notifyAll()方法。注意到wait()方法会抛出异常,所以在面在代码中要对异常进行捕获处理。
- wait(long timeout):该方法与wait()方法类似,唯一的区别就是在指定时间内,如果没有notify或notifAll方法的唤醒,也会自动唤醒。wait(0)等效于wait()。
- nofity():唤醒线程池中任意一个线程。
- notifyAll():唤醒线程池中的所有线程。
比较wait()和sleep()方法的区别:
- 两个方法声明的位置不同:Thread类中声明sleep() ,Object类中声明wait()。
- 使用方法不同:wait()可以指定时间,也可以不指定时间,sleep()必须指定时间。
- 调用的要求不同:sleep()可以在任何需要的场景下调用, wait()必须使用在同步代码块或同步方法中。
- 关于是否释放同步监视器:如果两个方法都使用在同步代码块或同步方法中,sleep()不会释放锁,wait()会释放锁。
这些方法都在Object类中定义的原因:这些方法都是同步锁的方法,锁可以是任意对象,任意的对象都可以调用的方法需要定义在Object类中。
这些方法都需要在synchronized代码块中使用的原因:
- 这些方法用于操作线程状态,所以必须要明确操作的锁是哪个。
- 如果是在非同步的方法里调用这些方法,程序会编译通过,但是在运行时候程序会报出IllegalMonitorStateException异常,这个异常的含义是调用方法的线程在调用这些方法前必须拥有这个对象的锁,或者当前调用方法的对象锁不是之前同步时的那个锁。
5.3 交替执行
示例:
1 | public class Demo { |
结果:
1 | 线程2 >>> 10 |
6 虚假唤醒
6.1 复现
在使用wait()方法时,当被唤醒时有可能会被虚假唤醒,建议使用while而不是if来进行判断,即在循环中使用wait()方法。
没有在循环中使用wait()方法:
1 | public class Demo { |
结果:
1 | 0 1 0 1 0 1 0 1 2 3 2 1 0 1 0 1 |
可以看到即便使用了synchronized关键字,仍然出现了线程安全问题,原因如下:
- 在某一刻,一个负责增加的线程获得了资源,此时num为1,所以执行this.wait()并等待。
- 下一刻,另一个负责增加的线程获得了资源,此时num仍然为1,所以再次执行this.wait()并等待。
- 此后负责减少的线程将num减少到0并唤醒所有等待进程,两个负责增加的线程被唤醒,执行两次增加运算,导致num为2的情况产生。
6.2 解决
解决办法就是将if (num > 0) {
和if (num == 0) {
中的if换成while。
模拟生产者和消费者:
1 | public class Demo { |
7 案例分析
7.1 八个案例
通过分析代码,推测打印结果,并运行代码进行验证。
7.1.1 案例一
一个对象,两个线程调用一个对象的两个同步方法。
示例:
1 | public class Demo { |
结果:
1 | one |
被synchronized修饰的方法,锁的对象是方法的调用者。因为两个方法的调用者是同一个,所以两个方法用的是同一个锁,先调用方法的先执行。
7.1.2 案例二
一个对象,两个线程调用一个对象的两个同步方法。
给其中的某个方法增加Thread.sleep()等待。
示例:
1 | public class Demo { |
结果:
1 | // 等待一秒 |
被synchronized修饰的方法,锁的对象是方法的调用者。因为两个方法的调用者是同一个,所以两个方法用的是同一个锁,先调用方法的先执行,第二个方法只有在第一个方法执行完释放锁之后才能执行。
7.1.3 案例三
一个对象,两个线程调用一个对象的两个同步方法。
给其中的某个方法增加Thread.sleep()等待。
对象新增一个普通方法,新增一个线程调用同一对象的普通方法。
示例:
1 | public class Demo { |
结果:
1 | three |
新增的方法没有被synchronized修饰,不是同步方法,不受锁的影响,所以不需要等待。其他线程共用了一把锁,所以还需要等待。
7.1.4 案例四
两个对象,两个线程调用两个对象的两个同步方法。
给其中的某个方法增加Thread.sleep()等待。
示例:
1 | public class Demo { |
结果:
1 | two |
被synchronized修饰的方法,锁的对象是方法的调用者。因为用了两个对象调用各自的方法,所以两个方法的调用者不是同一个,所以两个方法用的不是同一个锁,后调用的方法不需要等待先调用的方法。
7.1.5 案例五
一个对象,两个线程调用一个对象的两个同步方法。
给其中的某个方法增加Thread.sleep()等待,并将其设为静态方法。
示例:
1 | public class Demo { |
结果:
1 | two |
被synchronized和static修饰的方法,锁的对象是类的class对象。仅仅被synchronized修饰的方法,锁的对象是方法的调用者。因为两个方法锁的对象不是同一个,所以两个方法用的不是同一个锁,后调用的方法不需要等待先调用的方法。
7.1.6 案例六
一个对象,两个线程调用一个对象的两个同步方法。
给其中的某个方法增加Thread.sleep()等待。
将两个同步方法设为静态方法。
示例:
1 | public class Demo { |
结果:
1 | // 等待一秒 |
被synchronized和static修饰的方法,锁的对象是类的class对象。因为两个同步方法都被static修饰了,所以两个方法用的是同一个锁,后调用的方法需要等待先调用的方法。
7.1.7 案例七
两个对象,两个线程调用两个对象的两个同步方法。
给其中的某个方法增加Thread.sleep()等待,并将其设为静态方法。
示例:
1 | public class Demo { |
结果:
1 | two |
被synchronized和static修饰的方法,锁的对象是类的class对象。仅仅被synchronized修饰的方法,锁的对象是方法的调用者。即便是用同一个对象调用两个方法,锁的对象也不是同一个,所以两个方法用的不是同一个锁,后调用的方法不需要等待先调用的方法。
7.1.8 案例八
两个对象,两个线程调用两个对象的两个同步方法。
给其中的某个方法增加Thread.sleep()等待。
将两个同步方法设为静态方法。
示例:
1 | public class Demo { |
结果:
1 | // 等待一秒 |
被synchronized和static修饰的方法,锁的对象是类的class对象。因为两个同步方法都被static修饰了,即便用了两个不同的对象调用方法,两个方法用的还是同一个锁,后调用的方法需要等待先调用的方法。
7.2 案例总结
非静态同步方法使用的锁是调用方法的实例对象本身,静态同步方法使用的锁是类对象本身。
因为锁的对象不同,非静态同步方法在执行时不需要等待静态同步方法的执行,静态同步方法在执行时也不需要等待非静态同步方法的执行。
非同步方法不需要锁,非同步方法的执行不受同步方法的影响。
条