线程安全与volatile关键字

文章讨论了线程安全和线程不安全的概念,通过代码示例解释了线程不安全可能导致的问题,如并发修改同一个变量时的bug。文章深入探讨了线程不安全的原因,包括非原子操作、内存可见性和指令重排序。解决方案主要涉及使用`synchronized`关键字确保原子性和内存可见性,以及`volatile`关键字的作用,防止指令重排序并确保内存可见性。
摘要由CSDN通过智能技术生成

线程都能带来哪些风险?想了解线程安全问题吗?请仔细阅读本篇文章,将详细带你学习什么是线程安全!!!


目录

一、什么是线程安全和线程不安全?

二、线程不安全的原因

三、如何解决线程不安全问题

四、内存可见性问题-volatile

4.1volatile详解

4.2“房子装修”


一、什么是线程安全和线程不安全?

简单来说,某个代码,在多线程环境下执行,会出bug,就叫做线程不安全。

线程安全:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

本质上是因为线程之间的调度顺序是不确定的,那么我来举例说明一下:

class Counter{
   private int count = 0;

   public void add(){
      count++;
  }

  public int get(){
       return count;
  }
}

class ThreadDemo{
    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.get());
    }
}

上述代码是俩个线程针对同一个变量,各自自增5w次,那么结果会是什么?

是10w吗?还是其他?下面来看结果:

此时会疑惑???为什么不是正常预期中的结果呢?这是因为由多线程引起的bug,线程不安全问题!!!

那么为什么会出现上述情况呢,和线程的随机调度密切相关!!!

count++操作,本质上是三个cpu指令构成

1.load,把内存中的数据读取到cpu寄存器中

2.add,把寄存器中的值,进行+1运算

3.save,把寄存器的值写回到内存中

初始状态:

 由于多线程调度顺序是不确定的,实际执行过程中,这俩线程的++操作实际的指令排列顺序就有很多种可能!!!如下图三种:

 

 那么不同排列顺序下,执行结果,可能是截然不同的!!!

那么上面代码的线程执行顺序可能是:

 

 此时发现,俩个线程自增俩次,结果是1,说明bug出现了,其中一次的自增的结果,被另一次给覆盖了!!!

由于线程的调度顺序是随机的,无序的,出现bug之后,得到的结果一定是<=10w

那么如果要是正确的结果,俩个线程,各自自增,此时是没bug 的,那么执行顺序应该是:

 

 t1和t2是俩个线程,可能是运行在不同的cpu核心上,也可能是运行在同一个cpu核心上(但是是并发的)

那么归根到底,线程安全问题,全是因为线程的无序调度,导致了执行顺序不确定,结果就不一致了!!!

二、线程不安全的原因

  1. 抢占式执行
  2. 多个线程修改同一个变量
  3. 修改操作,不是原子的(不可分割的最小单位,成为原子,像上述的++操作,不是原子的,里面可以拆分为三个操作,load,add,save,某个操作,对应单个cpu指令,就是原子的)
  4. 内存可见性(可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到)
  5. 指令重排序

三、如何解决线程不安全问题

需要从原因入手,能否让count++变成原子的呢?

那么加锁可以有效的使count++变成原子操作,锁,就能够起到保证“原子性”的效果。

一旦某个线程加锁了之后,其他线程也想加锁,就不能之间加上,就需要一直等到拿到锁的线程释放锁了为止,那么java中如何加锁?

使用synchronized关键字,直接使用这个关键字来实现加锁效果,锁有俩个核心操作,加锁和解锁:使用代码块来表示

 进入synchronized修饰的代码块的时候,就会触发加锁,出了synchronized代码块,就会触发解锁。

其中的this表示锁对象,表示对哪个对象加锁,如果俩个线程针对同一个对象加锁,此时就会出现“锁竞争”(一个线程先拿到锁,另一个线程阻塞等待),如果俩个线程针对不同线程加锁,就不会出现锁竞争,各自获取各自的锁

上述代码中,这里线程是在竞争同一个锁对象,就会产生锁竞争,此时就可以保证++操作是原子的,不受影响了 

 由于t1已经lock了,t2再尝试进行lock,就会出现阻塞等待的情况,此时就可以保证t2的load一定在t1的save之后,此时计算的结果就是线程安全的,加锁本质上是把并发的变成了串行的。

上述代码,一个线程的工作应该是:

1.创建i

2.判断i<50000

3.调用add

4.count++

5.add返回

6.i++

下面是加锁之后的代码:

class Counter{
   private int count = 0;

   public void add(){
       synchronized (this) {
           count++;
       }
   }

  public int get(){
       return count;
  }
}

class ThreadDemo{
    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.get());
    }
}

加锁也可以直接给方法加锁,用synchronized修饰,如果synchronized修饰静态方法,就是给类对象加锁

 还可以指定一个锁对象进行加锁:

四、内存可见性问题-volatile

先写个bug出来:
 

public class ThreadDemo1 {
     public static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {
                // 空着
//                try {
//                    Thread.sleep(10);
//                } catch (InterruptedException e) {
//                    e.printStackTrace();
//                }
            }
            System.out.println("循环结束! t1 结束!");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            flag = scanner.nextInt();
        });

        t1.start();
        t2.start();
    }
}

预期效果:t1通过flag ==0 作为条件进行循环,初始情况,将进入循环;t2通过控制台输入了一个整数,一旦用户输入了非0的值,此时t1的循环就会立即结束,从而t1线程退出!!

实际效果:输入非0的值之后,t1线程并没有退出,循环没有结束,仍然在运行!!

实际效果 != 预期效果

那么为什么会有这个问题?内存可见性的锅,

while循环,flag==0,load从内存读取数据到cpu寄存器,cmp比较寄存器里的值是否是0。

此处的load时间开销远远高于cmp!!!

于是编译器就发现:

1.load的开销很大

2.每次load的结果都一样

此时编译器就做了一个非常大胆的操作,把load就给优化掉了,只有第一次执行了load,后续都只cmp,不load!!!---这是编译器优化的手段,此时结果就发生了变化,出现了误判!!!

编译器优化,就是能够智能的调整你的代码执行逻辑,保证程序结果不变的前提下,通过加减语句,通过一系列操作,让整个程序执行的效率大大提升

那么我们为了解决这个问题,加上volatile关键字之后,此时编译器不再优化,此时编译器就能够保证每次都从内存中读取flag变量的值

 4.1volatile详解

volatile不保证原子性,volatile适合的场景是一个线程读,一个线程写的情况,synchronized则是多个线程写。

上面讲述了什么叫保证内存可见性,那么volatile还有另外一个效果,就是禁止指令重排序,所谓指令重排序,也是编译器优化的策略,调整了代码的执行的顺序,让程序更高效,前提是保证整体逻辑不会变。

举个例子:

假如小明去超市买菜,按照出口->入口的方式去买

那么如果调整顺序,先买入口的再买出口的,执行效果不变,效率提高了。那么使用volatile就可以禁止指令重排序,大部分情况下对于指令重排序都是正确的,但是加上volatile也什么影响哦!

4.2“房子装修”

如果是这样一个代码:

 大体可以分成三部操作:

1.申请内存空间

2.调用构造方法(初始化内存的数据)

3.把对象的引用赋值给s(内存地址的赋值)

这个操作就像是买房子:

 先装修了,再拿到钥匙,就叫做“精装修房”;先拿到钥匙,再装修,房子就叫做“毛坯房”

假设俩个线程开始执行,t1按照1 3 2的顺序执行,最后装修,当t1执行完1 3 之后,即将执行2的时候,t2开始执行,由于t1的3已经执行过了,这个引用就非空了!!!

t2线程就尝试调用s.learn(),由于还是个毛坯房,没有初始化过,此时learn就可能会出现bug!!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值