JAVAEE && 多线程--线程安全问题

目录

1 多线程带来的风险-- 线程安全

1.1 线程不安全问题

1.2 线程不安全的原因

  1.2.1 多个线程修改同一个变量

   1.2.2 不是原子性的

    1.2.3 内存可见性,引起的线程不安全

   1.2.4 指令重排序,引起的线程不安全

1.3 解决线程安全问题的方法

    1.3.1 synchronized 锁

      synchronized的写法

  1.3.2  volatile关键字


1 多线程带来的风险-- 线程安全

1.1 线程不安全问题

class Counter{
    private int count = 0;
    public void add(){
            count++;
    }
    public int getCount(){
        return count;
    }
}
public 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.getCount());
    }
}

   设计一个代码,两个线程针对同一个变量,各自自增5w次,预期结果是10w次;

   运行程序后,会发现预期结果是10w,实际结果缺像个随机值一样,每一次结果还不一样。实际结果和预期结果不相符,就是bug。这就是由多线程引起的bug ,也就是线程安全问题。

   本质上是因为线程之间的调度顺序是不确定的

public void add(){count++; }

    代码中的 count++ 操作,本质上是三个cpu指令构成:

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

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

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

    t1 和 t2 分别代表两个线程,两个线程调用count.add方法,由于count++分为三个原子步骤(原子性代表一条语句或者是一段语句时为不可分割的整体),CPU的核心在执行的时候是按照原子步骤走的,所以线程调度的时候,CPU核心不会一次性将count++操作执行完。在多线程的环境下,调度顺序的不确定,两个线程的count++操作实际的指令排列顺序就有很多的可能性。下列是部分举例:

    拿上图第一种排列方式计算一下,初始情况下count  = 0;假设 t1 和 t2 分别运行在不同的cpu核心上 ,t1线程执行load,把数据0从内存中读取到 cpu1上,t2也执行load,把数据0也读到cpu2上,接着 t2 执行add自增操作,数据变为1 ,然后save保存写回到内存中,现在内存中的数据为1。然后 t1 执行add操作,自增为1同样save写回内存中,但是内存中的数据已经是1了,自增了两次但最终结果仍然是1,bug就出现了,其中一次自增的结果,被另一次给覆盖了。

    这就是多线程带来的风险--线程安全问题。

1.2 线程不安全的原因

   导致线程不安全的原因有:

   1 抢占式执行(最主要的,是罪魁祸首);

   2 多个线程修改同一变量;

   3 不是原子性的;

   4 内存可见性,引起的线程不安全;

   5 指令重排序,引起的线程不安全;

  1.2.1 多个线程修改同一个变量

   上述的线程不安全的代码中,涉及到多个线程针对count变量进行修改,此时count是一个多个线程都能访问到的“共享数据”。

   1.2.2 不是原子性的

    一条java语句不一定是原子的,也不一定只是一条指令。

    比如count++,其实就是由三步操作组成的:load  add  save.

    不保证原子性会给多线程带来什么问题。

     如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。

    1.2.3 内存可见性,引起的线程不安全

   可见性指,一个线程对共享变量值得修改,能够及时地被其他线程看到。

   首先要了解一下java的内存模型。

   java内存模型(JMM):java虚拟机规范中定义了java内存模型。目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的并发效果。

   

    1 线程之间的共享变量储存在主内存中。

    2 每一个线程都有自己的“工作内存”。

    3 当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存读取数据。

    4 当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存。

   由于每个线程有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的副本,此时修改线程1的工作内存中的值,线程2的工作内存不一定会及时变化。这个时候代码中就容易出现问题。

    这里解释一下两个问题:

   1 为什么设置这么多的工作内存?

       实际上并没有这么多“内存”,这只是JAVA规范中的一个术语,属于抽象叫法。所谓的主内存才是真正硬件角度的内存,而所谓的“工作内存”则是指CPU的寄存器和高速缓存。

     

   2 为什么会频繁拷贝?

       因为CPU访问自身寄存器的速度以及高速缓存的速度,远超访问内存的速度。比如需要连续读取一个变量的值,如果每次都从内存读,速度是比较慢的,但是如果只是第一次从内存读,读到的结果缓存到CPU的寄存器中,那么后面的读数据就不必直接访问内存了,效率就提高了。

   1.2.4 指令重排序,引起的线程不安全

   假设一段代码: 1 .去快递站取快递A    2 .返回家   3 .去快递站取快递B

   如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如 按 1- 3 - 2的方式执行,这样既能保证结果不变,效率也变高,这种就称为指令重排序。

1.3 解决线程安全问题的方法

    1.3.1 synchronized 锁

  前面代码中,如何解决线程安全问题,可以观察到问题在count++身上,三种操作产生了多种排列顺序,我们可以把count++操作整体变为原子的,这样就能保证 t1 线程在调用count++的时候能完整的执行完。

  使用synchronized 关键字对其进行加锁操作,保证“原子性”的效果。

  锁的核心操作有两个:

  1 加锁

  2 解锁

一旦某个线程加锁之后,其他线程也想加锁,就不能直接加上,就需要阻塞等待

一直等到拿到锁的线程释放锁为止。

 public void add(){
        synchronized (this){
            count++;
       }
class Counter{
    private int count = 0;
    public void add(){
        synchronized (this){
            count++;
       }

    }
    public int getCount(){
        return count;
    }
}
//线程不安全
public class ThreadDemo13 {

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

Synchronized(this) ,( )里面是锁对象,表示在针对哪个对象加锁,如果两个线程,针对同一个对象加锁,此时就会出现“锁竞争” 。如果两个线程,针对不同对象加锁,就不会存在锁竞争,各自获取各自的锁即可。

  上述代码中,两个线程是在竞争同一个锁对象,就会产生锁竞争 。假设 t1 率先调用到count++操作,成功加上了锁,这样能完整地执行整个count++操作,如果此时 t2 执行到了count++,也想对同一个对象中count变量进行自增操作,由于t1已经率先上锁了,就只能阻塞等待,等到 t1 自增完毕,t2才能进行自增。此时就可以保证t2的load一定在t1的save之后,此时计算的结果就是线程安全的了。

  这样代码执行完就能达到预期效果了。进入synchronized修饰的代码块,相当于加锁,退出synchronized修饰的代码块,相当于解锁

    

    synchronized的特性:

    互斥:synchronized会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象synchronized就会阻塞等待。可以理解为:上单间厕所问题,ABC三人,A先抢到厕所,并对厕所(就是对象)进行上锁,这时B和C也想上厕所此时只能阻塞等待,当A解锁出来,B和C两人就会重新竞争厕所。

    加锁,本质上是把并发的变成了串行。

   可重入:后续会再介绍。

      synchronized的写法

  1 直接修饰普通方法:锁的synchronizedDemo对象 ,如果直接给方法使用synchronized修饰,就相当于以 this 为锁对象。

public class SynchronizedDemo {
    public synchronized void methond() {
   }
}

  2 修饰静态方法 :锁的synchronizedDemo类的对象,如果synchronized修饰静态方法(static)此时就不是给 this 加锁了,而是给类对象加锁,当一个线程A调用实例对象的非静态 synchronized方法,而线程B调用实例对象所属类的静态 synchronized方法,是允许的,不会发生互斥对象。


public class SynchronizedDemo {
    public synchronized static void method() {
   }
}

   3 修饰代码块:明确指定锁哪个对象


public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            
       }
   }

  比较常见的还是使用第三种,可以手动指定一个锁对象。

  1.3.2  volatile关键字

  使用volatile修饰的变量,能够保证“内存可见性”。

public class ThreadDemo14 {
    public static int flag = 0;
     public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while (flag == 0){

            }
            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 线程并没有退出,仍然在执行中。导致该bug的原因就是由内存可见性引起的线程不安全。 

   上述代码中,while()循环flag == 0 ,这里会有两步操作,一个是load读取操作,一个是cmp操作(比较寄存器里的值是否是0),这里两个操作,load的时间开销远远高于cmp,读内存虽然比读硬盘来得快,但是读寄存器,比读内存更快。计算机一秒钟执行上亿次,编译器就会发现: 1 load的开销很大  2 每次load的结果都一样。此时编译器就做了一个大胆的操作,把load给优化掉了,只有第一次执行load才真正执行了,后续循环都是cmp,不进行load,相当于是复用之前寄存器中的load过的值。

    编译器优化的手段,是一个非常普遍的事情。

    所谓的内存可见性,就是多线程环境下,编译器对于代码优化,产生了误判,从而引起了bug,进一步导致了代码的bug。

    此处处理方式,就是让编译器针对这个场景暂停优化。

    Volatile 关键字

 被该词修饰的变量,此时编译器就会禁止上述优化

 能够保存每次都是从内存重新读取数据。

  上面介绍内存可见性时介绍了,直接访问工作内存(CPU寄存器或者CPU的缓存),速度非常快,但是可能出现数据不一致的情况。加上volatile,强制读写内存,速度是慢了,但是数据变得更准确了。

volatile public static int flag = 0;
public class ThreadDemo {
   volatile public static int flag = 0;
     public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while (flag == 0){

            }
            System.out.println("循环结束 t1 结束");
        });

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

 volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性。

 volatile还有一个效果,就是禁止指令重排序。指令重排序也是编译器优化的策略,调整了代码执行的顺序,让程序更加高效,前提得是保证整体逻辑不变。谈到优化,都得保证调整之后的结果和调整之前的结果是不变的,单线程下容易保证,如果是多线程,就不好说了。

                                              

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值