文章目录
- 一、Volatile的定义和作用
- 1.1 Volatile简介
- 1.2 Volatile作用
- 二、并发编程中的三个问题:可见性、原子性、有序性
- 二、Java内存模型(JMM)
- 三、volatile变量的特性
- 3.1 线程可见性
- 3.2 禁止重排序
- 禁止重排序原理
- 禁止重排序举例
- 3.3 volatile特性
- 四、volatile原理
- 五、适用场景、不适用场景
- 5.1 volatile适用场景
- 5.2 volatile不适用的场景
- 5.2.1 volatile不适用 复合操作。
- 5.2.2 如何解决
- 六、Volatile与Synchronized比较
一、Volatile的定义和作用
1.1 Volatile简介
Java允许线程访问共享变量。为了确保共享变量能被一致、可靠地更新,线程必须确保 它是排他性地使用此共享变量,通常都是获得对这些共享变量强制排他性的同步锁。Java编程语言提供了另一种机制,volatile域变量,对于某些场景的使用 这会更加的方便。可以把变量声明为volatile,以让Java内存模型来保证所有线程都能看到这个变量的同一个值。
volatile是Java提供的一种轻量级的同步机制。Java语言包含两种内在的同步机制:同步块(或同步方法) 和 volatile变量,相比于synchronized(synchronized通常被称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。
注:Volatile只能修饰变量。
1.2 Volatile作用
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
-
保证线程间的可见性:当一个线程对共享变量进行了修改,另外的线程可以立即看到修改后的最新值。CPU都是有行缓存的,volatile能让行缓存无效,因此能读到内存中最新的值。
-
禁止进行指令重排序:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
二、并发编程中的三个问题:可见性、原子性、有序性
还记得并发编程中的可见性、原子性、有序性吗?如果忘记可以到这里重温复习【并发编程】1 synchronized底层实现原理、Java内存模型JMM;可重入、不可中断、monitor、CAS、乐观锁和悲观锁;对象内存结构、Mark Word、synchronized锁升级
- 可见性:当一个线程对共享变量进行了修改,另外的线程并没有立即看到修改后的最新值。 ——> 解决:当多个线程访问同一个变量时,一个线程修改了该变量的值,其他线程能够立即看到修改后的最新值
- 原子性:当一个线程对共享变量操作到一半时,另外的线程也有可能来操作共享变量,干扰了前一个线程的操作。 ——> 解决:在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有的操作都不执行
- 有序性:程序代码在执行过程中的先后顺序,由于Java在编译期以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序。 ——> 解决:程序执行的顺序按照代码的先后顺序执行
二、Java内存模型(JMM)
和Java内存结构不同,Java内存模型是一套规范、是标准化的,屏蔽掉了底层不同计算机的区别(Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则)。
JMM描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。细节如下。
- 主内存:主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。
- 工作内存:每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量。
对于普通的共享变量x,线程1将其修改为某个值 发生在线程1的工作内存中,此时还未同步到主内存中去;而线程2已经缓存了该变量的旧值,所以就导致了共享变量值的不一致。解决这种共享变量在多线程中的不可见问题,较粗暴的方式自然就是加锁,但此处使用synchronized或Lock这些方式 太重量级了,比较合理的方式是 volatile。
注意:JMM是抽象的内存模型,所谓的主内存、工作内存都是抽象概念,不一定就真实地对应cpu缓存、物理内存。
具体细节见【并发编程】1 synchronized底层实现原理、Java内存模型JMM;可重入、不可中断、monitor、CAS、乐观锁和悲观锁;对象内存结构、Mark Word、synchronized锁升级
三、volatile变量的特性
3.1 线程可见性
用 volatile 修饰共享变量,当一个线程对共享变量进行了修改,另外的线程可以立即看到修改后的最新值。CPU都是有行缓存的,volatile能让行缓存无效,因此能读到内存中最新的值。
- 当某个线程写volatile变量、将修改的值同步回主内存时,JMM会把该线程工作内存中的变量强制刷新到主内存中去
- JMM会把其他工作内存的值全部设置为失效,线程会重新读取共享内存的值
详解:
当一个线程把主内存中的共享变量读取到自己的本地内存中,然后做了更新。在还没有把共享变量刷新的主内存的时候,另外一个线程是看不到的。如何把修改后的值刷新到主内存中?
现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,较少对内存总线的占用。但是什么时候写入到内存是不知道的。
所以就引入了volatile,volatile是如何保证可见性的呢?
- 将当前处理器缓存行的数据写回到系统内存。
- 这个写回内存的操作会使其他cpu里缓存了该内存地址的数据无效。
- 如果声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的还是旧的,再执行操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
3.2 禁止重排序
用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。
首先看下不加volatile的情况
1)修改pom.xml文件,添加依赖
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>0.14</version>
</dependency>
2)编写代码
//为什么使用此工具,直接运行代码不行吗? 只有使用大量的线程去执行,才能测试出指令重排序的效果
@JCStressTest //用并发压缩工具jcstress 测试类方法
@Outcome(id = {"0, 0", "1, 1", "0, 1"}, expect = Expect.ACCEPTABLE, desc = "ok") //@Outcome 对输出结果进行处理, "0, 0", "1, 1", "0, 1" 打印ok
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class TestOrdering {
int x;
int y;
@Actor
public void actor1() { //写
x = 1;
y = 1;
}
@Actor
public void actor2(II_Result r) { //读
r.r1 = y;
r.r2 = x;
}
}
mvn clean install,java -jar xxx\target\jcstress.jar
3)查看项目对应路径results目录下的html文件,用浏览器打开,观察运行结果
根据运行结果,出现了”1,0“这种情况,说明actor1()
方法先给y赋值、再给x赋值,确实发生指令重排序(指令重排序是为了实现指令流水线,属于计算机组成原理的内容)
如何解决:在变量上添加volatile,禁止指令重排序,则可以解决问题(volatile原理就是加了一些屏障,使屏障后的代码一定不会比屏障前的代码先执行,从而实现有序性)
- 给 y 加上 volatile,不会出现”1,0“,解决了指令重排序的问题。
//省略部分代码
public class TestOrdering {
int x;
volatile int y;
//其他不变,省略
}
volatile的底层原理是,给共享变量加上不同的屏障,保证指令不会发生重排序
- 把volatile加在x上面,无法禁止指令重排序
//省略部分代码
public class TestOrdering {
int x;
volatile int y;
//其他不变,省略
}
写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下,但无法阻止下方的指令往上走,y=1可以往上走、r.r1=y可以往下走。
- 可以给x、y都加上volatile,也能解决问题,但性能不高,不推荐
禁止重排序原理
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分为如下三种:
1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。
当变量声明为volatile时,Java编译器在生成指令序列时,会插入内存屏障指令,通过内存屏障指令来禁止重排序。
JMM内存屏障插入策略如下:
- 在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个StoreLoad屏障。
- 在每个volatile读操作后面插入一个LoadLoad,LoadStore屏障。
Volatile写插入内存屏障后生成指令序列示意图:
Volatile读插入内存屏障后生成指令序列示意图:
通过上面这些我们可以得出如下结论:编译器不会对volatile读与volatile读后面的任意内存操作重排序;编译器不会对volatile写与volatile写前面的任意内存操作重排序。
禁止重排序举例
从一个最经典的例子来分析重排序问题,以单例模式的双重检查锁实现(线程安全)为例
public class Singleton {
public volatile static Singleton uniqueInstance;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {}
public static Singleton getUniqueInstance() {
if (uniqueInstance == null) {
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
为什么使用volatile关键字修饰uniqueInstance实例变量?
因为uniqueInstance = new Singleton()
这段代码执行时分为三步:
- 为uniqueInstance分配内存空间
- 初始化uniqueInstance
- 将uniqueInstance指向分配的内存地址
正常的执行顺序当然是 1–>2–>3,但由于JVM具有指令重排的特性,执行顺序可能变成 1–>3–>2。单线程环境时,指令重排没有什么问题;多线程环境下,会导致有些线程可能会获取到还没初始化的实例。如线程A只执行了1和3,此时线程B来调用getUniqueInstance()
,发现 uniqueInstance
不为空,便获取 uniqueInstance
实例,但是其实此时的 uniqueInstance
还没有初始化。
解决方法就是 加一个 volatile
关键字修饰 uniqueInstance
,volatile
会禁止 JVM 的指令重排,就可以保证多线程环境下的安全运行。
3.3 volatile特性
- volatile仅能使用在变量级别
- volatile仅能实现变量的修改可见性,不能保证原子性,volatile + cas 就实现了原子性,如atomic包下面的类。
- volatile不会造成线程的阻塞
- volatile标记的变量不会被编译器优化
volatile可以保证可见性、有序性,无法保证原子性,所以是线程不安全的(它对任意单个变量的读/写具有原子性,但是类似于i++这种复合操作不具有原子性,因为本质上i++是读、写两次操作)。要保证多线程的原子性, 可以通过AtomicInteger或者Synchronized来实现,本质上就是CAS操作(见下文5.2)
【synchronized可以保证可见性、原子性、有序性,因为锁的互斥执行的特性可以确保对整个临界区代码执行具有原子性】
java.util.concurrent.*里面的高级线程安全数据结构像ConcurrentHashMap以及java.util.concurrent.atomic.*等的实现都用到了volatile。可以多看看这些类的实现,以加深对volatile的理解和运用。
四、volatile原理
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用"内存屏障"来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
(1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
(2)它会强制将对缓存的修改操作立即写入主存
(3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
计算机科学里面,为了解决复杂性,都会分层。正如一个名人所说:“计算机的任何问题都可以通过增加一个虚拟层来解决”(“All problems in computer science can be solved by another level of indirection”)。volatile虚拟机层引入的,解决语言层面的问题,那么它的实现,必然是靠下一层的支持,也就是需要汇编或者说处理器指令的支持来实现,volatile是靠内存屏障和**MESI**(缓存一致性协议)来达成的它的作用的。
内存屏障(Memory Barriers)是处理器提供的一组内存操作指令,它的作用是限制内存操作的顺序,也就是说内存屏障像一个栅栏一样,它前面的指令要在它后面的指令之前完成;还能强制把缓存写入到主存;再有的就是触发缓存一致性,就是当有写变量时,会把其他CPU核心的缓存变为无效。
五、适用场景、不适用场景
5.1 volatile适用场景
对变量可见性有要求、对读取顺序没要求的情况下。如 多线程情况下的标志位
- 读操作,多于写操作
- 写操作,不依赖于变量的当前值,即纯赋值操作
- 只需要读取的值,不需要等待某一特定的值
5.2 volatile不适用的场景
5.2.1 volatile不适用 复合操作。
例如 number++ 不是一个原子性操作,由读取、加、赋值3步组成,下列程序可能返回多种结果,2677、4202、4722、4910、5000
public class Test02Atomicity {
private volatile static int number = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
number++;
}
};
List<Thread> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
list.add(t);
}
for (Thread thread : list) {
//让主线程等待自己创建的线程执行完毕,5个线程执行完 再取number的值
thread.join();
}
System.out.println("number=" + number); //值有多种可能,2677、4202、4722、4910、5000
}
}
5.2.2 如何解决
1)采用synchronized
public class Test02Atomicity {
private static int number = 0;
private static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
synchronized (obj) {
number++; //synchronized保证 number++ 为原子操作。线程获取不到锁 就会等待
}
}
};
List<Thread> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
list.add(t);
}
for (Thread thread : list) {
//让主线程等待自己创建的线程执行完毕
thread.join();
}
System.out.println("number=" + number); //5000
}
}
2)采用Lock
public class Test02Atomicity {
private static int number = 0;
private static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
try {
lock.lock();
number++;
} finally {
lock.unlock();
}
}
};
List<Thread> list = new ArrayList<>();
//使用5个线程来进行
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
list.add(t);
}
for (Thread thread : list) {
//让主线程等待自己创建的线程执行完毕,5个线程执行完 再取number的值
thread.join();
}
System.out.println("number=" + number); //5000
}
}
3)采用java并发包中的原子操作类,原子操作类是通过CAS的方式来保证其原子性的
public class Test02Atomicity {
private static AtomicInteger atomicInteger = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
atomicInteger.incrementAndGet(); //AtomicInteger的incrementAndGet()可保证变量赋值的原子性
}
};
List<Thread> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
list.add(t);
}
for (Thread thread : list) {
//让主线程等待自己创建的线程执行完毕
thread.join();
}
System.out.println("atomicInteger=" + atomicInteger.get()); //5000
}
}
六、Volatile与Synchronized比较
- Volatile是轻量级的synchronized,因为它不会引起上下文的切换和调度,因此Volatile性能更好。
- Volatile只能修饰变量(类的成员变量、类的静态成员变量),synchronized可以修饰方法,静态方法,代码块。
- Volatile对任意单个变量的读/写具有原子性,但是类似于i++这种复合操作不具有原子性。而锁的互斥执行的特性可以确保对整个临界区代码执行具有原子性。
- 多线程访问volatile不会发生阻塞,而synchronized会发生阻塞。
- volatile是变量在多线程之间的可见性,synchronize是多线程之间访问资源的同步性。
参考 https://zhuanlan.zhihu.com/p/633426082、https://blog.csdn.net/u012723673/article/details/80682208、https://blog.csdn.net/xinghui_liu/article/details/124379221