线程安全问题

🎈一.观察多线程下++操作

首先我们抛出一个问题,我们利用两个线程对一个变量进行自增操作,两个线程各加5w,最后预期得到10w的结果.但是,我们能得到这样的结果吗?我们可以利用代码来验证一下.

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

我们再来看一看,运行多次会出现什么样的结果
运行
运行
运行
根据结果的显示,很显然没有达到我们想要的结果.同学们可以试想一下这是为什么呢?原因是多个线程对同一变量进行修改操作,这样就造成了线程安全问题.

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

🧨🧨🧨二.为什么会造成线程安全呢

2.1) 线程的执行是抢占式执行的,线程的调度充满随机性(罪魁祸首,而且还改变不了)

2.2)多个线程对同一个变量进行修改(这个可以通过调节代码,可以解决)

只是多个线程对同一个变量进行修改会造成线程安全
多个线程对同一个变量进行读操作不会造成线程安全
多个线程对同一个变量进行写操作不会造成线程安全
单个线程对变量的操作也不会造成线程安全

2.3)针对变量的操作不是原子的.

什么是原子呢? 在数据库中我们也提到了原子性.就是一组操作(一行或者多行代码)是不可以拆分的最小执行单位,这就表示这组操作具有原子性.

正如我们上面的那个count++的例子,在CPU执行的时候,它可能是被细分成了三个不步骤:

  1. 把内存的数据读到CPU中
  2. 然后进行更新数据
  3. 把更新之后的数据重新放入内存中.

运行
在这里插入图片描述
上面的这些操作,是不会造成线程安全的,但是大家都知道,CPU的调度是随机的,不可能每次都是这样,还会出现一些别的情况.
t1
如果CPU是这样调度的,执行完两个这两个线程,结果就得不到我们想要的结果.
执行
2.4)内存可见性,一个频繁读,一个频繁写

可见性:指的是一个线程对共享变量值的修改,能够及时的被其他线程看到.

首先我们来看一端代码:

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

                }
                System.out.println("t线程执行完毕");
            });
            t.start();

            Scanner sc = new Scanner(System.in);
            flag = sc.nextInt();
            System.out.println("main线程执行完毕");
        }
}

结果
按照我们的想法,这里应该当我们输入一个不为0的时候,t 线程就不进入循环直接走到下面执行完,但是结果却并非如此.一直都没有结束.
出现这样的原因是为什么呢?这是由于编译器优化造成的原因,由于访问CPU寄存器的速度远远大于访问内存,鉴于这个情况,编译器就做了一些优化,把数据读取优化掉了,因为每次读到的数据都是一样的,然后只执行比较操作.以至于我们改值之后,对应的 t 线程感知不到.

???为什么要保持内存可见性呢

保证每次获取的值都是内存中最新的值

2.5) 指令重排序
指令重排序也是编译器优化造成线程不安全
???重排序怎么理解呢

比如:我们要执行的操作是这样的
1.去前台拿u盘
2.去教室写作业
3.去前台取快递

这个如果在单线程的情况下,编译器会对其进行一些优化,执行顺序变为1->3->2执行,这是没有问题的,可以让我们少跑一趟,提高执行效率.
但是在多线程的情况下,代码执行复杂度更高,编译器如果再进行重排序的话,很容易导致逻辑和以前的逻辑不一样.

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

3.1 使用synchronized加锁
synchronized有互斥性,当某个线程执行到某个对象的synchronized中时,如果其他线程也执行到同一个对象synchronized就会阻塞等待.

进入synchronized修饰的代码块,就相当于加锁
出synchronized修饰的代码块,就相当于解锁

注意:如果多个线程尝试对同一个锁对象加锁,此时就会产生锁竞争,针对不同的锁对象加锁,就不会产生锁竞争.

我们可以把这个理解为上厕所(一个厕所),如果没人在厕所,就显示无人,那么就可以直接使用,如果显示有人,那么就要在外面等着,等着这个人出来,才可以进去.这就是等待着锁释放,才可以加锁

滑稽
但是如果有一排的厕所呢,就不会产生都阻塞在外面的情况
滑稽
加锁的语法格式:

  • 直接修饰普通方法
public class SynchronizedDemo{
   	public synchronized void method(){
   	//......
   	}
}
  • 修饰静态方法
public class SynchronizedDemo{
   	public synchronized static void method(){
   	//......
   	}
}
  • 修饰代码块
 //修饰当前对象
public class SynchronizedDemo{
  	public void method(){
  		synchronized(this){
  		
  		}
  	}
}
//修饰类对象
public class SynchronizedDemo{
  	public void method(){
  		synchronized(SynchronizedDemo.class){
  		
  		}
  	}
}

知道了加锁的写法之后,我们可以尝试把第一个例子拿出来修改修改,加锁让线程安全.

//这是对普通方法进行加锁
class Counter{
    int count=0;
    public synchronized void add(){
        count++;
    }
}
public class ThreadDemo9 {
    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.count);
    }
}

//锁当前对象
class Counter{
    int count=0;
    public void add(){
        synchronized (this){
            count++;
        }
    }
}
public class ThreadDemo9 {
    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.count);
    }
}


用了两种方法进行了加锁,得到的结果都是我们预期的结果,说明线程是安全的.
使用synchronized加锁操作,保证原子性,也能保证内存可见性

3.2 volatile关键字

volatile主要用于修饰变量的,它的作用是保证内存可见性,有序性.它是不能保证原子性的.

对于我们刚刚讲到的内存可见性的代码,我们进行改进

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

                }
                System.out.println("t线程执行完毕");
            });
            t.start();

            Scanner sc = new Scanner(System.in);
            flag = sc.nextInt();
            System.out.println("main线程执行完毕");
        }
}

图
当我们使用了volatile关键字对变量进行了修饰,此时该变量就会禁止编译器进行优化,能够保证每次都从内存中读取数据
volatile除了保证可见性,还能禁止指令重排序.

以上是我对线程安全的理解,如有错误望指出

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值