从一个案例彻底理解volatile关键字
前几天奈飞网站我看到这个片段给我整懵了,虽然英语不是拔尖的我,但是这.....不至于不至于 please let us know now, 很明显就是“请马上告诉我们”,其实《鱿鱼游戏》剧中英语翻译有些并不总是与角色的对话相匹配,不必在意的。
扯远了哈,写 volatile 的文章非常多,本人也看过许多相关文章,但是始终感觉有哪里不对劲,但是又说不上来为什么,可能是太过分追求实现原理,老想问一个为什么吧。但是,原理还是要说,代码还是要写。
而写这篇文章的目的很简单,就是觉得应该更多的从工作实践为出发点,这样才有意义些,记得牢。好东西当然要拿出来分享,于是就有了这篇文章。
一,Volatile怎么念?
看到这个单词一直不知道怎么发音,额........ 那 Java 中 volatile 有啥用呢?
二,它有啥用呢?
-
volatile是 JVM 提供的
轻量级
的同步机制(有三大特性)-
保证可见性
-
不保证原子性
-
禁止指令重排
-
好汉你别激动,同步机制说白了就是 syhconized,这种机制你可以这样理解就是在一段时间内有序的发生操作。假如一个线程对某个共享资源加锁后,其他想要获取共享资源的线程必须进行等待,很显然,这种同步机制效率很低的,怎么用就不多说了,但synchronized是其他并发容器实现的基础,对它的理解也会让你提升对并发编程的感觉,从功利的角度来说,这也是面试高频的考点。
CPU缓存这有啥用
最初的 CPU 是没有缓存的,CPU 它只负责去读写内存。这时候有人就发现不对劲啊?CPU的运行效率与读写内存的效率差距是上百倍量级以上的。总不能 CPU 执行1个写操作耗时1个时钟周期吧,我就等着你内存执行一百多个时钟周期吧,那CPU就是闲人。怎么可能,于是出现下面存储系统的结构所示: 所以中间加了缓存(平时见到的 cache 就是它),特点一句话总结:临时,高速,优化数据用的。它存储的话肯定也没寄存器快,这就像 Mysql 出现瓶颈时,我们会考虑通过缓存数据来提高性能是类似的道理。
现在主流CPU通常采用三层缓存:
-
一级缓存(L1 Cache):主要分数据缓存 和 指令缓存,它们两个是分开的哈。L1是距离CPU最近的,因此它会比L2, L3的读写速度都快,存储空间都小。好比我们大脑的短期记忆,而长期记忆就好比L2/L3 Cache。它是作为核心独享的,说白了一个核就有一个L1;
-
二级缓存(L2 Cache):二级缓存的指令和数据是共享的,二级缓存的容量直接影响CPU的性能,吓得我马上看了掏出看了下:
三级缓存(L3 Cache):作用是进一步降低内存的延迟,同时提升海量数据计算的性能。三级缓存属于核心共享的,因此只有1个。 经过上述细分,可以将上图进一步细化:
这里再补充一个概念:缓存行(Cache-line),它是CPU缓存存储数据的最小单位,后面会用到。上面的CPU缓存,CPU缓存你也可以叫它高速缓存。也就是说高速缓存里面有很多的缓存行。
引入缓存之后,每个CPU的处理过程为:先将计算所需数据缓存在高速缓存中,当CPU进行计算时,直接从高速缓存读取数据,计算完成再写入缓存中。当整个运算过程完成之后,再把缓存中的数据同步到主内存中。
如果是单核CPU这样处理没有什么问题。但在多核系统中,每个CPU都可能将同一份数据缓存到自己的高速缓存中,这就出现了缓存数据一致性问题了。
CPU层提供了两种解决方案:总线锁和缓存一致性。
总线锁
从上面小节得出,加入缓存是为了CPU于物理内存之间加快你CPU的处理速度。现在假设一台PC上只有一个CPU和一份内部缓存,那么所有进程和线程看到的数都是缓存的数,不会存在问题;但实际情况不会如此的。现在的服务器一般是多CPU,更普遍的是,每块CPU里有多个内核,每个内核维护了自己的缓存,那么这时候多线程并发导致缓存不一致性,这个就是我们下面要讲的。回来我们再看看总线锁,那你得先了解什么是CPU总线?
CPU总线你可以叫它前端总线,作为PC系统最快的总线,是给CPU用的,与CPU相关的总线都统称为CPU总线。它用在与高速缓存,主存和北桥之间传送信息。这大的概念个根据具体功能又分数据总线,地址总线,控制总线。它的位置在哪里呢?处于芯片组和CPU之间,负责CPU与外界所有部件的通信。
缓存一致性协议
要搞清楚三大特性,前提是你要知道Java内存模型(JMM),那JMM又是个什么东东?大家兴中可能留下这张图了吧。
JMM(Java内存模型)
它是抽象的(不真实存在),描述的是一组规范。通过这组规范定义了程序中各个变量(实例字段,静态字段和构成对象的元素)的访问方式。
前提要点
在多线程中稍微不注意就会出现线程安全问题,那么什么是线程安全问题?我的认识是,在多线程下代码执行的结果与预期正确的结果不一致,这种就是,否则它是线程安全的。虽然这种回答似乎不能获取什么内容, 可以google下,在<<深入理解Java虚拟机>>中看到的定义。原文如下: 当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。
关于定义的理解这是一个仁者见仁智者见智的事情。出现线程安全的问题一般是因为主内存和工作内存数据不一致性和重排序导致的,而解决线程安全的问题最重要的就是理解这两种问题是怎么来的,那么,理解它们的核心在于理解java内存模型(JMM)。
在多线程条件下,多个线程肯定会相互协作完成一件事情,一般来说就会涉及到多个线程间相互通信告知彼此的状态以及当前的执行结果等,另外,为了性能优化,还会涉及到编译器指令重排序和处理器指令重排序。下面会一一来聊聊这些知识。
线程间协作通信
线程间协作通信可以类比人与人之间的协作的方式,在现实生活中,之前网上有个流行语“你妈喊你回家吃饭了”,就以这个生活场景为例,小明在外面玩耍,小明妈妈在家里做饭,做晚饭后准备叫小明回家吃饭,那么就存在两种方式:-
小明妈妈要去上班了十分紧急这个时候手机又没有电了,于是就在桌子上贴了一张纸条“饭做好了,放在...”小明回家后看到纸条如愿吃到妈妈做的饭菜,那么,如果将小明妈妈和小明作为两个线程,那么这张纸条就是这两个线程间通信的共享变量,通过读写共享变量实现两个线程间协作;
还有一种方式就是,妈妈的手机还有电,妈妈在赶去坐公交的路上给小明打了个电话,这种方式就是通知机制来完成协作。同样,可以引申到线程间通信机制。
通过上面这个例子,应该有些认识。在并发编程中主要需要解决两个问题:1. 线程之间如何通信;2.线程之间如何完成同步(这里的线程指的是并发执行的活动实体)。通信是指线程之间以何种机制来交换信息,主要有两种:共享内存和消息传递。这里,可以分别类比上面的两个举例。java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信。如果程序员不能理解Java的共享内存模型在编写并发程序时一定会遇到各种各样关于内存可见性的问题。
.哪些是共享变量
在java程序中所有实例域,静态域和数组元素都是放在堆内存中(所有线程均可访问到,是可以共享的),而局部变量,方法定义参数和异常处理器参数不会在线程间共享。共享数据会出现线程安全的问题,而非共享数据不会出现线程安全的问题。
JMM关于同步的规定:
1 线程解锁前,必须把共享变量的值刷新回主内存; 2 线程加锁前,必须读取主内存的最新值到自己的工作内存; 3 加锁解锁的是同一把锁;
由于 JVM 运行程序的实体是线程,它就对应上图线程A和B, 而每个线程创建是 JVM 都会为其创建一个属于线程的本地内存, 它是每个线程的私有数据区域。 而Java内存模型中规定所有变量都存储在主内存(也就是内存条), 主内存是共享内存区域,所有线程都是可以去访问的, 但是线程对变量的操作(读取或赋值等)必须在本地内存中来操作, 首先要将变量从主内存拷贝到自己线程的本地内存上, 然后对变量进行操作,操作完成以后把变量再写回到主内存, 你现在不能直接去操作主内存中的变量, 各个线程中的本地内存中存储主内存的变量副本, 因此不同的线程间无法访问对方的工作内存, 线程间的通信(传值)必须通过主内存来完成;
-
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
-
指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
-
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
如图,1属于编译器重排序,而2和3统称为处理器重排序。这些重排序会导致线程安全的问题,一个很经典的例子就是DCL问题,这个在以后的文章中会具体去聊。针对编译器重排序,JMM的编译器重排序规则会禁止一些特定类型的编译器重排序;针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序。
那么什么情况下,不能进行重排序了?下面就来说说数据依赖性。有如下代码:
double pi = 3.14 //A
double r = 1.0 //B
double area = pi * r * r //C
这是一个计算圆面积的代码,由于A,B之间没有任何关系,对最终结果也不会存在关系,它们之间执行顺序可以重排序。因此可以执行顺序可以是A->B->C或者B->A->C执行最终结果都是3.14,即A和B之间没有数据依赖性。具体的定义为:如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作就存在数据依赖性这里就存在三种情况:1. 读后写;2.写后写;3. 写后读,者三种操作都是存在数据依赖性的,如果重排序会对最终执行结果会存在影响。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序
另外,还有一个比较有意思的就是as-if-serial语义。
as-if-serial
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提供并行度),(单线程)程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。比如上面计算圆面积的代码,在单线程中,会让人感觉代码是一行一行顺序执行上,实际上A,B两行不存在数据依赖性可能会进行重排序,即A,B不是顺序执行的。as-if-serial语义使程序员不必担心单线程中重排序的问题干扰他们,也无需担心内存可见性问题。
happens-before定义
happens-before的概念最初由Leslie Lamport在其一篇影响深远的论文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出,有兴趣的可以google一下。JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
上面的1)是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
上面的2)是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。
下面来比较一下as-if-serial和happens-before:
as-if-serial VS happens-before
-
as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
-
as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
-
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
4.2 具体规则 具体的一共有六项规则:
-
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
-
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
-
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
-
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
-
start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
-
join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
-
程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
-
对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
下面以一个具体的例子来讲下如何使用这些规则进行推论:
依旧以上面计算圆面积的进行描述。利用程序顺序规则(规则1)存在三个happens-before关系:1. A happens-before B;2. B happens-before C;3. A happens-before C。这里的第三个关系是利用传递性进行推论的。A happens-before B,定义1要求A执行结果对B可见,并且A操作的执行顺序在B操作之前,但与此同时利用定义中的第二条,A,B操作彼此不存在数据依赖性,两个操作的执行顺序对最终结果都不会产生影响,在不改变最终结果的前提下,允许A,B两个操作重排序,即happens-before关系并不代表了最终的执行顺序。
举例子
假如主内存有个student对象, 其属性age=25; 第一步首先把25复制到线程A,线程B的本地内存A,本地内存B, 线程A在本地内存通过计算得到age=37,那么此时写回到主内存上的age=37了, 但是此时线程B还不知道线程A本地内存的age=37;要让他们通信该咋办呢?这个就是代表我们的主题 线程的可见性volatile, 通过主内存来让他们来通信, 但是请你注意volatile不保证原子性的哦,可以有序性(也就是上面的指令重排)。
Demo
class MyData { /*主内存*/
/*volatile*/ int number = 0; //共享变量(是放在主内存上的)
public void addTO60() {
this.number = 60;//(假如线程A调用此方法,会把60赋值到共享的主内存里面去,)
}
}
/*
1 验证volatile的可见性
1)假如 int number = 0; number变量之前没有添加volatile关键字修饰,没有可见性(及时通知机制)
2)
*/
public class volatileDemo {
public static void main(String[] args) { //main是一切方法的运行入口
MyData myData = new MyData(); //资源类
//实现了runnable接口的lomda表达式
new Thread() -> {
System.out.println(Thread.currentThread().getName()+"\t come in");
//暂停一会线程:(只要A线程进入调到number的值,他就得等一会大概 3秒钟,别的线程已经读取了变量了。)
try { TimeUnit.SECONDS.SLEEP(3);} catch
{InterruptedException e} { e.printStackTrace(); }
//3秒钟之后。我把number改为60
myData.addTO60();
System.out.println(Thread.currentThread().getName()+"\t updated number value: "+myData.number); //如果3秒后MyData确定把number变成60, MyData它自己肯定知道,
//那mydata.number的值已经从0变成60了啊
},"AAA").start(); //AAA线程(AAA线程要操作这个资源类MyData)
//第二个线程就是我们的main线程(但是一开始进来main线程读到的值是初始值0,)
while(myData.number == 0) {
//main线程就一直在这里等待循环,直到number值不在等于0
}
System.out.println(Thread.crurrentThread().getName()+"\t mission is over, ,main get number value: "+myData.number); //如果这个值是0,
//number在main线程的while一直死循环,这是在number前没有添加volatile关键字,他会一直不会
//输出该句,你现在去改下 number 前面加volatile,那本句话可以打出来,说明main线程已经感知到变成60,**可见性触发**;
}
}
结果一: 结果二:
这就有可能存在一个线程A修改了共享变量number,是不是把它变成了60,但是60还没写回主内存的时喉,另外一个线程B又对主内存中同一个共享变量number进行操作,但此时A线程工作内存中共享变量X对线程B来说并不是可见的,这种工作内存与主内存同步延迟现象就造成了可见性问题
1 可见性(一种及时通知机制)
2 不保证原子性
原子性:一个操作(不可分割,完整性)要么同时成功,要么同时失败。既是某个线程正在做某个具体业务时,中间是不可以被加塞或者分割。
class MyData {
volatile int number = 0;
//请注意,number前面是加了volatile关键字修饰的,volatile不保证原子性
public void addPlusPlus()
{
number++;
}
}
public class VolatileDemo {
public static void main(String[] args) {//main是一切方法的运行入口
MyData myData = new myData();
for (int i=1; i<=20; i++) { //for创建20个Thread 并且number最后其值为20000
new Thread(() -> {
for(int j=0; j<1000; j++) {
myData.addPlusPlus();
}
},String.valueOf(i).start();
}
//需要等待上面20个线程全部计算完成后,再用main线程取得最终的结果值是多少?
//暂停5秒钟(假如只运行1.5秒,5秒是不是给多了呀,所以我们得重写)
/*try { TimeUnit.SECONDS.sleep(5); }
catch(InterruptedException e) { e.printStackTrace(); }*/
while(Thread.activeCount() > 2)//等待上面20个线程全部执行完,其用了多少秒就开始返回结果,那你有可能就问我问啥它就是2? 由于默认后台有两个线程,1是main线程,2是后台GC线程;大于2
{
Thread.yield();//main线程退下来,不执行,让其他20个线程更好的执行完,
//算完了是多少就是多少,这个时候main线程再去拿值再打印出来
}
System.out.println(Thread.currentThread().getName()+"\t finally number values: "+MyDta.number);
//main线程拿到MyDta类的number的值
}
}
结果一:
结果二:
不对呀
运行好几次都不是20000呀?这是怎么回事呢,就是这么回事,为啥volatile它是不能保证原子性的。我们就来讲讲它是为啥!
分析下
addPlusPlus()方法是没有加synchronized,也没有加lock之类的锁,那说白了多线程来访问这个方法是不安全,在这种情况下没有安全机制,添加了volatile关键字,是不会保证原子性,所以它会丢失数据,永远到不了2万。 现在addPlusPlus()添加synchronized关键字,说明只能有一个线程去调用addPlusPlus(),一个线程加到
2000,29个线程完全可以跑到20000,如下;
写到这里我们能不能用synchronized关键字呢,可是可以,其实这里简直高射炮打蚊子,杀鸡用牛刀呀,你现在为了解决number+的问题,synchronized它太重了。
我们现在的问题是它为什么不能保证原子性? number++我们都明白,number++在干吗,根据开篇讲的 JMM缓存模型 它底层被分解为3个操作:
解决volatile不保证原子性
直接使用JUC下AtomicInteger(原子整型类),它所对应的对象天生就是完整不可分割,而且number和atomicInteger都是默认为0的,只不过它是原子属性,每次加1都会执行完毕,才能执行下一个。
package com.atlong.basic;
import java.util.concurrent.atomic.AtomicInteger;
class MyData {
volatile int number = 0;
public void addTO60() {
this.number = 60;
}
//请注意,number前面是加了volatile关键字修饰的,volatile不保证原子性
public void addPlusPlus()//这个类没有加synchronized,lock之类的
{
number++;
}
AtomicInteger atomicInteger =new AtomicInteger();//创建一个新的原子类伴随着初始值()里面什么都不写默认初始值为0,
// 我们的number值也是初始为0,相当于atomicInteger就是我们的number;也就是带原子性的number++
public void addMyAtomic()//带有原子性的加入方法
{
atomicInteger.getAndIncrement();//得到了以后在+1
}
}
class VolatileDemo {
public static void main(String[] args) {//main是一切方法的运行入口
MyData myData = new MyData();
for (int i=1; i<=20; i++) { //for创建20个Thread 并且number最后其值为20000
new Thread(() -> {
for(int j=0; j<1000; j++) {
myData.addPlusPlus();
myData.addMyAtomic();//这个方法是自己写的
}
},String.valueOf(i)).start();
}
//需要等待上面20个线程全部计算完成后,再用main线程取得最终的结果值是多少?
//暂停5秒钟(假如只运行1.5秒,5秒是不是给多了呀,所以我们得重写)
/*try { TimeUnit.SECONDS.sleep(5); }
catch(InterruptedException e) { e.printStackTrace(); }*/
while(Thread.activeCount() > 2)//等待上面20个线程全部执行完,其用了多少秒就开始返回结果,那你有可能就问我问啥它就是2? 由于默认后台有两个线程,1是main线程,2是后台GC线程;大于2
{
Thread.yield();//main线程退下来,不执行,让其他20个线程更好的执行完,
//算完了是多少就是多少,这个时候main线程再去拿值再打印出来
}
System.out.println(Thread.currentThread().getName()+"\t int type, finally number values: "+myData.number);
//main线程拿到MyDta类的number的值 这个不保证原子性
//原子整型类的对象对应的number应该等于多少 这个保证原子性
System.out.println(Thread.currentThread().getName()+"\t atomic type, finally number values: "+myData.atomicInteger);
}
}
- 点赞
- 收藏
- 关注作者
评论(0)