前面两篇都是对volatile进行铺垫:
文章目录
1 volatile关键字的语义
volatile修饰的实例变量或者类变量具备如下两层语义:
- 保证了不同线程之间对共享变量操作时的可见性,也就是说当一个线程修改volatile修饰的变量,另外一个线程会立即看到最新的值。(可见性)
- 禁止对指令进行重排序操作。(有序性)
也就是说volatile关键字保证的就是并发的三大特性的可见性和有序性,不保证原子性
1.1 理解volatile保证可见性
之前的案例说明了volatile保证共享变量在多线程间的可见性,代码如下:
定义两个线程一个线程负责读取数据,一个线程负责修改数据
public class VolatileFoo {
final static int MAX = 5;
// 有无volatile 关键词修饰
volatile static int init_value = 0;
public static void main(String[] args) {
// 启动一个Reader线程,当发现local_value和init_value不同时,则输出init_value被修改的信息
new Thread(() -> {
int localValue = init_value;
while (localValue < MAX) {
if (init_value != localValue) {
System.out.println("The init_value is update to " + init_value);
//对localValue进行重新赋值
localValue = init_value;
}
}
}, "Reader").start();
// 启动Writer线程,主要用于对init_value的修改,当local_value>=5的时候则退出生命周期
new Thread(() -> {
int localValue = init_value;
while (localValue < MAX) {
System.out.println("The init_value will be changed to " + (++localValue));
init_value = localValue;
try {
//短暂休眠,目的是为了使Reader线程能够来得及输出变化内容
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Writer").start();
}
}
当init_value没有volatile修饰时,是无法保证可见性的,所以Reader线程由于后来无法获取主内存的中init_value的新值,最后进入死循环,在Reader线程中工作内存里的init_value就一直没有变化,while判断一直为false,所以最后输出结果如下:
The init_value will be changed to 1
The init_value is update to 1 (Reader线程中工作内存里的init_value就一直是1,没有从主内存中读取新值)
The init_value will be changed to 2
The init_value will be changed to 3
The init_value will be changed to 4
The init_value will be changed to 5
当init_value没有volatile修饰时,对于共享资源init_value的读操作会直接在主内存中进行(当然也会缓存到工作内存中,当其他线程对该共享资源进行了修改,则会导致当前线程在工作内存中的共享资源失效,所以必须从主内存中再次获取),对于共享资源的写操作当然是先要修改工作内存,但是修改结束后会立刻将其刷新到主内存中。**简单来说,两个线程操作init_value,虽然都是在自己的工作内存,但是操作完之后会立即刷回主内存,失效掉自己工作内存中的init_value,下次操作又会从主内存中获取。这就保证了,init_value在两个线程之间是可见的。**具体的步骤如下:
- Reader线程从主内存中获取init_value的值为0,并且将其缓存到本地工作内存中。
- Writer线程将init_value的值在本地工作内存中修改为1,然后立即刷新至主内存中。
- Reader线程在本地工作内存中的init_value失效(反映到硬件上就是CPU的L1或者L2的Cache Line失效)。
- 由于Reader线程工作内存中的init_value失效,因此需要到主内存中重新读取init_value的值。
1.2 理解volatile保证顺序性
volatile关键字对顺序性的保证就比较霸道一点,直接禁止JVM和处理器对volatile关键字修饰的指令重排序,但是对于volatile前后无依赖关系的指令则可以随便怎么排序
int x = 0;
int y = 1;
volatile int z = 20;
x++;
y--;
在语句volatile int z=20之前,先执行x的定义还是先执行y的定义,我们并不关心,只要能够百分之百地保证在执行到z=20的时候x=0,y=1,同理关于x的自增以及y的自减操作都必须在z=20以后才能发生。
再比如:
private volatile boolean initialized = false;
private Context context;
public Context load(){
if(!initialized){
context=loadContext();
initialized = true;//阻止重排序
}
return context;
}
多线程的情况下发生了重排序,比如context=loadContext()的执行被重排序到了initialized=true的后面,那么这将是灾难性的了。比如第一个线程首先判断到initialized=false,因此准备执行context的加载,但是它在执行loadContext()方法之前二话不说先将initialized置为true然后再执行loadContext()方法,那么如果另外一个线程也执行load方法,发现此时initialized已经为true了,则直接返回一个还未被加载成功的context,那么在程序的运行过程中势必会出现错误。
如果对initialize布尔变量增加了volatile的修饰,那就意味着initialize=true的时候一定是执行且完成了对loadContext()的方法调用。就避免了上面的问题。
1.3 理解volatile不保证原子性
public class VolatileTest {
// 使用volatile关键字修饰 i
private static volatile int i = 0;
private static final Latch latch = new CountDownLatch(10);
private static void inc() {
i++;
}
/**
* 创建10个线程,每个线程对i进行自增1000次操作
*/
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() ->
{
for (int x = 0; x < 1000; x++) {
inc();
}
// 使计数器减1
latch.countDown();
}).start();
}
// 等待所有的线程完成工作
latch.await();
System.out.println(i);
}
}
上面这段代码创建了10个线程,每个线程执行1000次对共享变量i的自增操作,但是最终的结果i不一定是10000,而且每次运行的结果也是各不相同。
之前说过i++不是个原子操作,分为三步:
- 从主内存中获取i的值,然后缓存至线程工作内存中。
- 在线程工作内存中为i进行加1的操作。
- 将i的最新值写入主内存中
上面三个操作单独的每一个操作都是原子性操作,但是合起来就不是,因为在执行的中途很有可能会被其他线程打断,比如:
- 假设此时i的值为100,线程A要对变量i执行自增操作,首先它需要到主内存中读取i的值,可是此时由于CPU时间片调度的关系,执行权切换到了线程B,A线程进入了RUNNABLE状态而不是RUNNING状态。
- 线程B同样需要从主内存中读取i的值,由于线程A没有对i做过任何修改操作,因此此时B获取到的i仍然是100。
- 线程B工作内存中为i执行了加1操作,但是未刷新至主内存中。
- CPU时间片的调度又将执行权给了线程A,A线程直接对工作线程中的100进行加1运算(因为A线程已经从主内存中读取了i的值),由于B线程并未写入i的最新值,因此A线程工作空间中的100不会被失效。
- 线程A将i=101写入主内存之中。
- 线程B将i=101写入到主内存中。
两次运算实际上只对i进行了一次数值的修改变化。
2 内存屏障(了解)
volatile关键字可以保证可见性以及顺序性,那么它到底是如何做到的呢?通过对OpenJDK下unsafe.cpp源码的阅读,会发现被volatile修饰的变量存在于一个“lock;”的前缀,源码如下:
// Adding a lock prefix to an instruction on MP machine
#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1:"
……
inline jint Atomic:cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os:is_MP();
__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
:"=a" (exchange_value)
:"r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
:"cc", "memory");
return exchange_value;
}
“lock;”前缀实际上相当于是一个内存屏障,该内存屏障会为指令的执行提供如下几个保障:
- 确保指令重排序时不会将其后面的代码排到内存屏障之前
- 确保指令重排序时不会将其前面的代码排到内存屏障之后。
- 确保在执行到内存屏障修饰的指令时前面的代码全部执行完成。
- 强制将线程工作内存中值的修改刷新至主内存中。
- 如果是写操作,则会导致其他线程工作内存(CPU Cache)中的缓存数据失效。
3 volatile的使用场景
虽然volatile有部分synchronized关键字的语义,但是volatile不可能完全替代synchronized关键字,因为volatile关键字不具备原子性操作语义,我们在使用volatile关键字的时候也是充分利用它的可见性以及有序性(防止重排序)特点。
3.1 开关控制
利用可见性的特点。
开关控制中最常见的就是进行线程的关闭操作,参考:如何关闭一个线程
public class Demo3 {
static class MyThead extends Thread{
/**
* 是否关闭线程的标记,默认为false
*/
private volatile boolean closed = false;
@Override
public void run() {
System.out.println("I will start work");
while (!closed && !isInterrupted()){
System.out.println("i am working.");
}
System.out.println("I will be exiting.");
}
public void close()
{
this.closed = true;
this.interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
MyThead t = new MyThead();
t.start();
TimeUnit.MILLISECONDS.sleep(200);
t.close();
}
}
当外部线程执行close方法时,MyThead 会立刻看到closed 发生了变化(原因是因为MyThead 工作内存中的closed 失效了,不得不到主内存中重新获取)。
如果closed 没有被volatile关键字修饰,那么很有可能外部线程在其工作内存中修改了closed 之后不及时刷新到主内存中,或者MyThead 一直到自己的工作内存中读取closed 变量,都有可能导致closed =true不生效,线程就会无法关闭。
3.2 状态标记
利用顺序性特点
就是上面的这个代码:
private volatile boolean initialized = false;
private Context context;
public Context load(){
if(!initialized){
context=loadContext();
initialized = true;//阻止重排序
}
return context;
}
3.3 Singleton设计模式(单例模式)的double-check
Singleton设计模式的double-check也是利用了顺序性特点
4 volatile和synchronized区别
总结一下两个的区别:
-
使用上的区别
- volatile关键字只能用于修饰实例变量或者类变量,不能用于修饰方法以及方法参数和局部变量、常量等。
- synchronized关键字不能用于对变量的修饰,只能用于修饰方法或者语句块。
- volatile修饰的变量可以为null,synchronized关键字同步语句块的monitor对象不能为null。
-
对原子性的保证
- volatile无法保证原子性。
- 由于synchronized是一种排他的机制,因此被synchronized关键字修饰的同步代码是无法被中途打断的,因此其能够保证代码的原子性。
-
对可见性的保证
- 均可以保证共享资源在多线程间的可见性,但是实现机制完全不同。
- synchronized借助于JVM指令monitor enter和monitor exit对通过排他的方式使得同步代码串行化,在monitor exit时所有共享资源都将会被刷新到主内存中。
- 相比较于synchronized关键字volatile使用机器指令(偏硬件)“lock;”的方式迫使其他线程工作内存中的数据失效,不得到主内存中进行再次加载。
-
对有序性的保证
- volatile关键字禁止JVM编译器以及处理器对其进行重排序,所以它能够保证有序性。
- 虽然synchronized关键字所修饰的同步方法也可以保证顺序性,但是这种顺序性是以程序的串行化执行换来的,在synchronized关键字所修饰的代码块中代码指令也会发生指令重排序的情况
-
其他
- volatile不会使线程陷入阻塞。
- synchronized关键字会使线程进入阻塞状态。