Java并发编程与技术内幕:volatile的那些事

 一、volatile变量

       Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

       在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

Java并发编程与技术内幕:volatile的那些事_第1张图片

当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。

       而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。

当一个变量定义为 volatile 之后,将具备两种特性:

  1.保证此变量对所有的线程的可见性,这里的“可见性”,如本文开头所述,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存(详见:Java内存模型)来完成。

  2.禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。

volatile 性能:

  volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

 二、内存可见性

       由于 Java 内存模型(JMM)规定,所有的变量都存放在主内存中,而每个线程都有着自己的工作内存(高速缓存)。

线程在工作时,需要将主内存中的数据拷贝到工作内存中。这样对数据的任何操作都是基于工作内存(效率提高),并且不能直接操作主内存以及其他线程工作内存中的数据,之后再将更新之后的数据刷新到主内存中。

这里所提到的主内存可以简单认为是堆内存,而工作内存则可以认为是栈内存。

所以在并发运行时可能会出现线程 B 所读取到的数据是线程 A 更新之前的数据。

显然这肯定是会出问题的,因此 volatile 的作用出现了:

当一个变量被 volatile 修饰时,任何线程对它的写操作都会立即刷新到主内存中,并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。

 

volatile 修饰之后并不是让线程直接从主内存中获取数据,依然需要将变量拷贝到工作内存中。

内存可见性的应用

当我们需要在两个线程间依据主内存通信时,通信的那个变量就必须的用 volatile 来修饰:

public class Test {

private static /*volatile*/ boolean stop = false;

public static void main(String[] args) throws Exception {
    Thread t = new Thread(
            () -> {
                int i = 0;
                while (!stop) {
                    i++;
                 System.out.println("hello");
                }
            });
    t.start();

    Thread.sleep(1000);
    TimeUnit.SECONDS.sleep(1);
    System.out.println("Stop Thread");
    stop = true;
}
}

上面的例子如果不设置为volatile,则该线程可能永远都不会退出

但这里有个误区,这样的使用方式容易给人的感觉是:

对 volatile 修饰的变量进行并发操作是线程安全的。

这里要重点强调,volatile 并不能保证线程安全性!

如下程序:

public class VolatileInc implements Runnable {

private static volatile int count = 0; //使用 volatile 修饰基本数据内存不能保证原子性

//private static AtomicInteger count = new AtomicInteger() ;

@Override

public void run() {
    for (int i = 0; i < 100; i++) {
        count++;
        //count.incrementAndGet() ;

    }
}

public static void main(String[] args) throws InterruptedException {
    VolatileInc volatileInc = new VolatileInc();
    IntStream.range(0,100).forEach(i->{
        Thread t= new Thread(volatileInc, "t" + i);
        t.start();
        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });

    System.out.println(count);
}
}

当我们三个线程(t1,t2,main)同时对一个 int 进行累加时会发现最终的值都会小于 100000。

这是因为虽然 volatile 保证了内存可见性,每个线程拿到的值都是最新值,但 count ++ 这个操作并不是原子的,这里面涉及到获取值、自增、赋值的操作并不能同时完成。

所以想到达到线程安全可以使这三个线程串行执行(其实就是单线程,没有发挥多线程的优势)。也可以使用 synchronize 或者是锁的方式来保证原子性。还可以用 Atomic 包中 AtomicInteger 来替换 int,它利用了 CAS 算法来保证了原子性。

三、指令重排序

内存可见性只是 volatile 的其中一个语义,它还可以防止 JVM 进行指令重排优化。

举一个伪代码:

int a=10 ;//1
int b=20 ;//2
int c= a+b ;//3

一段特别简单的代码,理想情况下它的执行顺序是:1>2>3。但有可能经过 JVM 优化之后的执行顺序变为了 2>1>3。

可以发现不管 JVM 怎么优化,前提都是保证单线程中最终结果不变的情况下进行的。

可能这里还看不出有什么问题,那看下一段伪代码:

 private static Map value ;
 private static volatile boolean flag = fasle ;
  //以下方法发生在线程 A 中 初始化 Map
 public void initMap(){
 //耗时操作
 value = getMapValue() ;//1
 flag = true ;//2
}

 //发生在线程 B中 等到 Map 初始化成功进行其他操作
public void doSomeThing(){
while(!flag){
    sleep() ;
}
 //dosomething
 doSomeThing(value);
}

这里就能看出问题了,当 flag 没有被 volatile 修饰时,JVM 对 1 和 2 进行重排,导致 value都还没有被初始化就有可能被线程 B 使用了。

所以加上 volatile 之后可以防止这样的重排优化,保证业务的正确性。

指令重排的的应用

一个经典的使用场景就是双重懒加载的单例模式了:

class Singleton{
private volatile static Singleton instance = null;

private Singleton() {

}

public static Singleton getInstance() {
    if(instance==null) {
        synchronized (Singleton.class) {
            if(instance==null)
                instance = new Singleton();
        }
    }
    return instance;
}

}

这里的 volatile 关键字主要是为了防止指令重排。主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:

    1.给 instance 分配内存

    2.调用 Singleton 的构造函数来初始化成员变量

    3.将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。

       但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。


 

你可能感兴趣的:(Java并发编程与技术内幕)