大家好,又见面了,我是你们的朋友全栈君。
synchronized是阻塞式同步,在线程竞争激烈的情况下会升级为重量级锁,而volatile 可以说是JVM 提供的最轻量级的同步机制。jMM告诉我们,各个线程会将共享变量从主内存中拷贝到工作内存,然后执行引擎会基于工作内存中的数据进行操作处理。线程在工作内存进行操作后什么时候写回到主内存中,实际上没有明确的限制。而针对volatile修饰的变量给java虚拟机特殊的约定,线程对volatile 变量的修改会立刻被其他线程所感知,即不会出现数据脏读,从而保证数据的一个可见性。
被volatile 修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免数据脏读现象。
volatile 特性分析
特性一:可见性
前面介绍Java内存模型的时候,我们说过可见性是指当一个线程修改了共享变量的值,其他线程立即感知到这种变化。
而普通变量无法做到立即感知这一点,变量的值在线程之间的传递均需要通过主内存来完成,(比如线程A修改了一个普通变量的值,然后向主内存回写另外一条线程B只有在线程A的回写完成之后再从主内存中读取变量的值,才能够读取到新变量的值,也就是新变量才能对线程B可见)
再这期间可能会出现不一致的情况,比如:
(1)、线程A并不是修改完成后立即回写(线路A修改了变量X的值为5,但是还没有回写,线程B从主内存读取到的还是旧值0)
(2)、线程B还在用着自己工作内存中的值,而并不是立即从主内存中读取值;(线程A回写了变量x的值5到主内存中,但是线程B还没有读取主内存中的值,依然在使用旧值0在进行运算)
例子:
volatile 的可见性可以通过下面示例体现
public class VolatileTest {
// public static int finished = 0;
public static volatile int finished = 0; private static void checkFinished() {
while (finished == 0) {
// do nothing
}
System.out.println("finished");
}
private static void finish() {
finished = 1;
}
public static void main(String[] args) throws InterruptedException {
// 起一个线程检测是否结束
new Thread(() -> checkFinished()).start();
Thread.sleep(100);
// 主线程将finished标志置为1
finish();
System.out.println("main finished");
}
}
在上面的代码中,针对finished 变量,使用volatile 修饰是这个程序可以正常结束,不使用volatile 修饰时这个程序永远不会结束。
因为在不使用volatile修饰时,checkFinished()所在的现车每次都是堆区的它自己工作内存中的变量的值,这个值一直为0,所以一直不会跳出while循环。
使用volatile修饰时,checkFinished()所在的线程每次都是从主内存中加载最新的值,当finished 被主线程修改为1的时候,它会立即感知到,进而会跳出while循环。
特性二、禁止重排序
前面介绍Java 内存模型的时候,我们说过java中的有序性可以概况为一句话:如果在本线程中观察,所有的操作都是有序的;如果在另外一个线程中观察,所有的操作都是无序的。
前半句是指线程内表现为串行的语义,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象
普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获得正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致,因为一个线程的方法执行过程中无法感知到这点,这就是“线程内表现为串行的语义”。
比如,下面的代码:
// 两个操作在一个线程
int i = 0;
int j = 1;
上面两句话没有依赖的关系,JVM 在执行的时候为了充分利用CPU的处理能力,可能会线执行 int j=1;
这句,也就是重排序了,但是在线程内是无法感知的。
我们在看一个例子:
public class VolatileTest3 {
private static Config config = null;
private static volatile boolean initialized = false; public static void main(String[] args) {
// 线程1负责初始化配置信息 new Thread(() -> {
config = new Config();
config.name = "config";
initialized = true;
}).start(); // 线程2检测到配置初始化完成后使用配置信息 new Thread(() -> {
while (!initialized) {
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100));
}
// do sth with config
String name = config.name;
}).start();
}
}
class Config {
String name;
}
这个例子很简单,线程1负责初始化配置,线程2检测到配置初始化完毕,使用配置来干一些事。
在这个例子中,如果initialized不使用volatile来修饰,可能就会出现重排序,比如在初始化配置之前把initialized的值设置为了true,这样线程2读取到这个值为true了,就去使用配置了,这时候可能就会出现错误。
(此处这个例子只是用于说明重排序,实际运行时很难出现。)
通过这个例子,相信大家对“如果在本线程内观察,所有操作都是有序的;在另一个线程观察,所有操作都是无序的”有了更深刻的理解。
所以,重排序是站在另一个线程的视角的,因为在本线程中,是无法感知到重排序的影响的。
而volatile变量是禁止重排序的,它能保证程序实际运行是按代码顺序执行的。
实现: 内存屏障
上面讲了volatile 可以保证可见性和禁止重排序,那么它是怎么实现的那?
答案是:通过内存屏障
(1)、阻止屏障两侧的指令重排序
(2)、强制把写缓冲区/高速缓存中的数据写回到主内存,让缓存中相应的数据失效;
我们还是来看一个例子来理解内存屏障的影响:
public class VolatileTest4 {
// a不使用volatile修饰
public static long a = 0;
// 消除缓存行的影响
public static long p1, p2, p3, p4, p5, p6, p7;
// b使用volatile修饰
public static volatile long b = 0;
// 消除缓存行的影响
public static long q1, q2, q3, q4, q5, q6, q7;
// c不使用volatile修饰
public static long c = 0; public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (a == 0) {
long x = b;
}
System.out.println("a=" + a);
}).start(); new Thread(()->{
while (c == 0) {
long x = b;
}
System.out.println("c=" + c);
}).start();
Thread.sleep(100);
a = 1;
b = 1;
c = 1;
}
}
这段代码中,a和c不使用volatile修饰,b使用volatile修饰,而且我们在a/b、b/c之间各加入7个long字段消除伪共享的影响。
关于伪共享的相关知识,可以查看彤哥之前写的文章【杂谈 什么是伪共享(false sharing)?】。
在a和c的两个线程的while循环中我们获取一下b,你猜怎样?如果把long x = b;这行去掉呢?运行试试吧。
这里直接说结论了:volatile变量的影响范围不仅仅只包含它自己,它会对其上下的变量值的读写都有影响。 (待确定)
注意点
上面我们介绍了volatile关键字的两大语义,那么,volatile关键字是不是就是万能的了呢?
当然不是,忘了我们内存模型那章说的一致性包括的三大特性了么?
一致性主要包含三大特性:原子性、可见性、有序性。
volatile关键字可以保证可见性和有序性,那么volatile能保证原子性么?
public class VolatileTest5 {
public static volatile int counter = 0;
public static void increment() {
counter++;
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(100);
IntStream.range(0, 100).forEach(i->
new Thread(()-> {
IntStream.range(0, 1000).forEach(j->increment());
countDownLatch.countDown();
}).start());
countDownLatch.await();
System.out.println(counter);
}
}
这段代码中,我们采用100个线程对counter 自增1000次,一共相当于增加了100000,但我们运行结果发现实际结果永远不会到达10000.
让我们来看看increment()方法的字节码(IDEA下载相关插件可以查看)
0 getstatic #2 <com/coolcoding/code/synchronize/VolatileTest5.counter>
3 iconst_1
4 iadd
5 putstatic #2 <com/coolcoding/code/synchronize/VolatileTest5.counter>
8 return
可以看到counter++被分解成了四条指令:
(1)getstatic,获取counter当前的值并入栈
(2)iconst_1,入栈int类型的值1
(3)iadd,将栈顶的两个值相加
(4)putstatic,将相加的结果写回到counter中
由于counter是volatile修饰的,所以getstatic会从主内存刷新最新的值,putstatic也会把修改的值立即同步到主内存。
但是中间的两步iconst_1和iadd在执行的过程中,可能counter的值已经被修改了,这时并没有重新读取主内存中的最新值,所以volatile在counter++这个场景中并不能保证其原子性
综述:
valatile 关键字只能保证可见性和有序性,不能保证原子性,要解决原子性的问题,还是只能通过加锁或使用原子类的方式解决。即大体可以数据volatile 可以保证多线程下赋值put\get等(具有原子性)操作线程安全,无法保证比如++这种非原子性操作的线程安全。
如果确实要保证多线程下++操作的线程安全,可以参照Atomic 的实现方式。避免synchronized的高开销,也能执行效率大为提升。
进而,我们得出volatile 关键字使用场景;
(1)、运算的结果并不依赖于变量的当前中,或者能过确保只有单一线程修改变量的值
(2)、变量不需要与其他状态变量共同参与不变约束。
说白了,就是volatile本身不保证原子性,那就要增加其它的约束条件来使其所在的场景本身就是原子的。
private volatile int a = 0;
// 线程A
a = 1;
// 线程B
if (a == 1) {
// do sth
}
a = 1;这个赋值操作本身就是原子的,所以可以使用volatile来修饰。
综述:
(1) 可以防止编译器对代码进行优化;(双刃剑,防止代码优化代码不按顺序执行或小概率合并。但会影响性能)
(2)volatile关键字可以保证可见性;
(3)volatile关键字可以保证有序性;
(4)volatile关键字不可以保证原子性;
(5)volatile关键字的底层主要是通过内存屏障来实现的;
(6)volatile关键字的使用场景必须是场景本身就是原子的;
欢迎大家有问题 及时指正 谢谢
发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/161288.html原文链接:https://javaforall.cn
【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛
【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...