volatile关键字只能修饰类变量和成员变量,对于方法参数、局部变量以及实例常量、类常量都不能进行修饰。
在计算机中,所有的运算操作都是由CPU的寄存器来完成的,CPU 指令的执行过程需要涉及数据的读取和写人操作,CPU所能访问的所有数据只能是计算机的主存(通常是指RAM),虽然CPU的发展频率不断地得到提升,但受制于制造工艺以及成本等的限制,计算机的内存反倒在访问速度上并没有多大的突破,因此CPU的处理速度和内存的访问速度之间的差距越拉越大,通常这种差距可以达到上千倍,极端情况下甚至会在上万倍以上。
在CPU中至少要有六类寄存器:指令寄存器(IR)、程序计数器(PC)、地址寄存器(AR)、数据寄存器(DR)、累加寄存器(AC)、程序状态字寄存器(PSW)。
在计算机中,所有的运算操作都是由CPU的寄存器来完成的,CPU指令的执行过程需要涉及数据的读取和写入操作,CPU所能访问的所有数据只能是计算机的主存(通常是指RAM)。
RAM(random access memory)
即随机存储内存,这种存储器在断电时将丢失其存储内容,故主要用于存储短时间使用的程序。ROM(Read-Only Memory)
即只读内存,是一种只能读出事先所存数据的固态半导体存储器。手机中的RAM和ROM分别对应电脑的内存和硬盘
由于两边速度严重不对等,通过传统的FSB直连内存的访问方式很明显会导致CPU资源受到大量的限制,降低CPU整体的吞吐量,于是就有了CPU和主内存之间增加缓存的设计,现在缓存的数量可以增加到3级了,最接近CPU的缓存称为L1,然后依次是L2、L3和主内存。
由于程序指令和程序数据的行为和热点分布差异很大,因此L1 Cache又被划分成了L1i (i是instruction的首字母)和L1d (d是data的首字母)这两种有各自专门用途的缓存,CPU Cache又是由很多个Cache Line构成的,Cache Line可以认为是CPU Cache中的最小缓存单位,目前主流CPU Cache的Cache Line大小都是64字节。
Cache的出现是为了解决CPU直接访问内存效率低下的问题,程序在运行过程中,会将运算所需要的数据从主存复制一份到CPU Cache中,这样CPU进行计算时就可以直接对CPU Cache中的数据进行读取和写入,当运算结束之后,再将CPU Cache中的最新数据刷新到主存当中,CPU通过直接访问Cache的方式替代直接访问主存的方式极大提高了CPU的吞吐能力。
由于缓存的出现,极大地提高了CPU的吞吐能力,但是同时也引入了缓存不一致性的问题,比如i++操作,在程序运行过程中,首先需要将主内存中的数据复制一份到存放CPU Cache中,那么CPU寄存器在进行数值计算的时候就直接到Cache中读取和写入,当整个过程运算结束之后再将Cache中的数据刷新到主存中。
i++在单线程的情况下不会出现任何问题,但是多线程下就会有问题,每个线程都有自己的工作内存(本地内存,对应CPU中的Cache),变量i会在多个线程的本地内存中都存在一个副本。如果同时有两个线程执行i++操作,那么就会出现经典的缓存不一致问题。
为了解决缓存不一致问题,通常主流的解决方法有如下两种:
第一种方式常见于早期的CPU当中,而且是一种悲观的实现方式。
第二种缓存一致性协议中最出名的是Intel的MESI协议,MESI协议保证了每一个缓存中使用的共享变量副本都是一致的,它的大概意思是,当CPU在操作Cache中的数据时,如果发现该变量是一个共享变量,也就是说在其他的CPU Cache中存在一个副本,那么进行如下操作:
Java内存模型(Java Memory Mode,JMM)指定了Java虚拟机如何与计算机的主存(RAM)进行工作。
Java内存模型决定了一个线程对共享变量的写入何时对其他线程可见,Java内存模型定义了线程和主内存之间的抽象关系,具体如下:
假设主内存的共享变量为0,线程1和线程2分别拥有共享变量X的副本,假设线程1此时将工作内存中的x修改为1,同时刷新到主内存中,当线程2想要去使用副本x的时候,就会发现该变量已经失效了,必须到主内存中再次获取然后存人自己的工作内容中,这一点和CPU与CPU Cache之间的关系非常类似。
Java的内存模型是一个抽象的概念,与其计算机硬件的结构并不完全一样,比如计算机物理内存不会存在栈内存和堆内存的划分,无论是堆内存还是虚拟机栈内存都会对应到物理的内存,当然也有一部分堆内存的数据可能存入CPU Cache寄存器中。
并发编程的三个至关重要的特性:分别是原子性、有序性、可见性。
一般来说,处理器为了提高程序的运行效率,可能会对输入的代码指令做一定的优化,它不会百分之百的保证代码的执行顺序严格按照编写代码中的顺序来进行,但是它会保证程序的最终运行结果是编码时所期望的那样。
所谓原子性是指一次性的操作或者多次操作中,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。
总结:volatile关键字不能保证原子性,synchronized关键字可以保证数据的原子性
所谓可见性是指当一个线程对变量进行了修改,那么另外的线程可以立即看到修改后的最新值。
所谓有序性是指程序代码在执行过程中的先后顺序,由于Java在编译器以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序,比如:
intx=10;
int y =0;
x++ ;
y= 20;
上面这段代码定义了两个int类型的变量x和y,对x进行自增操作,对y进行赋值操作,从编写程序的角度来看上面的代码肯定是顺序执行下来的,但是在JVM真正地运行这段代码的时候未必会是这样的顺序,比如y=20语句有可能会在x++语句的前面得到执行,这种情况就是我们通常所说的指令重排序(Instruction Recorder)。
一般来说,处理器为了提高程序的运行效率,可能会对输人的代码指令做一定的优化,它不会百分之百的保证代码的执行顺序严格按照编写代码中的顺序来进行,但是它会保证程序的最终运算结果是编码时所期望的那样,比如上文中的x++与y=20不管它们的执行顺序如何,执行完上面的四行代码之后得到的结果肯定都是x=11, y=20。
当然对指令的重排序要严格遵守指令之间的数据依赖关系,并不是可以任意进行重排序的,比如下面的代码片段:
intx=10;
inty=0;
x++ ;
y=x+1;
对于这段代码有可能它的执行顺序就是代码本身的顺序,有可能发生了重排序导致int y=0优先于int x=10执行,但是绝对不可能出现y=x+1优先于x++执行的执行情况,如果一个指令x在执行的过程中需要用到指令y的执行结果,那么处理器会保证指令y在指令x之前执行,这就好比y=x+1执行之前肯定要先执行x++一样。
在单线程情况下,无论怎样的重排序最终都会保证程序的执行结果和代码顺序执行的结果是完全一致的,但是在多线程的情况下,如果有序性得不到保证,那么很有可能就会出现非常大的问题,比如下面的代码片段:
private boolean initialized = false; // 成员变量
private Context context;
public Context load() {
if(! initialized){
context=loadContext( ) ;
initialized = true;
}
return context ;
}
上述这段代码使用boolean变量initialized来控制context是否已经被加载过了,在单线程下无论怎样的重排序,最终返回给使用者的context都是可用的。如果在多线程的情况下发生了重排序,比如context=loadContext()的执行被重排序到了initialized = true 的后面,那么这将是灾难性的了。比如第一个线程首先判断到initialized=false,因此准备执行context的加载,但是它在执行loadContext()方法之前二话不说先将initialized置为true然后再执行loadContext() 方法,那么如果另外一个线程也执行load方法,发现此时initialized已经为true了,则直接返回一个还未被加载成功的context,那么在程序的运行过程中势必会出现错误。
JVM采用内存模型的机制来屏蔽各个平台和操作系统之间内存访问的差异,以实现让Java程序在各种平台下达到一致的内存访问效果,比如C语言中的整型变量,在某些平台下占用了两个字节的内存,在某些平台下则占用了四个字节的内存,Java则在任何平台下,Int类型就是四个字节,这就是所谓的一致内存访问效果。
Java的内存模型规定了所有的变量都是存在于主内存(RAM)当中的,而每个线程都有自己的工作内存或者本地内存(这一点很像CPU的Cache),线程对变量的所有操作都必须在自己的工作内存中进行,而不能直接对主内存进行操作,并且每一个线程都不能访问其他线程的工作内存或者本地内存。比如在某个线程中对变量i的赋值操作i=1,该线程必须在本地内存中对i进行修改之后才能将其写人主内存之中。
在Java语言中,对基本数据类型的变量读取赋值操作都是原子性的,对引用类型的变量读取和赋值的操作也是原子性的因此诸如此类的操作是不可被中断的,要么执行,要么不执行,正所谓一荣俱荣一损俱损。
(1) x=10; 赋值操作
x=10的操作是原子性的,执行线程首先会将x=10写人工作内存中,然后再将其写人主内存(有可能在往主内存进行数值刷新的过程中其他线程也在对其进行刷新操作,比如另外-一个线程将其写为11, 但是最终的结果肯定要么是10,要么是11,不可能出现其他情况,单就赋值语句这一一点而言其是原子性的)。
(2) y=x; 赋值操作
这条操作语句是非原子性的,因为它包含如下两个重要的步骤。
虽然第一步和第二步都是原子类型的操作,但是合在一-起就不是原子操作了。
(3) y++; 自增操作
这条操作语句是非原子性的,因为它包含三个重要的步骤,具体如下。
综合上面的例子,我们可以发现只有第- -种操作即赋值操作具备原子性,其余的均坏具备原子性,由此我们可以得出以下几个结论。
总结:volatile关键字不具备保证原子性的语义。
在多线程的环境下,如果某个线程首次读取共享变量,则首先得到主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同样如果对该变量执行了修改的操作,则先将新值写入工作内存,然后再刷新到主内存中。但是什么时候最新的值会被刷新到主内存是不太确定了。
Java提供了三种方式来保证可见性:
总结:volatile关键字可以保证可见性
在Java内存模型中,允许编译期和处理器对指令进行重排,在单线程先重排序不会引起什么问题,但是多线程下,重排序会影响到程序的正确运行,Java提供了三种保证有序性的方式。
总结:volatile关键字可以保证有序性
被volatile修饰的实例变量或者类变量具备如下两层含义:
经过上面几个步骤的分析,相信读者对volatile关键字保证可见性有了一个更加清晰的认识了。
volatile关键字对顺序性的保证就比较霸道一点,直接禁止JVM和处理器对volatile关键字修饰的指令重排序,但是对于volatile前后无依赖关系的指令则可以随便怎样排序。
volatile 关键字不保证操作的原子性。
虽然volatile有部分synchronized关键字的语义,但是volatile不可能完全替代synchronized关键字,因为volatile 关键字不具备原子性操作语义,我们在使用volatile关键字的时候也是充分利用它的可见性以及有序性(防止重排序)特点。
两者均可以保证共享资源在多线程间的可见性,但是实现机制完全不同。synchronized借助于JVM指令monitor enter和monitor exit对通过排他的方式使得同步代码串行化,在monitor exit时所有共享资源都将会被刷新到主内存中。相比较synchronized关键字volatile使用机器指令(硬编码)“lock;”的方式迫使其他线程工作内存中的数据失效,不得到主内存中进行再次加载。
volatile关键字禁止JVM编译期以及处理器对其进行重排序,所以它能够保证有序性。虽然synchronized关键字所修饰的同步方法也可以保证有序性,但是这种有序性是以程序串行化执行换来的,在synchronized关键字修饰的代码块中代码指令也会发生指令重排序的情况。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。