提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
前言
上节博客我们已经了解了线程的几种状态,NEW,RUNNABLE,TERMINATED,BLOCKED,WAITING,TIMED_WAITING这5种状态。
今天我们就要一起去了解线程安全问题。let's go!bro
一、线程安全是什么
我们先来看这一段代码:
java
class demo {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i =0 ;i<=5000;i++){
count++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i <=5000 ; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}
我们预测count的结果应该是10000,但是运行代码的结果不是10000这是上面原因,代码没有出错,但是没有得到我们预期的结果。
这里不是谁先join的问题,谁先都无所谓。
1)t1先结束,t2后结束,main先在t1.join阻塞等待,t1结束,main再在t2.join阻塞等待,
t2结束,main继续执行后续打印=>最终结果、打印的值,就是t1和t2都执行完的值。
2)t2先结束,t1后结束,main先在t1.join阻塞,t2结束,t1.join继续阻塞,t1结束,t1.join继续执行
main执行到t2.join(由于t2已经结束了,此处的t2.join是不会阻塞的)
main继续执行后续打印=>最终结果,打印的值还是t1和t2都执行完的值。
这就是线程不安全导致的bug。在说解决线程不安全的bug之前,我们先需要了解一些概念。
1.线程安全的概念
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们的预期结果的,即使在单线程环境下得到的结果和我们预期的结果一样,则说这个程序是安全的。
2.线程不安全的原因
- **线程调度是随机的。**这是线程安全问题的罪魁祸首,随机调度使一个程序在多线程环境下,执行顺序存在很多的变数。
程序猿必须保证在任意执行顺序下,代码都能正常工作。
- 修改共享数据。多个线程修改同一个变量,上面的线程不安全的代码中,涉及到多个线程针对count变量进行修改,此时这个count是一个多个线程都能访问到的"共享数据"。

这看似是一行代码,实际上对应到3个CPU指令
1)load把内存中count的值,加载到CPU的寄存器中。
2)add:把寄存器中的内容+1
3)save:把寄存器中的内容保存回内存上。
因为操作系统对于线程的调度是随机的,执行123三个指令的时候不一定是"一口气执行完",很可能是执行到其中的一部分,该线程就被调度走了。
可能123,
可能1调度走......调度回来2 3,可能 1 2调度走......调度回来3,可能1调度走......调度回来2调度走......调度回来 3......
线程调度是随机的,不可预期的。

只有这两种线程调度方式,运行的代码才能达到我们的预期效果,其他的调度方式很难达到我们的预期效果。

两个线程在CPU上执行的时候,可能是并发(在同一个CPU上执行)
也可能是并行(在不同的CPU上执行),两个线程,有不同的上下文(一组自己的寄存器的值)
按照这个次序调度执行,其最终结果是正确的。反之如下图这是错误的

当t1被调度回来之后,此时还是按照刚才执行的进度,继续往下执行。明明是两次++最后的结果还是1(相当于一次++加丢了).

总结:
- 如果是一个线程修改一个变量--没问题。
- 如果是多个线程,不是同时修改同一个变量--没问题。
- 如果多个线程修改不同变量--没问题(不会出现中间结束相互覆盖的情况)。
- 如果多个线程读取同一个变量--没问题。
修改操作--写;取值操作--读。
- 原子性
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,小李进入房间之后,,还没有出来;小苏是不是也可以进入,打断小李在房间里的隐私。这个就是不具备原子性的。那我们应该如何解决这个问题呢?是不是只要给房间上一把锁,小李进去就把门锁上,其他人是不是进不来了。这样就保证了这段代码的原子性了。有时也把这个现象叫做同步互斥,表示操作是互相排斥的。提醒一句这里的原子性和数据库的原子性不是同一个概念,数据库--事务这部分中原子性--代表着不可再分。
如果修改操作只是对应到一个CPU指令,就可以认为是原子的,CPU不会出现'一条指令执行一半'这样的情况的。如果对应到多个CPU指令就不是原子的。比如刚才的count++这一条Java语句不一定是原子的,也不一定只是一条指令。这一条语句有三个CPU指令--load、add、save。
不保证原子性会给多线程带来什么问题?
如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。这点也和线程的抢占式调度密切相关,如果线程不是"抢占"的,就算没有原子性,问题也不大。
- 可见性
可见性指,一个线程对共享变量值的修改,能够及时地被其他线程看到。
Java内存模型(JMM):Java虚拟机规范中定义了Java虚拟机规范中定义了Java内存模型。
目的是屏蔽掉各种硬件和操作系统地内存访问差异,以实现让Java程序在各种平台下都能达到一直地并发效果。

- 线程之间地共享变量存在主内存(Main Memory)。
- 每一个线程都有自己地"工作内容"(Working Memory)。
- 当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存中读取数据。
- 当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,在同步会主存。
由于每个线程都有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的"副本"。此时修改线程t1的工作内存中的值,线程t2的工作内存不一定回及时变化。

最上面的:1) 初始情况下, 两个线程的⼯作内存内容⼀致.
下面是:2) ⼀旦线程1 修改了 a 的值, 此时主内存不⼀定能及时同步. 对应的线程2 的⼯作内存的 a 的值也不⼀定能及时同步.
聊到这里就又引入了两个新的问题:
- 1)为啥整那么多内存
实际并没有这么多"内存",这只是Java规范中的一个术语,是属于"抽象"的叫法。所谓的"主内存"才是真正硬件角度的"内存"。而所谓的"工作内存",则是指CPU的寄存器和高速缓存。
- 2)为啥要这么麻烦的拷来拷去?
因为cpu访问自身寄存器的速度以及高速缓存的速度,远远超过访问内存的速度(快了3-4个数量级,也就是几千倍,上万倍。)
那么接下来问题又来了,既然访问寄存器速度这么快,还要内存干啥?
答案就是一个字:贵。

值得一提的是,快和慢都是相对的,cpu访问寄存器速度远远快于内存,但是内存的访问速度又远远快于硬盘。对应的,cpu的价格最贵,内存次之,硬盘最便宜。
- 指令重排序
一段代码是这样的:1.去前台取下U盘;2.去教室写30分钟作业;3.去前台取下快递。
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按1->3->2的顺序去执行,也是没问题,可以少跑一次前台。这种叫做指令重排序。
编译器对于指令重排序的前提是"保持逻辑不发生变化"。这一点在单线程环境下比较容易判断,但是在多线程环境下就没那么容易了,多线程的代码执行复杂程度更高,编译器很难在编译阶段对代码的执行效果进行预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价。
二、线程安全问题的解决
1.[根本]操作系统对于线程的调度是随机的,抢占式执行;因为是操作系统的底层设定,咱们无法左右。
2.多个线程修改同一个变量,这和代码的结构直接相关,调整代码结构,规避一些线程不安全的代码的,但是这样的方案,不够通用。然而,在有些情况下,需求上就是需要多线程修改同一变量--超买/超卖的问题。
3.修改操作,不是原子的。
1.加锁(synchronized()
Java中解决线程安全问题,最主要的方案:加锁--通过加锁操作,让不是原子的操作,打包成一个原子的操作。计算机中的锁和生活中的锁,是同样的概念,互斥/排他。

把锁'锁上'称为"加锁",把锁 "解开"称为"解锁",一旦把锁加上了,其他人要想加锁,就只能阻塞等待。在计算机中,是不允许暴力拆锁,只能阻塞等待。了解完这个加锁的操作,我们就可以使用锁,把刚才不是原子的count++包裹起来。在count++之前,先加锁,然后进行count++,计算完毕之后,再解锁。执行完这3步走过程中,其他线程就没发法插队了。
java
class demo {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locker){
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locker){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}
加锁/解锁本身是操作系统提供的api很多编程语言都对于这样的api进行封装了,大多数的封装风格,都是采取两个函数。
加锁:lock();
//执行一些要保护起来的逻辑
解锁 unlock();
但是在Java中使用synchronized这样的关键字,搭配代码块,来实现类似的效果的。
synchronized{ //进入代码块,就相当于加锁
//执行一些要保护的逻辑。
} //出了代码快,就相当于解锁。

()填写上面呢?填写的是,用来加锁的对象,要加锁,要解锁,前提是得先有一把锁,在Java中,任何一个对象,都可以用作"锁"。这个对象的类型是啥,不重要。重要的是,是否有多个线程尝试针对这同一个对象加锁(是否在竞争同一个锁)。

两个线程针对同一个对象加锁,才会产生互斥效果。(一个线程加上锁了,另一个线程就得阻塞等待,等到第一个线程释放锁,才有机会)。
如果是不同的锁对象,此时不会有互斥效果,线程安全问题,没有得到改变。如果把锁对象想象成美女,线程就是追求这位美女的小哥,如果我和这位小哥追同一位美女,如果我先追上了(加锁),这位小哥就得阻塞等待,等待到我分手(解锁),他才有机会。如果是追不同的女人,我们俩的进度,各自不影响。
理解阻塞等待:针对每一把锁,操作系统内部维护了一个等待队列,当这个锁被某个线程占有的时候,其他线程尝试进行加锁,就加不上了,就会阻塞等待,一直等到之前的线程解锁之后,由操作系统唤醒一个新的线程,再来获取到这个锁。
注意:
- 上一个线程解锁之后,下一个线程并不是立即就能获取到锁,而是要靠操作系统来"唤醒"。这也就是操作系统线程调度的一部分工作。
- 假设A B C三个线程,线程A先获取到锁,然后B尝试获取锁,然后C在尝试获取锁,此时B和C都在阻塞队列中排队等待,但是当A释放锁之后,虽然B比C先来的,但是B不一定就能获取到锁,而是和C重新竞争,并不遵守先来后到的规则。
解决线程安全问题,不是我们写了synchronized就可以而是要正确的使用锁。
1)synchronized()代码块要合适。
2)synchronized()指定的锁对象也得合适。
2.可重入
synchronized同步块对同一条线程来说是可重入得,不会出现自己把自己锁死得问题;
理解"把自己锁死"
一个线程没有释放锁,然后又尝试再次加锁。
//第一次加锁,加锁成功。
lock();
//第二次加锁,锁已经被占用,阻塞等待。
lock();
按照之前对于锁得设定,第二次加锁得时候,就会阻塞等待,直到第一次得锁被释放,才能获取到第二个锁。但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想干了,也就无法进行解锁操作,这时候就会死锁。
面试官也可能会要求我们写一个死锁:
接下来我写一个死锁代码:
java
class demo{
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
synchronized (locker1){
//我拿起酱油
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2){
//尝试拿起醋
System.out.println("t1两个锁都拿到");
}
}
});
Thread t2 = new Thread(()->{
//辣子鸡要拿起醋
synchronized (locker2){
//他拿酱油
synchronized (locker1){
System.out.println("t2线程两个锁拿到");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}



想要解开死锁,就是需要我们改变锁对象顺序和加Thread.sleep()方法。
java
class demo {
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
synchronized (locker1){
//我拿起酱油
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2){
//尝试拿起醋
System.out.println("t1两个锁都拿到");
}
}
});
Thread t2 = new Thread(()->{
//辣子鸡要拿起醋
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//他拿酱油
synchronized (locker2){
System.out.println("t2线程两个锁拿到");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}

这样得锁称为:不可重入锁。
Java中synchronized是可重入锁,因此没有上面的问题。
java
class demo {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locker){
synchronized (locker){
count++;
}
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locker){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}
在可重入锁的内部,包含了"线程持有者"和"计数器"两个信息。
- 如果某个线程加锁的时候,发现已经被人占用,但是恰好占用的正式自己,那么仍然可以继续获取到锁,并让计数器自增。
- 解锁的时候计数器递减为0的时候,才真正释放锁。(才能被别的线程获取到)
计数器的逻辑:先引入一个边,计数器(0),每次触发{的时候,把计数器++,每次触发}的时候,把计数器--,当计数器--为0的时候,就是真正需要解锁的时候。
上面的可重入问题可能被面试官作为题目来对我吗进行拷问:
HR问我们如何自己实现一个可重入锁?
1.在锁内部记录当前是哪个线程持有的锁,后续每次加锁,都进行判定。
2.通过计数器,记录当前加锁的次数,从而确定何时真正进行解锁。
3.synchronized的几种变种写法
1)修饰代码块:明确指定锁对象
锁任意对像
java
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locker){
count++;
}
}
});
锁当前对象
java
public class demo {
publci void method(){
synchronized(this) {
}
}
}
2)直接修饰普通方法:锁的demo对象
java
class demo {
public synchronized void method(){
}
}
3)修饰静态方法:锁的demo类的对象
java
class demo {
public synchronized static void way(){
}
}
4.死锁。
一个经典的模型,哲学家就餐问题

1.思考人生(方下筷子,思考);2.吃面条(拿起筷子)。
5个哲学家,随机的触发吃面条和思考人生。5个哲学家就相当于5个线程。
5根筷子就相当于5把锁,每个线程只需要拿到其中的两根筷子即可。
大部分情况下,上述模型,可以很好的运转,在一些极端情况下会造成死锁的,
同一时候,大家都想吃面条,同时拿起左手的筷子,此时,任何一个线程都无法拿起右手的筷子,任何一个哲学家都吃不成面条。
哲学家,非常执拗的人,每个人都不会放下手中的筷子,而是等。
如何避免代码中出现死锁呢
死锁是这样构成的?
构成死锁的四个必要条件(重要)
1.锁是互斥的(锁的基本性质),一个线程拿到锁之后,另一个线程在尝试获取锁,必须要阻塞等待,
2.锁是不可抢占的(不可剥夺。锁的基本特性)。线程1拿到锁,线程2也尝试获取这个锁,线程2必须阻塞等待而不是线程2直接把锁抢过来。
3.请求和保持。一个线程拿到锁1之后,不释放锁1的前提下,获取锁2.
又回到上面的哲学家就餐模型,如果先放下左手的筷子,在拿右手的筷子。就不会构成死锁,
代码中加锁的时候,不要去"嵌套"。这种做法,通用性不够。
4.循环等待,多个线程,多把锁之间的等待过程,构成了"循环"A等待B,B也等待A或者A等待A等待B,B等待C,C等待A。约定好加锁的顺序,就可以破解循环等待了。

约定每个线程加锁的时候永远是先获取序号小的锁后获取序号大的锁。
死锁的小结:
1.构成死锁的场景:
a)一个线程一把锁=>可重入锁。
b)两个线程两把锁=>代码如何编写。
c)N个线程M把锁=>哲学家就餐问题。
2.死锁的的四个必要条件:
a)互斥。
b)不可剥夺。
c)请求和保持。
d)循环等待。
3.如何避免死锁
打破c):把配套的锁改成并列的锁和d):加锁的顺序做出约定。
5.Java标准库中的线程安全类
Java标准库中很多都是线程不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施。
- ArrayList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
但是还有一些是线程安全的,使用了一些锁机制来控制。
- Vector(不推荐使用)
- HashTable(不推荐使用)
- ConcurrentHashMap
- StringBuffer

StringBuffer 的核⼼⽅法都带有 synchronized.
还有的虽然没有加锁,但是不涉及"修改",仍然是线程安全的。
- String
6.volatile关键字
volatile能保证内存可见性。
volatile修饰的变量,能够保证"内存可见性"。

代码在写入volatile修饰的变量的时候,
- 改变线程工作内存中volatile变量副本的值。
- 将改变后的副本的值从工作内容刷新到主内存。
代码在读取volatile修饰的变量的时候,
- 从主内存中读取volatile变量的最新值到线程的工作内存中。
- 从工作内存中读取volatile变量的副本。
前面我们讨论内存可见性时说了,直接访问工作内存(实际是CPU的寄存器或CPU的缓存),速度非常快,但是可能出现数据不一致的情况,
加上volatile,强制读写内存、速度是慢了,但是数据变的更准确了。
在这个代码中:
- 创建两个线程t1,t2
- t1中包含一个循环,这个循环以flag==0为循环条件。
- t2中从键盘读入一个整数,并把这个整数赋值给flag
- 预期当用户输入非0的值时,t1线程结束
java
import java.util.Scanner;
public class demo13 {
public static int flag =0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (flag==0){
}
System.out.println("循环结束!!");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
//执行效果
//当用户输入非0值时,t1线程循环不会结束
t1读的是自己工作内存中的内容,当t2对flag变量进行修改,此时t1感知不到flag的变化。
想要解决这个问题,就需要我们在定义flag变量时加上volatile关键字。
java
class demo {
public static volatile int flag =0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (flag==0){
}
System.out.println("循环结束!!");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}

volatile不保证原子性
volatile和synchronized有着本质的区别,synchronize能够保证原子性,volatile保证的是内存可见性。
给count套上volatile关键字,没有加锁,count的值是不准确的
java
class demo {
private static volatile int count =0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}

7.wait和notify
由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知,但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。
可以让后执行的逻辑,等待先执行的逻辑,先跑。虽然无法直接干预调度器的调度顺序,但是可以让后执行的逻辑(线程)等待,等待到先执行的逻辑跑完了,通知一下当前的线程,让他继续执行。join也是等,但是join式等另一个线程彻底执行完,才继续走。
而wait也是等,wait是等到一个线程执行notify,才继续走(不需要另一个线程执行完)。
7.1wait()方法
wait做的事情:
- 使当前执行代码的线程进行等待。(把线程放到等待队列中)
- 释放当前的锁
- 满足一定条件时被唤醒,重新尝试获取这个锁,
wait要搭配synchronized来使用,脱离synchronized使用wait会直接抛出异常。
wait结束等待的条件:
- 其他线程调用该对象的notify方法。
- wait等待时间超时(wait方法提供一个带有timeout参数的版本,来指定等待时间)。
- 其他线程调用该等待线程的interrupted方法,导致wait抛出InterruptedException异常。
java
class demo {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
synchronized (locker){
System.out.println("等待中");
locker.wait();
System.out.println("等待结果");
}
}
}
这样在执行到locker.wait()这里就会一直等下去,程序肯定不能一直这么等待下去。这个时候就需要使用到了另一个方法来唤醒---notify().
7.2notify()方法
notify方法时唤醒等待的线程。
- 调用前提:必须在同步上下文中执行
notify() 必须在 synchronized 方法 或 synchronized 代码块 中调用,原因是:调用 notify() 的线程必须 先持有该对象的锁(监视器锁) 。如果当前线程没有持有锁就调用 notify() ,会直接抛出 IllegalMonitorStateException 异常。
2.作用:唤醒等待同一锁的线程
- 当一个线程调用某个对象的 wait() 方法时,它会 释放该对象的锁 并进入「等待队列」(Waiting state),等待其他线程的通知。
- notify() 的作用就是从这个「等待队列」中 随机选择一个线程 ,将其状态从「等待」改为「阻塞」(Blocked state)------ 即通知它:「可以准备重新获取锁了」。
- 锁的释放时机
调用 notify() 后, 当前线程不会立即释放锁 !它会继续执行完整个 synchronized 方法/代码块的逻辑,直到退出同步上下文时,才会真正释放锁。
- 被唤醒线程的后续行为
被 notify() 唤醒的线程,此时处于「阻塞状态」,它需要和其他所有等待该锁的线程(包括原本就处于阻塞状态的线程)一起 竞争获取锁 。只有竞争到锁的线程,才能继续执行 wait() 方法之后的代码。
举一个栗子:
想象一个「共享会议室」(对象锁):
- 线程A进入会议室(获取锁)后,发现需要等待资料,于是调用 wait() 离开会议室(释放锁),在门外的「等待区」排队。
- 线程B进入会议室(获取锁)后,准备好资料,然后调用 notify() 喊一声:「资料好了,来一个人进来拿!」
- 线程B不会立即离开会议室,而是继续处理完自己的事情(执行完同步代码)后才离开(释放锁)。
- 此时,「等待区」的线程A(被notify唤醒)和其他在「排队区」的线程一起竞争进入会议室的资,谁抢到锁谁就能进去拿资料并继续工作。/
java
class demo {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
while (true){
synchronized (locker){
try {
System.out.println("开始等待");
locker.wait();
System.out.println("漫长的等待结束,我的时代到来了");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
Thread t2 = new Thread(()->{
while (true){
synchronized (locker){
System.out.println("notify开始解锁");
locker.notify();
System.out.println("notify解锁结束");
}
}
});
t1.start();
Thread.sleep(1000);
t2.start();
}
}
7.3notifyAll()方法
notify方法只是唤醒某一个等待线程,使用notifyAll方法可以一次唤醒所有的等待线程。
java
class demo {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(()->{
synchronized (locker){
System.out.println("wait等待");
try {
locker.wait();
System.out.println("t1 wait 之后");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker){
System.out.println("t2也在等待");
try {
locker.wait(3000);
System.out.println("t2 wait 之后");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t3 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
synchronized(locker){
System.out.println("请输入任意内容开始唤醒全部线程:");
scanner.next();
System.out.println("t3开始解锁所有等待的线程");
locker.notifyAll();
System.out.println("t3已经全部解锁完毕");
}
});
t1.start();
t2.start();
t3.start();
}
}
理解notify和notifyAll
notify只唤醒等待队列中的一个线程,其他线程还是乖乖等着

notifyAll一下全都唤醒,需要这些线程重新竞争锁

7.4wait和sleep的对比(面试题)
其实理论上wait和sleep完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,
唯一的相同点就是都可以让线程放弃执行一段时间。
当然为了面试的目的,我们还是总结下:
1.wait需要搭配synchronized使用,sleep不需要。
2.wait是Object的方法,而sleep是Thread的静态方法。
总结
到这里,我们的线程安全就已经讲解完了,总结一下:线程安全问题的根源是 线程抢占式调度 与 共享资源并发修改 的矛盾。解决核心是通过 加锁机制 保证操作的原子性、可见性和有序性。正确使用 synchronized 是Java中解决线程安全问题的基础且有效的手段!
下节预告:多线程案例