并发与高并发(九)-线程安全性-可见性
前言
乍看可见性,不明白它的意思。联想到线程,意思就是一个线程对主内存的修改及时的被另一个线程观察到,即为可见性。
那么既然有可见性,会不会存在不可见性呢?
答案是肯定的,导致线程不可见的原因是什么呢?
有三个原因:
(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也不会出现问题了。