目录
在多线程编程中,如果仍然使用常规的单线程开发手段来进行开发,会产生很多的线程不安全问题,本章会介绍一些经典的线程不安全案例和解决方法
线程不安全的原因
- 抢占式执行
- 多个线程修改同一个变量
- 修改操作不是原子的
- 内存可见性
- 指令重排序
在下面这段代码中:
class Counter{
private int count = 0;
public void add(){
count++;
}
public int getCount(){
return count;
}
}
public class work2 {
public static void main(String[] args)throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
创建了两个线程,这两个线程分别对一个count变量进行自增50000次,
理论上来说,最后count的结果应该为100000,但实际输出结果却每一次都不同
这就是一个非常典型的线程不安全问题。
因为count++这个指令,在cpu上执行时,是分为三步的:
- load操作,把内存中的数据读取到cpu寄存器中。
- add操作,把寄存器中的值+1。
- save操作,把寄存器中的值写回内存中。
而在多线程实际执行代码时,cpu对于这些每一条指令的执行,都是随机调度的,换言之,这两个count++操作的六条指令在cpu上会进行随机的排列组合,从而组合出很多种可能。 例如
六条指令经过随机打乱顺序后,
就很有可能出现:
- t1读取之后,t2读取自增写入完毕,内存的值从0变为了1,
- t1在进行自增写入,寄存器的值又变为了1。
- count自增两次,但结果却只自增了一次。
这就是一个线程不安全的典型案例
原因是:线程的无序调度(抢占式执行)
归根结底,count++的操作并非原子的,在java中,我们是有办法让这个操作变成原子的。只要这三条指令执行时一起执行,那么就可以保证线程的安全了。
加锁:synchronized ()
为了解决上述问题,我们可以对count++这些复合型指令进行加锁,加锁后,指令会具有原子性,也就可以解决线程不安全的问题。 举一个生动的案例:上厕所
三个人要上同一个厕所,为了防止厕所上一半,裤子还没脱就被揪出来,所以每一个人进入厕所后,会把厕所给锁上,我们把这个过程叫做加锁,加锁后,滑小稽在厕所里就可以进行脱裤子-蹲下-窜-冲水等操作而不必担心被抢占。
但是在cpu上,线程是抢占式执行的,也就是说,他们三个上厕所,并不是一个先来后到的顺序,而是需要抢的,三个人在上厕所时,谁先抢先一步进入到厕所里,谁就拿到了锁,这就是cpu的抢占式执行
语法
synchronized (锁对象){
}
这里的锁对象指的是针对哪个对象加锁,锁对象可以任意指定,通常有三种写法
- 写做this
- 类名.class
- 单独创建一个Object locker = new Object();锁对象
也可以使用其他对象,但需要注意
- 锁对象必须是引用类型(对象)。
- 锁对象应该是所有线程共享的变量。
- 对于同一个锁对象,同一时刻只有一个线程可以获取到锁,其他线程需要等待。
通过锁,就可以解决上面的count++的不安全问题。
class Counter{
private int count = 0;
public void add(){
synchronized (this){
count++;
}
}
public int getCount(){
return count;
}
}
public class work2 {
public static void main(String[] args)throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
修改过后,count++这条复合型指令被赋予了原子性每一次打印的结果都为10000
内存可见性导致线程不安全
看这样一段代码
public static int flg = 0;
public static void main(String[] args) throws InterruptedException{
Thread t1 = new Thread(()->{
while (flg == 0){
;
}
});
Thread t2 = new Thread(()->{
Scanner sc = new Scanner(System.in);
System.out.println("请输入一个值");
flg = sc.nextInt();
});
t1.start();
t2.start();
}
在线程t1中写入了一个死循环,一直反复判断flg是否为0
在线程t2中可以给flg赋值,如果在t2中给flg赋值以后,t1理论上来说会停止循环,但实际代码执行效果却是:输入值后却依旧没有结束循环。
这就是内存可见性的问题导致了线程不安全。
在flg == 0 这个操作中,也涉及到多条cpu指令
- 1.load 从内存读取到数据到寄存器
- 2.cmp 比较寄存器的值
在cpu中,寄存器的操作是比内存的操作快3-4个数量级的,所以操作2的效率会比1快很多很多倍,换而言之,在这两条cpu指令中,1的操作占了绝大部分资源。
而此时为了处理这种极度不协调的情况
寄存器做了一个相当大胆的决定:把load给优化掉了(大胆!),编译器会将load只执行一次,而在后续的操作中,只执行cmp,相当于复用之前load过的值。
这样的话,即使t2中改了flg的值,但在t1中也不会去读取了。所以循环无法结束
但这也其实不能完全怪编译器,编译器优化是一个常普遍的事情,它的存在使得大部分代码执行效率大大提高,但也保不齐会出现一些问题。 为了解决这个问题,我们使用volatile关键字来禁止编译器优化,保证每次都从内存中读取数据。
volatile
为了解决这个问题,我们使用volatile关键字来禁止编译器优化,保证每次都从内存中读取数据。
修改代码如下
volatile public static int flg = 0;
public static void main(String[] args) throws InterruptedException{
Thread t1 = new Thread(()->{
while (flg == 0){
;
}
});
Thread t2 = new Thread(()->{
Scanner sc = new Scanner(System.in);
System.out.println("请输入一个值");
flg = sc.nextInt();
});
t1.start();
t2.start();
}
我们给flg变量加入volatile关键字,这样编译器在处理比较flg时就不会自动优化了。
指令重排序
看下面这两段代码
此时t2和t1同时运行,t1可以在cpu上分为三个指令
t1中的指令1、2、3是可以进行重排序的,如果在单线程里,123的执行顺序怎样,最终结果都是一样的。
而由于指令在CPU中是随机调度的。 现在假设t1按照1-3-2的顺序开始执行,当t1执行完1-3之后 t2开始执行4
t1执行完3后,s中已经存在了地址。但是由于没有调用构造方法,此时s中是没有任何东西的,但是t2已经开始执行s.learn这个方法,就会导致不可预估的后果。
这个不安全案例很难用代码来演示,因为cpu的随机调度,出错的概率也是不定的。
wait()和notify()
为了更好的理解这两个方法,以进银行取钱来举例
有四个小滑稽来银行取钱,通过锁竞争以后,假设1号拿到了锁进入到了ATM所在的房间中,想要取钱。
但此时很尴尬的是:ATM机中没钱,1号进入之后,无法进行取钱的操作,只能释放锁并且出来
此时会开始下一轮锁竞争,参与竞争的对象仍然是:1、2、3、4号,虽然1号第一次进去没有拿到,但释放锁后,本着人人平等的原则。1号在第二轮竞争中又幸运的拿到了锁。
1号再次进去ATM房间中,但此时还是没有钱可以取,于是他又只能出来。
假设1号身强力壮,谁都抢不过他,那就会出现一个很尴尬的事情:1号一直进进出出,却始终没有完成一个有效操作。
此时2、3、4号就会一直处于等待的状态。
我们把这个状态叫做:线程饿死
那么这个问题怎么解呢,很简单
当1号小滑稽第一次发现没有钱可以取的时候,从房间中出来,就让他一边呆着去。不参与下一次的竞争。直到运钞车来了,在让4号把1号叫过来参与竞争
此时,我们将让1号一边呆着的方法称为:wait()方法
让1号回来重新参与竞争的方法称为:notify()方法
wait()和notify()方法是需要配合使用的
注意:这两个方法是Object里的方法,所有类默认继承。
现在我们示范一下它的用法
public static void main(String[] args) throws InterruptedException{
Object object = new Object();
System.out.println("wait之前");
object.wait();
System.out.println("wait之后");
}
}
当我们运行的时候,结果却是这样的
哦豁,这是怎么一回事!
别急,先让我们看一下错误报告:IllegalMonitorStateException 非法的锁异常
结果显而易见了,这里报错的原因是:
wait没有获取到锁! 所以可以引申出wait方法执行时要做的三件事
- 1.解锁(从ATM房间里出来)
- 2.阻塞等待(一边呆着)
- 3.当收到通知时就唤醒,并且尝试重新获取锁(当被叫醒就重新参与竞争)
很显然,在这段代码中我们没有给它加锁,它自然就无锁可解,所以wait的正确用法是:加到synchronized代码块内。
public static void main(String[] args) throws InterruptedException{
Object object = new Object();
System.out.println("wait之前");
synchronized (object){
object.wait();
}
System.out.println("wait之后");
}
注意:这里synchronized ()括号中的锁对象要和wait的锁对象一致。且使用wait()方法一定要处理InterruptedException这个异常,这个异常的作用就是唤醒被wait方法阻塞的对象。
这个例子告诉我们,不要等事情还没有发生的时候就去想着结果了!就像你不要看见一个好看的妹子就把你们的孩子名字想好了一样
notify()方法的使用
既然使用了wait方法,那自然也需要在必要的时候使用notify()方法唤醒线程。 notify的使用方法和wait基本一致。
public static void main(String[] args) throws InterruptedException{
Object locker = new Object();
Thread t1 = new Thread(()->{
System.out.println("wait开始");
try {
synchronized (locker){
locker.wait();
}
System.out.println("wait结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(()->{
synchronized (locker){
System.out.println("notify 开始");
locker.notify();
System.out.println("notify 结束");
}
});
t1.start();
Thread.sleep(1000);//这里为了先让t1线程执行
t2.start();
}
}
在这段代码中,t1线程创建之后执行wait方法,此时t1方法输出wait开始后阻塞等待,随后t2线程输出notify开始,然后开始执行notify,结束后输出notify结束,随后唤醒t1,输出wait结束。
这样,就做到了虽然t1先执行,但t1执行开始之后可以先让t2执行一些顺序,然后在回来执行t1。 notify()还有一个notifyAll()方法,是用于唤醒所有线程的。但实际开发中用处不大,不做过多描述
wait和sleep的区别
最大的区别在于初心不同,wait解决的是线程之间的顺序控制,sleep单纯是让线程休眠一会。且wait要搭配锁和notify使用,sleep不需要。