注意:volatile不能保证原子性
所以volatile写的内存语义是将其数据立即从工作内存中刷新到主内存中,读的内存语义是直接从主内存中读取;
在读指令之前插入读屏障。让工作空间或cpu缓存当中的缓存数据失效,重新回到主内存中去读取数据
在写指令后插入写屏障,强制写缓存区的数据刷回到内存中
当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。 |
---|
当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatile写之后。 |
当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。 |
在多线程的环境中,线程的执行是并发的,然而在并发的环境中可能会导致指令的顺序发生变化,如果一个线程的写操作需要被其他线程立即可见就需要保证写操作在其他线程的读操作之前执行,类似地,如果一个线程的读操作需要读取到最新的值,就需要保证读操作在其他线程的写操作之后执行。
“保证写操作在其他线程的读操作之前执行”
解释:
普通变量:A线程将共享变量写完后,在B线程读取共享变量时,可能A线程并没有将其刷新回主内存中,这就导致了B线程读取到的还是旧数据,也就是读在写之前执行(由于B线程并没有感知到A的写操作)
volatile变量:A线程将其共享变量写完后,会立即将其共享变量刷新回主内存中,这时如果B线程在读取volatile变量时会将其自己的工作内存中的共享变量置为无效然后在去主内存中读取最新值,这样就保证了写操作在读操作之前执行;
保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可见;
public class Demo {
static boolean flag = true; //不加volatile,没有可见性
//static volatile boolean flag = true; //加了volatile,保证可见性
public static void main(String[] args) {
new Thread(() -> {
System.out.println("线程开启。。");
while (flag) { }
System.out.println("线程退出。。。");
}, "t1").start();
//暂停2秒钟后让main线程修改flag值
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {}
flag = false;
System.out.println("main线程修改完成");
}
}
变量不加volatile修饰,线程无法退出;
新线程第一次读取变量时将其flag拷贝到自己的工作内存中,再对其进行读操作,主线程同理,当主线程对flag修改时,将其写到自己的工作内存,或者后续将其写回主内存中,但是由于新线程无法感知到,导致线程陷入死循环无法结束!
变量加volatile修饰,线程退出;
变量加volatile后当新线程将其读取flag数据时,如果自己的工作内存中存在flag变量,会将其工作内存中的flag置为失效,然后从主内存中读取flag;当主线程写数据时会强制将其写回主内存中,这样保证了对变量flag的修改,其他线程会立即感知到;
Java内存模型中定义的8种工作内存与主内存之间的原子操作
read(读取)→load(加载)→use(使用)→assign(赋值)→store(存储)→write(写入)→lock(锁定)→unlock(解锁)
read: 作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存 |
---|
load: 作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载 |
use: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作 |
assign: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作 |
store: 作用于工作内存,将赋值完毕的工作变量的值写回给主内存 |
write: 作用于主内存,将store传输过来的变量值赋值给主内存中的变量 |
由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令: |
lock: 作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,锁了写变量的过程。对读变量的过程并不影响;(注意:这里只锁住了将其变量写回主内存的的操作) |
unlock: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用 |
在这里就拿i++操作来说
public class DemoTo {
volatile int num = 0;
public void addNum() {
num++;
}
public static void main(String[] args) throws InterruptedException {
DemoTo demo = new DemoTo();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
demo.addNum();
}
}).start();
}
TimeUnit.SECONDS.sleep(3);
System.out.println(demo.num);
}
}
结果:28427
volatile的复合操作没有原子性!!!
从字节码中我们可以看出其实i++操作被分为了三步;
原子性是指一个操作不可被总断,在多线程的环境下,一旦执行不可被其他线程影响!
从图中可以看出对于一个普通变量从read -> write的过程中随时可能被其他线程对当前变量发起操作!
对于一个volatile修饰的变量,底层对 read -> load->use 和assign->store -> write做了特殊处理,成为了两个不可分割的原子操作;
虽然对volatile修饰的变量做了特殊的处理但在对数据进行计算等一系列操作时还是会出现一段真空期,在这期间可能其他线程会对其变量读取等操作;
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序不存在数据依赖关系,可以重排序; 存在数据依赖关系,禁止重排序但重排后的指令绝对不能改变原有的串行语义;
数据依赖性:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性。
计算机硬件系统
多核cpu中高速缓存通过缓存一致性协议进行通信(也有一少部分比较旧的计算机通过总线进行通信,这里不做说明)寄存器等之间不能直接通信
我们所理解的
这里的主内存在真实计算机中是缓存和主内存这两部分组成
在单核CPU的情况下,由于不存在多核处理器中的缓存一致性问题,volatile变量的可见性可以通过编译器和运行时环境的优化来实现。
当一个线程修改了一个volatile变量的值并写回主内存时,在单核CPU的情况下,编译器和运行时环境会确保该变量的最新值在其他线程中能够立即可见。具体的实现方式可以包括以下两个方面:
需要注意的是,在单核CPU的情况下,由于不存在多核处理器中的缓存一致性问题,所以volatile变量的可见性可以通过编译器和运行时环境的优化来实现,而不需要通过总线嗅探机制来进行通知。这样可以提高程序的执行效率。
在多核处理器中,存在一种总线嗅探机制(bus snooping),通过这种机制,当一个核心(CPU)修改了一个volatile变量的值并写回主内存时,其他核心可以通过嗅探总线上的数据传输来检测到该变量的更新。
具体过程如下:
通过总线嗅探机制,其他核心可以检测到volatile变量的更新,并在需要的时候从主内存中获取最新的值。这样就保证了volatile变量的修改对其他线程是可见的。
需要注意的是,总线嗅探机制是一种硬件支持的机制,对于不同的处理器架构和实现细节可能有所差异。但无论具体的实现方式如何,总线嗅探机制的目的都是为了保证多核处理器中的缓存一致性和数据的可见性。