Talk is cheap , show me your code!
欢迎来到付振南Java博客,让我们一起学习Java吧!

Java并发系列之volatile详解 你真的知道DCL为什么要加volatile吗?

Java并发系列之volatile详解

在Java的并发编程中,synchronized和volatile不得不提,他们在整个Java并发编程中扮演着十分重要的角色。关于synchronized的详解,前面已经写过一篇文章来讲解,现在,我们再来谈一谈volatile。

volatile我们主要分以下几个方面来讲。

volatile

Java内存模型(JMM)

假设你已经知道了操作系统的知识,处理器运行的速度是非常快的,而内存的读写速度对处理器来说是较慢的,所以处理器和内存之间会有一个速度上的矛盾。

为了提高处理器的效率,协调处理器和内存之间的矛盾是很有必要的。目前基于高速缓存的存储交互很好的解决了cpu和内存等其他硬件之间的速度矛盾,只需要遵循相关的缓存一致性协议即可。

在Java中,线程是通过cpu调度的,主内存存放一些共享变量,所以每个线程都会有一个私有的本地内存来解决处理器和内存的速度矛盾,线程之间的通信通过JMM来控制。

JMM

volatile的作用

volatile是Java虚拟机提供的轻量级同步机制,使用volatile主要有二大作用,保证内存可见性禁止指令重排序,但是他不能保证操作原子性

保证内存可见性

前面的Java内存模型我们讲到主内存存放线程之间共享的变量,每个线程拷贝共享变量副本到自己的本地内存中,主内存是共享的,而每个线程的本地内存是私有的。

假设主内存中有一个变量flag的值为false,线程A负责写,线程B负责读。线程A首先将主内存中flag拷贝回自己的本地内存中,然后将flag的值从false修改为true,此时还没来得及把最新flag=true的值写回到主内存中,线程B过来读取主内存的flag了,然后将flag=false旧值拷贝回线程B的本地内存中,导致数据出错,这就是一个很典型的内存不可见性问题了。

我们通过一段代码来演示一下。

/*
 * @author fuzhennan
 */
public class TestVolatile {
    public static void main(String[] args){
        ThreadDemo td = new ThreadDemo();
        new Thread(td).start();
        while (true){
            if (td.isFlag()){
                System.out.println("-----------");
                break;
            }
        }
    }
}

class ThreadDemo implements Runnable{

    private boolean flag = false;

    public boolean isFlag() {
        return flag;
    }

    public void setFlag(boolean flag) {
        this.flag = flag;
    }

    @Override
    public void run() {
        try{
            Thread.sleep(100);
        }catch (InterruptedException e){
            e.printStackTrace();
        }
        flag=true;
        System.out.println("flag= "+isFlag());
    }
}

子线程相当于A线程,负责写,main线程相当于B线程,负责读。子线程将flag由默认值false改为true,然后打印flag的值,然后在main线程的一个死循环里面判断如果flag=true,就打印横线并且中断死循环。

如果说线程AB之间互相可见的话,那么线程A修改完flag的值,线程B立马就能知道flag最新值为true了,然后main线程能成功打印出横线,并且中断死循环,正常退出。

我们来执行一下代码看看结果。

好像并没有打印出横线,而且程序没有正常退出,还在死循环里。

这就说明线程之间是内存不可见的,线程A将flag值修改为true后,还没来得及写回到主内存中,线程B就来读flag值了,所以读取到的就是false,永远在死循环里,不会打印横线,因为线程B并不知道线程A修改了flag值。

volatile就是用来解决这个问题的,volatile可以保证内存可见性,当一个变量被 volatile 修饰后,表示着线程本地内存无效。当一个线程修改共享变量后他会立即被更新到主内存中;当其他线程读取共享变量时,它会直接从主内存中读取。

我们在上面的代码给flag加上volatile再试试运行结果。
private volatile boolean flag = false;

正常打印出横线,并且程序正常退出。

禁止指令重排序

在执行程序的时候,为了提高性能,编译器和处理器常常会对指令做重排序。

重排序分为三大类。

  • 编译器优化重排序
  • 指令级并行重排序
  • 内存系统重排序

在讲重排序之前,我们需要了解两个概念,happens-before和as-if-serial。

happens-before

happens-before是用来阐述操作之间的内存可见性,也就是说如果一个操作执行的结果要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。

注意:

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行! happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)
-----摘自《Java并发编程的艺术》3.1.5

as-if-serial

serial就是连续的意思,as-if-serial是指不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器和处理器不会对存在数据依赖关系的操作做重排序。
a=1;b=a;
a=1;a=2;
a=b;b=1;

上面三种情况,只要发生了重排序,程序的结果就会发生改变。因为a和b之间存在依赖关系。

我们都知道圆的面积计算公式是pi * r * r

double pi = 3.14;//A
double r = 1.0;//B
double area = pi * r * r;//C

那么A和B之间有没有依赖关系呢?答案是显而易见的,不管AB哪个先执行,对面积都不会产生影响,所以如果有需要的话,A和B是可能会发生重排序的。

在单线程中,重排序遵循as-if-serial语义,所以不会有什么影响,但是在多线程中呢?

假设有两个变量。

    int a = 0;
    boolean flag = false;

然后有一个线程负责写,

    public void write(){
        a = 1;//A
        flag = true;//B
    }

一个线程负责读,

    public void read(){
        if (flag){
            a=a+5;
            System.out.println(a);
        }
    }

那么答案是多少呢?6?还是5?答案是不确定的。

A和B语句在写线程中不存在依赖关系,所以是可能发生重排序的,假设A先执行,在B,a的结果就是6,那如果重排序后B先执行呢?B先执行然后程序太快还没来得及执行A就读线程就执行了,那么a的结果就是5了。

所以在多线程环境下,加volatile可以禁止指令重排序。

不保证操作原子性

volatile是不能保证操作原子性的。原子性操作是指什么呢?在化学中我们知道原子是不可分割的,同理,在Java中,原子操作就是操作不可分割的,要么全部执行,要么全部不执行。

我们知道i++不是操作原子性的,他是分三步走的,读改写。

int temp = i;
i = i + 1;
i = temp;

在并发环境下,任何一步都有可能先执行,造成最后结果不一致,假设i的初始值为0,即使有100个线程访问i++,最终结果也未必是100。

既然volatile不能保证操作原子性,我们可以通过加synchronized锁或者使用juc下的Atomic包,比如AtomicInteger等,来实现数据的操作原子性。

volatile与synchronized的区别

  • volatile是同步机制的轻量级实现,而synchronized还有一个锁升级的过程。
  • volatile可以保证内存可见性,但是不能保证原子性,而synchronized都可以保证
  • 多线程环境下,volatile不会发生阻塞,而synchronized可能发生阻塞。
  • volatile主要用于解决多个多个线程之间的可见性问题,而synchronized主要用于解决线程之间共享资源的同步性问题。

volatile的应用

了解DCL(双重检验锁单例模式)的肯定知道,需要用到volatile。我们先看看DCL的代码吧。

public class TestDCLSingleton {
    public static void main(String[] args){
        for (int i = 0; i <= 10 ; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    DCLSingleton.getInstance();
                }
            },String.valueOf(i)).start();
        }
    }
}

class DCLSingleton{
    /**需要加volatile禁止指令重排序*/
    private volatile static DCLSingleton instance;

    private DCLSingleton(){
        System.out.println(Thread.currentThread().getName()+":"+"我是构造函数");
    }

    public static DCLSingleton getInstance(){
        /**先判断对象是否被实例过,没有实例化在进行加锁*/
        if (instance == null){
            //类对象加锁,假设线程1拿到锁,往下执行,实例化对象,释放锁,
            // 线程2拿到锁,如果不判断instance是否已经被实例化过,就会多次实例化,违背了单例的原则
            synchronized (DCLSingleton.class){
                //第二次检测对象是否已经被之前的线程实例过
                if (instance == null){
                    instance = new DCLSingleton();
                }
            }
        }
        return instance;
    }
}

在这里我们需要给instance加volatile。
DCL机制不一定线程安全,原因是有指令重排序的存在,加入volatile可以禁止instance = new DCLSingleton()指令重排序,同时也使得在多线程环境下的instance达到内存可见性。

原因在于第一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有初始化完成。
instance = new DCLSingleton(); 可以分为三步完成:
1.分配对象内存空间 2.初始化对象 3.设置instance指向刚刚分配好的内存地址。
当一条线程访问instance不为null时,由于instance实例未必已经初始化完成,所以会造成线程安全问题。

参考资料

  1. 《Java并发编程的艺术》
  2. 《深入理解Java虚拟机-JVM高级特性与最佳实践》
赞(3) 打赏
未经允许不得转载:付振南Java博客 » Java并发系列之volatile详解

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏