内存可见性
定义:Java语言规范第3版中对volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
volatile是java提供的轻量级的同步机制,而synchronized则是重量级锁。
public class TestVolatile {
// 共享变量状态置为false
boolean status = false;
// 解决可见性问题,只需将共享变量加以volatile关键字修饰
// volatile boolean status = false;
/**
* 状态切换为true
*/
public void changeStatus(){
status = true;
}
/**
* 若状态为true,则running。
*/
public void run(){
if(status){
System.out.println("run...");
}
}
}
上面代码块中,倘若多线程环境里,假设A执行了changeStatus方法后,这个时候线程B运行了run方法,这个时候控制台并不会打印run...,注意前提为多线程环境,正因为是在多线程的环境下,线程A对共享变量status状态切换为true,对于线程B来说是不可见的,因此System.out.println("run...")并不一定会执行。
发生可见性问题,需要两个条件,①各线程操作数据是在缓存中操作,才会存在缓存不一致;②没有触发缓存一致性协议。
Java内存模型(JMM)
JMM可以屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的内存访问效果。
JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。这三者之间的交互关系如下
通过上图和我们的代码块进行分析,对于共享变量而言,线程A修改了status状态为true这个动作是发生在上图本地内存A中,此时尚未同步到主内存(main memory),线程B缓存的status初始值为false。且线程B此时并不知道线程A对status进行了修改。这就发生了可见性问题。
解决方法:
粗暴的方式就是对其进行加锁,但是上文提到过synchronized等锁都是重量级锁,因此volatile刚好合适。
volatile特性:
1.当写一个volatile变量时,JMM会把该线程对应的本地内存中的变量强制刷新到主内存中去,这个写会操作会导致其他线程中的缓存无效。
2.禁止指令重排序
复合类操作:
volatile与synchronized虽然在内存语义上有共同之处,但是并不能完全替代,它仍是一个轻量级锁,在很多场景下volatile并不能胜任。
package com.sxd.test;
import java.util.concurrent.CountDownLatch;
/**
* Created by sxd on 2022/6/6.
*/
public class Counter {
// 可见的共享变量num
public static volatile int num = 0;
//使用CountDownLatch来等待计算线程执行完
static CountDownLatch cdl= new CountDownLatch(30);
public static void main(String []args) throws InterruptedException {
//开启30个线程进行累加操作
for(int i=0;i<30;i++){
new Thread(){
public void run(){
for(int j=0;j<10000;j++){
num++;//自加操作
}
cdl.countDown();
}
}.start();
}
//等待计算线程执行完
cdl.await();
System.out.println(num);
}
}
执行结果为:
保证了可见性不应该输出300000?其实问题出在这个num++上,num++(①读取②加一③赋值)相当于num=num+1
在多线程情况下,该操作非原子性操作,可能A线程将num读取到本地内存中,但是B、C等等其他线程已经完成了对num+1并赋值的操作。这个时候线程A依旧对过期读取到的num进行加一并写入到主存,所以导致最终结果小于300000。
解决方法:
对于类似num++等复合类操作,可以使用java并发包(juc)中的原子操作类通过CAS的方式保证原子性。
/**
* Created by sxd on 2022/6/6.
*/
public class Counter {
//使用原子操作类
public static AtomicInteger num = new AtomicInteger(0);
//使用CountDownLatch来等待计算线程执行完
static CountDownLatch cdl= new CountDownLatch(30);
public static void main(String []args) throws InterruptedException {
//开启30个线程进行累加操作
for(int i=0;i<30;i++){
new Thread(){
public void run(){
for(int j=0;j<10000;j++){
num.incrementAndGet();//原子性的num++,通过循环CAS方式
}
cdl.countDown();
}
}.start();
}
//等待计算线程执行完
cdl.await();
System.out.println(num);
}
}
执行结果为:
禁止指令重排序
指令重排序:java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
指令重排序的意义是什么?
JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
解释说明:
例如①a=1;②b=3;③c=a+b这三个操作,因为①和②之间不存在数据依赖所以可能会发生指令重排序,但是正如它的定义所说要程序的最终结果与它顺序化情况的结果相等,所以③不会进行指令重排序。
重排序在单线程模式下一定会保证最终结果的正确性,但是多线程环境下就无法保证
public class TestVolatile {
int a = 1;
boolean status = false;
/**
* 状态切换为true
*/
public void changeStatus(){
a = 2;//1
status = true;//2
}
/**
* 若状态为true,则running。
*/
public void run(){
if(status){//3
int b = a+1;//4
System.out.println(b);
}
}
}
假设A线程执行了changeStatus方法,线程B执行run方法,那么是否能保证最后控制台打印结果为3吗?如果你看过了上文你肯定会得到答案:肯定不能保证,其结果很有可能为2。
上文说到jvm可能会对不存在数据依赖进行指令重排序。那么由于1和2之间不存在数据依赖关系,所以很有可能线程A执行了status = true;还没来得及执行a = 2;的赋值操作。线程B已经执行到了4,所以步骤1根本没执行,导致最终结果仍为2。
使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
volatile禁止指令重排序也有一些规则:
操作① | 操作② | 说明 |
volatile读 | 任何操作 | 不可进行指令重排序 |
volatile写 | volatile读 | 不可进行指令重排序 |
任何操作 | voaltile写 | 不可进行指令重排序 |
总结:
volatile是一种轻量级的同步机制,它主要有两个特性:一是保证共享变量对所有线程的可见性;二是禁止指令重排序优化。volatile对于单个的共享变量的读/写具有原子性,但是像num++这种复合操作,volatile无法保证其原子性,当然文中也提出了解决方案,就是使用并发包中的原子操作类,通过循环CAS地方式来保证num++操作的原子性。
扩展:
在多线程情况下安全的单例模式也可以使用synchronized+volatile进行内存刷新来进行double-check。
大家也可以看看system.out.println和sleep对可见性的影响。