volatile的实现原理以及应用场景
volatile是轻量级的synchronized,但是volatile不会引起线程的上下文切换和调度。
共享变量的可见性
volatile在多核处理器进行开发时保证了共享变量的可见性,即当一个线程修改这个变量时,其他线程能立马得到最新修改的值。
volatile的硬件实现原理
1、 为了提高处理速度,避免内存IO速度的木桶短板,现代处理器不直接和内存进行通信,而是将内存中的数据读取到CPU的内部高速缓存中(L1,L2,L3等),这里普及一下高速缓存的概念(cache),高速缓存一般集成在CPU内部,保存着CPU刚用过或循环使用的一部分数据,是内存数据的部分拷贝,计算机内部的数据通信为:CPU <–> 寄存器 <–> 高速缓存 <–> 内存
2、对volatile变量的写操作,会在正常汇编指令前加一个lock前缀的指令。lock前缀在多核处理器中会发生以下两件事情:1)lock前缀指令将引起当前处理器缓存的数据写回到系统内存。对于内存中可以缓存并已经缓存的数据,系统不会在总线上声言LOCK#信号而是锁定这块内存区域的缓存并写回到内存中(锁缓存);如果内存中的数据没有被缓存,那么将在总线上声言LOCK#信号,锁住总线,并将数据写回到内存中。
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的过程将给写操作带来很大的性能消耗。
volatile的特性
1、可见性:对一个volatile变量的读,总是能看到其他线程对这个变量最新的修改。
2、原子性:volatile变量的单个读/写操作是原子性的且具有可见性,复合操作(依赖当前值的读写复合操作等,比如i++;以及该变量包含在具有其他变量的不变式中)不具有原子性。
3、 volatile的内存语义
java虚拟机内存模型中存在主内存和工作内存之分,实际上并不存在,只是抽象出来的用来表述问题的概念。
主内存中存储的是各个线程共享的数据,每一个线程都有一份与其他线程隔离独立的数据空间叫做工作内存,工作内存一部分存储的是线程私有数据,一部分是主内存中共享数据的拷贝。
对于普通共享变量:线程持有主内存中共享变量的数据拷贝,当发生读操作时,线程首先在自己的拷贝中查找,如果没有则从主内存中拷贝;发生写操作时,将会修改线程拷贝的数据,而不是主内存中的共享数据,所以无法保证共享变量的可见性。
volatile的写内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。和锁synchronized的释放内存语义一致。
volatile的读内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效。线程接下来将从主内存中读取共享变量。和锁synchronized的获取内存语义一致。
java编译器通过在volatile的读写前后插入内存屏障指令(指令重排序不可越过内存屏障)来禁止特定类型的编译器和处理器重排序来实现上述内存语义。
具体为:1) 编译器禁止volatile读与volatile读后面的任意内存操作重排序。
2) 编译器禁止volatile写与volatile前面的任意内存操作重排序。
volatile的写-读和锁的释放-获取具有相同的内存语义:volatile的写和锁的释放有相同的内存语义,volatile的读与锁的获取有相同的内存语义。
JSR-133(从JDK5开始)对volatile内存语义的增强
在JSR-133之前,虽然不允许volatile变量之间的重排序,但是旧的Java内存模型允许对volatile变量与普通变量重排序,因此旧的内存模型中,volatile的写-读没有锁的释放-获取所具有的内存语义。
为了提供一种比锁更轻量的线程间通信机制,JSR-133增强了volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
应用场景
1、轻量级的“读-写锁”策略。即通过锁来实现独占写锁,使用volatile实现共享的读锁(多个线程可以同时读value值)
private volatitle int value;
public int getValue(){
return value;
}
public synchronized void doubleValue(){
value = value*value;
}
2、状态标志,用来对线程接下来的逻辑进行控制
private volatitle boolean status = false;
public void shutdown(){
status = true;
}
public void dowork(){
while(!status){
doTask();
}
}
3、单例模式(双检查锁机制)
利用volatile修改的可见性以及禁止特定指令的重排序和synchronized的独占性保证同时只有一个线程进入同步方法块,既保证了其高效性,也保证了其线程安全性
private volatitle static Singleton instace;
public static Singleton getInstance(){//没有使用同步方法,而是同步方法块
//第一次null检查,利用volatile的线程间可见性,不需要加锁,性能提高
if(instance == null){
synchronized(Singleton.class){ //锁住类对象,阻塞其他线程
//第二次null检查,以保证不会创建重复的实例
if(instance == null){
instance = new Singleton(); //禁止重排序
}
}
}
return instance;
}
如果3排在2的前面,如果不使用volatile关键字,可能发生还没有完成自定义初始化,value变量的值已经不为null了,此时其他线程读操作获取的将是未完全初始化的实例。
而volatile通过禁止2和3的重排序而避免这种情况(3为volatile写)。
防止对象逸出
安全发布对象
如果一个对象是可变对象,那么它就要被安全发布,通常发布线程与消费线程必须同步化。一个正确创建的对象可以通过下列条件安全发布:
通过静态初始化器初始化对象引用。 如public static Holder holder=new Holder();
将发布对象的引用存储到volatile域或者具有原子性的域中,如AtomicReference。
将发布对象引用存放到正确创建的对象的final域中。
将发布对象引用存放到由锁保护的域中(如:同步化的容器)。
高效不可变对象
一个对象是可变的,但是它的状态不会在发布后被修改,这样的对象称作“高效不可变对象”。任何线程都可以在没有额外同步的情况下安全的使用一个高效不可变对象,但前提是这些对象必须被安全发布,即必须满足上面提到的安全发布条件。
安全地共享对象
现在我们来总结一下,在并发编程中的一些安全共享对象的策略。
1、线程限制:一个被线程限制的对象,由线程独占,并且只能被占有它的线程修改。
2、共享只读:一个共享只读的对象,在没有额外同步的情况下,可以被多个线程并发访问,但是任何线程都不能修改它。如高效不可变对象。
3、线程安全对象:一个线程安全的对象或者容器,在内部通过同步机制来保证线程安全,所以其他线程无需额外的同步就可以通过公共接口随意访问它。
4、被守护对象:被守护对象只能通过获取特定的锁来访问。
5、通过对volatile变量禁止指令重排序来实现单线程的顺序执行,而volatile的写操作的lock CPU指令保证了多线程(CPU内核)之间共享内存的同步。
重量级锁,将会引发线程阻塞,线程切换,但是从Java SE 1.6对synchronized进行了各种优化后,性能上提升了不少。synchronized是可重入锁
-锁的三种形式
Java中的每一个对象(类对象,和类的实例对象)都可以作为锁
JVM规范中,synchronized的实现:同步方法是通过方法表中的方法访问标志中是否含有ACC_SYNCHRONIZED标志来实现的;而同步方法块是在方法块的开始和结束分别插入monitorenter和monitorexit来完成的。
Java对象头与锁
在堆上创建对象时会设置对象头,
在32位JVM中对象头中和锁相关的有以下数据:锁状态,是否偏向锁,锁标志位
锁一共有4中状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁的状态只能升级不能降级,提高了获取和释放锁的效率。
偏向锁使用CAS竞争锁,轻量级锁使用CAS将对象头中的Mark Work替换为指向线程帧栈锁记录的指针,这两种锁都不会使得竞争的线程阻塞,而重量级锁将会阻塞竞争锁的线程。
监视器的获取
无论是同步方法块还是同步方法,本质都是对一个对象的监视器(monitor)进行获取,这个获取过程是排他的,也就是说同一时刻只有一个线程获取到由synchronized所保护对象的监视器。
任意一个对象都拥有自己的监视器对象,当这个对象由同步方法或方法块调用时,执行方法的线程必须先获取到该对象的监视器才能进入到同步块或方法,而没有获取到监视器(执行该方法)的线程将会被阻塞在同步块或方法的入口处,进入阻塞状态。
CAS
比较并替换,具有和volatile读和写的内存语义。
JVM中CAS操作利用了处理器提供的CMPXCHG指令配合LOCK指令实现的,这个指令操作是原子性的
java.util.concurrent.atomic包下的原子操作类都是基于CAS实现的,接下去我们通过AtomicInteger来看看是如何通过CAS实现原子操作的:
CAS的内存语义
CAS操作具有volatile读和写的内存语义,编译器禁止对CAS与CAS前面和后面的任意内存操作进行重排序。
为什么会有这样的内存语义?
主要取决于LOCK指令。在单核处理器中,所有操作都是串行进行的,CMPXCHG指令前面没有LOCK指令,但是在多核处理器中,CMPXCHG指令前面会有LOCK指令。
而这个LOCK指令在现在处理器中采用的是锁缓存来保证指令执行的原子性,并禁止与之前之后读写指令重排,还会把写缓存区中的所有数据刷新到内存中,而这正好和volatile的读和写的内存语义一致(不管是底层机制还是表现效果)
使用CAS会出现ABA问题
解决思路就是使用版本号:A→B→A 变为 1A→2B→3A,JDK的Atomic包提供的AtomicStampedReference来解决ABA问题。
个人学习记录哪里不对还望提出宝贵意见