java volatile关键字浅析

1.volatile的定义与实现原理

The Java programming language allows threads to access shared variables (§17.1). As a rule, to ensure that shared variables are consistently and reliably updated, a thread should ensure that it has exclusive use of such variables by obtaining a lock that, conventionally, enforces mutual exclusion for those shared variables.

The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes.

A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable (§17.4). 

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。--《Java并发编程的艺术》 方腾飞等

Java内存模型

Java内存模型如上图所示,众所周知,CPU的运算速度是远超过内存的读写速度的,估CPU不会直接和内存进行数据交互,而是先将内存中的数据读到高速缓存,CPU缓存是有多级的,如L1,L2,L3等,为说明问题,此处简单看做一级。

CPU对读到高速缓存的数据进行读写后,至于何时把高速缓存刷新到内存是未知的,取决于缓存写的策略(write-through或write-back),所以内存中的数据可能已经失效,即使是进行双写,即写缓存的同时写内存,其他处理器中的高速缓存的数据也不能保证是最新的。

怎样才能保证每个处理器读到的数据是一致的?需要做两件事,一是在处理器中的高速缓存发生变化后马上回写内存,二是在内存里的数据发生修改后,马上通知读取了该内存区域的缓存来更新数据。

volatile做了什么?通过JIT生成的volatile变量赋值操作汇编指令如下:

volatile JIT 汇编代码  --《Java并发编程的艺术》

 根据IA-32软件开发者手册 第八章内容可知,lock前缀的指令在多核处理器下会做两件事:

1.将当前处理器缓存行的数据回写系统内存;2.这个回写内存的操作会使引用了该内存地址的其他CPU高速缓存中的数据无效。

这样就可以保证每个CPU写高速缓存对其他CPU都是可见的,volatile的两条实现原则如下:

1)Lock前缀指令会引起处理器缓存回写到内存。Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。但是,在最近的处理器里,LOCK #信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。对于Intel486和Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据

2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32处理器和Intel 64处理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。--《Java并发编程的艺术》

For the Intel486 and Pentium processors, the LOCK# signal is always asserted on the bus during a LOCK operation, even if the area of memory being locked is cached in the processor. For the P6 and more recent processor families, if the area of memory being locked during a LOCK operation is cached in the processor that is performing the LOCK operation as write-back memory and is completely contained in a cache line, the processor may not assert the LOCK# signal on the bus. Instead, it will modify the memory location internally and allow it’s cache coherency mechanism to ensure that the operation is carried out atomically. This operation is called “cache locking.” The cache coherency mechanism automatically prevents two or more processors that have cached the same area of memory from simultaneously modifying data in that area. --《IA-32开发者手册》

以上两点分别总结如下:1.多CPU通过锁总线(较早的CPU)或锁缓存(较新CPU)的方式将当前处理器的缓存行刷新到内存;2.处理器通过嗅探技术保证自己的高速缓存在其对应的内存地址处于共享状态,且其他CPU可能会写的情况下,将自己的缓存置为无效,强行从内存中读取填充缓存。

2.指令重排与内存屏障

2.1 指令重排

package share.vola;

import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * @author shanpao
 */
public class VolaResort {
    private static int a = 0;
    private static boolean flag = false;
    private static ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, 1,
            TimeUnit.SECONDS, new LinkedBlockingDeque<>(), r -> new Thread(r));

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10000; i++) {
            a = 0;
            flag = false;

            //线程一:更改数据
            Thread t1 = new Thread(() -> {
                a = 1;
                flag = true;
            });
            executor.submit(t1);
            //线程二:读取数据
            Thread t2 = new Thread(() -> {
                if (flag) {
                    if (a == 0) {
                        System.out.println("=======");
                    }
                }
            });
            executor.submit(t2);
            t1.join();
            t2.join();
        }
        executor.shutdown();
    }

}

上述代码中,t2会有一定几率读到a值为0,即在t1中,flag的赋值操作被重排到a=1之后,这种优化是编译器或执行器为了提高程序的执行效率而做的一种优化,但有时会引入线程安全问题,如示例中的代码,而volatile关键字可以显示的避免指令重排。

package share.vola;

import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * @author shanpao
 */
public class AvoidResort {
    private static int a = 0;
    private volatile static boolean flag = false;
    private static ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, 1,
            TimeUnit.SECONDS, new LinkedBlockingDeque<>(), r -> new Thread(r));

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            a = 0;
            flag = false;

            //线程一:更改数据
            Thread t1 = new Thread(() -> {
                a = 1;
                flag = true;
            });
            executor.submit(t1);
            //线程二:读取数据
            Thread t2 = new Thread(() -> {
                if (flag) {
                    if (a == 0) {
                        System.out.println("happen-before->" + a);
                    }
                }
            });
            executor.submit(t2);
            t1.join();
            t2.join();
        }
        executor.shutdown();
    }

}

flag使用volatile关键字修饰后,遵从volatile happens-before原则,加上单线程的happens-before,就能够实现多线程中对一个volatile变量及其之前的写操作,先于另一个线程的读操作发生。volatile禁止重排序的规则如下:

  • volatile写与前一条语句不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • volatile读与之后的一条语句不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • volatile写与之后的一条volatile读不能重排序。

2.2 内存屏障

对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

 

内存屏障类型 《java并发编程的艺术》

 为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。--《java并发编程的艺术》

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

 

编译器插入内存屏障示意图

以上可以看出,对volatile变量的操作插入相应的内存屏障保证了volatile变量的可见性,也依据内存屏障的规则,禁用了volatile变量相关的指令重排。

3.原子性?

volatile定义的变量保证了可见性和有序性(volatile变量),那么volatile的读写对多线程而言是否具有原子性呢?示例代码如下,执行一万次多线程赋值后,a的值可能为1,也可能为2,可见volatile的赋值操作是具备原子性的,但64位的long或double类型在32位虚拟机下的赋值操作无法保证原子性,下面的代码在32位JVM下执行的结果可能会出现不可预见的结果。

package share.vola;

import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * @author shanpao
 */
public class VolaAtomic {
    private volatile static long a = 1;
    private static ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, 1,
            TimeUnit.SECONDS, new LinkedBlockingDeque<>(), r -> new Thread(r));

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10000; i++) {
            //线程一:更改数据
            Thread t1 = new Thread(() -> {
                a = 1;
            });
            executor.submit(t1);
            //线程二:读取数据
            Thread t2 = new Thread(() -> {
                a = 2;
            });
            executor.submit(t2);
            System.out.println("a = " + a);
            t1.join();
            t2.join();
        }
        executor.shutdown();
    }

}

但原子性仅限于赋值操作,下方的实例代码中,对volatile变量进行了多条虚拟机指令的操作,就会产生不可预知的结果。

package share.vola;

import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * @author shanpao
 */
public class VolaNoneAtomic {
    private volatile static long a = 1;
    private static ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, 1,
            TimeUnit.SECONDS, new LinkedBlockingDeque<>(), r -> new Thread(r));

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            //线程一
            Thread t1 = new Thread(() -> {
                a++;
            });
            executor.submit(t1);
            //线程二
            Thread t2 = new Thread(() -> {
                a--;
            });
            executor.submit(t2);
            try {
                t1.join();
                t2.join();
                System.out.println("a = " + a);//预期结果为1
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        executor.shutdown();
    }

}

以上代码结果不可预知的原因为,赋值语句在虚拟机指令中只需要一条语句写回高速缓存,不存在高速缓存加载寄存器的情况,以下代码示例及其字节码指令为:

====== java 代码 ======

private volatile static int a = 1;

public static void main(String[] args) {
    a = 2;
}

====== 对应的虚拟机指令 ======

0: iconst_1 //加载常数到操作数栈
1: putstatic     #2                  // Field a:I 写静态变量
4: return

累加操作的代码示例及其字节码指令为:

====== java 代码 ======

private volatile static int a = 1;

public static void main(String[] args) {
    a++;
}

====== 对应的虚拟机指令 ======

 0: getstatic     #2                  // Field a:I 加载a的值到栈顶
 3: iconst_1                          // 加载常量1到栈顶
 4: iadd                              // 执行加操作
 5: putstatic     #2                  // Field a:I 写回到缓存

当CPU还在执行多条指令时,高速缓存中的值可能已经改变了,回写时覆盖了已经发生了变化的缓存值。

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值