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

 一、volatile变量

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

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

当对非 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<String,String> 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,然后使用,然后顺理成章地报错。


 

[code="java"] /** * VolatileTest.java * * Copyright ekupeng,Inc. 2012 */ package test; /** * @ClassName: VolatileTest * @Description: Volatile测试 * @author Emerson emsn1026@gmail.com * @date 2012-11-29 下午06:57:44 * @version V1.0 * */ public class VolatileTest extends Thread { // 非volatile标志 private static boolean flag1 = false; // volatile标志 private static volatile boolean flag2 = false; private int i = 0; public void run() { //Object o = new Object(); //synchronized (o) { /* * 注释1 */ while (!flag1) { i++; //注意 : System.out.println(i); /* * 注释2 */ if (flag2) { System.out.println("over:" + i); break; } } //} } public static void main(String[] args) { VolatileTest t = new VolatileTest(); t.start(); try { Thread.currentThread().sleep(2000); // 先更改flag1 t.flag1 = true; /* * 注释3 */ Thread.currentThread().sleep(1000); // 将flag2置为true,如果有机会进入if(flag2),则将退出循环 t.flag2 = true; } catch (InterruptedException e) { e.printStackTrace(); } } } [/code] 因为预览时发现大段的注释会自动换行,影响阅读代码,所以我将代码中的注释提取出来: 注释1 外围标志flag1为非volatile,该线程(t)跑起来后由另一线程(main)将flag1改为true后,如果出现情况1.flag1如果不从主存重新读取,那他将继续以false运行,所以会继续循环并进入内部的flag2的if判断;如果出现情况2.flag1从主存重新读取,那他将以true运行,所以会跳出循环,也就没有机会进入flag2的if判断了; 注释2 如果出现情况1,将进入该判断,内部标志flag2为volatile,当线程(main)将flag2改为true后,因为flag2会从主存重新读取,将以true运行,所以将跳出循环,并打印"over"语句 注释3 为了确保flag1的变更有机会被t察觉,并保证flag2能在flag1变为true后进行一次以上while(!flag1)条件判断后再判断if(flag2),sleep1秒(1秒可以跑很多循环了) 以上是我为了说明volatile的功能写的一段程序,目的是想说一个线程1在循环中通过非volatile的布尔变量来进行条件判断,即使在另一个线程2中修改了该布尔变量,由于该线程1的代码执行得到了某种性能优化,不会从主存重新读取布尔值,导致进入死循环,直到内部的volatile布尔值被改变才跳出。 我的问题是:原本我以为会像预想那样的输出“over”语句,这样也说明了volatile的用处。但是我尝试了Sun JDK1.6,1.5,1.4,1.3,1.2(因为volatile是针对jit带来的优化,所以1.2之前的版本就没有尝试)之后发现只有1.2下才会看到该程序对于volatile的演示效果,输出了“over”语句。其他的都只是在外围的while(!flag1)中实时察觉flag1的变化并跳出了循环。原本以为是hotspot的问题,但是我尝试了hotspot的server或client,以及1.3的classic,都是没有效果的,只有1.2才能看到volatile的演示效果。哪位大神给细说下这个情况? ps:Object那把锁没有实质的意义,只是进出synchronized块时会重新从主存同步数据,我当时随手写了测了下,所以大家可以不考虑,我暂且将它注掉吧....为了说明这一点,我加了行代码: //注意 : System.out.println(i); 这行代码要是去掉注释,volatile在JDK1.2下也将失去作用,因为System.out.println中含有同步块,一执行该方法,变量将从主存中重新读取。
©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页