1. volatile关键字介绍(volatile包含两层语义)
2. 在讲之前先补充几个概念:Java 内存模型中的可见性、原子性和有序性
3. 看完概念,再来介绍 volatile 关键字
4. 保证原子性解决办法:把 num++ 操作限制为原子操作
5. 使用volatile关键字的场景
6. 深入了解volatile的可见性和有序性: 内存屏障(Memory Barrier)
1.1 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(保证可见性)
1.2 禁止进行指令重排序。(保证有序性)
如何保证可见性和有序性 ?这个后面会有详解
可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。
比如:用volatile修饰的变量,就会具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0;之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子操作,也就是这个操作同样存在线程安全问题。
在 Java 中 volatile、synchronized 和 final 实现可见性。
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子性可以应用于除long和double之外的所有基本类型之上的“简单操作”,对于读取和写入除long和double之外的基本类型变量这样的操作,可以保证它们会被当作不可分(原子)的操作来操作。
原子性说白了就是不可分割性。
比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作就是原子操作。
再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。从JVM 编译后的指令解释:为什么 i++ 不是原子操作?
非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。java的concurrent包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。
在 Java 中 synchronized 和在 lock、unlock 中操作都能保证原子性。
有序性:即程序执行的顺序按照代码的先后顺序执行。
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。
划重点啦:volatile 关键字能保证可见性和有序性,但是不能保证操作的原子性。
可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
理解不了?那举个例子来说明一下 :
public class Test_volatile {
private volatile int num = 0;
public void increase() {
num++;
}
public static void main(String[] args) throws InterruptedException {
final Test_volatile test = new Test_volatile();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++){
test.increase();
}
};
}.start();
}
// 睡眠 3s 保证 10个线程的自增操作全部执行完毕
Thread.sleep(3000);
System.out.println("num 的值为: " + test.num);
}
}
如果可以保证 num++ 原子性的话,那么预期结果是:num 的值为:10000
来看第一次运行结果:
来看第二次运行结果:
来看第三次运行结果:
在前面已经提到过,自增操作是不具备原子性的(从JVM 编译后的指令解释:为什么 i++ 不是原子操作? ),它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
假如某个时刻变量num的值为10,
线程1对变量进行自增操作,线程1先读取了变量num的原始值,然后线程1被阻塞了;
然后线程2对变量进行自增操作,线程2也去读取变量num的原始值,由于线程1只是对变量num进行读取操作,而没有对变量进行修改操作,所以线程2去主存读取num的值,发现num的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。
然后线程1接着进行加1操作,由于已经读取了num的值,注意此时在线程1的工作内存中num的值仍然为10,所以线程1对num进行加1操作后num的值为11,然后将11写入工作内存,最后写入主存。
那么两个线程分别进行了一次自增操作后,num只增加了1。
举例 1 - 使用synchronized关键字(只是在 increase()方法上加了synchronized关键字):
public class Test_volatile_solvedBySynchronized {
private int num = 0;
public synchronized void increase() {
num++;
}
public static void main(String[] args) throws InterruptedException {
final Test_volatile_solvedBySynchronized test = new Test_volatile_solvedBySynchronized();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++){
test.increase();
}
};
}.start();
}
// 睡眠 3s 保证 10个线程的自增操作全部执行完毕
Thread.sleep(3000);
System.out.println("num 的值为: " + test.num);
}
}
运行结果为:
举例 2 - 使用Lock(在 increase()方法里使用Lock 加锁):
public class Test_volatile_solvedByLock {
private int num = 0;
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
num++;
} finally{
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
final Test_volatile_solvedByLock test = new Test_volatile_solvedByLock();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++){
test.increase();
}
};
}.start();
}
// 睡眠 3s 保证 10个线程的自增操作全部执行完毕
Thread.sleep(3000);
System.out.println("num 的值为: " + test.num);
}
}
运行结果为:
举例 3 - 采用AtomicInteger(推荐):
public class Test_volatile_solvedByAtomicInteger {
private AtomicInteger num = new AtomicInteger();
public void increase() {
num.getAndIncrement(); // 相当于num++
}
public static void main(String[] args) throws InterruptedException {
final Test_volatile_solvedByAtomicInteger test = new Test_volatile_solvedByAtomicInteger();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++){
test.increase();
}
};
}.start();
}
// 睡眠 3s 保证 10个线程的自增操作全部执行完毕
Thread.sleep(3000);
System.out.println("num 的值为: " + test.num);
}
}
运行结果为:
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。
最后分享几个常见的用到volatile关键字的例子(以下都是伪代码,只是列出用法):
(情况1)用于状态标记量
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
(情况2)用于状态标记量
volatile boolean inited = false;
//线程1:
context = loadContext();
inited = true;
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
(情况3)Java 中的双重检查(Double-Check),比如创建单例模式的时候,使用 volatile 来实现双重检查, 防止 instance = new Singleton() 这行语句中,赋值在实例创建之前执行。
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
由于现代的操作系统都是多处理器,而每一个处理器都有自己缓存(有的甚至多级缓存L1,L2,L3),缓存的目的就是提高性能,避免每次都要从内存中获取。但是这样的弊端也很明显:就是不能实时的和内存发生信息交换,分在不同处理器的不同线程对同一个变量的缓存值不同。
用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令。
为了解决写缓冲器和无效化队列带来的有序性和可见性问题,我们引入了内存屏障。内存屏障是被插入两个CPU指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将写缓冲器的值写入高速缓存,清空无效队列,从而“附带”的保障了可见性。
lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;
unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用;
load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本
use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用;
write(操作):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
其中内存屏障有两个作用:
6.3.1 阻止屏障两侧的指令重排序
6.3.2 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
LoadLoad屏障:对于这样的语句 Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句 Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被执行前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
我们可以根据可见性和有序性将这四种内存屏障分为两类:
按照可见性保障来划分
内存屏障可分为:加载屏障(Load Barrier)和存储屏障(Store Barrier)。
加载屏障:StoreLoad
屏障可充当加载屏障,作用是使用load 原子操作,刷新处理器缓存,即清空无效化队列,使处理器在读取共享变量时,先从主内存或其他处理器的高速缓存中读取相应变量,更新到自己的缓存中
存储屏障:StoreLoad
屏障可充当存储屏障,作用是使用 store 原子操作,冲刷处理器缓存,即将写缓冲器内容写入高速缓存中,使处理器对共享变量的更新写入高速缓存或者主内存中
这两个屏障一起保证了数据在多处理器之间是可见的。
按照有序性保障来划分
内存屏障分为:获取屏障(Acquire Barrier)和释放屏障(Release Barrier)。
获取屏障:相当于LoadLoad屏障
与LoadStore屏障
的组合。在读操作后插入,禁止该读操作与其后的任何读写操作发生重排序;
释放屏障:相当于LoadStore屏障
与StoreStore屏障
的组合。在一个写操作之前插入,禁止该写操作与其前面的任何读写操作发生重排序。
这两个屏障一起保证了临界区中的任何读写操作不可能被重排序到临界区之外。
java中对内存屏障的使用在一般的代码中不太容易见到.常见的有Synchronized和volatile两种。
通过 Synchronized关键字包住的代码区域,当线程进入到该区域读取变量信息时,保证读到的是最新的值.这是因为在同步区内对变量的写入操作,在离开同步区时就将当前线程内的数据刷新到内存中,而对数据的读取也不能从缓存读取,只能从内存中读取,保证了数据的读有效性.这就是插入了StoreStore屏障
使用了volatile修饰变量,则对变量的写操作,会插入StoreLoad屏障.
其余的操作,则需要通过Unsafe这个类来执行.
UNSAFE.putOrderedObject类似这样的方法,会插入StoreStore内存屏障
Unsafe.putVolatiObject 则是插入了StoreLoad屏障
Unsafe 大家可能有点陌生,这里简单说一下这个东西,在J.U.C(java.util.concurrent)包中有个比较重要的概念 - CAS(Compare-and-Swap,这个后面的博客也会有介绍),操作系统和JVM可以使用CAS这样的一些指令来实现锁和并发的数据结构。JUC包中大量使用了CAS,涉及并发或资源争用的地方都使用了sun.misc.Unsafe类的方法进行CAS操作。比如说:AtomicInteger
内部都是使用了Unsafe
类来进行CAS操作,感兴趣的童鞋可以详细去了解一下。
代码已上传GitHub:https://github.com/higminteam/practice/tree/master/src/main/java/com/practice/concurrent/keyWord_volatile
感谢大家的阅读,附上值得一看的参考文章:
https://www.iteye.com/topic/652440
https://www.cnblogs.com/dolphin0520/p/3920373.html
https://blog.csdn.net/kuangzhanshatian/article/details/47949059
https://www.jianshu.com/p/2ab5e3d7e510