volatile详解

本文详细探讨了Java中的volatile关键字及其确保原子性、可见性和有序性的原理。通过一个实例解释了volatile如何保证线程间变量的即时同步,并分析了MESI缓存一致性协议的作用。此外,还讨论了volatile在保证有序性方面的限制,以及在单例模式中的应用。文章揭示了volatile在多线程编程中的重要性及其局限性。
摘要由CSDN通过智能技术生成

什么是volatile(先看下节内容再回来看这节)

JVM的三大特性

  1. 原子性

    1. 什么是原子性

      1. 原子性就是最小的不可分割的操作,原子操作是不能被线程调度机制中断的操作,一旦开始就一定会在可能的上下文切换之前完成,即一个操作开始后无法中断该操作,例如我吃一个橘子,要么我不吃这个橘子,要么我就吃完这个橘子.
      2. 在jvm中有8个原子操作分别是
        1. read 将数据从内存中读出
        2. load 将从主内存中读出的数据写入到工作内存中
        3. use 对数据进行操作
        4. assign 将更改后的数据重新赋值给工作内存
        5. store 将工作内存中的值写会到主内存
        6. lock 在经过总线的时候上锁
        7. write 将写到主内存的数据的拷贝赋值给对象
        8. unlock 解锁

      整个详细流程参见
      在这里插入图片描述https://www.processon.com/diagraming/600d7ca3e0b34d3f9b7ab2f7

  2. 有序性

    1. 什么是有序性
      在通常来讲编译器和虚拟机并不会按照我们所写的代码来顺序的执行,为了提高运行效率编译器可能会在保证结果正确的前提下乱序执行代码,而有序性就是要求程序的执行顺序和我们写的代码顺序是一样的,不可以乱序执行
  3. 可见性

    1. 什么是可见性
      可见性是指当一个线程更改了变量的值之后所有线程都会立即知道,当其他线程读取这个变量的时候必须从主内存中重新读取该变量的值,而不是读自身的工作线程中的变量的副本

Volatile是怎么保证可见性的

问: 在下面这段代码中 线程1会在线程2执行完tag = true;语句之后立即停止吗

答:不会

static  boolean tag = false;
public static void main(String[] args) throws Exception {
    new Thread(new Runnable() {//线程1
        @Override
        public void run() {
            while (!tag) {
            }
            System.out.println("thread2结束");
        }
    }).start();

    Thread.sleep(2000);
    new Thread(new Runnable() {//线程2
        @Override
        public void run() {
            tag = true;
            System.out.println("tag已经更改完成");
        }
    }).start();
}

问: 为什么线程1不会停止

答:

  1. 最开始 tag 在主内存中,当线程1启动的时候 线程1 将主内存中tag拷贝一份到线程1 的工作内存中,此时线程1中存在tag的副本 tag值为false
  2. main线程sleep2000ms保证线程1启动并且执行到while(true)
  3. 启动线程2 ,同样线程2拷贝一份主内存中的tag的副本到线程2的工作内存,此时线程2 中的tag为false,然后线程2 中执行tag = true,则线程2中的tag的副本的值变成true,然后线程2中的tag 被写入到主内存中(注意并不是立即写回).
  4. 但是线程1并不知道数据已经发生了改变所以线程1中的tag的值还是false,进而导致线程1不会停止

问:如何让线程1在tag更改的时候立即停止

答: tag使用volatile修饰.修饰之后代码变为

static volatile boolean tag;
public static void main(String[] args) throws Exception {
    new Thread(new Runnable() {
        @Override
        public void run() {
            while (!tag) {
            }
            System.out.println("thread2结束");
        }
    }).start();

    Thread.sleep(2000);
    new Thread(new Runnable() {
        @Override
        public void run() {
            tag = true;
            System.out.println("tag已经更改完成");
        }
    }).start();
}

问:为什么使用volatile修饰之后会立即停止

答:使用vloatile 修饰的变量会开启缓存一致性协议(以intel为代表的MESI),在修改完毕之后立即写回到主内存中,并通过总线嗅探机制通知所有持有该变量的副本的线程其线程中的变量副本失效.

问:什么是MESI缓存一致性协议,

答: 缓存一致性协议是用于保证工作内存之间的数据一致性的一种协议,而我们常说的MESI协议是intel实现的一种具有代表性缓存一致性协议,

1. M(Modified) 数据有效但被修改过,和主内存不一致,但是仅存在于本工作空间
2. E(Exclusive)数据有效且和主内存中的数据一致
3. S(Share) 数据和主内存中的数据一致,但是数据存在于很多个工作空间
4. I(Invalid) 本工作空间的数据无效,需要重新从主内存中读取

问: 开启MESI之后上述代码的执行流程是怎样的

答: 从MESI流程方面来讲,开启之后线程1在线程2执行完毕后立即结束,具体如下

1. 线程1开始的时候tag处于E状态
2. 线程2启动tag随即变成S态
3. tag = true;的时候tag在赋值的时候会被加上lock前缀指令,而lock指令前缀会使得数据立即被写回到主内存,并将其他线程中的缓存行失效.此时线程1中的tag被标记为I态
4. 线程1 中继续执行while(!tag),在使用tag的时候发现tag被标记为I态则立即从主内存中更新tag的值
5. 线程1 结束

从内存模型来,具体图形见1.1图或链接其主要步骤可分为

1. 线程1 read 将主内存的数据读出
2. 线程1 load 将主内存中的数据拷贝到线程1 的工作空间的副本
3. 线程1 use 工作空间的数据提现为while(!tag)

4. 线程2 read从主内存指令读取tag = false;
5. 线程2 load指令将tag = false加载到线程所在的工作空间
6. 线程2 cpu 使用 use指令 将tag 赋值为 false
7. 线程2 cpu将tag = true assign到工作空间的副本中 
8. 线程2 由于使用volatile修饰CPU立即将tag = true store到主内存
9. 在主内存的过程中对tag进行lock并触发MESI和总线嗅探机制,此时线程1 发现tag失效将tag所在的缓存行设置为I态(失效),并在使用的时候尝试重新读取
10. 此时tag被lock CPU将store到主内存的tag副本给到真正的tag,此时tag变量更新为true
11. 解锁unlock,其他线程可以访问该变量
12. 线程1 use tag变量的时候发现所在的缓存行失效,重新读取tag所在的缓存行

Volatile是怎么保证有序性的

问下面代码会产生几种结果

static int a, b;
static int c, d;

public static void main(String[] args) {
    Map<String, String> map = new HashMap<>();
    for (;;) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                d = b;//1                    
				a = 1;//2
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                c = a;//3
                b = 1;//4
            }
        }).start();
    }
}

答: 4种

1. c = 0 d = 0 
2. c = 1 d = 0
3. c = 0 d = 1
4. c = 1 d = 1

由于在编译和运行期间会产生指令重排序 和CPU的时间片切换,而注释12 和注释34 分别处于两个线程,在各自线程中其结果没有相关性,所以指令的执行顺序理论上有432*1种

1. 先注释1 后注释2  先注释3 后注释4
2. 先注释1 后注释2  先注释4 后注释3
3. 先注释2 后注释1  先注释3 后注释4	  
4. 先注释2 后注释1  先注释4 后注释3 	  
5. 先注释1 后注释3  先注释2 后注释4	  
6. 先注释1 后注释3  先注释4 后注释2	  
7. 先注释1 后注释4  先注释2 后注释3	
8. 先注释1 后注释4  先注释3 后注释4
.
.	
.
24

这24中执行顺序就会出现上述的4种结果组合,但是只有在出现a=1; b=1 或者b=1 ;a=1;最先出现的情况下才会出现第四种结果也就是 c = 1 d=1;的情况,以笔者I7 8750HQ为例约运行了4871599次出现该种情况

问: 如何避免结果中出现 c =1 d =1这种情况,金典的使用场景是什么

答: 在变量前添加volatile修饰消除 c=1 d=1;的情况,金典的使用场景是单例模式中的Double check lock (DCL)单例

问: DCL实现单例为什么必须要使用volatile修饰

class DCLTest {
    private volatile static DCLTest instance;
    private DCLTest() {
    }
    public static DCLTest getInstance() {
        if (instance == null) {//A
            synchronized (DCLTest.class) {//B. 上锁
                if (instance == null) {//C
                    instance = new DCLTest();//D 非原子操作
                }
            }
        }
        return instance;
    }
}

答: 由于指令重排序的存在,在DCL中instance必须使用volatile修饰,否则可能出现百万分之一的几率导致空针异常

问: 为什么出现上面的问题

答: 在类的初始化的过程中主要有三个步骤

1. 开辟堆内存空间
2. 初始化对象
3. 将instance和堆内存关联 

而在1/2/3 三个步骤中2和3之间的顺序是可能改变的,假设现在同时有两个getInstance同时执行到注释A(线程2)和注释D(线程1)处(这种情况是可能的,因为A处没有synchronized),线程1的注释D处实际上又可以分解为上面提到的3个步骤,那么当线程1 的执行顺序是132的时候由于instance已经和堆内存的空间建立了映射但是实际上堆内存中的变量是没有真正的初始化的,则出现线程2处的instance是不为空的,但是又没有被真正的初始化,则instance是!= null的但是真正用到的时候发现instance又是空的,从而出现空指针异常.

问: volatile是如何保证有序性的(该答案以笔者目前水平无法判断那种说法正确,故都写出供大家讨论)

答: 目前有两种说法

1. 任何带有lock指令前缀的指令都会变成原子操作,而lock前缀指令本身是带有内存屏障的功能的
2. 在volatile修饰的变量前后会插入读写屏障,从而保证有序性

笔者个人是比较倾向于说法1的,因为在反汇编出来的代码中确实没有看到读写屏障,.此处如有同学有精准的答案可以在评论区留言

为什么volatile不能保证有序性

问: 下面的代码的运行结果有几种可能,为什么

private static int x, y = 0;
private static int a, b = 0;
static Thread threadA, threadB;
static volatile int result = 0;

public  static void add() {
    result++;
}

public static void main(String[] args) throws Exception {
    Thread[] threads = new Thread[100];
    for (int i = 0; i < 100; i++) {
        Thread th = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i1 = 0; i1 < 100; i1++) {
                    add();
                }
            }
        });
        threads[i] = th;
        th.start();
    }
    for (int i = 0; i < threads.length; i++) {
        threads[i].join();
    }
    System.out.println(result);
}

答: 两种

  1. 结果小于10000
  2. 结果等于10000

因为:
i++;本身不是一个原子操作,i++;可以分解为 temp = i+1; i = tem;
假设

  1. 当前的result的值是5,AB两个线程同时读取了result到工作空间
  2. 此时A线程执行temp =i+1; B线程随后也执行了temp = i+1;则当前AB线程中temp都为6 i均为5
  3. A线程执行了i=temp 变成了6 同时刷新回主内存,通知其他线程result失效
  4. B线程接收到失效之后重新读取i = 6; 然后执行了i= temp(当前temp是6); 则此时 i还是6所以导致最后的计算结果少了1.

问如何保证结果是10000

答: 使用synchronized修饰add方法

private static int x, y = 0;
private static int a, b = 0;
static Thread threadA, threadB;
static volatile int result = 0;

public synchronized static void add() {
    result++;
}

public static void main(String[] args) throws Exception {
    Thread[] threads = new Thread[100];
    for (int i = 0; i < 100; i++) {
        Thread th = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i1 = 0; i1 < 100; i1++) {
                    add();
                }
            }
        });
        threads[i] = th;
        th.start();
    }
    for (int i = 0; i < threads.length; i++) {
        threads[i].join();
    }
    System.out.println(result);
}

则结果一定为10000

具体原因请听下回分解

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值