Java学习笔记(1)—volatile
原理构建上层逻辑,学会弄懂为什么。
一、作用
- 保证变量的内存可见性;
- 禁止指令超昂排序;
二、可见性
2.1 场景
多线程访问共享变量场景
/**
* 变量的内存可见性例子
*
* @author star
*/
public class VolatileExample {
/**
* main 方法作为一个主线程
*/
public static void main(String[] args) {
MyThread myThread = new MyThread();
// 开启线程
myThread.start();
// 主线程执行
for (; ; ) {
if (myThread.isFlag()) {
System.out.println("主线程访问到 flag 变量");
}
}
}
}
/**
* 子线程类
*/
class MyThread extends Thread {
private boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改变量值
flag = true;
System.out.println("flag = " + flag);
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
问题:程序运行后,无法在控制台打印“主程序访问到“flag变量”;
思考:子线程已经将flag置为true,然而主线程没有获取到flag的最新值,是什么原因造成的?
分析:Java内存模型(JMM)自身特性;
JMM(Java Memory Model):Java 内存模型,是 Java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别。也就是说,JMM 是 JVM 中定义的一种并发编程的底层模型机制。
JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。
JMM 的规定:
- 所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
- 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
- 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
- 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
JMM 的抽象示意图:
JMM 这样的规定可能会导致线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。
结论:JMM的主内存与线程的私有内存的共享变量并非同步,造成可见性问题。
2.2 探讨
内存可见性是指当一个线程修改了某个变量的值,其它线程总是能知道这个变量变化。也就是说,如果线程 A 修改了共享变量 V 的值,那么线程 B 在使用 V 的值时,能立即读到 V 的最新值。
可见性问题的解决方案
想解决多线程下共享变量的可见性,即某一线程修改值后,其他线程可以读取到最新值,一般有两种方法:加锁和使用volatile。
加锁
/**
* main 方法作为一个主线程
*/
public static void main(String[] args) {
MyThread myThread = new MyThread();
// 开启线程
myThread.start();
// 主线程执行
for (; ; ) {
synchronized (myThread) {
if (myThread.isFlag()) {
System.out.println("主线程访问到 flag 变量");
}
}
}
}
解决原理:当一个线程进入 synchronizer 代码块后,线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,执行代码,又将修改后的副本值刷新到主内存中,最后线程释放锁。
使用volatile
/**
* 子线程类
*/
class MyThread extends Thread {
private volatile boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改变量值
flag = true;
System.out.println("flag = " + flag);
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
解决原理:使用 volatile 修饰共享变量后,每个线程要操作变量时会从主内存中将变量拷贝到本地内存作为副本,当线程操作变量副本并写回主内存后,会通过 CPU 总线嗅探机制告知其他线程该变量副本已经失效,需要重新从主内存中读取。
volatile 保证了不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改后的变量写回主内存时,其他线程能立即看到最新值。
总线嗅探机制
在现代计算机中,CPU 的速度是极高的,如果 CPU 需要存取数据时都直接与内存打交道,在存取过程中,CPU 将一直空闲,这是一种极大的浪费,所以,为了提高处理速度,CPU 不直接和内存进行通信,而是在 CPU 与内存之间加入很多寄存器,多级缓存,它们比内存的存取速度高得多,这样就解决了 CPU 运算速度和内存读取速度不一致问题。
由于 CPU 与内存之间加入了缓存,在进行数据操作时,先将数据从内存拷贝到缓存中,CPU 直接操作的是缓存中的数据。但在多处理器下,将可能导致各自的缓存数据不一致(这也是可见性问题的由来),为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,而嗅探是实现缓存一致性的常见机制。
其中缓存一致性问题,不是由多核处理器引起的,而是多级缓存引起的;
嗅探机制工作原理:每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。
注意:基于 CPU 缓存一致性协议,JVM 实现了 volatile 的可见性,但由于总线嗅探机制,会不断的监听总线,如果大量使用 volatile 会引起总线风暴。所以,volatile 的使用要适合具体场景。
volatile 和 synchronized 锁都可以保证共享变量的可见性。相比 synchronized 而言,volatile 可以看作是一个轻量级锁,所以使用 volatile 的成本更低,因为它不会引起线程上下文的切换和调度。但 volatile 无法像 synchronized 一样保证操作的原子性。
在多线程环境下,volatile 关键字可以保证共享数据的可见性,但是并不能保证对数据操作的原子性。也就是说,多线程环境下,使用 volatile 修饰的变量是线程不安全的。
要解决这个问题,我们可以使用锁机制,或者使用原子类(如 AtomicInteger)。
对任意单个使用 volatile 修饰的变量的读 / 写是具有原子性,但类似于 flag = !flag
这种复合操作不具有原子性。简单地说就是,单纯的赋值操作是原子性的。
三、指令重排序
3.1 场景
int a = 0;
// 线程 A
a = 1; // 1
flag = true; // 2
// 线程 B
if (flag) { // 3
int i = a; // 4
}
问题:为了提高性能,编译器和处理器常常会在不改变数据依赖的情况下对指令做重排序。假设线程 A 在执行时被重排序成先执行代码 2,再执行代码 1;而线程 B 在线程 A 执行完代码 2 后,读取了 flag 变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,那么 i 最后的值是 0,导致执行结果不正确。
思考:那么如何程序执行结果正确呢?
分析:可以使用 volatile 关键字。
2.2 探讨
重排序
为了提高性能,在遵守 as-if-serial
语义(即不管怎么重排序,单线程下程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守。)的情况下,编译器和处理器常常会对指令做重排序。
一般重排序可以分为如下三种类型:
- 编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
从 Java 源代码到最终执行的指令序列,会分别经历下面三种重排序:
volatile在I/O时,会在内存中插入内存屏障来禁止特定类型的处理器重排序;
可以知道 volatile 读 / 写插入内存屏障规则:
- 在每个 volatile 读操作的后面插入 LoadLoad 屏障和 LoadStore 屏障。
- 在每个 volatile 写操作的前后分别插入一个 StoreStore 屏障和一个 StoreLoad 屏障。
总结:编译器不会对 volatile 读与 volatile 读后面的任意内存操作重排序;编译器不会对 volatile 写与 volatile 写前面的任意内存操作重排序。
四、小结
4.1 应用场景
- 某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值;
- 或者作为状态变量,如 flag = ture,实现轻量级同步。
4.2 作用领域
- volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
- volatile 只能作用于属性,我们用 volatile 修饰属性,这样编译器就不会对这个属性做指令重排序。
4.3 作用
- volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中读取。
- volatile 提供了 happens-before 保证,对 volatile 变量 V 的写入 happens-before 所有其他线程后续对 V 的读操作。
- volatile 可以使纯赋值操作是原子的,如
boolean flag = true; falg = false
。 - volatile 可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。
五、参考
见性,任何一个线程对其的修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中读取。
- volatile 提供了 happens-before 保证,对 volatile 变量 V 的写入 happens-before 所有其他线程后续对 V 的读操作。
- volatile 可以使纯赋值操作是原子的,如
boolean flag = true; falg = false
。 - volatile 可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。