大飞老师带你再看Java线程(八)

volatile 是JDK中的一个关键字,用于修饰成员变量或静态成员变量, 使用volatile关键字修饰的变量在并发环境下具备2层特殊含义:
1>线程可见性
2>防止指令重排序
要想深入理解volatile关键字, 得先了解可见性,有序性

可见性

可见性指线程操作某个共享变量,其他线程可以察觉到该变量的改变。看下例子:

public class Resource {
    private  boolean flag=false;
    public void doSomething() {
        System.out.println("进来了....");
        while(!flag){
            //不要打印任何指令,println具有获取/释放锁动作
            //System.out.println();
        }
        System.out.println("出去了....");
    }
    public void stop(){
        System.out.println("停止了.....");
        this.flag = true;
    }
}
public class App {
    public static void main(String[] args) throws InterruptedException {
        final Resource resource = new Resource();
        Thread t1 = new Thread(new Runnable() {
            public void run() {
                //线程t1执行打印动作
                resource.doSomething();
            }
        }, "t1");
        Thread t2 = new Thread(new Runnable() {
            public void run() {
                //线程t2执行终止
                resource.stop();
            }
        }, "t2");
        t1.start();
        Thread.sleep(1000);
        t2.start();
    }
}

结果:
没有volatile修饰的flag
线程t1启动时,执行Resource中的doSomething方法,打印”进来了…..”,然后执行空循环,1s之后, 线程t2启动,执行Resource中的stop方法, 目标改变flag变量的值,停止doSomething方法中空循环.最后结果发现并没有停止,”出去了….” 没有打印出来。为何?

分析:
导致上述问题主要原因是无法保证不同线程对共享变量flag操作的可见性。而要解释这个可见性,那就必须从java内存模式说起。JVM在处理指令时,为提供指令的效率,会适当给共享变量做缓存处理。 JVM将所有的共享变量存储在一个主存空间中,每一个线程也设置一个叫工作内存空间。如下图:
java内存模式
运行时,JVM会将共享变量flag复制多份,一份存放置到线程t1的工作内存中, 另一份放置到线程t2的工作内存中。当线程t1,线程t2对flag变量进行操作(读写操作)结束后,会将操作结果刷新到主存空间。需要注意的是,线程t1,t2修改的flag变量是各自工作缓存中的flag变量,它们相互是不可见的,即线程t1无法感知到线程t2的修改, 线程t2也无法感知得到t1的修改。

上面案例之所以线程t2执行了stop方法,修改了flag = true 依然无法让线程t1停止的原因就是这个: 线程t2仅仅改动的是它自己的工作内存中的flag的值,没有影响到线程t1, 线程t1工作内存中的flag依然是最初的备份flag= false.

解决:

private volatile boolean flag=false;

使用volatile修饰flag 变量,让线程对共享变量的操作具有可见性。 volatile 修饰变量时,强迫所有线程每次操作共享变量flag时,都从主内存中获取,这样就可以做到某个线程对共享变量修改,其他线程都可见。

重排序

重排序指的是编译器和处理器为了优化程序性能而对指令序列进行重新排序的手段。

int a;  //语句①
boolean b;//语句②
a = 1; //语句③
b = true;//语句④

上面代码按照我们的编码思维,执行顺序是①②③④⑤, 但是JVM在真正执行这段代码时,顺序就不一定了。处理器为了提高程序运行效率,可能对输出的代码进行优化,做一些额外顺序调整操作,此时就无法保证执行顺序跟我们编写代码顺序一致。对我们而言,顺序谁先谁后,无所谓,但必须保证程序最终执行结果和代码顺序执行的结果是一致。比如下面代码就无法进行随意调整代码顺序了, 因为会改变代码原意。

int a=0;
int b=1;
b = a+1;
a++;

到这,我们不经发问,处理器重排序要遵循哪些原则么?本篇我们就不做深入探讨,有兴趣的朋友可以看一下JSR133线程规范中的happen-before规则传送门:JSR133线程规范。这里你只需要知道,如果后面操作结果依赖(受影响)前一操作,这种情况下不允许重排序。

回归到volatile关键字,文中开篇讲到,volatile有防止指令重排序的意思, 这怎么理解?看下面代码

public class Singleton {
    private static  Singleton singleton;
    private Singleton() {
        System.out.println(Thread.currentThread().getName());
    };
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

这是一个经典的单例写法。咋一看,貌似没有任何问题, 如果用上刚讲的重排序知识分析,问题就出来了。

Singleton  singleton = new Singleton();

这行代码,我们站在微观角度看,可以分解成3个步骤:
1>给Singleton 对象分配内存空间
2>调用构造器初始化对象
3>将开辟好的内存地址赋值给singleton变量

此时,1 2 3步是否可以重排序?答案是yes
1>给Singleton 对象分配内存空间
3>将开辟好的内存地址赋值给singleton变量, *(此时对象没有初始化)
2>调用构造器初始化对象

2操作不依赖3,3操作不依赖2, 这2步骤操作可以重排序。

回到单例案例,假设线程t1,执行了singleton = new Singleton();操作,分3步执行
1>给Singleton 对象分配内存空间
3>将开辟好的内存地址赋值给singleton变量, *(此时对象没有初始化)
此时尚未执行
2>调用构造器初始化对象
给对象初始化时, 线程t2马上执行外层的if (singleton == null) 判断,singleton != null, 执行return singleton; 问题就出现了,此时的singleton是尚未初始化的singleton对象,是一个不完整的对象, 操作时可能出现问题。所以正确的写法:使用volatile 禁止处理器重排序

private volatile static  Singleton singleton;
volatile 与synchronized区别

1>volatile 是比synchronized更为轻量级同步机制,作用与变量, synchronized作用域代码块
2>volatile 无法保证原子性, 而synchronized可以

volatile  int count;
count ++

多线程情况下,count操作存在线程安全问题,可以使用synchronized限制,或者使用后续要讲的原子操作类:AtomicInteger 完成

总结

并发环境下, volatile 可以保证线程操作的共享变量可见性,但无法保证变量的原子性。最常见的例子就是上面的双检查方式的单例模式。


猜你喜欢

转载自blog.csdn.net/wolfcode_cn/article/details/80907131