volatile是轻量级的synchronized,但是volatile不会引起线程的上下文切换和调度。
共享变量的可见性
volatile在多核处理器进行开发时保证了共享变量的可见性,即当一个线程修改这个变量时,其他线程能立马得到最新修改的值。
1. volatile的硬件实现原理
- 为了提高处理速度,避免内存IO速度的木桶短板,现代处理器不直接和内存进行通信,而是将内存中的数据读取到CPU的内部高速缓存中(L1,L2,L3等),这里普及一下高速缓存的概念(cache),高速缓存一般集成在CPU内部,保存着CPU刚用过或循环使用的一部分数据,是内存数据的部分拷贝,计算机内部的数据通信为:CPU <--> 寄存器 <--> 高速缓存 <--> 内存
- 对volatile变量的写操作,会在正常汇编指令前加一个lock前缀的指令。lock前缀在多核处理器中会发生以下两件事情:1)lock前缀指令将引起当前处理器缓存的数据写回到系统内存。对于内存中可以缓存并已经缓存的数据,系统不会在总线上声言LOCK#信号而是锁定这块内存区域的缓存并写回到内存中(锁缓存);如果内存中的数据没有被缓存,那么将在总线上声言LOCK#信号,锁住总线,并将数据写回到内存中。2)处理器的缓存写回到内存中将会导致其他处理器的缓存无效。 多核CPU之间的缓存一致性协议(锁缓存的保证):每个处理器通过嗅探总线上有其他处理器写内存地址,如果处理器发现正好是自己缓存行所对应的内存地址,就会将当前处理器的缓存行设置成无效状态,当处理器再次访问这个数据时,就会重新将内存中的数据加载到处理器缓存中。当然如果是锁总线,那么就不需要缓存一致性协议来保障,因为锁总线将会独占所有共享内存。
- CPU缓存一致性(MESI 协议及 RFO 请求)
M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有);
E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据;
S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝;
I(无效,Invalid):缓存行失效, 不能使用。
下面说明这四个状态是如何转换的:
- 初始:一开始时,缓存行没有加载任何数据,所以它处于 I 状态。
- 本地写(Local Write):如果本地处理器写数据至处于 I 状态的缓存行,则缓存行的状态变成 M。
- 本地读(Local Read):如果本地处理器读取处于 I 状态的缓存行,很明显此缓存没有数据给它。此时分两种情况:(1)其它处理器的缓存里也没有此行数据,则从内存加载数据到此缓存行后,再将它设成 E 状态,表示只有我一家有这条数据,其它处理器都没有;(2)其它处理器的缓存有此行数据,则将此缓存行的状态设为 S 状态。(备注:如果处于M状态的缓存行,再由本地处理器写入/读出,状态是不会改变的)
- 远程读(Remote Read):假设我们有两个处理器 c1 和 c2,如果 c2 需要读另外一个处理器 c1 的缓存行内容,c1 需要把它缓存行的内容通过内存控制器 (Memory Controller) 发送给 c2,c2 接到后将相应的缓存行状态设为 S。在设置之前,内存也得从总线上得到这份数据并保存。
- 远程写(Remote Write):其实确切地说不是远程写,而是 c2 得到 c1 的数据后,不是为了读,而是为了写。也算是本地写,只是 c1 也拥有这份数据的拷贝,这该怎么办呢?c2 将发出一个 RFO (Request For Owner) 请求,它需要拥有这行数据的权限,其它处理器的相应缓存行设为 I,除了它自已,谁不能动这行数据。这保证了数据的安全,同时处理 RFO 请求以及设置I的过程将给写操作带来很大的性能消耗。
2. volatile的特性
- 可见性:对一个volatile变量的读,总是能看到其他线程对这个变量最新的修改。
- 原子性:volatile变量的单个读/写操作是原子性的且具有可见性,复合操作(依赖当前值的读写复合操作等,比如i++;以及该变量包含在具有其他变量的不变式中)不具有原子性。
3. volatile的内存语义
java虚拟机内存模型中存在主内存和工作内存之分,实际上并不存在,只是抽象出来的用来表述问题的概念。主内存中存储的是各个线程共享的数据,每一个线程都有一份与其他线程隔离独立的数据空间叫做工作内存,工作内存一部分存储的是线程私有数据,一部分是主内存中共享数据的拷贝。
对于普通共享变量:线程持有主内存中共享变量的数据拷贝,当发生读操作时,线程首先在自己的拷贝中查找,如果没有则从主内存中拷贝;发生写操作时,将会修改线程拷贝的数据,而不是主内存中的共享数据,所以无法保证共享变量的可见性。
volatile的写内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。和锁synchronized的释放内存语义一致
volatile的读内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效。线程接下来将从主内存中读取共享变量。和锁synchronized的获取内存语义一致
java编译器通过在volatile的读写前后插入内存屏障指令(指令重排序不可越过内存屏障)来禁止特定类型的编译器和处理器重排序来实现上述内存语义。
具体为:1) 编译器禁止volatile读与volatile读后面的任意内存操作重排序。2) 编译器禁止volatile写与volatile前面的任意内存操作重排序。
volatile的写-读和锁的释放-获取具有相同的内存语义:volatile的写和锁的释放有相同的内存语义,volatile的读与锁的获取有相同的内存语义。
4. JSR-133(从JDK5开始)对volatile内存语义的增强
在JSR-133之前,虽然不允许volatile变量之间的重排序,但是旧的Java内存模型允许对volatile变量与普通变量重排序,因此旧的内存模型中,volatile的写-读没有锁的释放-获取所具有的内存语义。
为了提供一种比锁更轻量的线程间通信机制,JSR-133增强了volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
5. 应用场景
- 轻量级的“读-写锁”策略
即通过锁来实现独占写锁,使用volatile实现共享的读锁(多个线程可以同时读value值)
private volatile int value;
public int getValue(){ return value;}
public synchronized void doubleValue(){ value = value*value; }
- 状态标志,用来对线程接下来的逻辑进行控制
private volatile boolean status = false;
public void shutdown(){ status = true;}
public void doWork(){
while(!status){
doTask();
}
}
- 单例模式(双检查锁机制)
利用volatile修改的可见性以及禁止特定指令的重排序和synchronized的独占性保证同时只有一个线程进入同步方法块,既保证了其高效性,也保证了其线程安全性
private volatile static Singleton instace;
public static Singleton getInstance(){ // 没有使用同步方法,而是同步方法块
//第一次null检查 ,利用volatile的线程间可见性,不需要加锁,性能提高
if(instance == null){
synchronized(Singleton.class) { //锁住类对象,阻塞其他线程
//第二次null检查,以保证不会创建重复的实例
if(instance == null){
instance = new Singleton(); // 禁止重排序
}
}
}
return instance;
instance = new Singleton();
语句编译成字节码指令一般是含有这两条指令:new,invokespecial(new指令顺序先于invokespecial指令),其中new用来分配对象内存空间并初始化默认值并返回堆对象的引用,而invokespecial指令用来调用对象自定义初始化方法
这条语句instance = new Singleton();
可以分解为三个步骤(正常指令顺序):
- memory = allocate(); // 分配内存空间 是new指令的一部分
(memory); // 初始化对象 对应invokespecial调用对象自定义初始化方法 - instance = memory ; // 返回分配的内存地址引用 是new指令的一部分
而且编译器允许2和3之间的重排序(处理器重排序)。如果3排在2的前面,如果不使用volatile关键字,可能发生还没有完成自定义初始化,value变量的值已经不为null了,此时其他线程读操作获取的将是未完全初始化的实例。而volatile通过禁止2和3的重排序而避免这种情况(3为volatile写)。
防止对象逸出
- 安全发布对象
如果一个对象是可变对象,那么它就要被安全发布,通常发布线程与消费线程必须同步化。一个正确创建的对象可以通过下列条件安全发布:
- 通过静态初始化器初始化对象引用。 如public static Holder holder=new Holder();
- 将发布对象的引用存储到volatile域或者具有原子性的域中,如AtomicReference。
- 将发布对象引用存放到正确创建的对象的final域中。
- 将发布对象引用存放到由锁保护的域中(如:同步化的容器)。
- 高效不可变对象
一个对象是可变的,但是它的状态不会在发布后被修改,这样的对象称作“高效不可变对象”。任何线程都可以在没有额外同步的情况下安全的使用一个高效不可变对象,但前提是这些对象必须被安全发布,即必须满足上面提到的安全发布条件。
- 安全地共享对象
现在我们来总结一下,在并发编程中的一些安全共享对象的策略。
1、线程限制:一个被线程限制的对象,由线程独占,并且只能被占有它的线程修改。
2、共享只读:一个共享只读的对象,在没有额外同步的情况下,可以被多个线程并发访问,但是任何线程都不能修改它。如高效不可变对象。
3、线程安全对象:一个线程安全的对象或者容器,在内部通过同步机制来保证线程安全,所以其他线程无需额外的同步就可以通过公共接口随意访问它。
4、被守护对象:被守护对象只能通过获取特定的锁来访问。
6. 总结
通过对volatile变量禁止指令重排序来实现单线程的顺序执行,而volatile的写操作的lock CPU指令保证了多线程(CPU内核)之间共享内存的同步。
7.补充
想象不到的陷阱:不需并发控制的普通共享变量也会因为处于同一缓存行而不能同时被访问。
参考伪共享,并发编程无声的性能杀手