• 并发与高并发(九)-线程安全性-可见性


    前言

    乍看可见性,不明白它的意思。联想到线程,意思就是一个线程对主内存的修改及时的被另一个线程观察到,即为可见性

    那么既然有可见性,会不会存在不可见性呢?

    答案是肯定的,导致线程不可见的原因是什么呢?

    有三个原因:

    (1)线程交叉执行。

    (2)重排序结合线程交叉执行。

    (3)共享变量更新后的值没有在工作内存与主存间及时更新。

    主体内容

    一、这里的可见性涉及到synchronized,顺便了解一些一下JMM对synchronized的两条规定:

      1.线程解锁前,必须把共享变量的最新值刷新到主内存中

      2.线程加锁时,将清空工作内存中存储的共享变量的值,从而使用共享变量时,必须从主内存中重新读取最新的值。(注意:解锁和加锁,是指同一把锁)

    二、同时涉及到volatile。

      1.volatile通过内存屏障和禁止重排序优化来实现内存可见性。

      (1)对volatile变量进行的操作时,会在写操作后加入一条store屏障指令,将本地内存中的共享变量值刷新到主内存。

      (2)对volatile变量进行的操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量。

            临时了解一下这几个内存屏障的作用(如果还不理解:可以参照https://blog.csdn.net/onroad0612/article/details/81382032详细讲解volatile内存屏障)

    屏障名称作用
    写屏障(store barrier) 所有在storestore内存屏障之前的所有执行,都要在该内存屏障之前执行,并发送缓存失效的信号
    所有在storestore barrier指令之后的store指令,都必须在storestore barrier屏障之前的指令执行完后再被执行
    读屏障(load barrier) 所有在loadbarrier读屏障之后的load指令,都在loadbarrier屏障之后执行
    全屏障(Full Barrier) 所有在storeload barrier之前的store/load指令,都在该屏障之前被执行
    所有在该屏障之后的的store/load指令,都在该屏障之后被执行

      这样就能保证线程读写的都是最新的值。

      此处有个小疑问,重排序是什么意思呢?举个栗子:

    int a=2;
    int b=1;

      从顺序上看a应该先执行,而b会后执行,但实际上却不一定是,因为cpu执行程序的时候,为了提高运算效率,所有的指令都是并发的乱序执行,如果a和b两个变量之间没有任何依赖关系,那么有可能是b先执行,而a后执行,因为不存在依赖关系,所以谁先谁后并不影响程序最终的结果。这就是所谓的指令重排序

      然后,我们简单的通过两张图分别看一下读写操作时的过程。

                         

          volatile写插入内存屏障示意图

      

           volatile读插入内存屏障示意图

    2.那么猜想一下,如果我们用volatile修饰之前我们计数器的变量,会不会得到线程安全的结果呢?

    package com.controller.volatile_1;
    import java.util.concurrent.CountDownLatch;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.Semaphore;
    import com.annoations.NotThreadSafe;
    import lombok.extern.slf4j.Slf4j;
    
    @Slf4j
    @NotThreadSafe
    public class VolatileTest {
        //请求数
        public static int clientTotal=5000;
        //并发数
        public static int threadTotal=200;
        //计数值
        public static volatile int count=0;
        
        public static void main(String[] args) throws InterruptedException{
            //创建线程池
            ExecutorService executorService = Executors.newCachedThreadPool();
            //定义信号量(允许并发数)
            final Semaphore semaphore = new Semaphore(threadTotal);
            //定义计数器
            final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
            for(int i =0;i<clientTotal;i++){
                executorService.execute(()->{
                    try {
                        //.acquire方法用于判断是否内部程序达到允许的并发量,未达到才能继续执行
                        semaphore.acquire();
                        add();
                        //.release相当于关闭信号量
                        semaphore.release();
                    } catch (Exception e) {
                        log.error("exception",e);
                    }
                    countDownLatch.countDown();
                });
            }
            //等待计数值为0,也就是所有的过程执行完,才会继续向下执行
            countDownLatch.await();
            //关闭线程池
            executorService.shutdown();
            log.info("count:{}",count);
        }
        
        private static void add(){
            count++;
        }
    }

    结果发现出现了:

    21:45:10.666 [main] INFO com.controller.volatile_1.VolatileTest - count:4914

    由此可见,即使给计数器加上volatile,也无法保证线程安全,上面的猜想错误!那么错误的原因是什么呢?

    答:其实在执行add()方法中的count++操作的时候执行了三步,哪三步呢?

    (1)取出内存里的count值,这时的count值是最新的,是没有问题的

    (2)进行+1操作

    (3)重新将count写回主存

    问题就出现了,当两个线程同时运行count++这个操作,如果两个线程同时给count进行+1操作,并同时写回主存,这一来,count本该算起来+2,最终结果却只+1。

    最终说明volatile这个关键字不具备原子性。

    3.如果说volatile不适合计数的这种场景,那么它会适用于什么场景呢?下面来正式谈一谈volatile的使用。

    通常来说,使用volatile必须具备两个条件:

    1、对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
    2、该变量没有包含在具有其他变量的不变式中。

    因此,volatile特别适合作为状态标记量。下面看一个例子:

        volatile boolean inited =false;
        
        //线程一
        context = loadContext();
        init = true;
        
        //线程二
        while(!inited){
            sleep();
        }
        doSomethingWithConfig(context);    

    解释:这里面有两个线程,线程二的执行必须保证初始化完成,线程一中的context = loadContext()表示初始化,init=true给其打一个初始化完成的标识,当init被打为true,一直观察的线程二立马就知道上面的初始化已经完成,然后走到下面这个doSomethingWithConfig(context)操作里来,这时候线程二使用已经初始好的context也不会出现问题了。

  • 相关阅读:
    Pascal's Triangle II
    Pascal's Triangle
    Best Time to Buy and Sell Stock II
    Best Time to Buy and Sell Stock
    Populating Next Right Pointers in Each Node
    path sum II
    Path Sum
    [转载]小波时频图
    [转载]小波时频图
    [转载]Hilbert变换及谱分析
  • 原文地址:https://www.cnblogs.com/xusp/p/12046052.html
Copyright © 2020-2023  润新知