由于JMM
这样的机制,就导致了可见性的问题。
JMM三大特性
二、可见性
内存可见性指当一个线程修改了某个变量的值后,其他线程总能知道这个值的变化。
这里用例子来说明一下:
package com.fzkj.juc;import java.util.concurrent.TimeUnit;/** * @DESCRIPTION volatile关键字测试类 */public class VolatileTest { public static void main(String[] args) { Number number = new Number(); new Thread(() -> { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } number.numTo(20); System.out.println(Thread.currentThread().getName() + ":\t number的值是: " + number.num); }, "A线程").start(); while(number.num == 0){} System.out.println(Thread.currentThread().getName() + ":\t number的值是: " + number.num); }}class Number{ int num = 0; public void numTo(int target){ this.num = target; } public void add(){ this.num++ ; }}
运行上面的例子就会发现,程序会陷入死循环,永远不会输出最后一句话。就是因为A线程中对变量num
的修改对main
线程不可见,导致while
循环一直进行。
可见性问题常见的解决方案包括:
volatile
对上面代码进行改造
class Number{ volatile int num = 0; public void numTo(int target){ this.num = target; } public void add(){ this.num++ ; }}
这样在运行上面例子。就不会在陷入死循环了。
volatile是如何保证可见性的?
其他线程又是如何知道共享变量被修改了呢?
为了解决缓存一致性问题,需要遵循一些协议,叫做缓存一致性协议,如:MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。
嗅探
通过嗅探机制来保证及时的知道自己的缓存过期了。
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,
当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里
由于嗅探机制会不断的监听总线,打量使用volatile可能会引起总线风暴
三、原子性
在来看另一种情况。
package com.fzkj.juc;import java.util.concurrent.TimeUnit;/** * @DESCRIPTION volatile关键字测试类 */public class VolatileTest { public static void main(String[] args) { atomicity(); } // 原子性 public static void atomicity(){ Number num = new Number(); for (int i = 0; i { for (int j = 0; j < 1000; j++) { // 每个线程对num的值操作1000次 num.add(); } }).start(); } try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(num.num); }}class Number{ int num = 0; public void numTo(int target){ this.num = target; } public void add(){ this.num++ ; }}
在上面的例子中,我们启动了10个线程,每个线程调用了1000次add方法,对num的值进行1000累加,那么我们期待的最终结果就是num的值是10000。但是实际上运行程序就会发现,每次的结果都会比10000少。
这个问题的成因,其实跟jvm有关系,我们都知道,程序员写的代码只是给程序员自己看的,还需要将代码编译才是机器执行的。一个++操作被编译成字节码文件之后,可以简化成三个步骤。第一步取值;第二步加一;第三步赋值。所以在高并发的场景下,就会出现值被覆盖的情况。
原子性的定义:指在一组操作中,要么全部操作都成功,要么全部操作都失败。
原子性是JMM
的特性之一,但是volatile
却并不支持原子性。要想在多线程的环境下保证原子性,可以使用锁机制,或者使用原子类(AtomicInteger)。
四、有序性
禁止指令重排就叫做有序性。
什么是指令重排?
为了提高性能,在遵守as-if-serial
语义的情况下,编译器和处理器往往会对指令做重排序。在多线程的情况下,指令重排可能会导致一些意想不到的情况。
那volatile
是怎么禁止指令的重排序的呢?这里又引出一个新的概念:内存屏障
内存屏障
内存屏障的作用是禁止指令重排序和解决内存可见性的问题。
先了解两个指令:
JMM
主要将内存屏障分为四类
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 确保Load1数据的装载先于Load2 |
StoreStore | Store1;StoreStore;Store2 | 确保Store1立刻刷新数据到内存的操作先于Store2 |
LoadStore | Load1;LoadStore;Store2 | 确保Load1数据装载先于Store2数据刷新 |
StoreLoad | Store1StoreLoad;Load2 | 确保Store1数据刷新先于Load2数据装载 |
StoreLoad
被称为全能屏障,因其同时具备其他三个屏障的效果,但是相对于其他屏障,消耗会多。
了解了这些,下面就来看看volatile
是如何插入内存屏障的。
可以看到,
这就是说,编译器不会对volatile
读和读后面的操作重排序;不会对写和写前面的操纵重排序。这样就保证了volatile
本身的有序性。