Java 并发编程之Volatile原理剖析及使用

Java 并发编程之Volatile原理剖析及使用

在开始介绍Volatile之前,回顾一下在并发中极其重要的三个概念:原子性,可见行和有序性

  • 原子性: 是指一个操作不可以被中断.比如赋值操作a=1和返回操作return a,这样的操作在JVM中只需要一步就可以完成,因此具有原子性,而想自增操作a++这样的操作就不具备原子性,a++在JVM中要一般经历三个步骤:
    1. 从内存中取出a.
    2. 计算a+1.
    3. 将计算结果写回内存中去.
  • 可见性: 确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的.
  • 有序性: 程序执行的顺序按照代码的先后逻辑顺序执行.

只有同时保证了这三个特性才能认为操作是线程安全的.
在Java中,volatile是轻量级的Synchronized,在并发编程中保证了共享变量的可见性,与synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized 的一部分,想在程序中用volatile代替锁,一定要谨慎再谨慎(最好还是不要用,确实容易出错).

volatile保证可见性的原理

在X86处理器通过工具获取JIT编译器生成的汇编指令来查看对volatile修饰变量进行写操作时,CPU会做什么事情.

Java代码如下

instance = new Singleton(); //instance是volatile变量

转变为汇编代码如下.

0X01a3deld: movd $0X0,0X1104800(%esi);0x01a3de24: lock add1 $0X0,(%esp)
在对volatile修饰的共享变量进行写操作的时候多出了0x01a3de24: lock add1 $0X0,(%esp)这行代码, 这里的Lock前缀的指令是实现可见性原理的关键.
Lock前缀指令在多核处理器中会引发两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存
  2. 这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效.

所有的变量都存储在主内存中,为了提高程序执行速度,线程拥有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。但是这样便会带来缓存一致性问题,解决了缓存一致性问题,也就解决了可见性问题.

缓存一致性:如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致。

线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。

volatile关键字如何保证可见性(解决缓存一致性问题)

volatile变量时:

  • JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中.
    对应volatile的第一条实现原则—Lock前缀指令会引起当前处理器缓存行的数据写回到系统内存

vlatile变量时:

  • JMM会把该线程对应的本地内存置为无效,然后将主内存最新的共享变量刷新到本地内存中来.
    对应volatile的第二条实现原则—一个处理器的缓存会写到主内存中会导致其他处理器的缓存无效(使用嗅探技术保证)

如何使用volatile关键字

只能在有限的一些情形下使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。

两种常见错误

最初使用volatile关键字的时候,大家可能最常见的就是第一种错误了.

class VolatileExample{
    private volatile int value;
    public void add(){
        value++;
    }
    public int get(){
        return value;
    }
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
        VolatileExample volatileExample = new VolatileExample();
        ExecutorService executorService = Executors.newCachedThreadPool();
        CountDownLatch countDownLatch = new CountDownLatch(100);
        for (int i = 0; i <100; i++) {
            executorService.execute(()->{
                volatileExample.add();
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        System.out.println(volatileExample.get());
    }

代码结果输出
98

问题分析:

vaule++这样的操作并不是原子的,即使被volatile修饰了依旧不是原子操作.假如线程A从主内存中读取value=10,随后线程B也从主内存中读取value=10,线程A执行value++,线程B执行value++,线程A将value=11写入主内存,线程B也将value=11写入主内存,最终主内存中value=11,而不是value=12.像这种初级失误是一定要避免的.

下面演示了一个非线程安全的数值范围类,违反了第二个条件。它包含了一个不变式 —— 下界总是小于或等于上界。

@NotThreadSafe 
public class NumberRange {
    private int lower, upper;
 
    public int getLower() { return lower; }
    public int getUpper() { return upper; }
 
    public void setLower(int value) { 
        if (value > upper) 
            throw new IllegalArgumentException(...);
        lower = value;
    }
 
    public void setUpper(int value) { 
        if (value < lower) 
            throw new IllegalArgumentException(...);
        upper = value;
    }
}

问题分析:

这种方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;从而仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3) —— 一个无效值。至于针对范围的其他操作,我们需要使 setLower() 和 setUpper() 操作原子化 —— 而将字段定义为 volatile 类型是无法实现这一目的的。

正确使用示范

讲一种最常用也是最不容易出错的使用方式—将volatile变量作为状态标志使用

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { shutdownRequested = true; }
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

当前线程一直在执行doWork()方法,假如这个时候另一个线程调用shutdown()方法将shutdownRequested设置为true,当前线程本地内存的shutdownRequested拷贝副本马上失效,需从主内存中重新读取,读取到shutdownRequestedtrue,立即停止工作.

猜你喜欢

转载自blog.csdn.net/tubro2017/article/details/84581072