JUC高级五-volatile与Java内存模型
1. volatile的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
- 所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
2. volatile凭什么可以保证可见性和有序性-内存屏障(Memory Barriers / Fences)
内存屏障即先行发生原则的落地
内存屏障
(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。
-
内存屏障之前的所有写操作都要回写到主内存,
-
内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。
-
因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。
一句话:对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读,也叫写后读。
2.1 JVM中提供了四类内存屏障指令
2.1.1 C++源码分析
Unsafe.class–>Unsafe.java–>Unsafe.cpp–>OrderAccess.hpp–>orderAccess_linux_x86.inline.hpp
2.1.2 四大屏障分别是什么意思
2.1.3 JMM 就将内存屏障插⼊策略分为 4 种
2.1.3.1 写屏障
-
StoreStore 屏障(写写屏障)
在每个 volatile 写操作的前⾯插⼊⼀个 StoreStore 屏障
-
StoreLoad 屏障(写读屏障)
在每个 volatile 写操作的后⾯插⼊⼀个 StoreLoad 屏障
2.1.3.2 读屏障
- LoadLoad 屏障(读读屏障)
在每个 volatile 读操作的后⾯插⼊⼀个 LoadLoad 屏障
-
LoadStore屏障(读写屏障)
在每个 volatile 读操作的后⾯插⼊⼀个 LoadStore 屏障
总结
3. volatile特性
3.1 保证可见性
保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可见
3.1.1 示例
package site.zhourui.juc.volatileTest;
import java.util.concurrent.TimeUnit;
public class VolatileSeeDemo {
// static boolean flag = true; //不加volatile,没有可见性
static volatile boolean flag = true; //加了volatile,保证可见性
public static void main(String[] args)
{
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"\t come in");
while (flag)
{
}
System.out.println(Thread.currentThread().getName()+"\t flag被修改为false,退出.....");
},"t1").start();
//暂停2秒钟后让main线程修改flag值
try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); }
flag = false;
System.out.println("main线程修改完成");
}
}
执行结果:
如果不加volatile那么对flag变量的操作对t1线程不可见,程序会一直等待
上述代码原理解释:
3.1.2 volatile变量的读写过程
Java内存模型中定义的8种工作内存与主内存之间的原子操作:
**read(读取)→load(加载)→use(使用)→assign(赋值)→store(存储)→write(写入)**→lock(锁定)→unlock(解锁)
read
: 作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存load
: 作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载use
: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作assign
: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作store
: 作用于工作内存,将赋值完毕的工作变量的值写回给主内存write
: 作用于主内存,将store传输过来的变量值赋值给主内存中的变量
由于上述6条只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令:
lock
: 作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。unlock
: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
3.2 没有原子性
3.2.1 volatile变量的复合操作(如i++)不具有原子性
package site.zhourui.juc.volatileTest;
import java.util.concurrent.TimeUnit;
public class VolatileNoAtomicDemo {
public static void main(String[] args) throws InterruptedException
{
MyNumber myNumber = new MyNumber();
for (int i = 1; i <=10; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
myNumber.addPlusPlus();
}
},String.valueOf(i)).start();
}
//暂停几秒钟线程
try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread().getName() + "\t" + myNumber.number);
}
}
class MyNumber
{
volatile int number = 0;
public void addPlusPlus()
{
number++;
}
}
执行结果:
我们开启10个线程每个线程对number做number++操作1000次,理论上程序执行完成后number应该等于10000,但是程序多次执行每次结果都不一样,且小于10000,这就是因为volatile没有原子性导致的
3.2.1.1 从i++的字节码角度说明
- 原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
- i++不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分3步完成
- 如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,
- 并执行相同值的加1操作,这也就造成了线程安全失败,因此对于add方法必须使用synchronized修饰,以便保证线程安全.
由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步
3.2.1.2 既然一修改就是可见,为什么还不能保证原子性?
-
要use(使用)一个变量的时候必需load(载入),要载入的时候必需从主内存read(读取)这样就解决了读的可见性。 (load和user关联)
-
写操作是把assign和store做了关联(在assign(赋值)后必需store(存储))。store(存储)后write(写入)。也就是做到了给一个变量赋值的时候一串关联指令直接把变量值写到主内存。(assign和store关联)
-
就这样通过用的时候直接从主内存取,在赋值到直接写回主内存做到了内存可见性。注意蓝色框框的间隙(但是use和assign直接仍然有有间隙)
结论:
read-load-use 和 assign-store-write 成为了两个不可分割的原子操作,但是在use和assign之间依然有极小的一段真空期,有可能变量会被其他线程读取,导致写丢失一次
但是无论在哪一个时间点主内存的变量和任一工作内存的变量的值都是相等的。这个特性就导致了volatile变量不适合参与到依赖当前值的运算,如i = i + 1; i++;之类的那么依靠可见性的特点volatile可以用在哪些地方呢? 通常volatile用做保存某个状态的boolean值或者int值。
深入理解Java虚拟机》提到:
3.3 指令禁重排(有序性)
3.3.1 volatile的底层实现是通过内存屏障
3.3.2 四大屏障的插入情况
- 在每一个volatile写操作前面插入一个StoreStore屏障
- StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
- 在每一个volatile写操作后面插入一个StoreLoad屏障
- StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序
- 在每一个volatile读操作后面插入一个LoadLoad屏障
- LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
- 在每一个volatile读操作后面插入一个LoadStore屏障
- LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
3.3.3 示例
//模拟一个单线程,什么顺序读?什么顺序写?
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
public void write(){
i = 2;
flag = true;
}
public void read(){
if(flag){
System.out.println("---i = " + i);
}
}
}
加上volatile后顺序如下,不加的话可能会重排序,顺序不定
4. 如何正确使用volatile
4.1 单一赋值可以,but含复合运算赋值不可以(i++之类)
最好是int或者boolean类型
4.2 状态标志,判断业务是否结束
4.3 开销较低的读,写锁策略
public class UseVolatileDemo
{
/**
* 使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
* 理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性
*/
public class Counter
{
private volatile int value;
public int getValue()
{
return value; //利用volatile保证读取操作的可见性
}
public synchronized int increment()
{
return value++; //利用synchronized保证复合操作的原子性
}
}
}
4.4 (单例模式)DCL双端锁的发布
public class SafeDoubleCheckSingleton
{
private static SafeDoubleCheckSingleton singleton;
//私有化构造方法
private SafeDoubleCheckSingleton(){
}
//双重锁设计
public static SafeDoubleCheckSingleton getInstance(){
if (singleton == null){
//1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
synchronized (SafeDoubleCheckSingleton.class){
if (singleton == null){
//隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取
singleton = new SafeDoubleCheckSingleton();
}
}
}
//2.对象创建完毕,执行getInstance()将不需要获取锁,直接返回创建对象
return singleton;
}
}
问题:
单线程看问题代码–>没问题
单线程环境下(或者说正常情况下),在"问题代码处",会执行如下操作,保证能获取到已完成初始化的实例
由于存在指令重排序… —>多线程看问题代码
隐患:多线程环境下,在"问题代码处",会执行如下操作,由于重排序导致2,3乱序,后果就是其他线程得到的是null而不是完成初始化的对象