1、测试代码
public class VolatileDemo {
public static void main(String[] args) {
FlagThread flagThread = new FlagThread();
new Thread(flagThread).start();
for (; ; ) {
if (flagThread.isFlag()) {
System.out.println("flag变量修改对于其他线程可见...");
}
}
}
static class FlagThread implements Runnable {
private boolean flag = false;
public boolean isFlag() {
return flag;
}
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("当前flag值为:" + flag);
}
}
}
程序执行流程分析:
1)flagThread线程执行时候休眠一秒
2)主线程从主存中读取flag变量的值false到本地工作内存中
3)每次循环都从本地工作内存中读取flag变量的值都为原始值false
4)flagThread线程从休眠中醒来,并且将flag设置为true
5)但是由于没有加volatile修饰flag,所以主线程还是会从自己的本地工作内存中读取flag,flagThread线程对于flag变量的修改对于其他线程是不可见的
思考:为什么会出现这种情况?
要想深入了解就得从现代计算机的内存模型和Java内存模型说起了
2、现代计算机的内存模型
由于内存和CPU的运算速度有几个数量级的差距,所以引入了一层与CPU计算速度差不多的高速缓存作为CPU和内存之间的缓冲,在CPU运算时从主存中加载数据到高速缓存中,提升CPU运算速度。
在多核CPU处理器中,每个CPU都有自己的高速缓存,当多个CPU执行任务都操作主存中同一个共享变量时,那么同步回主存的数据以哪个CPU缓存为准呢?此时就存在一个缓存一致性问题。为了解决缓存一致性问题,就需要多核处理器在访问缓存时遵循一定的协议,也就是缓存一致性协议。常见的就是Intel的MESI协议。
MESI协议
当CPU写数据时,如果发现操作的是一个共享变量,即其他CPU中也存在该变量的缓存副本,那么就会发出信号将其他CPU中该变量的缓存行置为无效状态,当其他CPU操作该变量时,发现自己缓存中该数据的缓存行是无效的,那么就会从主存中重新读取。
MESI协议的四种状态
CPU中每个缓存行使用4种状态标记:
1)Exclusive(独享状态):该缓存行的数据只缓存在该CPU中,并且与主存中的数据是一致的。该状态在其他CPU读取主存中的共享变量时变成共享状态;在其他CPU修改该变量时变成修改状态。
2)Shared(共享状态):该缓存行可能被多个CPU缓存,并且缓存行的数据与主存中的数据一致。如果某个CPU修改了缓存行中的数据,那么其他CPU中的缓存行会被置为无效状态。
3)Modify(修改状态):该缓存行只被缓存在该CPU中,并且是被修改过的,即与主存中的数据不一致。CPU将缓存中的数据在未来的某个时间点(在其他CPU读取主存中该共享变量之前)写回主存中。当被写回主存之后,该缓存行的状态会变成独享状态。
4)Invalid(无效状态):该缓存行中的数据是无效的,当CPU操作该共享变量时,会从主存中重新读取。
状态变化说明表:
3、Java内存模型
Java虚拟机规范中定义了Java内存模型(JMM),用于屏蔽各种硬件和操作系统的内存访问差异,以实现在不同平台下达到一致的并发效果。JMM规定了所有的共享变量都存储于主内存,每个线程都有自己的工作内存,每个线程操作变量都只能在本地工作内存中进行,而不能直接操作主内存中的数据。所以当线程修改本地工作内存的变量值,没有及时同步回到主存中,那么就会出现可见性问题。也就是测试代码中的,flag变量被其他线程修改,而对于主线程不可见。
4、内存可见性问题及其解决方案
JMM规定了所有的共享变量都存储于主内存,每个线程都有自己的工作内存,每个线程操作变量都只能在本地工作内存中进行,而不能直接操作主内存中的数据。所以当线程修改本地工作内存的变量值,没有及时同步回到主存中,那么就会出现可见性问题。
1)加锁
当线程获取锁时,会清空本地工作内存,从主存中读取最新的变量值到本地工作内存中,执行任务修改后的变量值会被同步回主内存中,线程释放锁。因此在线程同步过程中获取的共享变量值一直会是最新的。
2)Volatile修饰共享变量
Volatile如何保证可见性,当用Volatile修饰共享变量时,就相当于告诉CPU,这个共享变量需要使用MESI缓存一致性协议和总线嗅探方式进行处理。
假设CPU0读取共享变量到高速缓存中,该共享变量缓存行的状态置为独占状态,当CPU0修改了共享变量的值,那么CPU0中该共享变量缓存行的状态由独占状态变为修改状态。当CPU1需要读取该共享变量时,CPU0中的会将缓存行数据同步回主存中,并且将状态置为共享状态,其他CPU读取主存中最新的值,同时也将该状态置为共享状态。
当CPU1修改共享变量时,会将缓存行中的数据置为修改状态(在下一次remote read时将数据同步回主存),并且会发送信号到总线上,当CPU0在总线上嗅探到共享变量被修改时,会将该变量的缓存行置为无效状态,并在下一次操作该共享变量时去主内存中读取最新数据。
5、指令重排序
当CPU0发现共享变量的缓存行状态为无效状态,会丢弃该数据,并且从主内存中读取最新值的,其他修改了共享变量的CPU未必会及时回刷最新修改的数据,那么CPU在操作共享变量时可能就会跳过该指令执行先执行后面的指令,此时指令执行会经过CPU优化而发生指令重排序。
什么是重排序
为了提高程序运行性能,编译器和处理器通过会对代码指令执行顺序进行重排序。
重排序类型
1)编译器优化重排序
2)指令级并行重排序
3)内存系统重排序
as-if-serial语义
不管怎么重排序,单线程运行结果都不能够被改变。编译器、Runtime、处理器都必须遵守as-if-serial语义。所以编译器和处理器不会对单个线程中数据存在依赖的操作进行重排序,否则这种重排序会改变执行结果。
happens-before语义
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
Volatile是如何防止指令重排序的?答案是内存屏障
6、内存屏障防止指令重排序
什么是内存屏障
硬件中包含的内存屏障有两种,一种是Load Barrier(读屏障,x86中的ifence指令),另外一种是Store Barrier(写屏障,x86中的sfence指令)。
内存屏障的类型
1)LoadLoad:Load1,LoadLoad,Load2;Load2要读取数据需要保证Load1先完成数据读取。
2)StoreStore:Store1,StoreStore,Store2;Store2存入数据之前需要保证Store1先完成数据的存入
3)LoadStore:Load1,LoadStore,Store2;Store2数据存储之前需要保证Load1先完成数据读取
4)StoreLoad:Store1,StoreLoad,Load2;Load2加载数据之前需要保证Store1完成数据存入
Volatile通过内存屏障防止指令重排序
Java编译器会在生成指令时在合适的位置插入内存屏障指令来防止指令重排序。
1)Volatile写
Java编译器会在Volatile字段写操作之前插入StoreStore内存屏障指令,防止之前写和当前Volatile写重排序。在Volatile写后面插入StoreLoad内存屏障指令,防止Volatile写和后续读重排序。
2)Volatile读
Java编译器会在Volatile字段读之前插入LoadLoad内存屏障指令,防止Volatile读和后续读指令重排序。在Volatile读后面插入LoadStore内存屏障指令,防止Volatile读和后续写指令重排序。
7、Volatile与Synchronized的区别
1)volatile无法保证原子性,Synchronized保证原子性
2)volatile虽然无法保证原子性,但是对于64位的long和double操作,如果没有volatile修饰,对于其操作可以是两次32位操作,当使用volatile修饰long和double变量时,可以保证原子性
3)volatile作用于实例变量或类变量,Synchronized可以作用于方法,同步代码块
4)volatile的读写操作都是无锁的,不保证原子性,也正因为是无锁,不会有锁竞争引起的阻塞,线程上下文切换等消耗