zoukankan      html  css  js  c++  java
  • 并发与高并发(九)-线程安全性-可见性

    前言

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

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

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

    有三个原因:

    (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也不会出现问题了。

  • 相关阅读:
    javaBean的理解
    配置tomcat8数据源(采用局部数据源方式)
    windows下apache报os 10048错误
    Windows下Apache的下载安装启动停止
    java通过数据库连接池链接oracle
    java连接oracle数据库
    eclipse配置svn方法
    JAVA多线程中start方法与run方法区别
    java程序在没有java环境的电脑上执行的方法(关键词jar,exe)
    js监听不到组合键
  • 原文地址:https://www.cnblogs.com/xusp/p/12046052.html
Copyright © 2011-2022 走看看