本文核心理论来自《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的存在使得共享数据发送变化时做了几件事情:
- 数据发生变化,立刻写到主内存里。
- 写到主内存会导致其他处理器(线程)的缓存(这里的缓存也就是本地内存里的数据)无效。
- 既然其他线程的缓存无效,那么它们只能去主内存取了。
这里引入了缓存一致性协议的原理:
当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
这就是原子性被破坏了,那么如何保证原子性呢?
- 使用synchronized关键字:
synchronized(a){
a++;
}
这样同一时间只有一个线程访问a,三个过程安全的执行。
- 使用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();
在指令层面上,这段代码执行了几个指令:
- 分配一个新的内存空间,用于存放一个Singleton实例
- 使用构造函数对实例进行初始化
- 将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都读出来,然后对他们进行计算,最后再将他们写入内存,这样就很连贯,也避免一会儿写,一会儿读。
当然了,在单线程下,重排序没毛病,重排序在三个期间会发生:
- 编译器优化的重排序。对语句进行重排序,当然了,要分析过语句之间的数据依赖性。
- 指令级并行的重排序。CPU的指令级并行技术。
- 内存系统的重排序。
只有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并发编程的艺术》。