[03]从零开始的JAVAEE-线程安全

目录

线程不安全的原因

加锁:synchronized () 

语法

内存可见性导致线程不安全

volatile

指令重排序

wait()和notify()

notify()方法的使用

wait和sleep的区别 


在多线程编程中,如果仍然使用常规的单线程开发手段来进行开发,会产生很多的线程不安全问题,本章会介绍一些经典的线程不安全案例和解决方法

线程不安全的原因

  • 抢占式执行
  • 多个线程修改同一个变量
  • 修改操作不是原子的
  • 内存可见性
  • 指令重排序

在下面这段代码中:

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,但实际输出结果却每一次都不同

061e7c417db667b42425331990e185ef.png

f91b5019b303b0fd12b005ea7a463e79.png

这就是一个非常典型的线程不安全问题。

因为count++这个指令,在cpu上执行时,是分为三步的:

  • load操作,把内存中的数据读取到cpu寄存器中。
  • add操作,把寄存器中的值+1。
  • save操作,把寄存器中的值写回内存中。

而在多线程实际执行代码时,cpu对于这些每一条指令的执行,都是随机调度的,换言之,这两个count++操作的六条指令在cpu上会进行随机的排列组合,从而组合出很多种可能。 例如

e6ef387a2a8644328880195a7f9adec3.png

六条指令经过随机打乱顺序后,

就很有可能出现:

  1. t1读取之后,t2读取自增写入完毕,内存的值从0变为了1,
  2. t1在进行自增写入,寄存器的值又变为了1。
  3. count自增两次,但结果却只自增了一次。

这就是一个线程不安全的典型案例

原因是:线程的无序调度(抢占式执行)

归根结底,count++的操作并非原子的,在java中,我们是有办法让这个操作变成原子的。只要这三条指令执行时一起执行,那么就可以保证线程的安全了

加锁:synchronized () 

为了解决上述问题,我们可以对count++这些复合型指令进行加锁,加锁后,指令会具有原子性,也就可以解决线程不安全的问题。 举一个生动的案例:上厕所

10d72521ebe4ffcffcf64325c7a8398a.png

三个人要上同一个厕所,为了防止厕所上一半,裤子还没脱就被揪出来,所以每一个人进入厕所后,会把厕所给锁上,我们把这个过程叫做加锁,加锁后,滑小稽在厕所里就可以进行脱裤子-蹲下-窜-冲水等操作而不必担心被抢占。

但是在cpu上,线程是抢占式执行的,也就是说,他们三个上厕所,并不是一个先来后到的顺序,而是需要抢的,三个人在上厕所时,谁先抢先一步进入到厕所里,谁就拿到了锁,这就是cpu的抢占式执行

语法

synchronized (锁对象){
        
    }

这里的锁对象指的是针对哪个对象加锁,锁对象可以任意指定,通常有三种写法

  • 写做this
  • 类名.class
  • 单独创建一个Object locker = new Object();锁对象

也可以使用其他对象,但需要注意

  1. 锁对象必须是引用类型(对象)。
  2. 锁对象应该是所有线程共享的变量。
  3. 对于同一个锁对象,同一时刻只有一个线程可以获取到锁,其他线程需要等待。

通过锁,就可以解决上面的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 

caaa3b458d31ffc57ab8e7f0f3d0bdb3.png

内存可见性导致线程不安全

看这样一段代码

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理论上来说会停止循环,但实际代码执行效果却是:输入值后却依旧没有结束循环。

e944e8abda41e78a92b6a327c9cf4d99.png

这就是内存可见性的问题导致了线程不安全。

在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时就不会自动优化了。

指令重排序

看下面这两段代码

e7521006b192eaba20ea77449d8f6f0e.png

此时t2和t1同时运行,t1可以在cpu上分为三个指令

1c6bcd2abb4ebc2405fda8a5c2b34566.png

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()

为了更好的理解这两个方法,以进银行取钱来举例

d9e8c815b7865074b202bb5d029758bf.png

有四个小滑稽来银行取钱,通过锁竞争以后,假设1号拿到了锁进入到了ATM所在的房间中,想要取钱。 

cc825bfda704397b71eb6fe76221c245.png

但此时很尴尬的是:ATM机中没钱,1号进入之后,无法进行取钱的操作,只能释放锁并且出来

此时会开始下一轮锁竞争,参与竞争的对象仍然是:1、2、3、4号,虽然1号第一次进去没有拿到,但释放锁后,本着人人平等的原则。1号在第二轮竞争中又幸运的拿到了锁。

1号再次进去ATM房间中,但此时还是没有钱可以取,于是他又只能出来。

假设1号身强力壮,谁都抢不过他,那就会出现一个很尴尬的事情:1号一直进进出出,却始终没有完成一个有效操作。

此时2、3、4号就会一直处于等待的状态。

我们把这个状态叫做:线程饿死 

aa26ae59eb6a3d79da859cbcd902d5ac.png

那么这个问题怎么解呢,很简单

当1号小滑稽第一次发现没有钱可以取的时候,从房间中出来,就让他一边呆着去。不参与下一次的竞争。直到运钞车来了,在让4号把1号叫过来参与竞争 

5c21256a5f8a5f3b634c50d785153625.png

b3ef2265e86b4f4ebc5a6e99393f6301.png

此时,我们将让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之后");
    }
}

当我们运行的时候,结果却是这样的

e4ae962cb788dc2c504a9b49f7a80596.png

哦豁,这是怎么一回事!

别急,先让我们看一下错误报告:IllegalMonitorStateException 非法的锁异常

结果显而易见了,这里报错的原因是:

wait没有获取到锁! 所以可以引申出wait方法执行时要做的三件事

  1. 1.解锁(从ATM房间里出来)
  2. 2.阻塞等待(一边呆着)
  3. 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结束。

ea3bfa0cd5f709f268c572eb24818e37.png

这样,就做到了虽然t1先执行,但t1执行开始之后可以先让t2执行一些顺序,然后在回来执行t1。 notify()还有一个notifyAll()方法,是用于唤醒所有线程的。但实际开发中用处不大,不做过多描述 

wait和sleep的区别 

最大的区别在于初心不同,wait解决的是线程之间的顺序控制,sleep单纯是让线程休眠一会。且wait要搭配锁和notify使用,sleep不需要。

  • 5
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不卷啊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值