volatile
随着硬件的晋升,机器的外围数从已经的单核变为多核,为了晋升机器的利用率,当初的并发编程变得越来越重要,成为工作中、面试中的重中之重,而为了可能更好的了解、应用并发编程,就应该构建出本人的Java并发编程常识体系。
本篇文章将围绕Java中的volatile关键字,深入浅出的形容原子性、可见性、有序性,volatile的作用、实现原理、应用场景以及波及到的JMM、伪共享等问题
为了更好的形容volatile,咱们先来聊聊它的前置常识:有序性、可见性、原子性
有序性
什么是有序性?
当咱们应用高级语言和简略的语法进行编程时,最终还要将语言翻译为CPU意识的指令
因为干活的是CPU,为了放慢CPU的利用率,会对咱们流程管制的指令进行重排序
在Java内存模型中,指令重排序的规定须要满足后行先产生(happens-before)规定,比如说对一个线程的启动要后行产生于这个线程的其余操作,不能把启动线程的指令重排序到线程执行的工作前面
也就是说在Java内存模型中,指令重排序不会影响咱们规定好的单线程执行流程,但在多线程的状况下不能预估各个线程的执行流程
为了更贴切的形容,看上面这一段代码
static int a, b, x, y; public static void main(String[] args){ long count = 0; while (true) { count++; a = 0;b = 0;x = 0;y = 0; Thread thread1 = new Thread(() -> { a = 1; x = b; }); Thread thread2 = new Thread(() -> { b = 1; y = a; }); thread1.start(); thread2.start(); try { thread1.join(); thread2.join(); } catch (Exception e) {} if (x == 0 && y == 0) { break; } } //count=118960,x=0,y=0 System.out.println("count=" + count + ",x=" + x + ",y=" + y); }
初始化 a,b,x,y四个变量都是0
以咱们的思维认为执行程序为
//线程1a = 1;x = b;//线程2b = 1;y = a;
可是通过指令重排序后,可能呈现的四种状况:
//线程1//1 2 3 4 a = 1; a = 1; x = b; x = b; x = b; x = b; a = 1; a = 1; //线程2//1 2 3 4 b = 1; y = a; b = 1; y = a;y = a; b = 1; y = a; b = 1;
当呈现第4种状况时,x与y都可能为0
那么如何能够保障有序性呢?
应用volatile润饰变量就能够保障有序性
为了晋升CPU利用率,会对指令进行重排序,重排序只能保障单线程下流程的运行逻辑
在多线程下无奈预估执行程序,及不能保障有序性,如果想在多线程下保障有序性能够应用volatile,volatile会应用内存屏障禁止指令重排序来实现有序性
也能够间接加锁去保障同步执行
同时能够应用并发包中Unsafe
类的内存屏障禁止重排序
//线程1a = 1;unsafe.fullFence();x = b;//线程2b = 1; unsafe.fullFence();y = a;
可见性
什么又是可见性呢?
在Java内存模型中,每个线程有一份本人的工作内存和主内存,读取数据时须要先从主内存拷贝到工作内存,批改数据时只在本人的工作内存中进行批改,如果多个线程同时操作某个数据,进行批改后未写回主内存,那么其余线程无奈感知该数据变动
比方上面这一段代码,创立的线程会始终循环,因为它感知不到其余线程对变量进行批改
//nonVolatileNumber 是未被volatile润饰的 new Thread(() -> { while (nonVolatileNumber == 0) { } }).start(); TimeUnit.SECONDS.sleep(1); nonVolatileNumber = 100;
那么如何让该变量具备可见性呢?
对该变量应用volatile润饰即可保障可见性,也能够通过加锁synchronized的形式,加锁后相当于要从新读取主内存的数据
原子性
什么是原子性?
实际上就是一个或一组操作是否同时实现,如果不能,那他们就必须都失败,而不能一部分执行胜利一部分执行失败
Java内存模型中的read、load(上图)指令的原子性由虚拟机实现
应用一个变量的自增来说,实际上须要先从主内存进行读取再批改最初写回主内存
那么volatile是否保障原子性呢?
咱们应用两个线程对用volatile润饰的同一变量进行一万次自增
private volatile int num = 0; public static void main(String[] args) throws InterruptedException { C_VolatileAndAtomic test = new C_VolatileAndAtomic(); Thread t1 = new Thread(() -> { forAdd(test); }); Thread t2 = new Thread(() -> { forAdd(test); }); t1.start(); t2.start(); t1.join(); t2.join(); //13710 System.out.println(test.num); } /** * 循环自增一万次 * * @param test */ private static void forAdd(C_VolatileAndAtomic test) { for (int i = 0; i < 10000; i++) { test.num++; } }
很惋惜,后果并不是20000,阐明volatile润饰的变量并不能保障其原子性
那么什么形式能保障原子性呢?
synchronized加锁的形式就能够保障原子性,因为拿到锁同一时间只有它能进行拜访
应用原子类,底层应用CAS的形式也能够保障原子性,什么是CAS呢?咱们后续文章再来叨叨
volatile原理
通过有序性、可见性、原子性的形容与测试,咱们能够晓得volatile可能保障有序性和可见性,但不能保障原子性的特点
那么volatile底层又是如何实现有序性与可见性的呢?
JVM会对应用volatile润饰的变量,加上volatile的拜访标识,在字节码指令运行时会应用操作系统的内存屏障来禁止指令进行重排序
罕用的万能内存屏障是storeload,store1 storeload load2 它禁止写的指令重排到屏障之下,禁止读的指令重排到屏障之上,也就是store写回内存对其余处理器可见(可能感知),后续load 读就从内存中读
volatile汇编指令实现实际上是lock前缀指令
lock前缀指令在单核下没啥影响,因为单核能够保障有序性、可见性以及原子性
lock前缀指令在多核下会在批改数据时,将其写回内存中,写回内存须要确保同时只有一个处理器操作,能够通过锁总线的形式,但其余处理器就不能拜访
后续为了晋升并发粒度,处理器反对锁缓存时(只锁住缓存行),并通过缓存一致性协定保障不能同时批改同一缓存行数据
写回内存后,通过嗅探技术让其余处理器感知数据变动,在后续应用前从新读取内存
伪共享问题
因为每次读取都是对一个缓存行操作,那么如果多线程频繁对同一缓存行的两个变量进行批改,会不会导致其余用到该缓存行的处理器总是须要从新进行读数据呢?
这其实就是所谓的伪共享问题
比方两个变量i1,i2都在同一个缓存行中,其中处理器1频繁对i1进行写,处理器2频繁对i2进行写,并且i1与i2都被volatile润饰,这也就导致i1被批改时,处理器2感知到缓存行变脏,于是要从新读内存获取最新缓存行,但这样的性能开销对处理器2对i2进行写操作没有任何意义
解决伪共享问题的罕用方法,就是在这两个字段之间减少足够多的字段,让它们不在同一缓存行上,这样也就会导致节约空间
为了解决伪共享问题,JDK也提供了@sun.misc.Contended
注解来帮咱们填充字段
上面的代码,让两个线程循环10亿次去进行自增,当产生伪共享问题时总耗时三十多秒,未产生伪共享问题耗时才几秒
须要留神的是,应用@sun.misc.Contended
注解时,须要携带JVM参数-XX:-RestrictContended
@sun.misc.Contended private volatile int i1 = 0; @sun.misc.Contended private volatile int i2 = 0; public static void main(String[] args) throws InterruptedException { D_VolatileAndFalseSharding test = new D_VolatileAndFalseSharding(); int count = 1_000_000_000; Thread t1 = new Thread(() -> { for (int i = 0; i < count; i++) { test.i1++; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < count; i++) { test.i2++; } }); long start = System.currentTimeMillis(); t1.start(); t2.start(); t1.join(); t2.join(); //31910 i1:1000000000 i2:1000000000 //应用@sun.misc.Contended解决伪共享问题 须要携带JVM参数 -XX:-RestrictContended //5961 i1:1000000000 i2:1000000000 System.out.println((System.currentTimeMillis() - start) + " i1:"+ test.i1 + " i2:"+ test.i2); }
volatile的应用场景
volatile通过内存屏障来禁止指令重排序,从而保障可见性与有序性
基于可见性的特点,volatile非常适合用在并发编程中的读场景,因为volatile保障可见性,配合不必加锁的读操作,开销十分小
比方:并发包中AQS队列的同步状态status会用volatile润饰
而写操作经常须要进行加锁的同步保障
基于有序性的特点,volatile能够在双重检测锁时,禁止创建对象指令重排序,从而防止其余线程获取到的对象还未初始化
创建对象能够分为三个步骤:
//1.分配内存//2.初始化对象//3.将对象指向调配的空间
因为2、3步骤都会依赖1步骤,所以1步骤不能重排序,而2、3步骤没有依赖关系,因而重排序可能会导致先将对象指向调配的空间,再去初始化
如果此时在双重检测锁中,有线程正好判断不为空去应用这个对象,然而此时还未初始化,就可能产生获取到的对象还未初始化的问题
因而正确的双重检测锁须要加上volatile禁止重排序
private static volatile Singleton singleton; public static Singleton getSingleton(){ if (Objects.isNull(singleton)){ //有可能很多线程阻塞到拿锁,拿完锁再判断一次 synchronized (Singleton.class){ if (Objects.isNull(singleton)){ singleton = new Singleton(); } } } return singleton; }
总结
本篇文章围绕volatile关键字深入浅出的形容有序性、可见性、原子性、JMM、volatile原理、应用场景、伪共享问题等等
为了晋升CPU的利用率,会对指令进行重排序,重排序不影响单线程下的指向流程,但多线程下的执行流程不能预测
在Java内存模型中,每个线程都有本人的工作内存,读取数据须要从主内存读取,批改数据须要写回主内存;在并发编程中,当其余线程无奈感知到变量被批改时还持续应用就可能出错
volatile通过内存屏障禁止指令重排序以达到满足有序性和可见性,但不能满足原子性
volatile底层汇编应用lock前缀指令实现,在多核下在批改数据时会锁总线将数据写回内存中,因为锁总线开销大,后续应用锁缓存行,同时应用缓存一致性协定保障同时只能一个处理器批改同一缓存行,通过嗅探技术让其余领有该缓存行的处理器感知到缓存行变脏,后续从新读取
如果多线程频繁写操作的变量在同一缓存行,会呈现伪共享问题,此时须要通过填充字段,让它们不处于同一缓存行
volatile基于可见性的特点,常在并发编程中实现无锁的读操作;基于有序性的特点,能够在双重检测锁中保障获取到的实例不是还没初始化的
最初(不要白嫖,一键三连求求拉~)
本篇文章被支出专栏 由点到线,由线到面,深入浅出构建Java并发编程常识体系,感兴趣的同学能够继续关注喔
本篇文章笔记以及案例被支出 gitee-StudyJava、 github-StudyJava 感兴趣的同学能够stat下继续关注喔\~
案例地址:
Gitee-JavaConcurrentProgramming/src/main/java/A_volatile
Github-JavaConcurrentProgramming/src/main/java/A_volatile
有什么问题能够在评论区交换,如果感觉菜菜写的不错,能够点赞、关注、珍藏反对一下~
关注菜菜,分享更多干货,公众号:菜菜的后端私房菜
本文由博客一文多发平台 OpenWrite 公布!