文章目录
前言
前一篇博客简单梳理总结了一下重排序的问题,需要知道的是重排序是编译和CPU层面对指令的优化操作,但是在线程之间正常交互运行的时候,也会出现数据不一致的情况,这个时候我们就需要看一下可见性的问题了。
可见性
什么是可见性
还是通过实例代码来说明
/**
* autor:liman
* createtime:2021-10-22
* comment:可见性实例,演示可见性带来的问题
*/
@Slf4j
public class FieldVisibly {
int a = 1;
int b = 2;
//修改变量a,b的值
public void changeFieldValue() {
a = 3;
b = a;
}
//打印变量a,b的值
private void printFieldValue() {
System.out.println("a = " + a + ",b = " + b);
}
public static void main(String[] args) {
//一直循环
while(true) {
FieldVisibly fieldVisibly = new FieldVisibly();
//这个线程修改变量的值
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
fieldVisibly.changeFieldValue();
}
}).start();
//这个线程打印变量的值
new Thread(() -> {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
fieldVisibly.printFieldValue();
}).start();
}
}
}
自然每次循环,得到的情况都不同,这里重点分析一下a = 1,b = 3的情况。
按照正常的代码逻辑,在线程2中,将变量b置成3了之后,a应该也为3,而这里a为1,这就尴尬了。这就是可见性造成的问题。线程在修改了变量a的值,并没有即时同步到主内存中,导致另一个线程无法即时查看到变量的最新值,这就是可见性问题,也是后面要讨论的内容。
JMM的抽象
从计算机组成原理的角度来说,**CPU是有多层缓存的。这可以说是可见性问题产生的根本原因。通常的CPU缓存结构如下所示,线程间的共享变量的访问不是由于多核引起的,而是由于多层缓存引起的。**如果所有的CPU都是从内存中直接读取数据,则不会有这种问题(这当然是不可能的,毕竟CPU从寄存器读取数据的速度和从内存读取数据的速度不是一个量级)
Java的内存模型中有如下规定
1、所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝
2、线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中。
3、主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转。
在JMM中,所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。
JMM将CPU的多级缓存,抽象成了本地内存。这样我们就不用关心不同平台的多级缓存造成的差异性。
这也是Java作为高级语言,为开发者屏蔽了底层的差异化的具体体现之一。当然这里说的本地内存,并不是真的给每个线程物理分配一个内存,而是一个逻辑概念,是JMM中的一个抽象。
happens-before原则
什么是happens-before
关于happens-before原则,在一篇老外的网站上介绍过一些,个人认为是目前为止比较完整的对happens-before的一个介绍——Java - Understanding Happens-before relationship。
Happens-before定义了程序潜在的执行顺序。比如,为了确保线程Y能看到线程X的运行结果,线程X与线程Y就必须建立一个happens-before的关系,如果二者之间没有该关系,则JVM会任意排序其运行的指令,这样造成运行结果不一致。Happens-before 不仅仅是对指令执行结果时间上的重排序,而且也是主存数据读写先后顺序的保证。
happens-before的规则
1、单线程原则
单线程内部,运行在之前的语句,结果对运行在之后的语句可见(这里说的是运行)
2、锁操作(synchronized和lock)
线程加锁之后一定能看到前一个解锁的线程对数据的修改
3、volatile变量
如果变量被volatile修饰,如果线程A是对volatile变量的写操作,线程B是对同一个变量的读操作,则前者先于后者发生。
4、线程的启动
启动的线程中可以看到线程启动之前的变量数据
5、线程的join
这个应该很好理解
6、传递
If A happens-before B, and B happens-before C, then A happens-before C.
老外粗暴的解释,也比较简单,不赘述。
实例解析
回到我们开头的案例本身
int a = 1;
volatile int b = 2;
public void changeFieldValue() {
a = 3;
b = a;
}
可以看到关键的就是在这两个变量的修改上,但是我们有必要对变量a和变量b都加上volatile么?
答案:没有必要,只需用volatile关键字修饰变量b即可,无需再修饰变量a
因为happens-before中有一个传递原则,b=a;这条语句之前的代码,都对读取b之后的代码可见。而我们在另一个线程中,只对这两个变量有读的操作,并没有写的操作,对b变量加了volatile关键字修饰之后,即使对变量a不加volatile修饰,只要b读到3,根据happens-before原则,其他线程是一定都能看得到变量b的最新值。同时由于线程内的happens-before原则,可以保证a=3,一定发生在b=a;之前,毕竟第二句要用到第一句的变量值,这种情况是不会被重排序的。因此我们这里只需要用volatile修饰一下变量b即可。
从这里可以看到,我们给变量b加上了volatile关键字修饰之后,好像实现了轻量级的同步。
volatile关键字
volatile是什么
是一个关键字?废话,这是语法上。
volatile是一种同步机制,这一点和synchronized或者Lock相关的类一样,但是更加轻量级,因为使用volatile并不会发生上下文切换等开销很大的行为。
如果一个变量被修饰成volatile,则JVM就会知道这个变量可能会被并发修改。因为volatile开销小,所以其相关的功能也比较小,虽然说volatile是一种同步机制,是用来保证线程安全的,但是volatile做不到synchronized和Lock相关类那样的原子保护,volatile仅仅在有限的场景下才能发挥作用,这个后面会补充总结。
关于volatile禁止指令重排序,其实其底层是在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排,也就是说通过插入内存屏障,就能禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用就是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
这一点内容,可以参考这篇博客——volatile禁止重排序分析。
volatile的适用场合
不适用a++
/**
* autor:liman
* createtime:2021-10-25
* comment:volatile关键字无法适用的场景
*/
public class VolatileNoWork implements Runnable {
//变量加了volatile
volatile int a;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new VolatileNoWork();
Thread threadOne = new Thread(runnable);
Thread threadTwo = new Thread(runnable);
threadOne.start();
threadTwo.start();
threadOne.join();
threadTwo.join();
//这里输出的并不是200000
System.out.println(((VolatileNoWork)runnable).a);
}
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
a++;
}
}
}
适用的场合
1、如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替,synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就能保证线程安全
2、作为刷新之前变量的触发器,本篇博客针对开篇实例的分析,就是这种场景
volatile的作用
volatile的作用就两个:保证可见性,禁止指令重排序优化。
其中保证可见性,是在读一个volatile变量之前,需要先使相应的本地缓存失效,CPU必须到主内存中读取最新的值,写一个volatile修饰的变量时,变量修改的值会立即输入到主内存。
禁止指令重排序优化这一点,在我们总结单例模式的时候,会再次探讨,这里先不总结。
从作用上来看,volatile可以看成是一个轻量级的synchronized,如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值本身就是原子性的,而volatile保证了可见性,因此其足矣保证线程安全。
volatile小结
1、volatile适用于被多个线程共享,其中有一个线程修改了这个属性,其他线程可立即得到修改后的值。有时候基于其保证可见性的能力,也作为触发器变量实现轻量级同步。
2、volatile的读写操作是无锁的,因此无法完全替代synchronized,因此也不能保证原子性和互斥性。因为无锁,不需要花费时间再获取锁和释放锁上面,因此其性能成本较低。
3、volatile只能作用于属性,我们用volatile修饰属性,这样编译器就不会对这个属性做指令的重排
4、volatile提供了可见性,其修饰的变量不会被线程缓存,始终从主存中读取
5、volatile提供了happens-before的保证,对volatile变量a的写入发生于其他线程后续对变量a的读操作
6、volatile可以使long和double的赋值是原子性的。
volatile可以保证可见性,在一定的场景能保证线程安全。
一些保证可见性的措施
除了volatile可以让变量保证可见性之外,synchronized、Lock、并发集合、Thread.join()和Thread.start()等其实都可以保证可见性(这一点在介绍happens-before的时候已经有所体现)
再聊聊synchronized
synchronized这个关键字,不仅仅保证了原子性,还保证了可见性。
int a=1;
int b=2;
int c=3;
int d=4;
int e=0,f=0,g=0,h=0;
//一个线程修改数据
public void changeFieldValue() {
a=1;
b=2;
c=3;
synchronized(this){
d=4;
}
}
//其他线程读取数据
public void readFieldValue(){
synchronized(this){
e=a;
}
f=b;
g=c;
h=d;
}
利用synchronized的可见性,上述代码在多线程环境下,也能保证线程安全。
总结
简单絮叨了一下可见性,下一篇博客介绍一下原子性。