目录
前言
1、认识volatile
public class VolatileDemo {
public static boolean stop = false;
// public static volatile boolean stop = false; //加上volatile后就OK了
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
System.out.println("i= " + i );
}
});
thread.start();
System.out.println("begin start thread");
Thread.sleep(1000);
stop = true;
}
}
如果运行上面的代码,发现这个程序并不会像我们期望的那样停下来,但是只要将stop加上volatile关键字修饰,这个程序就可以达到预期,今天主要探究一下这个问题的原理。
2、硬件层面的优化及问题
-
问题-破坏原子性:由于cpu的采用抢占式调度线程的执行;cpu时间片破坏了线程执行的 原子性;
-
问题- 破坏 可见性: 使用缓存是数据存在多个副本,导致了数据的可见性问题;
-
cpu指令:单线程和单cpu是可以指令重排优化的
-
编译器指令:原始指令-编译器重排-cpu重排(指令+内存)-最终的指令
-
缓存指令:因为3级缓存的数据提交到主存的顺序是不确定的,导致了指令的重排序;
-
重排序条件原则
-
没有数据依赖
-
程序结果不变,顺序一致性 as-if-serial
-
-
问题- 破坏 有序性: 使多线程指令的执行不再按程序次序;

3、缓存一致性问题的浮现
-
缓存的使用;
-
指令重排序;
-
1、 总线锁: LOCK#,锁住了cpu和其他模块的通信总线,使其他 cpu无法通过总线访问其他内存地址的数据;
-
缺点:开销很大,使多核cpu无法并行访问数据,性能低, 所以需要控制锁的粒度;
-
2、缓存锁 :MESI,核心机制是基于缓存一致性协议;
3.1、MESI协议
-
M - modify:表示共享数据只缓存在当前的CPU缓存中,并且是被修改的状态,也就是缓存数据和主内存中的数据不一致;
-
E - exclusive:表示缓存的独占状态,数据只缓存在当前的cpu缓存中,且没有被修改过;
-
S - share:表示数据可能被多个cpu缓存,并且各个缓存中的数据和主内存数据一致;
-
I - invalid:表示缓存已经失效
-
cpu 读请求:缓存处于 MES都可以读,但是 I缓存必须从主存读取;
-
cpu 写请求: ME缓存可以写, S缓存需要将其他的缓存置无效 i才可以写;

4、volatile的出现
-
第一步:更新的数据先写入strorebuffer;
-
第二步:异步通知其他的cpu失效;
5、volatile的作用
-
一、禁止指令重排序: 进行指令优化的时候,禁止将volatile变量前后的语句进行重排序;
-
二、强制刷缓存:工作内存写完后立即写到主内存,并通知其他线程使其本地该变量缓存无效;
-
写屏障:storestore - 保证在写屏障之前所有的写都已经刷回到主存中,之后的读屏障都是可见的;
-
读屏障:loadload - 保证在读操作在读屏障之后操作,配合写屏障保证之前所有的修改对其都可见;
-
全屏障:解决线程之间的可见性;
-
storeLoad 存储store在读load前面执行;
-
loadStore: 读load在存储store前面执行;
5.1、volatile的可见性
//中断程序
//线程1
boolean volatile stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
问题分析:在中断程序中,如果stop没有用volatile修饰,一定会发生中断吗?答案是不确定。因为每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,这样会导致线程一工作内存中的值永远是false,因此还会一直循环下去。
-
指令重排顺序无法确定;
-
读写缓冲区;
-
缓存的使用;
-
使用volatile修饰,对该变量的读取会插入一条内存屏障lock,cpu不会将其后面的指令放在其前面执行,反之亦然;
-
使用volatile修饰,写操作会强制将修改的值刷新到主存;
-
使用volatile修饰,其它读,对变量的写操作会导致其他缓存行的值无效,再次读取时会到主存去读取;
5.2、volatile的原子性
public class VolatileAtomicTest {
public volatile int inc = 0;
public void increase() { //增加1
inc++;
}
public static void main(String[] args) {
VolatileAtomicTest atomicTest = new VolatileAtomicTest();
for (int i = 0; i < 3; i++) { //3个线程并发修改inc的值;
new Thread() {
public void run() {
for (int j = 0; j < 1000; j++)
atomicTest.increase();
}
}.start();
}
try {
sleep(1000); //保证所有的线程都执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(atomicTest.inc);
}
}
问题分析:这个结果或是多少呢,我们的理想值是3000,但实际上每次都不一样,并且该值是一个小于3000的值。可能这里就会有疑问了,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有3个线程分别进行了1000次操作,那么最终inc的值应该是1000*3=3000。
-
第一步:读取变量的原始值到工作内存;
-
第二步:对变量进行加1操作;
-
第三步:写回主内存;
// 法一:加入synchrozied,保证执行的原子性
public synchronized void increase() {
inc++;
}
//方法二:采用lock机制
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
} finally{
lock.unlock();
}
}
方法三:采用AtomicInteger机制
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.getAndIncrement();
}
//AtomicInteger类的getAndIncrement的源代码
public final int getAndIncrement() {
for (; ; ) {
int current = get(); // 取得AtomicInteger里存储的数值
int next = current + 1; // 加1
if (compareAndSet(current, next)) // 调用compareAndSet执行原子更新操作
return current;
}
}
CAS利用了基于冲突检测的乐观并发策略 ,CAS自旋volatile变量,可以很高效的解决原子问题。在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。
5.3、volatile的有序性
//线程1:
x = 1; //语句1
y = 4; //语句2
config = getConfig(); //语句3
inited = false; //语句4 volatile
//线程2:
while( inited ){ //语句5
x = x + 1;
y = y + 1;
}
doSomethingwithconfig(config); //语句6
分析:我们知道编译器中由于语句3-4没有依赖性,可能会发生指令重排,可能导致config没有获取到配置信息,当线程2去执行的时候出错。但是当我们加上volatile后volatile inited = false; 就可以避免此类问题的发生。因为volatile保证了在执行语句4的时候,语句1-2-3一定执行完了,1-2-3的执行结果对语句5是可见的,禁止了volatile前后语句的指令重排序,保证来指令执行的有序。但是语句1-2-3和语句5-6的执行顺序是不做保证的。
//TODO 通过volatile设置内存屏障,禁止指令排序,使写先与读执行;
public class DoubleCheckLockSingletonTest {
private static volatile DoubleCheckLockSingletonTest singleInstance;
private DoubleCheckLockSingletonTest() {
}
public static DoubleCheckLockSingletonTest getInstance() {
if (singleInstance == null) {
synchronized (DoubleCheckLockSingletonTest.class) {
if (singleInstance == null) {
singleInstance = new DoubleCheckLockSingletonTest();
}
}
}
return singleInstance;
}
memory = allocate(); //第一步:给 singleton 分配内存;
DoubleCheckLockSingletonTest(memory); //第二步:调用 构造函数来初始化成员变量,形成实例;
singleInstance = memory; //第三步:将singleInstance对象指向分配的内存空间(执行完这步 singleInstance才是非 null 了);
但是由于步骤2-3之间没有依赖性,所以步骤2-3可能会发生指令的重排序。这种重排序在串行的单线程是OK的,但是如果发生在高并发的多线程将产生不可估计的后果。有可能产生如下的执行顺序:
//2-3步发生来指令的重排序
memory = allocate(); //第一步:给 singleton 分配内存;
singleInstance = memory; //第二步:将singleInstance对象指向分配的内存空间;
DoubleCheckLockSingletonTest(memory); //第三步:调用 构造函数来初始化成员变量,形成实例;
如果此时线程一正执行到重排后的第三步还未完成,此时线程2请求到达后,判断singleInstance不为空,但是实例化未完成,此时线程2返回的将是一个【线程一初始化未完成的实例这样一个中间状态的值】,所以肯定会出问题。
6、volatile使用场景
-
状态标志量:读取配置文件 - 中断的场景使用的较多,一般都使用范围来判断而非==值判断;
-
Double-check,单例的生成;
-
一写多读,不支持复合操作的原子性(i++) ;
-
AQS中的Node-state-双向链表的node-next-pre-waitstatus等变量;
-
中断的 isIntrrupted变量;
-
ConcurrentHashmap中的节点;
-
线程池中完成任务数的统计;
7、volatile使用技巧
-
性能问题:volatile的使用 导致缓存行失效,不能利用 cpu的缓存加速,频繁读内存开销,影响性能;
-
volatile关键字在一写多读的情况下性能要优于synchronized/lock锁机制,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字在变量存在依赖关系的时候 无法保证操作的原子性。
-
不要将volatile用在getAndOperate场合( 这种场合不原子,需要再加锁synchronized或者lock或者使用Atomic*类),仅仅set或者get的场景是适合volatile的。
-
对于 volatile数组来说,只能保证对于引用的可见,不能保证数组里数据元素的可见性;
8、小结
-
volatile保证访问写变量的可见性和有序性,不保证原子性;
-
volatile主要实现原理:
-
禁止变量前后指令重排序;
-
缓存行的数据刷回到主存并且使其他的缓存无效;
-
-
volatile为实现JMM付出了汗马功劳;