Java中的volatile与可见性、原子性、有序性

614 阅读7分钟

本文核心理论来自《Java并发编程的艺术》

volatile是Java中的一个关键字,它旨在实现多线程下共享变量的可见性。

为什么要说到可见性?

可见性是什么?在一个单线程Java程序中,一份变量只会被一个线程所访问,所以,变量对所有线程都是可见的(当然这是一句废话)。

但是如果一个变量被被多个线程所访问呢?那么就会产生问题:也许线程获取的变量并不是最新的,这很奇怪,但是这是Java内存模型所导致的。

Java内存模型简述:

在Java运行时,有一块公共的区域用于存放变量,这是主内存

但是对于各个线程而言,他们并不一定直接通过主内存访问共享变量,他们各自有一块本地内存,其中存储了该线程以读/写共享变量的副本。

概括地说,一个多线程共享变量,不仅仅会存在于主内存,还存在于本地内存。

那问题就来了,如果一个线程在自己的本地内存里修改了某共享变量,而它没有及时地去把修改后的变量再存放到主内存,那么就导致前面所说的问题:另一个线程获取不到最新的共享变量了,也就是不可见了

volatile利用什么工作机制保证了可见性?

boolean stop = false;
//线程1
while(!stop){
    doSomething();
}

//线程2
stop = true;

上一段代码是一个非常典型的不可见例子。

这段代码里,while可能跳不出来。原因是,stop变量不可见了。

线程2对stop变量的修改并不一定能反映到线程1里去。

但是如果我们修改成:

boolean volatile stop = false;
//线程1
while(!stop){
    doSomething();
}

//线程2
stop = true;

就变得不一样了,这段程序可以正常运行。

volatile的存在使得共享数据发送变化时做了几件事情:

  1. 数据发生变化,立刻写到主内存里。
  2. 写到主内存会导致其他处理器(线程)的缓存(这里的缓存也就是本地内存里的数据)无效。
  3. 既然其他线程的缓存无效,那么它们只能去主内存取了

这里引入了缓存一致性协议的原理:

当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

当然了,这就是volatile实现可见性。

不过,你并不一定只能通过volatile来实现可见性,你也可以使用synchronized关键字:

boolean stop = false;
//线程1
while(!stop){
    doSomething();
}

//线程2
synchronized(stop){
    stop = true;
}

synchronized规定,线程在加锁时:

先清空工作内存→在主内存中拷贝最新变量的副本到工作内存→执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁

通过对共享变量的一个内存控制,synchronized直接就解决了可见性问题。

但是它的弊端就是:引起线程上下文的切换和调度。

换句话说,如果使用volatile的方式合适,那么它能比synchronized在实现可见性上花费的成本更低(它不会阻塞线程)。

变量原子性被破坏

何谓原子性?

int a = 1;
a++;

这段代码中a++并不是一个操作,而是三个操作:

1. 获取a的值
2. 计算一下a+1的值
3. 将计算好了的a+1的值写到a里去

显而易见,这段代码在多线程环境下,仍是罪恶的根源。。。

举一个多线程中a++的情况,假设有A B 两个线程,他们都要执行a++,那时序上,他们可能会这样:

A:1.获取a的值  a==1
B:1.获取a的值  a==1
B:2.计算a+1  a+1=temp_b=2
B:3.将计算好了a+1写回a  2->a
A: 2.计算a+1  a+1=temp_a=2
A:3.将计算好了a+1写回a  2->a

糟糕的结果就是a最后都是2

这就是原子性被破坏了,那么如何保证原子性呢?

  1. 使用synchronized关键字:
synchronized(a){
    a++;
}

这样同一时间只有一个线程访问a,三个过程安全的执行。

  1. 使用AtomicInteger,这是JDK自带的保证原子性的整形类,具体实现请自行学习。

那么,volatile能不能保证原子性呢?很可惜,是不能的。

虽然volatile不能实现原子性,但是它还实现了有序性

首先来一段非常经典的单例模式实现:

class Singleton{
    private static Singleton instance = null;

    private Singleton() {
    }

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

但是就算利用了双检查机制,此段单例模式仍然出现问题。

问题的根源来源于指令的重排序

我们来看看其中的这段代码:

instance = new Singleton();

在指令层面上,这段代码执行了几个指令:

  1. 分配一个新的内存空间,用于存放一个Singleton实例
  2. 使用构造函数对实例进行初始化
  3. 将instance引用指向实例

我们知道,当instance引用指向了一个实例的时候,instance就不会是null了(表示它引用了一个对象),但是问题在于,这三个指令不一定是1->2->3,而有可能是1->3->2:

这会导致,第二部中的初始化还没做,instance引用就指向一个对象了。

这就直接会导致某些线程报错,因为它们以为引用已经指向一个被初始化好的对象了,说起来也挺奇怪的,但是这就是指令重排序导致的问题。

但是,指令重排序是有好处的:

CPU计算要访问值,如果值一直都在寄存器中就不用去内存读取了,看下面的一段代码:

int a = 1;
a = a + 1;
int b = 2;
a = a + 2;
a = a + 3;
b = b - 1;

我们发现,明明有关a的操作是可以一起做的,但是在中间却乱入了有关b的读取与写入。

那也就是说,我们可以先把a、b都读出来,然后对他们进行计算,最后再将他们写入内存,这样就很连贯,也避免一会儿写,一会儿读。

当然了,在单线程下,重排序没毛病,重排序在三个期间会发生:

  1. 编译器优化的重排序。对语句进行重排序,当然了,要分析过语句之间的数据依赖性。
  2. 指令级并行的重排序。CPU的指令级并行技术。
  3. 内存系统的重排序。

只有1是在编译期发生的。

volatile有能力防止重排序

使用Lock前缀指令实际上如同一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面。

在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

volatile int a = 1;
int c = 1;

void func()
{
    int b = 1; //1
    b++; //2
    a++; //3
    b++; //4
    c++; //5
    b++; //6
}

在以上语句中,由于第三句是利用了volatile变量a,所以语句3之前的语句可能会重排序,语句3后的语句也可能被重排序,but,第三句就会待在中间

这是内存屏障的一个基本概念,需要详细了解请去参考《Java并发编程的艺术》。