认识线程安全和解决线程安全

简介: 认识线程安全和解决线程安全

目录

                                   🎉 1.线程安全引发的原因

                                   🚀1.多线程在调度的时候是随机的,抢占式执行的

      🚀2.多个线程修改同一个变量

                                  🚀3.修改时不是原子的

                                 🚀4.内存可见性

      🚀5.指令重排序

                                 🎉2.线程安全问题的解决办法

        🚀1.加锁  synchronized

     🚀2.加关键字  volitale

 

    1.线程安全引发的原因


1.多线程的抢占式执行


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


3.修改操作不是原子的


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


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


1和2没有办法去改变,我们就从第三个开始看


下面我们就来具体说一说每一个原因


1.多线程的抢占执行


这个是线程安全原因的万恶之源,最根本的原因


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


多个线程是共用同一个内存资源,变量在内存上,所以多个线程修改同一个变量,到底谁修改,修改结果是啥,都是不确定的,所以这个是不安全的


3.修改操作不是原子的


接着上期的继续看,这个操作我们用一段代码来看


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

3e91f95a388141999e8c52d5c23fe32c.png


看这个执行结果


发现这个结果没有按照预期打印10000,这是什么原因呢?其实就是因为修改操作不是原子的


count++这个操作,其实是由三条cpu指令构成的,它们分别是load,add,save,针对这个代码案例我们来画一下它的执行过程


f14ade8a8bd34611b138537ca862dfe3.png



26d8704a67f942318a26c81460655945.png

执行情况还有很多,这里就不一一列举了,来看看在内存和cpu寄存器上是咋执行的,还得画个图


c8bcdaee8f6347c1877b22371666d012.png

这图就表示了修改操作不是原子的

那么我们可以通加锁操作保证原子性

就在这个count++操作这里进行加锁操作

来认识一下加锁:synchronized

将它放在代码中


class Counter {
    private Object locker =new Object();
    private int count = 0;
    public void add() {
        synchronized (locker){
            count++;
        }
    }
    public int get(){
        return count;
    }
}
    public class ThreadDemo16 {
        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.get());
        }
    }


看运行结果

    锁可以保证操作的原子性

锁有两个核心操作,一个是加锁,一个是解锁

当进入synchronized代码块的时候,就触发加锁,出synchronized代码块进行解锁操作

一旦对一个线程进行加锁操作,其他线程就要阻塞等待,一直要等到该线程释放锁其他线程才可以拿到锁


eff1fba7b78644c48a2b2420be525ba8.png


4cf1972c2bb34cadab9e31c3a7aa7833.png

现在重点来说一下锁括号里面的东西


1.括号里面如果写this,那么就代表对counter这个对象加锁,那么因为是对一个同对象加锁的,所以代码执行效果是正确的


2.如果在Counter类里面单独new一个Object类的对象,注意!!!,是new一个,那么放到锁对象的括号里,


也代表对同一个对象locker加锁,因为还是对同一个对象,那么加锁还是有效的,代码依然执行正确


其实synchronized()的这个括号里面写啥对象都行,只要是Object类的对象都行,不可以是内置类型


这括号主要就是为为了告诉大家,多个线程针对同一个对象加锁就会出现锁竞争,如果针对不同的对象加锁,就不会出现锁竞争了,再也没有别的作用


加锁以后,操作就变成原子的了,那么我们再来画一下这个图


cecf834e81c7492b8875efc252e92df1.png此时t1线程先加了锁,t2想要加锁,就必须等到t1释放锁,t2才能拿到锁,才能进行下面的操作


这个时候是t1线程执行完以后才是t2执行,所以相当于串行化执行


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


在我学习这部分的时候,我就产生了疑问,既然都是阻塞等待,那么synchronized和join有啥区别呢?


其实,他俩区别大了去了


join操作是完全让t1,t2线程串行执行,而synchronized操作是让部分操作串行执行,其他的还是并发的


比如这个代码中,t1和t2不仅有count++操作,还有创建变量,判断大小,调用ADD方法等操作,这些依然是并发执行的,就像A同学和B同学都要去教室,但是要去上厕所,并且那个厕所只有一个坑位,这样的例子是不是很好理解呢


任何事物都有两面性,加锁会阻塞等待,那么代码运行效率也就变慢了,比不加锁慢,比串行快,也要比不加锁算得准


de3de3647dcd4405a1f94f28bab546c3.png

这叫做给一个静态方法加锁,

这两种是一样的

那么谈到static修饰,我们就来具体说一说,static修饰的方法和对象


static修饰的方法叫做类方法,类方法就是我们所说的静态方法


Java文件首先以javac存在,通过编译,变成java.class文件到JVM虚拟机上跑 ,要运行的话得读到内存上,这个过程叫做类加载,类对象就是 记录该类的对象,属性,方法等信息就是说描述一个类


好滴,现在我们来说一说另一个线程不安全的场景


由于内存可见性,引起的线程不安全的问题


我们先来写一个错误的代码


public class ThreadDemo1 {
    public static   int flag=0;
    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while(flag==0){
            }
            System.out.println("线程结束");
        });
        t1.start();
        Thread t2=new Thread(()->{
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个数");
            flag=scanner.nextInt();
        });
        t2.start();
    }
}

按照我们想的,t2线程中如果输入一个不为0的数,那么会打印线程结束,我们来运行代码来看结果


 可以看到线程一直没有结束,因为什么呢,就是因为编译器的优化,编译器在多次从内存读取数据到CPU寄存器的过程中,因为一直是一个值,编译器就只在内存上读取第一次独到的数据,后期会在工作内存(CPU寄存器和缓存)上读取数据.所以这个代码中就算将flag改为1,也依旧读不到,所以一直是0所以线程一直不结束


在while(flag==0)这个操作里,有两部,一个是load,一个是compare,load的开销要远远大于compare操作,load是在内存上读取数据到CPU寄存器上,而compare操作是在寄存器上比较值的大小


访问CPU寄存器的速度要远大于访问内存,读几千次寄存器,相当于读一次内存,那么鉴于这个情况,编译器就做了优化,把load给优化掉,后续执行的只有cmp操作


总的来说编译器优化就是在代码执行逻辑改变条件下,执行结果不变,这一条对于单线程是成立的,但是对于多线程来说,大多数情况是不安全的


因此,我们要采取措施保证线程安全,那么就要用到关键字volatile


针对此代码,我们在变量flag前加一个volatile,就可以保证线程是安全的


public class ThreadDemo6 {
     volatile    public static   int flag=0;
    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while(flag==0){
            }
            System.out.println("线程结束");
        });
        t1.start();
        Thread t2=new Thread(()->{
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个数");
            flag=scanner.nextInt();
        });
        t2.start();
    }
}

87002661d83e49a0bfa0f53a3b3972e2.png


这样就保证了线程安全


volatile让编译器停止优化,保证每次都是从内存中读取flag的值


下一个问题


指令重排序


volatile还有一个可以禁止指令重排序的功能


什么是指令重排序呢?


也是编译器优化的手段


也是改变代码的执行逻辑,结果不变,但是在多线程中就会产生问题


举个例子


有一个牛对象


COW{


t1


cow=new Cow()


}


t2.


if(cow!=null){


cow.eataGrass();


}


这是一段伪代码


我们主要是为了感受一下指令重排序


在创建这个对象的时候,应该有三个步骤


1.申请内存空间


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


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


2和3的执行顺序可以调换,假设按照 1 3  2的顺序执行,俺们来分析一下


啥情况呢?


t1执行1和3,即将执行2的时候,t2开始执行,t2拿到的就不是一个空的对象,是一个非空的,他就去调用cow的方法,但是实际上,t1还没有初始化,调用方法,会产生bug,所以我们可以在cow对象前加关键字volatile,保证执行顺序


当然,要是不加volatile关键字,也可以采用加锁(synchronized)的方法


如果既不想加锁,也不想加volatile,就让代码按照123执行,也不会出问题


因为这个问题的设想比较极端,所以具体的例子就无法举出,就根据这个例子感受一下


总结:


volatile关键字的作用主要有如下两个:


保证内存可见性:基于屏障指令实现,即当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。

保证有序性:禁止指令重排序。编译时 JVM 编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。

注意:volatile不能保保证原子性

今天的讲解就到这里,我们下期再见!!!

53fb16f60aeb41108a666ed298c40d74.gif

相关文章
|
3月前
|
存储 监控 安全
一天十道Java面试题----第三天(对线程安全的理解------>线程池中阻塞队列的作用)
这篇文章是Java面试第三天的笔记,讨论了线程安全、Thread与Runnable的区别、守护线程、ThreadLocal原理及内存泄漏问题、并发并行串行的概念、并发三大特性、线程池的使用原因和解释、线程池处理流程,以及线程池中阻塞队列的作用和设计考虑。
|
3月前
|
监控 安全 Java
Java多线程调试技巧:如何定位和解决线程安全问题
Java多线程调试技巧:如何定位和解决线程安全问题
134 2
|
3月前
|
安全 算法 Java
【Java集合类面试二】、 Java中的容器,线程安全和线程不安全的分别有哪些?
这篇文章讨论了Java集合类的线程安全性,列举了线程不安全的集合类(如HashSet、ArrayList、HashMap)和线程安全的集合类(如Vector、Hashtable),同时介绍了Java 5之后提供的java.util.concurrent包中的高效并发集合类,如ConcurrentHashMap和CopyOnWriteArrayList。
【Java集合类面试二】、 Java中的容器,线程安全和线程不安全的分别有哪些?
|
2月前
|
安全 Java
LinkedBlockingQueue 是线程安全的,为什么会有两个线程都take()到同一个对象了?
LinkedBlockingQueue 是线程安全的,为什么会有两个线程都take()到同一个对象了?
44 0
|
3月前
|
安全 算法 Java
17 Java多线程(线程创建+线程状态+线程安全+死锁+线程池+Lock接口+线程安全集合)(下)
17 Java多线程(线程创建+线程状态+线程安全+死锁+线程池+Lock接口+线程安全集合)
77 6
|
3月前
|
存储 安全 Java
17 Java多线程(线程创建+线程状态+线程安全+死锁+线程池+Lock接口+线程安全集合)(中)
17 Java多线程(线程创建+线程状态+线程安全+死锁+线程池+Lock接口+线程安全集合)
86 5
|
3月前
|
存储 安全 Java
17 Java多线程(线程创建+线程状态+线程安全+死锁+线程池+Lock接口+线程安全集合)(上)
17 Java多线程(线程创建+线程状态+线程安全+死锁+线程池+Lock接口+线程安全集合)
84 3
|
3月前
|
存储 安全 Java
【多线程面试题十七】、如果不使用synchronized和Lock,如何保证线程安全?
这篇文章探讨了在不使用`synchronized`和`Lock`的情况下保证线程安全的方法,包括使用`volatile`关键字、原子变量、线程本地存储(`ThreadLocal`)以及设计不可变对象。
|
4月前
|
安全 Java
多线程线程安全问题之避免ThreadLocal的内存泄漏,如何解决
多线程线程安全问题之避免ThreadLocal的内存泄漏,如何解决
|
4月前
|
存储 安全 Java
多线程线程安全问题之ThreadLocal是什么,它通常用于什么场景
多线程线程安全问题之ThreadLocal是什么,它通常用于什么场景