Java并发编程:深入了解volatile关键字

volatile这个关键字出现的频率是挺高的,作为并发编程重要武器之一,它一直被认为是轻量级的synchronized。在并发编程中,volatile主要是保证共享变量的可见性这里的可见性下面会详细说到。volatile相比于synchronized,使用成本更低,在特定条件下效率更高,毕竟减少了上下文的切换。关于volatile的学习使用,我们从以下几个方面来学习。

一:并发编程中的三个关键

并发编程重要的三个关键:原子性,可见性,有序性。在并发编程中,对数据的操作无非就是保证这三个关键特性,三个特性可以保证在并发环境中最终结果的准确性。

原子性:原子指化学反应不可再分的基本微粒,原子在化学反应中不可分割。而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。在开发中,原子性意为着“一个或一系列操作”,要么成功,要么失败。如果只是单个操作,本身其实就是原子性,不需要特定的去维护原子性;如果是一系列操作,就需要通过各种方法去保证原子性。这类操作比较经典的案例就是“银行转账”,该案例在这里不再详述,大家可自行了解。

可见性: 可见性是指在一个线程中修改了某个变量的值,其它线程能够立即获取到这个修改的值。这就是可见性。但仅仅利用可见性并不能保证并发编程的安全,这点需要谨记。下文会代码举例说明volatile的可见性作用。

有序性:  有序性是指程序执行的顺序按照代码的先后顺序执行。

int i = 10; 第一步
int j = 20; 第二步
i = 20; 第三步
j = 10; 第四步

按照正常的流程来说,示例代码是从上向下一步一步执行的,但实际上可能不是的。

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。在重排序的时候,JVM不保证代码执行的顺序,但会保证代码的执行结果,当然这些操作是在一定的前提下进行的。比如上述代码第三步和第四步的执行顺序发生变化,对最终输出的i和j的值会产生影响吗?答案当然是不会的。原因是这两者之间没有存在数据依赖。所以在进行重排序的一个重要前提是数据之间前后不会存在依赖关系。

对于重排序主要分为三类:

1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

 

二:java内存模型

既然要深入了解volatile,这就需要我们了解java的内存模型,看看java内存模型在多线程情况下给予了我们哪些保证。

在java中,实例化数据,基本类型数据都是存放在堆内存中,而堆内存是线程共享的,局部变量不会在线程中共享,所以局部变量不会存在内存可见性问题。

Java内存模型(JMM)定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(也可叫工作内存),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

如图所示,线程操作的变量实际上操作的是本地内存中的副本,而不是主存,这样大大提高了操作的效率,从而也引发了一些多线程的问题。

问题:线程A对共享的变量i = 1进行加1操作,然后赋值给本地内存A,此时本地内存A中i的值为2。在线程A还没有进行刷新主存操作的时候,线程B对共享的变量i = 1进行加2操作,赋值给本地内存B,这时本地内存B中i的值是3。在最后一步刷新主存的时候,事实上我们无法准确的知道变量的值。

解决思路:上述就是内存中的数据可见性问题,这两个线程之间操作了同一个共享变量,但彼此之间

并没有进行线程通信。所以线程间进行通信可以解决这个问题。

解决办法:线程A把本地内存A中更新过的共享变量刷新到主内存中去。线程B到主内存中去读取线程A之前已更新过的共享变量。接下来就会介绍volatile对此的作用。

三:volatile的语义和实现原理

1:volatile的语义

当声明共享变量为volatile后,对这个变量的读或写将会很特别。

volatile的两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

2)禁止进行指令重排序。

给大家演示如下代码:

public class TestVolatile extends Thread {
    boolean flag = false;
    int i = 0;
    public void run() {
        while (!flag) {
            i++;
        }
    }
    public static void main(String[] args) throws Exception {
        TestVolatile vt = new TestVolatile();
        vt.start();
        Thread.sleep(2000);
        vt.flag = true;
    }
}

这段代码有很严重的问题。主线程设置flag为true,完全没有任何作用,结果会导致子线程死循环。原因就是上面我们说到的,子线程将共享的变量flag拷贝了一份副本到本地内存中,尽管主线程在后来修改了flag的值,但子线程仍然不知道。解决的方法:

public class TestVolatile extends Thread {
    volatile boolean flag = false;  // 使用volatile修饰共享变量
    int i = 0;
    public void run() {
        while (!flag) {
            i++;
        }
    }
    public static void main(String[] args) throws Exception {
        TestVolatile vt = new TestVolatile();
        vt.start();
        Thread.sleep(2000);
        vt.flag = true;
    }
}

使用volatile修饰共享变量就可以解决。

2:volatile的实现原理(来自于《JAVA并发编程的艺术》)

Java代码: instance = new Singleton();  // instance是volatile变量

转变成汇编代码,如下。

0x01a3de1d: movb $0×0,0×1104800(%esi);

0x01a3de24: lock addl $0×0,(%esp);

有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查IA-32架构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情。

(1  将当前处理器缓存行的数据写回到系统内存。

(2  这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

四:volatile和synchronized的使用

通过上面所说的,我们已经知道了volatile修饰的共享变量在线程中拥有可见性,文章开头我们说过,原子性,可见性,有序性三个特性必须全部满足才能确保结果的准确。


public class TestThead {
    volatile static int i = 10;
    public static void main(String[] args) {
        for (int j = 0; j < 100; j++) {
            add("demo");
        }
    }

    private static void add(String str) {
        i = i + 10;
        System.out.println(Thread.currentThread().getName() + "_______" + i);
    }
}

在上述代码中,只存在一个主线程,最终的结果是1010,这是正确的。现在,我们准备另外再开启100个线程。


public class TestThead {
    /**
     * 在单线程情况下正确结果是1010
     */
    volatile static int i = 10;
    public static void main(String[] args) {
        for (int j = 0; j < 100; j++) {  // 循环开启100个线程
            ExecutorUtil.getInstance().execute(new Runnable() {
                @Override
                public void run() {
                    add("demo");
                }
            });
        }
    }

    private static void add(String str) {
        i = i + 10;
        System.out.println(Thread.currentThread().getName() + "_______" + i);
    }
}

最后输出的结果没有办法确定,尽管我们使用了volatile修改共享变量i。原因在于add方法中的i = i+10不是一个原子操作,没有保证原子性。

经过改良。如下代码:


public class TestThead {
    /**
     * 在单线程情况下正确结果是1010
     */
    volatile static int i = 10;
    public static void main(String[] args) {
        for (int j = 0; j < 100; j++) {  // 循环开启100个线程
            ExecutorUtil.getInstance().execute(new Runnable() {
                @Override
                public void run() {
                    add("demo");
                }
            });
        }
    }

    private static void add(String str) {
        synchronized (str.intern()) {
            i = i + 10;
            System.out.println(Thread.currentThread().getName() + "_______" + i);
        }
    }
}

最后一版代码就能保证结果的准确了,我们加入了synchronized锁来保证操作的原子性。所以,volatile的使用需要格外谨慎。当然这个例子并不是太好,因为既然使用了synchronized锁,那还需要使用volatile吗?是的,在这个例子中确实不需要了。我主要是让大家明白,volatile并不能凭借自身的可见性去满足并发编程中的关键要素。

记录自己的学习和成长,如果写的有什么不对的地方,还请大家多多指正。

 

参考资料:

《JAVA并发编程的艺术》

https://www.cnblogs.com/dolphin0520/p/3920373.html

http://www.cnblogs.com/daxin/p/3364014.html

 

 

发布了21 篇原创文章 · 获赞 18 · 访问量 7580

猜你喜欢

转载自blog.csdn.net/love1793912554/article/details/88618453