目录
- 1. 线程不安全原因
- 1.1 引入——线程不安全的例子(抢占式执行)
- 1.2 线程不安全的原因(5点+其他)
- 2. 抢占式执行引起的线程不安全——synchronized
- 3. 内存可见性引起的线程不安全——volatile
- 3.1 例子——编译器优化误判
- 3.2 volatile——编译器暂停优化
- 4. 指令重排序引起的线程不安全——volatile
- 5. 等待通知——wait和notify关键字(锁中使用)
- 6. wait和sleep的对比(面试题)
1. 线程不安全原因
1.1 引入——线程不安全的例子(抢占式执行)
由于线程调度顺序是无序的,则让两个线程对同一个变量各自自增5w次,看变量的运行结果。
为啥会出现线程安全问题?
本质原因:线程在系统中的调度是无序的/随机的(抢占式执行)。
public class TestDemo {public static int x;public static void main(String[] args) throws InterruptedException {//两个线程对同一个变量各自自增5w次,看变量的运行结果Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {x++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {x++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(x);}
}//另一种写法
class Counter {public int count = 0;public void add() {count++;}public int getCount() {return count;}
}
public class TestDemo2 {public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println(counter.getCount());//实际结果和预期结果不相符,由多线程引起的bug【与线程的调度随机性密切相关】}
}
运行结果:
分析:有多少次是“顺序执行”,有多少次是“交错执行”是不知道的,得到的结果是啥也是变化的。
count++操作,本质上是3个cpu指令构成的(不是原子的)
- load把内存中的数据读取到cpu寄存器中
- add把寄存器中的值进行+1运算
- save把寄存器中的值写回到内存中
归根结底,线程安全问题全是因为线程的无序调度导致了执行顺序不确定,结果就变化了。
1.2 线程不安全的原因(5点+其他)
①抢占式执行,随机调度(罪魁祸首,万恶之源)
线程中的代码执行到任意一行,都随时可能被切换出去
②多个线程同时修改同一个变量
注:一个线程修改同一个变量(安全)
多个线程读取同一个变量(安全)
多个线程修改不同变量(安全)
③修改操作不是原子的
注:如果某个操作对应多个cpu指令,大概率不是原子的,正是因为不是原子的,导致两个线程的指令排序存在更多的变数了。
例如++操作就不是原子的。
④内存可见性
编译器执行代码的时候对代码进行优化,有些操作下频繁读取内存,但是读取的结果不变,则优化成只从寄存器去读,不从内存中读。在这种情况下,如果另外一个线程修改了内存的值,原来读的那个线程无法感知到。【一个线程频繁读,一个线程修改】
⑤指令重排序
编译器优化产生的线程不安全。
2. 抢占式执行引起的线程不安全——synchronized
- 锁有2个核心操作:加锁和解锁。
public void add() {synchronized (this) {count++;}}
①此处使用代码块的方式来表示,进入synchronized 修饰的代码块的时候,就会触发加锁,出synchronized 的代码块,就会触发解锁。
②synchronized (),括号()中表示锁对象,在针对哪个对象加锁,如果2个线程针对不同对象加锁,此时不会存在锁竞争,各自获取各自的锁即可;如果2个线程针对同一个对象加锁,此时就会出现“锁竞争”,一个线程先拿到了锁,另一个线程阻塞等待。
③()里的锁对象可以是写任意一个Object对象,基本数据类型不可以,此处写了this相当于counter对象。
例如:手动指定一个锁对象,相当于吉祥物,仅仅是起到了一个标识的效果。
private Object locker = new Object();public void add() {synchronized (locker) {count++;}}
注:如果多个线程尝试对同一个锁对象加锁,就会产生锁竞争;针对不同对象加锁,就不会有锁竞争。
- 给方法加锁
synchronized public void add() {count++;}
如果直接给方法使用synchronized修饰,此时就相当于以this为锁对象。
下图2种加锁方法等价。
3. 给静态方法加锁
public static void test() {synchronized (Counter.class) {}}synchronized public static void test1() {}
如果synchronized修饰静态方法(static),此时就不是给this加锁了,而是给类对象加锁。
类对象是什么?
Counter.class
类对象相当于“对象的图纸”,描述了类的方方面面的详细信息。
类对象可以用来表示.class文件的内容。
3. 内存可见性引起的线程不安全——volatile
3.1 例子——编译器优化误判
import java.util.Scanner;public class ThreadDemo1 {public static int falg = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {while (falg == 0) {}});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);falg = scanner.nextInt();});t1.start();t2.start();}
}
运行结果:
分析:理论上当输入2之后,线程1应该结束,实际上通过jconsole发现并没有结束。这是因为循环条件flag=0,编译器每次从内存中读数据发现都为真,因此编译器自动帮助我们优化了,编译器对于代码优化产生了误判。
内存可见性:就是多线程的环境下,编译器对于代码优化产生了误判,从而引起了bug,导致代码bug。
编译器优化:智能的调整代码执行逻辑,保证程序结果不变的前提下,通过加减语句、语句变换、一系列操作,让整个程序的执行效率大大提升。编译器对于“程序结果不变”单线程下判定是非常准确的,但是多线程就不一定了。
加了sleep循环执行速度就非常慢,当循环的次数下降了,此时load操作就不再是负担,编译器就不需要优化了。
Thread t1 = new Thread(() -> {while (falg == 0) {try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}}});
3.2 volatile——编译器暂停优化
volatile关键字:加上volatile关键字之后,编译器就能够保证每次都是重新从内存读取flag变量的值。
volatile public static int falg = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);falg = scanner.nextInt();});t1.start();t2.start();}
运行结果:
分析:此时t2修改falg,t1就可以立即感知到了,t1就可以正确退出循环。
volatile适用的场景:一个线程读,一个线程写
sychronized适用的场景:多个线程写
volatile的效果,称为“保证内存可见性”。
4. 指令重排序引起的线程不安全——volatile
指令重排序也是编译器优化的策略,调整了代码执行的顺序,让程序更高效。
谈到优化,都得保证优化之后的结果和之前是不变的,在单线程的情况下容易保证,但是在多线程的情况下就不好说了。
class Student {}
public class ThreadDemo2 {public static Student s;public static void main(String[] args) {Thread t1 = new Thread(() -> {s = new Student();});Thread t2 = new Thread(() -> {if (s != null) {System.out.println("111");}});t1.start();t2.start();}
}
分析:s = new Student()
大体可以分为3步:①申请内存空间②调用构造方法,初始化内存的数据③把对象的引用赋值给s。②和③编译器优化可对其进行调换顺序,这样上述代码就可能会因为指令重排序出现问题。
解决办法:使用volatile关键字,volatile关键字的作用主要有如下2个:
- 保证内存可见性:当一个线程修改一个共享变量的时候,另一个线程能读到这个修改的值。
- 保证有序性:禁止指令重排序,编译时jvm编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。
注意:volatile不能保证原子性。
5. 等待通知——wait和notify关键字(锁中使用)
由于线程的调度是无序的、随机的,但是在一定的需求场景下,希望线程有序执行。
- join,算是一种控制顺序的方式,但是功效有限
- wait,就是让某个线程先暂停下来等一等;发现条件不满足/时机不成熟,就先阻塞等待。
- notify,就是把该线程唤醒,能够继续执行;其他线程构造了一个成熟的条件,就可以唤醒wait的线程。
- 分析代码的报错信息
public static void main(String[] args) throws InterruptedException {Object o = new Object();System.out.println("wait之前");o.wait();System.out.println("wait之后");}
运行结果:
分析:IllegalMonitorStateException 非法锁状态异常,锁还没获取到,就尝试解锁,就会产生上述异常。【此时wait需要解锁,但是都没加上锁】
wait主要做3件事:①解锁 ②阻塞等待 ③当收到通知的时候唤醒,同时尝试重新获取锁。
因为wait必须写到synchronized代码块里面,这样才能尝试①解锁。
同理notify也是要放到synchronized中使用的。
先有wait再有notify,否则就相当于一炮打空了。
使用wait,阻塞等待会让线程进入WAITING状态。
synchronized (o) {o.wait();}
注意:加锁的对象必须和wait的对象是同一个,这样wait操作就是在针对当前对象进行解锁。
- wait和notify的例子
public static void main(String[] args) throws InterruptedException {Object object = new Object();//waitThread t1 = new Thread(() -> {System.out.println("wait之前");synchronized (object) {try {object.wait();} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("wait之后");});t1.start();Thread.sleep(1000);//notifyThread t2 = new Thread(() -> {System.out.println("notify之前");synchronized (object) {object.notify();}System.out.println("notify之后");});t2.start();}
运行结果:
分析:
t1先执行,执行到了wait,就阻塞了
1秒之后,t2开始执行,执行到了notify,就会通知t1线程唤醒。
注意:notify是再synchronized内部,就需要t2释放锁之后t1才能继续往下走,因为t1要重新获取锁,所有要t2释放锁。
join和wait、notify的区别
join只能让t2线程先执行完,再继续执行t1,一定是串行的。
wait notify可以让t2执行完一部分,再让t1执行,t1执行一部分,再让t2执行,非常灵活。
- notifyAll
可以有多个线程,等待同一个对象。比如t1 t2 t3中都调用了object.wait,
此时在main中调用object.notify,会随机唤醒上述的1个线程,另外2个仍然是waiting状态。
如果调用object.notifyAll,此时就会把上述3个线程都唤醒,此时这3个线程就会重新竞争锁,然后依次执行。
6. wait和sleep的对比(面试题)
wait有一个带参数的版本,用来体现超时时间,这个时候感觉和sleep差不多。wait和sleep都能提前唤醒。
最大的区别:初心不同,设计这个东西解决的问题不同。
wait解决线程之间的顺序控制。
sleep解决让当前线程休眠一会。
使用上也有明显的区别:wait要搭配锁使用,sleep不需要。
再进一步,只是java这里的sleep和wait用法看起来比较像,其他语言的sleep和wait差别很大。