JUC并发编程(12):Volatile的可见性、不保证原子性、有序性

Volatile的可见性、不保证原子性、有序性

参考blog.csdn.net/TZ845195485…

  • 前面我们讲过的JMM、Happen-before,JMM是规范,有个细则叫happen-before,用来保证有序性的是volatile、synchronized关键字
  • volatile凭什么可以保证有序性和可见性,靠的是内存屏障,内存屏障分为 loadload、StoreLoad、LoadStore、StoreStore

1、被volatile修改的变量有2大特点

特点:可见性、有序性,不保证原子性

volatile的内存语义

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
  • 所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取

2、volatile特性

2.1、保证可见性

  • 保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可以看到
  • 代码展示
/*
验证volatile的可见性:
1.加入int number=0; number变量之前没有添加volatile关键字修饰,没有可见性
2.添加了volatile,可以解决可见性问题
 * */
class Resource{
    //volatile int number=0;
    volatile int number=0;

    public void addNumber(){
        this.number=60;
    }
}
public class Volatile_demo1 {
    public static void main(String[] args) {
        Resource resource=new Resource();
        new Thread(()->{
            System.out.println(Thread.currentThread().getName()+"\t coming ");
            try {TimeUnit.SECONDS.sleep(4);}catch (InterruptedException e){e.printStackTrace();}
            resource.addNumber();
            System.out.println(Thread.currentThread().getName()+"\t update "+resource.number);
        },"线程A").start();

        //如果主线程访问resource.number==0,那么就一直进行循环
        while(resource.number==0){

        }
        //如果执行到了这里,证明main现在通过resource.number的值为60
        System.out.println(Thread.currentThread().getName()+"\t"+resource.number);

    }
}

复制代码

结果

不加volatile,没有可见性,程序无法停止

加了volatile,保证可见性,程序可以停止
复制代码

上述代码原理解释

  • 没有添加volatile关键字,线程A对共享变量改变了以后(number=60),主线程(这里的线程B)访问number的值还是0,这就是不可见
  • 添加volatile之后,线程A对共享数据进行了改变以后,那么main线程再次访问,number的值就是改变之后的number=60

image.png

2.2、不保证原子性

代码展示:我们对20个线程进行循环100次的操作)

/**
 * 不保证原子性
 * number <=2w
 * 
 */
public class VDemo02 {

    private static volatile int number = 0;

    public static void add(){
        number++; 
        //++ 不是一个原子性操作,是两个~3个操作
        //
    }

    public static void main(String[] args) {
        //理论上number  === 20000

        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 1; j <= 1000 ; j++) {
                    add();
                }
            }).start();
        }
        //需要等待上面20个线程都全部计算完后,再用main线程取得的最终的结果值是多少
        while (Thread.activeCount()>2){
            //默认有两个线程,一个main线程,二是后台gc线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName()+",num="+number);
    }
}
复制代码

lock和synchronized可以解决这个问题,但是如果不加lock和synchronized ,怎么样保证原子性?

解决方法:使用JUC下的原子包下的class;

image.png

public class VDemo02 {

    //使用AutoInteger保证原子性,原子类的int
    private static volatile AtomicInteger number = new AtomicInteger();

    public static void add(){
        //number++;
        number.incrementAndGet();  //底层是CAS保证的原子性
    }

    public static void main(String[] args) {
        //理论上number  === 20000

        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 1; j <= 1000 ; j++) {
                    add();
                }
            }).start();
        }

        while (Thread.activeCount()>2){
            //main  gc
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName()+",num="+number);
    }
}
复制代码

底层原理:

这些类的底层都直接和操作系统挂钩!是在内存中修改值。Unsafe类是一个很特殊的存在;

对于一读一写操作,不会有数据问题

(假设主内存的共享变量number=1,需要对主内存的number++处理,对于两个线程t1、t2如果是一读一写的操作(不会有数据丢失的情况),某一时刻,t1抢到CPU的执行权,将共享数据1读回t1的工作内存,进行number++的操作,这个时候number=2,将2从工作内存写回到主内存中。写回后马上通知t2线程,将number=2读到t2的工作线程)

对于两个写,会出现数据问题

(假设主内存的共享变量number=0,需要对主内存进行10次的number++处理,最终的结果就是10,对于两个线程t1、t2如果是两个写的操作(会造成数据丢失的情况),t1和t2将主内存的共享数据读取到各自的工作内存去,某一时刻,t1线程抢到CPU的执行权,进行number++的处理,将工作内存中的number=1写回到主内存中,就在这一刻,t2也抢到CPU执行权,进行number++的处理,这个时候number++后的结果也等于1,t1将number=1写回到主内存中去,并通知t2线程,将主内存中的number=1读到t2的工作内存中去,这个时候对于t2,它之前也进行了一次number++的操作将会无效,回重新进行一次number++的操作。这也数据也就写丢了一次,那么10次number++后的结果也就不会等于10)

read-load-useassign-store-write 成为了两个不可分割的原子操作,但是在useassign之间依然有极小的一段真空期,有可能变量会被其他线程读取,导致写丢失一次.

image.png

3.3、禁止指令重排

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序(不存在数据依赖关系,可以重排序;存在数据依赖关系,禁止重排序)

重排序的分类和执行流程

  • 编译器优化的重排序:编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序
  • 指令级并行的重排序:处理器使用指令级并行技术来讲多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行

image.png

数据依赖性

若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性(存在数据依赖关系,禁止重排序===> 重排序发生,会导致程序运行结果不同)

image.png

volatile可以避免指令重排:

  • volatile中会加一道内存的屏障,这个内存屏障可以保证在这个屏障中的指令顺序。
  • 内存屏障:CPU指令,作用:
    • 保证特定的操作的执行顺序;
    • 可以保证某些变量的内存可见性(利用这些特性,就可以保证volatile实现的可见性)

image.png

    • 在每个volatile写操作的前⾯插⼊⼀个StoreStore屏障
    • 在每个volatile写操作的后⾯插⼊⼀个StoreLoad屏障
    • 在每个volatile读操作的后⾯插⼊⼀个LoadLoad屏障

      在每个volatile读操作的后⾯插⼊⼀个LoadStore屏障

总结

  • volatile可以保证可见性;
  • 不能保证原子性
  • 由于内存屏障,可以保证避免指令重排的现象产生

面试官:那么你知道在哪里用这个内存屏障用得最多呢?单例模式

3、内存屏障

内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性

内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)

一句话:对一个volatile域的写, happens-before于任意后续对这个volatile域的读,也叫写后读

内存屏障四大指令:StoreStore、StoreLoad 、LoadLoad、LoadStore

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享