关键字volatile可以说是java虚拟机提供的最轻量级的同步机制。
当一个变量定义为volatile之后,将具备两种特性:
可见性
(1)volatile保证了变量对所有线程可见,但是并不保证原子性,也不是安全的;可见性是因为使用volatile变量之前都会进行刷新,而不安全的原因是因为java中的运算并不是原子性的,因为java中的运算需要先转成字节码代码,即一条java代码可能就会变成多条字节码指令,那么多线程情况下,其中一个线程运行某条指令的时候,其他线程可能已经对变量值进行了回写操作,那么这个时候运算过程中使用的变量值就是过期的。就算只有一条字节码指令,也可能出现这样的情况,因为运算任务最终转成机器码执行,而一条字节码有可能转成多条机器码
volatile的原子性,只能保证对任意单个volatile变量的读写操作,但是不能保证复合操作的原子性,比如volatile++这样的复合操作。
保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量的值,在线程间传递需要通过主内存来完成,即A线程修改了变量值,则需要从A线程的工作内存中将变量值存储写入到主内存中,然后B线程在使用该变量的时候,都需要线程A回写完成之后,再从主内存中获取到最新的值,这样新的变量值才会对线程B可见。
虽然volatile变量的写操作都能立即反应到其他的线程中,但是volatile变量在各个线程工作内存中并不存在一致性问题,并不能得出基于volatile变量的运算在并发线程中是安全的这样的结论。volatile变量是可以存在不一致的情况,但是由于volatile变量每次使用之前都会先刷新,因为在volatile变量执行将数据写入主内存中时,会使其他CPU的工作内存中缓存的该变量内存地址变为无效,所以需要刷新。执行引擎看不到不一致的情况,因此可以认为不存在一致性的问题。
不过java里面的运算并不是原子操作,而是非原子的,导致volatile变量的运算在并发下一样是不安全的,java中的安全是通过添加锁定和解锁的方式来将主内存中的变量锁定,然后保证运算的原子性,这样才保证了安全,但是volatile变量的运算并没有通过锁定和解锁。
public class VolatileTest{
public static volatile int race = 0;
public static void increase(){
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for(int i=0;i<THREADS_COUNT;i++){
threads[i] = new Thread(new Runnable(){
@Override
public void run(){
for(int i=0;i<10000;i++){
increase();
}
}
});
threads[i].start();
}
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.println(race);
}
}
如果volatile变量是安全的,是原子性的,那么上述的操作结果就应该是200000,但是实际运行结果并不是,而且每次运行结果都不一样。
其实问题就是出在了race++这个自增运算中。通过javap反编译这段代码后,我们看increase函数的字节码指令
public static void increase();
code:
Stack=2,Locals=0,Args_size=0
0: getstatic #13;// Field race:I
3: iconst_1
4: iadd
5: pubstatic #13;//Field race:I
8: return
LineNumberTable:
line 14:0
line 15:8
从increase的字节码代码可以看出,increase函数其实就一行的方法体,但是在Class文件中是由4行字节码指令构成的(return指令并不是由race++产生的),
从字节码层面上很容易分析出并发失败的原因:当getstatic指令把race的值取到操作数栈顶的时候,volatile关键字保证了race的值此时是正确的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把race的值加大了,而在操作数栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race值同步回主内存中。
就算java代码编译出来只有一条字节码指令,也不能说是原子操作,因为一条字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语义,如果是编译执行,一条字节码指令可能转化成若干调本地机器码指令。
禁止指令重排序
- 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全 部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能 把 volatile 变量后面的语句放到其前面执行。
(2)使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。即普通变量可能在赋值的时候可能最终能获取到正确的结果,但是变量赋值的顺序可能是串行的,即先写的变量赋值代码可能是在之后赋值的,而后写的变量赋值代码可能是先写的,除非后一个赋值操作需要依赖于前一个赋值操作,才会有顺序。
而使用volatile变量,能够保证该变量的赋值严格按照字节码指令顺序执行机器码指令,如果不使用volatile关键字,那么代码在转成机器码代码之后,运行机器码代码的时候会由于指令重排序的优化,将需要后运行的代码提前运行(字节码指令在解释器中解释运行的时候会转成机器码代码),即只有在volatile变量赋值之前的赋值操作都执行完成之后才会对volatile变量进行复制。
使用volatile关键字,相当于一个内存保障,这样有多个CPU的时候,就可以通过这个内存保障来保障一致性。其实使用volatile关键字,其实从汇编代码可以看出差别,使用了volatile关键字的变量在赋值的时候会多执行一条带有lock的汇编代码。内存屏障的存在,指令重排序不能把内存屏障后面的指令放到内存屏障之前运行
在每个 volatile 写操作的前面插入一个 StoreStore 屏障。 该屏障除了保证 了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证了 volatile 写操作之前,任何的读写操作都会先于 volatile 被提交。
在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。 该屏障除了使 volatile 写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使 volatile 变量的写更新对其他线程可见。
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。 该屏障除了使 volatile 读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存, 使 volatile 变量读取的为最新值。
在每个 volatile 读操作的后面插入一个 LoadStore 屏障。 该屏障除了禁止 了 volatile 读操作与其之后的任何写操作进行重排序,还会刷新处理器缓 存,使其他线程 volatile 变量的写更新对 volatile
指令重排序(处理器执行机器码指令的重排序)是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。但是并不是说指令任意重排序,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。有依赖关系的多条指令,在执行的时候依然是有序的,而没有依赖关系的,则可以通过重排序优化,将后运行的代码并发的交由电路单元执行,然后赋值。
通过下面的例子来看指令重排序:
Map configOptions;
char[] configText;
// 此变量必须定义为volatile
volatile boolean initialized = false;
// 假设一下代码在线程A中执行
// 模拟读取配置信息,当读取完后将initialized设置为true以通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假设以下代码在线程B中执行
// 等待initialized变为true,代表线程A已经把配置信息初始化完成
while(!initialized) {
sleep();
}
// 使用线程A中初始化好的配置信息
doSomethingWithConfig();
这是一段伪代码。如果定义initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一句的代码“initialized=true”被提前执行(这里虽然使用java作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行是指这句话对应的汇编代码被提前执行),这样在线程B中使用配置信息的代码就可能出现错误,而volatile关键字则可以避免此类情况的发生。
分析双重检索的单例
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) { // 1
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton(); //2
}
}
return instance;
}
}
为什么单例模式的双重检索模式需要使用到volatile关键字呢?
假如线程1作为第一个调用单例模式的getInstance()方法,那么这个时候就会对instance进行初始化赋值,而instance = new Singleton();并不只是执行一条指令,而是要执行多条机器指令,那么这个时候就可能会因为这个操作的非原子性以及处理器指令重排序的原因导致出现以下问题:
- 比如非原子性,在执行指令执行到一半的时候发生了线程切换的情况,可能是因为时间片用完发生轮转,导致线程去处理其他任务,而这个时候线程2又调用了getInstance(),但是此时instance并不为null,不过instance的初始化工作并没有完成,则这个时候的instance的值就会有问题;
- 而指令重排序,则可能是在初始化instance的时候,初始化对象和分配地址的顺序发生了一个变化,比如先分配地址,后初始化对象,那么在分配地址之后,此时instance不为null,因为这里的判断是否为null,是判断instance的地址是否为null,那么这个时候初始化工作还没有完成,线程2调用getInstance()获取对象的时候,其实也是还没有初始化完成,则发生了错误。
比如:
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
根源在于代码中的 2 和 3 之间,可能会被重排序。例如:
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象
那么这里有一个疑问?为什么双重检索去了一次检索就不行了?
- 对应第一个检索而言,如果去除第一个检索,那么就会变成完全同步,所有的线程都会串行运行,效率低下。
- 而对于第二个检索而言,如果没有第二次检索,当两个线程同时调用getInstance()方法的时候,此时instance返回给两个线程的结果都是null,则进入第一次检索条件,此时因为synchronized同步锁,那么就会有一个线程先进入同步代码块,并且进入第二次检索,而另一个线程就会在外面等待,当第一个线程执行完new Singleton()语句后,就会退出synchronized保护的区域,这时如果没有第二重检索,那么第二个线程就会再一次执行new Singleton()语句创建一个实例
volatile的同步机制要优于锁。如果让volatile自己与自己比较,那么可以确定一个原则:volatile变量的读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存保障指令来保障处理器不发生乱序执行。
原子性、可见性、有序性
原子性:
由java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,大致可以认为除了64位数据以外的基本数据类型的访问读写是原子性的。
可见性:
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。volatile保证可见性是能保证新值立即他同步到主内存,以及在使用前立即从主内存刷新。synchronized和final也能保证可见性。synchronized保证可见性是由堆一个变量执行unlock操作之前,必须先把变量同步回内存中;而final的可见性是指被final修饰的字段在构造器中一旦初始化完成,并且构造器并没有把”this“的引用传递出去,那么在其他线程就可以看见final字段的值。
有序性:
java内存模型的有序性
Happens-Before内存模型和程序顺序规则
程序顺序规则:如果程序中操作A在操作B之前,那么线程中操作A将在操作B之前执行。
只有在Happens-Before内存模型中才会出现这样的指令重排序问题。Happens-Before内存模型维护了几种Happens-Before规则,程序顺序规则最基本的规则。程序顺序规则的目标对象是一段程序代码中的两个操作A、B,其保证此处的指令重排不会破坏操作A、B在代码中的先后顺序,但与不同代码甚至不同线程中的顺序无关。
- 程序顺序原则:如果程序操作A在操作B之前,那么多线程中的操作依然是A在B之前执行
- 监视器锁原则:在监视器锁上的解锁操作必须在同一个监视器上的加锁操作之前执行。
- volatile变量原则:对volatile修饰的变量写入操作必须在该变量的读操作之前执行。
- 线程启动原则:线程的任何操作必须在其他线程检测到该线程结束前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive返回false。
- 中断原则:当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行。
- 终结器规则:对象的构造方法必须在启动对象的终结器之前完成。
- 传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
内存屏障
内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重 排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会 使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而 保障可见性。
常见的内存屏障
- LoadLoad 屏障: 对于这样的语句 Load1; LoadLoad; Load2,在 Load2 及后续读取操 作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。
- StoreStore 屏障: 对于这样的语句 Store1; StoreStore; Store2,在 Store2 及后续写入 操作执行前,保证 Store1 的写入操作对其它处理器可见。
- LoadStore 屏障: 对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操 作被执行前,保证 Load1 要读取的数据被读取完毕。
- StoreLoad 屏障: 对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读 取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最大 的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个 万能屏障,兼具其它三种内存屏障的功能。
举个例子:
Store1;
Store2;
Load1;
StoreLoad; //内存屏障
Store3;
Load2;
Load3;
对于上面的一组 CPU 指令(Store 表示写入指令,Load 表示读取指令),StoreLoad 屏障之前的 Store 指令无法与 StoreLoad 屏障之后的 Load 指令进行交换位置,即 重排序。但是 StoreLoad 屏障之前和之后的指令是可以互换位置的,即 Store1 可 以和 Store2 互换,Load2 可以和 Load3 互换。
Synchronized关键字实现的内存屏障效果:(synchronized 实现原理跟 monitor 的关系)
在java中最基本的互斥同步的手段就是synchronized关键字,synchronized关键字在翻译之后会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那么就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。
monitorenter指令执行时,就是获取对象锁,然后锁计数器加1;而monitorexit指令执行时,就是将锁计数器减1,当计数器为0的时候,锁就被释放。如果获取对象锁失败的时候,当前线程就要阻塞等待,直到另外一个线程释放锁为止。
synchronized同步块对于同一条线程来说是可重入的,即再同步块中可以调用另外一个synchronized同步块,并且是采用的同一个锁。