(4) 线程系列 - volatile关键字解析
Java并发编程:volatile关键字解析 - Matrix海子 - 博客园 (cnblogs.com)
既然CPU有缓存一致性协议(MESI),为什么JMM还需要volatile关键字? - 知乎 (zhihu.com)
这是篇非常系统非常好的一篇文章
我总结该文章,以更简洁、加上Demo形式更加透彻理解,所以想了解的朋友可以先看上面的文章。因为volatile关键字涉及到内存模型相关的概念和知识,所以先讲解内存最后再重现相关使用场景
以下是本文的目录大纲:
- 内存模型的相关概念
- 并发编程中的三个概念
- Java内存模型
- 深入剖析volatile关键字
- 使用volatile关键字的场景
一.内存模型的相关概念
比如下面这段代码,i初始值为0
i = i + 1;
- 先从主存当中读取i的值
- 复制一份到高速缓存
- CPU执行指令对i进行加1操作
- 加1后的数据写入高速缓存
- 将高速缓存中i最新的值刷新到主存
单线程运行没问题,但是在多线程中运行就会有问题了。
在多核CPU中,每条线程可能
运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(单核CPU可能也会出现)。所以最终就可能会导致,各自的线程的i最终值都只是1,而不是2。这就是著名的缓存一致性
问题。
解决这个问题,有两种方式:
- 通过在总线加LOCK#锁的方式
- 通过缓存一致性协议
第一种在总线上就进行了锁,当第一个CPU在计算内存变量/方法中途还没计算完的时候,当第二个CPU要访问同一个的时候,会等待,等第一个CPU计算完了才会访问。所以这也就导致了效率低下。
所以就出现了第二种缓存一致性MESI协议,当CPU写数据时,如果该变量是公共变量即是别的CPU也存在该变量时,该CPU就会通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
那么即使是第二种,也会消耗一些性能,所以就有了volatile
修饰符告诉计算机要不要使用第二种MESI协议。
二.并发编程中的三个概念
1. 原子性
A想要从自己的帐户中转1000块钱到B的帐户里。从A开始转帐,到转帐结束的这一个过程,称之为一个事务。
在这个事务里,要做如下操作:
- 从A的帐户中减去1000块钱。如果A的帐户原来有3000块钱,现在就变成2000块钱了。
- 在B的帐户里加1000块钱。如果B的帐户如果原来有2000块钱,现在则变成3000块钱了。
如果在A的帐户已经减去了1000块钱的时候,忽然发生了意外,比如停电什么的,导致转帐事务意外终止了,而此时B的帐户里还没有增加1000块钱。那么,我们称这个操作失败了,要进行回滚。回滚就是回到事务开始之前的状态,也就是回到A的帐户还没减1000块的状态,B的帐户的原来的状态。此时A的帐户仍然有3000块,B的帐户仍然有2000块。
我们把这种要么一起成功(A帐户成功减少1000,同时B帐户成功增加1000),要么一起失败(A帐户回到原来状态,B帐户也回到原来状态)的操作叫原子性操作。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
3.有序性
即程序执行的顺序按照代码的先后顺序执行
三.java讲解
Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
1. 原子性
简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作
x = 10; // 原子性
y = x; // 不是原子性
x++; // 不是
x = x + 1; // 不是
2.可见性
Java提供了volatile关键字来保证可见性
另外,通过synchronized和Lock也能够保证可见性
3.有序性
Java内存模型本身就是有序性,如果是加入了多线程,那么通过volatile关键字来保证一定的“有序性”。当然,synchronized和Lock也可以
四.深入剖析volatile关键字
1. volatile的作用
- 保证了不同线程对这个变量进行操作时的立即可见性
- 禁止进行指令重排序
先看一段代码
// 线程1
boolean stop = false;
while(!stop){
doSomething();
}
// 线程2
stop = true;
之前说过,每个线程运行时都有自己的工作内存,所以很小的可能性
下,线程2的stop就算设置为true,然后线程1此时取得值依然为false,就会导致线程1继续死循环下去
那么使用volatile关键字即可解决这种情况
2.volatile不保证原子性
volatile关键字能保证可见性,但是如果volatile修饰的是自增操作的话就会出现问题,代码如下:
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
每次运行结果都不一致,都是一个小于10000的数字,这是因为自增操作导致的
首先自增操作包含了以下三个操作
- 读取变量的原始值
- 进行加1操作
- 写入工作内存
所以,即使用了volatile修饰,两个线程都会可能在第一步读取变量后准备进行加1的时候卡住。最后导致两个线程最后只加1,那么上面的第一点为什么可行呢,因为while一直判断stop是否为true,仅仅用到可见性进行判断。
解决方式可以采用synchronized、Lock等解决
3.volatile保证有序性
volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。直接举例子:
// x、y为非volatile变量
// flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
那么我们回到前面举的一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
前面举这个例子的时候,提到有可能语句2会在语句1之前执行,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。
这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。
五.使用volatile关键字的场景
1.状态标记量
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
volatile boolean inited = false;
//线程1:
context = loadContext();
inited = true;
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
2.double check
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;
}
}
六.不能使用volatile关键字的场景
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中
1.对变量的写操作不依赖于当前值
a++;
a = a + 2;
a = 6 * a;
a = a * a;
原因就是上面所说的,volatile无法保证原子性。如果指令执行到中间被打断,就会出现共享变量不一致的情形。
2.该变量没有包含在具有其他变量的不变式中
不变式
那什么是不变式呢,它是程序状态的始终为true的属性。可以确保保持不变的函数或方法称为保持不变。例如,二叉搜索树可能具有不变性,即对于每个节点,该节点的子节点的键小于该节点自己的键.正确编写此树的插入函数将保持该不变。
例子代码
public class NumberRange {
private int lower, upper;
public int getLower() {
return lower;
}
public int getUpper() {
return upper;
}
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
代码解释
代码显示了一个非线程安全的数值范围类。它包含了一个不变式 :下界(lower)总是小于或等于上界(upper)。
如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3) 。那么这很明显的就有问题了
让我们再回头看这句话该变量没有包含在具有其他变量的不变式中
只将字段定义为 volatile 类型是无法实现这一目的,因为lower和upper都互相影响包含了。
最后总结
多线程场景下,【变量做依赖于自己的值的操作】,【变量包含在另一个变量的不变式中】,这两种情况下即使使用volatile修饰变量,同步操作仍然会出现问题