volatile 详解
文章目录
1. volatile是什么
volatile是Java中的关键字,用来修饰会被不同线程访问和修改的变量。volatile可以说是java虚拟机提供的最轻量级的同步机制。
synchronized是阻塞式同步,在线程竞争激烈的情况下会升级为重量级锁。而volatile就可以说是java虚拟机提供的最轻量级的同步机制。但它同时不容易被正确理解,也至于在并发编程中很多程序员遇到线程安全的问题就会使用synchronized。Java内存模型告诉我们,各个线程会将共享变量从主内存中拷贝到工作内存,然后执行引擎会基于工作内存中的数据进行操作处理。线程在工作内存进行操作后何时会写到主内存中?这个时机对普通变量是没有规定的,而针对volatile修饰的变量给java虚拟机特殊的约定,线程对volatile变量的修改会立刻被其他线程所感知,即不会出现数据脏读的现象,从而保证数据的“可见性”。
现在我们有了一个大概的印象就是:被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。
2. volatile的内存语义
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内中
当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取
3. volatile变量的读写过程
Java内存模型中定义的8种工作内存与主内存之间的原子操作:
read(读取) -> load(加载) -> use(使用)-> assign(赋值) -> store(存储) -> write(写入) -> lock(锁定)-> unlock(解锁)
- read:作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
- load:作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载
- use:作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
- assign:作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
- store:作用于工作内存,将赋值完毕的工作变量的值写回给主内存
- write:作用于主内存,将store传输过来的变量值赋值给主内存中的变量
由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令:
- lock:作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。
- unlock:作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
回到顶部
4. 内存屏障
4.1 什么是内存屏障
内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。
内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。 因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。
4.2 内存屏障的作用
- 阻止屏障两边的指令重排序
- 写数据时加入屏障,强制将线程私有工作内存的数据刷回主物理内存
- 读数据时加入屏障,线程私有工作内存的数据失效,重新到主物理内存中获取最新数据
4.3 内存屏障四大指令
内存屏障,其实也就是4条CPU的屏障指令
- loadload():保证load1的读取操作在load2及后续读取操作之前执行。保证load2在读取的时候,自己缓存内到相应数据失效,load2会去主内存中获取最新的数据
- storestore():在store2及其后的写操作执行前,保证store 1的写操作已刷新到主内存
- loadstore():在store2及其后的写操作执行前,保证load1的读操作已读取结束
- storeload( ): 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行。同时保证:强制把写缓冲区的数据刷回到主内存中,让工作内存/CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据
java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:
"NO"表示禁止重排序。为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障;
- 在每个volatile写操作的后面插入一个StoreLoad屏障;
- 在每个volatile读操作的后面插入一个LoadLoad屏障;
- 在每个volatile读操作的后面插入一个LoadStore屏障。
需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障
- StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
- StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
- LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
- LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序
4.4 volatile关键字系统底层如何加入内存屏障
volatile关键字影响的是Class 内的Field 的flags :添加了一个ACC_ _VOLATILE,JVM在把字节码生成为机器码的时候,发现操作是volatile 的变量的话,就会根据JMM要求,在相应的位置去插入内存屏障指令
5. volatile的三大特性
5.1 可见性
5.1.1 什么是可见性
在说volatile保证可见性之前,先来说说什么叫可见性。谈到可见性,又不得不说JMM(java memory model)内存模型。JMM内存模型是逻辑上的划分,及并不是真实存在。Java线程之间的通信就由JMM控制。JMM的抽象示意图如下:
如上图所示,我们定义的共享变量,是存储在主内存中的,也就是计算机的内存条中。线程A去操作共享变量的时候,并不能直接操作主内存中的值,而是将主内存中的值拷贝回自己的工作内存中,在工作内存中做修改。修改好后,在将值刷回到主内存中。
假设现在new 一个 student , age为 18,这个18是存储在主内存中的。现在两个线程先将18拷贝回自己的工作内存中。这时,A线程将18改为了20,刷回到主内存中。也就是说,现在主内存中的值变为了20。可是,B线程并不知道现在主内存中的值变了,因为A线程所做的操作对B是不可见的。我们需要一种机制,即一旦主内存中的值发生改变,就及时地通知所有的线程,保证他们对这个变化可见。这就是可见性。我们通常用happen - before(先行发生原则),来阐述操作之间内存的可见性。也就是前一个的操作结果对后一个操作可见,那么这两个操作就存在 happen - before 规则。
5.1.2 为什么volatile能保证可见性
先来说一说内存屏障(memory barrier),这是一条CPU指令,可以影响数据的可见性。当变量用volatile修饰时,将会在写操作的后面加一条屏障指令,在读操作的前面加一条屏障指令。这样的话,一旦你写入完成,可以保证其他线程读到最新值,也就保证了可见性。
5.1.3 验证volatile保证可见性
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() + " come in");
while (flag) {
new Integer(308);
}
System.out.println("t1 over");
}, "t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
flag = false;
}, "t2").start();
}
}
不加volatile,没有可见性,线程无法停止
加volatile,保证可见性,线程停止
5.1.4 可见性小结
保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可见
使用volatile修饰共享变量,被volatile修改的变量有以下特点:
- 线程中读取的时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存
- 线程中修改了工作内存中变量的副本,修改之后会立即刷新到主内存
从volatile变量的读写过程分析
要use(使用)一个变量的时候必需load(载入),要载入的时候必需从主内存read(读取)这样就解决了读的可见性。写操作是把assign和store做了关联(在assign(赋值)后必需store(存储)),store(存储)后write(写入)。 也就是做到了给一个变量赋值的时候一串关联指令直接把变量值写到主内存。 就这样通过用的时候直接从主内存取,在赋值到直接写回主内存做到了内存可见性
5.2 无原子性
5.2.1 什么叫原子性
所谓原子性,就是说一个操作不可被分割或加塞,要么全部执行,要么全不执行。
5.2.2 volatile不保证原子性解析
java程序在运行时,JVM将java文件编译成了class文件。我们使用javap命令对class文件进行反汇编,就可以查看到java编译器生成的字节码。最常见的 i++ 问题,其实 反汇编后是分三步进行的。
- 第一步:将i的初始值装载进工作内存;
- 第二步:在自己的工资内存中进行自增操作;
- 第三步:将自己工作内存的值刷回到主内存。
我们知道线程的执行具有随机性,假设现在i的初始值为0,有A和B两个线程对其进行++操作。首先两个线程将0拷贝到自己工作内存,当线程A在自己工作内存中进行了自增变成了1,还没来得及把1刷回到主内存,这是B线程抢到CPU执行权了。B将自己工作内存中的0进行自增,也变成了1。然后线程A将1刷回主内存,主内存此时变成了1,然后B也将1刷回主内存,主内存中的值还是1。本来A和B都对i进行了一次自增,此时主内存中的值应该是2,而结果是1,出现了写丢失的情况。这是因为i++本应该是一个原子操作,但是却被加塞了其他操作。所以说volatile不保证原子性。
多线程环境下,"数据计算"和"数据赋值"操作可能多次出现,即操作非原子。若数据在加载之后,若主内存count变量发生修改之后,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致。
对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步。
以i++为例,不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分3步完成。如果第二个线程在第一个线程读取旧值和写回新值期间(上图所指三步期间)读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于add方法必须使用synchronized修饰,以便保证线程安全。
从volatile变量的读写过程分析
read-load-use和assign-store-write成为了两个不可分割的原子操作,但是在use和assign之间依然有极小的一段真空期,有可能变量会被其他线程读取,导致写丢失一次
5.2.3 volatile不保证原子性验证
如下列代码所示:volatile修饰的number几乎不可能加到10000,就是因为存在极小段真空期,被其他线程读取,导致写丢失一次
public class VolatileNoAtmicDemo {
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();
}
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + "\t" + myNumber.number);
}
}
class MyNumber {
volatile int number = 0;
public void addPlusPlus() {
number++;
}
}
5.3 禁止指令重排
5.3.1 什么叫指令重排
重排序:
是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序。不存在数据依赖关系,可以重排序; 存在数据依赖关系,禁止重排序 。但重排后的指令绝对不能改变原有的串行语义。
5.3.2 重排序的分类和执行流程
- 编译器优化的重排序: 编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序。
- 指令级并行的重排序: 处理器使用指令级并行技术来将多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行。
5.3.3 volatile有关禁重排的行为
- 当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前**(volatile读之后的操作,都禁止重排序到volatile之前)**
- 当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatile写之后 (volatile写之前的操作,都禁止重排序到volatile之后)
- 当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。(volatile写之后volatile读,禁止重排序的)
5.3.4 内存屏障四大指令插入情况
- 在每个volatile写操作的前面插入一个StoreStore屏障,保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
- 在每个volatile写操作的后面插入一个StoreLoad屏障,避免volatile写与后面可能有的volatile读/写操作重排序
- 在每个volatile读操作的后面插入一个LoadLoad屏障,禁止处理器把上面的volatile读与下面的普通读重排序。
- 在每个volatile读操作的后面插入一个LoadStore屏障,禁止处理器把上面的volatile读与下面的普通写重排序。
6. 如何正确使用volatile
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized、java.util.concurrent中 的锁或原子类)来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
1.单一赋值可以,但是含复合运算赋值不可以(i++之类)
volatile int a = 10;
2.状态标志,判断业务是否结束
使用:作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束
理由:状态标志并不依赖于程序内任何其他状态,且通常只有一种状态转换
例子:判断业务是否结束 volatile boolean flag = false
3.开销较低的读,写锁策略
使用:当读远多于写,结合使用内部锁和volatile变量来成少同步的开销
理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性)
//当读远多于写,结合使用内部锁和volatile变量来成少同步的开销
public class Counter{
private volatile int value;
public int getValue(){
return value; //利用volatile保证读取操作的可见性
}
public synchronized int increment(){
return value++; //利用synchronized保证复合操作的原子性
}
}