共计 5464 个字符,预计需要花费 14 分钟才能阅读完成。
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
以咱们的思维认为执行程序为
// 线程 1
a = 1;
x = b;
// 线程 2
b = 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
类的内存屏障禁止重排序
// 线程 1
a = 1;
unsafe.fullFence();
x = b;
// 线程 2
b = 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 公布!