首先回顾一下线程不安全的原因:
- 线程是随机调度,抢占式执行的
- 修改共享数据,多个线程修改同一个变量
- 多个线程修改共享数据的操作不是原子性,(count++是3个CPU指令,但是赋值操作就是原子性的)
- 内存可见性问题
- 指令重排序
前三点已做讲解,接下来对最后两点进行讲解
一、内存可见性问题
1.1 引入概念
先来看下面的代码:
java
public class Demo4 {
public static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (count == 0) {
; //循环体为空
}
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数");
count = scanner.nextInt();
});
t1.start();
t2.start();
}
}
上述代码就是t1线程来读count,t2来修改count,以原来的逻辑来看:当把count修改为一个非0的值后,t1线程就会结束
输入之后发现,程序没有任何反应,说明t1线程并没有结束,接下来我们仍然站在指令的角度来解释,t1线程中的循环条件count == 0相当于两个指令
- load:读取内存中的数据到CPU寄存器
- cmp:比较寄存器中的数据,条件成立就继续执行循环体中的逻辑,不成立就跳转到另外一个地址执行
当前循环体为空,意味着循环速度很快,由于CPU访问寄存器的速度远大于访问内存的速度,所以load执行消耗的时间远多于cmp,也就是执行一次load,会执行很多次load
t2线程中是我们要手动修改count的,要知道load是计算机执行的指令,肯定比人要快很多,所以在t2修改之前会执行很多次的load,JVM发现每次load执行的结果都一样就会把load操作优化掉,后续再执行到对应的代码就不再真正load,而是直接读取load过的寄存器中的值了
上述优化的初衷是为了让程序执行的速度更快,但在多线程这里反而引起了bug
在上述代码中添加一个IO操作或者阻塞操作,循环速度就会大幅降低,也就不会优化掉load,IO操作是不会被优化的:
java
public static int count = 0;
Thread t1 = new Thread(() -> {
while (count == 0) {
System.out.println("执行IO操作");
}
});
总结:上述问题本质是编译器/JVM优化引起的,一个线程对共享变量的修改可能不会被其他线程立即看到,导致其他线程读取到的可能是旧值,从而引发线程安全问题。这就是内存可见性问题
那么该如何解决该问题
1.2 volatile 关键字
给变量加上volatile关键字后,编译器就不会触发上述优化
java
public class Demo {
public static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (count == 0) {
}
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数");
count = scanner.nextInt();
});
t1.start();
t2.start();
}
}
注意:volatile只能保证内存可见性,并不能保证操作的原子性
java
public class Demo {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
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);
}
}
二、线程等待通知机制
2.1 引入概念
先看下面一个ATM取钱的场景
此时小新正在取钱,发现ATM机中钱不够,于是就开锁出来,接下来就该其他人去取钱,但有可能小新觉得自己操作不对就又进去,出来之后发现又不对于是又进去,像这样某个线程频繁获取释放锁,以至于其他线程分配不到CPU资源的问题称为"线程饿死"
系统中线程调度是无序的,线程饿死的情况就有可能出现,但注意:这并不是死锁,死锁是卡死,而线程饿死只会卡住一下下
线程等待通知机制可以调整线程的执行顺序来解决这个问题 ,通过添加判断条件判定当前逻辑是否能够执行,如果不能就wait(主动进行阻塞)就把执行的机会让给别的线程了,避免该线程进行无意义的重试
2.2 wait()方法
wait()做的事情:
- 使当前执行代码的线程进行等待(把线程放在等待队列中)
- 释放当前的锁
- 被唤醒时,重新尝试获取这个锁
可以看到wait()做的事情有释放当前的锁,也就是wait()必须放在synchronized里面,否则会抛出异常:
java
public class Demo {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("等待之前");
object.wait();
System.out.println("等待之后");
}
}
正确的写法如下:
java
public class Demo {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
System.out.println("等待之前");
object.wait();
System.out.println("等待之后");
}
}
}
当前代码会一直等待下去
wait()结束等待的条件:
- 其他线程调用该对象的notify()
- wait等待时间超时(和sleep(1000)效果类似,wait(1000),就是等待1s后如果没有被唤醒就自动唤醒)
- 其他线程调⽤该等待线程的interrupted⽅法,导致wait抛出InterruptedException异常
2.3 notify()方法
notify 方法用来唤醒等待线程的
- 该⽅法是⽤来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁
- 如果有多个线程等待,则有线程调度器随机挑选出⼀个呈wait状态的线程(并没有"先来后到")
看如下示例:
java
public class Demo {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker) {
System.out.println("t1等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1等待之后");
}
});
Thread t2 = new Thread(() -> {
synchronized (locker) {
System.out.println("t2等待之前");
locker.notify();
System.out.println("t2等待之后");
}
});
t1.start();
t2.start();
}
}
打印结果:
注意:t2线程的notify()执行完后,并不会释放锁,而是代码走出synchronized后才会真正把锁释放t1线程拿到锁之后继续执行,因此肯定先打印t2等待之后,后打印t1等待之后
2.4 notifyAll() 方法
notifyAll 可以一次唤醒所有的等待线程
java
public class Demo {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker) {
System.out.println("t1等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1等待之后");
}
});
Thread t2 = new Thread(() -> {
synchronized (locker) {
System.out.println("t2等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t2等待之后");
}
});
Thread t3 = new Thread(() -> {
synchronized (locker) {
System.out.println("t3等待之前");
locker.notifyAll();
System.out.println("t3等待之后");
}
});
t1.start();
t2.start();
Thread.sleep(1000);
t3.start();
}
}
在t3.start()方法之前放一个sleep是为了防止t3先执行了notify,此时t1或t2还没有wait,此时直接notify没有任何效果,也不会抛异常,放一个sleep是为了保证先wait再notify
接下来看代码的执行效果
所有线程都执行结束,如果改为notify方法,再看代码的执行效果
由于notify会随机唤醒一个等待线程,这里唤醒的t1,此时t3没有被唤醒也就不会尝试获取锁,没有锁就不会继续执行接下来的逻辑,所以t3一直处于等待
如果不想让t3一直等下去,就将t3的wait改为带有时间版本的,这样时间一到就会自动被唤醒
2.5 面试题:wait() 和 sleep()的区别
- wait必须搭配 synchronized 来使用,否则会抛出IllegalMonitorStateException 异常,而 sleep可以在任何地方使用
- wait是Object类的一个普通方法 ,sleep是Thread类的一个静态方法
- 线程可以等 sleep 中的计时结束后主动唤醒 ,但如果是无参版本的 wait,则需要等其他线程调用 notify 或 notifyAll 来被动唤醒
- 调用 sleep 方法线程会进入TIMED_WAITING 有时限等待状态,而调用无参数的 wait 方法,线程会进入 WAITING无时限等待状态