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

前言

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

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

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

有三个原因:

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

原文地址:https://www.cnblogs.com/xusp/p/12046052.html