赞
踩
目录
2 volatile变量读写的原子性和普通变量读写的非原子性
volatile变量有三个特性:
volatile变量的读和写操作都是原子的,在读和写的过程中其它线程不能对变量进行读或写。而普通变量是没办法做到的,因为将普通变量读和写并不是原子的,非原子操作的各条指令之间Anything can happen。
根据《java并发编程的艺术》,可以把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。示例代码如下:
- class VolatileFeaturesExample {
- //使用volatile声明64位的long型变量
- volatile long vl = 0L;
-
- public void set(long l) {
- vl = l; //单个volatile变量的写
- }
- public void getAndIncrement () {
- vl++; //复合(多个)volatile变量的读/写
- }
- public long get() {
- return vl; //单个volatile变量的读
- }
- }
上面的代码可以等价于:
- class VolatileFeaturesExample {
- long vl = 0L; // 64位的long型普通变量
-
- //对单个的普通 变量的写用同一个锁同步
- public synchronized void set(long l) {
- vl = l;
- }
-
- public void getAndIncrement () { //普通方法调用
- long temp = get(); //调用已同步的读方法
- temp += 1L; //普通写操作
- set(temp); //调用已同步的写方法
- }
- public synchronized long get() {
- //对单个的普通变量的读用同一个锁同步
- return vl;
- }
- }
其揭示了volatile变量的一个重要特性:volatile的读写操作可以看成是原子的。
在java内存模型中,普通变量的读写操作是不具有原子性的,这里我们必须要提到java内存模型的内存交互操作。《深入理解java虚拟机》书中介绍了在java内存模型中,主内存与工作内存之间的具体交互是通过8种操作来完成的,虚拟机保证了这八种操作每一种都是原子的,8中操作如下:
- lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
- read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;
- load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
- use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;
- assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;
- store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;
- write(写入):作用于主内存,它把store传送值放到主内存中的变量中。
- unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;
书中还介绍了:如果要把一个变量从主内存复制到工作内存,就要顺序地执行read和load操作(该过程对应全局变量的读操作);如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作(该过程对应全局变量的写操作)。同时,java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说,read与load之间、store与write之间是可以插入其它指令的(包括线程的切换),这就是为什么说普通变量的读写操作不具有原子性。
如果有两个线程对主内存中的普通变量a进行访问,线程1先对a进行写操作,线程2随后对a进行读操作,则可能出现的顺序是store a(线程1)、read a(线程2)、load a(线程2)、write a(线程1),这个代码执行的结果是线程2读的仍是线程1修改之前的值,而从代码顺序上来看,我们的预期是线程2读到的是线程1修改之后的值,这就导致对变量a的修改不能立刻被其它线程所知道。
从上述过程我们可以得出一个结论,那就是对于一个普通的全局变量来说,组成读写操作的每一条指令是原子的,但读或写操作并不仅仅是由一条指令组成,且jvm中允许在一个操作的多条指令中插入其它指令,这就导致了对普通变量的写操作可能对其它线程不是立即可见的,对某个变量的读操作可能会出现读到过期数据(读期间被修改)的情况。
volatile变量的可见性体现在两方面:
通过第2点可知,volatile变量克服了普通变量读写操作的非原子性弊端,在写回主内存的过程中其它线程不能对变量进行读或写,直到当前线程把变量从主内存读到工作内存或写到主内存为止。通过将读写操作变成原子操作,可以保证:线程1对变量a的写操作若先发生于线程2对变量a的读操作,那么线程2读到的a的值必定是线程1修改过的最新值。
对volatile变量进行的写操作代码,翻译成汇编语言后会发现在写指令的后面会多出另一行汇编代码。lock前缀指令的作用是:
这样下次其它处理器读取该volatile变量数据时都会从内存中获取,不会直接从缓存中获取。
- volatile Object instance = new Singleton();
-
- // 汇编指令
- movb addr1, addr2;
- lock addl addr1,addr2; // 多出的汇编代码
在jvm中,指令重排序在多个线程同时访问一个普通变量的情况下,可能会导致程序执行结果发生错误,具体例子可以查看我的另一篇文章:指令重排序。为了避免这种错误,可以用volatile变量代替普通变量。
为了实现volatile语义,java内存模型(JMM)会分别限制这两种类型的重排序类型,具体见下表:
第一个操作(行) | 是否能重排序 | 第二个操作(列) | 普通读/写 | volatile读 | volatile写 |
普通读/写 | NO | ||
volatile读 | NO | NO | NO |
volatile写 | NO | NO |
比如说,当第一个操作是对普通变量的读/写,第二个操作是对volatile变量的写时,不允许第一个操作重排序到第二个操作后面。
为了限制重排序,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,内存屏障的具体知识参见:指令重排序。对于一个编译器来说,发现一个最优不止来最小化插入屏障的总数几乎不可能,为此java内存模型采取保守策略:
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确得volatile内存语义。
等等,这里好像有个问题,表中显示,当第一个操作时普通读、第二个操作时volatile写时,重排序不被允许。但volatile的4个内存屏障插入策略似乎不能防止这种情况,因为volatile写操作之前没有插入LoadStore屏障?这个问题留着之后去探究吧。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。