Java内存模型基础学习(二)——絮叨一下可见性

前言

前一篇博客简单梳理总结了一下重排序的问题,需要知道的是重排序是编译和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的可见性,上述代码在多线程环境下,也能保证线程安全。

总结

简单絮叨了一下可见性,下一篇博客介绍一下原子性。

猜你喜欢

转载自blog.csdn.net/liman65727/article/details/121002642