AndroidAndroid开发Android开发

(4) 线程系列 - volatile关键字解析

2021-12-07  本文已影响0人  zhongjh

Java并发编程:volatile关键字解析 - Matrix海子 - 博客园 (cnblogs.com)
既然CPU有缓存一致性协议(MESI),为什么JMM还需要volatile关键字? - 知乎 (zhihu.com)
这是篇非常系统非常好的一篇文章

我总结该文章,以更简洁、加上Demo形式更加透彻理解,所以想了解的朋友可以先看上面的文章。因为volatile关键字涉及到内存模型相关的概念和知识,所以先讲解内存最后再重现相关使用场景
以下是本文的目录大纲:

一.内存模型的相关概念

比如下面这段代码,i初始值为0

i = i + 1;
  1. 先从主存当中读取i的值
  2. 复制一份到高速缓存
  3. CPU执行指令对i进行加1操作
  4. 加1后的数据写入高速缓存
  5. 将高速缓存中i最新的值刷新到主存

单线程运行没问题,但是在多线程中运行就会有问题了。
在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(单核CPU可能也会出现)。所以最终就可能会导致,各自的线程的i最终值都只是1,而不是2。这就是著名的缓存一致性问题。

解决这个问题,有两种方式:

第一种在总线上就进行了锁,当第一个CPU在计算内存变量/方法中途还没计算完的时候,当第二个CPU要访问同一个的时候,会等待,等第一个CPU计算完了才会访问。所以这也就导致了效率低下。
所以就出现了第二种缓存一致性MESI协议,当CPU写数据时,如果该变量是公共变量即是别的CPU也存在该变量时,该CPU就会通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

那么即使是第二种,也会消耗一些性能,所以就有了volatile修饰符告诉计算机要不要使用第二种MESI协议。

二.并发编程中的三个概念

1. 原子性

A想要从自己的帐户中转1000块钱到B的帐户里。从A开始转帐,到转帐结束的这一个过程,称之为一个事务。
在这个事务里,要做如下操作:

  1. 从A的帐户中减去1000块钱。如果A的帐户原来有3000块钱,现在就变成2000块钱了。
  2. 在B的帐户里加1000块钱。如果B的帐户如果原来有2000块钱,现在则变成3000块钱了。
    如果在A的帐户已经减去了1000块钱的时候,忽然发生了意外,比如停电什么的,导致转帐事务意外终止了,而此时B的帐户里还没有增加1000块钱。那么,我们称这个操作失败了,要进行回滚。回滚就是回到事务开始之前的状态,也就是回到A的帐户还没减1000块的状态,B的帐户的原来的状态。此时A的帐户仍然有3000块,B的帐户仍然有2000块。
    我们把这种要么一起成功(A帐户成功减少1000,同时B帐户成功增加1000),要么一起失败(A帐户回到原来状态,B帐户也回到原来状态)的操作叫原子性操作。
2.可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

3.有序性

即程序执行的顺序按照代码的先后顺序执行

三.java讲解

Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

1. 原子性

简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作

x = 10;         // 原子性
y = x;          // 不是原子性
x++;            // 不是
x = x + 1;      // 不是
2.可见性

Java提供了volatile关键字来保证可见性
另外,通过synchronized和Lock也能够保证可见性

3.有序性

Java内存模型本身就是有序性,如果是加入了多线程,那么通过volatile关键字来保证一定的“有序性”。当然,synchronized和Lock也可以

四.深入剖析volatile关键字

1. volatile的作用

先看一段代码

// 线程1
boolean stop = false;
while(!stop){
    doSomething();
}
 
// 线程2
stop = true;

之前说过,每个线程运行时都有自己的工作内存,所以很小的可能性下,线程2的stop就算设置为true,然后线程1此时取得值依然为false,就会导致线程1继续死循环下去
那么使用volatile关键字即可解决这种情况

2.volatile不保证原子性

volatile关键字能保证可见性,但是如果volatile修饰的是自增操作的话就会出现问题,代码如下:

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

每次运行结果都不一致,都是一个小于10000的数字,这是因为自增操作导致的
首先自增操作包含了以下三个操作

  1. 读取变量的原始值
  2. 进行加1操作
  3. 写入工作内存

所以,即使用了volatile修饰,两个线程都会可能在第一步读取变量后准备进行加1的时候卡住。最后导致两个线程最后只加1,那么上面的第一点为什么可行呢,因为while一直判断stop是否为true,仅仅用到可见性进行判断。

解决方式可以采用synchronized、Lock等解决

3.volatile保证有序性

volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。直接举例子:

// x、y为非volatile变量
// flag为volatile变量
 
x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

那么我们回到前面举的一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

前面举这个例子的时候,提到有可能语句2会在语句1之前执行,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。

这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。

五.使用volatile关键字的场景

1.状态标记量

volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}
volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            
 
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

2.double check

class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

六.不能使用volatile关键字的场景

1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中

1.对变量的写操作不依赖于当前值

a++;
a = a + 2;
a = 6 * a;
a = a * a;

原因就是上面所说的,volatile无法保证原子性。如果指令执行到中间被打断,就会出现共享变量不一致的情形。

2.该变量没有包含在具有其他变量的不变式中

不变式

那什么是不变式呢,它是程序状态的始终为true的属性。可以确保保持不变的函数或方法称为保持不变。例如,二叉搜索树可能具有不变性,即对于每个节点,该节点的子节点的键小于该节点自己的键.正确编写此树的插入函数将保持该不变。

例子代码
public class NumberRange {

  private int lower, upper;

  public int getLower() {
     return lower;
  }

  public int getUpper() {
    return upper;
  }

  public void setLower(int value) {
    if (value > upper)
      throw new IllegalArgumentException(...);
    lower = value;
   }


  public void setUpper(int value) {
    if (value < lower)
      throw new IllegalArgumentException(...);
    upper = value;
  }
}
代码解释

代码显示了一个非线程安全的数值范围类。它包含了一个不变式 :下界(lower)总是小于或等于上界(upper)。
如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3) 。那么这很明显的就有问题了

让我们再回头看这句话该变量没有包含在具有其他变量的不变式中
只将字段定义为 volatile 类型是无法实现这一目的,因为lower和upper都互相影响包含了。

最后总结

多线程场景下,【变量做依赖于自己的值的操作】,【变量包含在另一个变量的不变式中】,这两种情况下即使使用volatile修饰变量,同步操作仍然会出现问题

上一篇下一篇

猜你喜欢

热点阅读