java并发关键字:volatile深入浅出:可见性、防止指令重排

文章目录

  • 一. volatile的作用
    • 1. 防止重排序
    • 2. 变量修改的可见性
    • 3. 保证单次的读/写的原子性
  • 二. volatile的实现原理
    • 1. 可见性的实现
    • 2. 有序性的实现
      • 2.1. volatile 的 happens-before 关系
      • 2.2. volatile 禁止重排序
  • 三. volatile的应用场景
    • 1. 双重检查(double-checked)
    • 2. 独立观察(independent observation)
    • 3. 开销较低的读-写锁策略

一. volatile的作用

1. 防止重排序

案例:双重检查的单例模式

public class Singleton {
    public static volatile Singleton singleton;
    //构造函数私有,禁止外部实例化
    private Singleton() {};
    public static Singleton getInstance() {
        if (singleton == null) { //当下一次再获取实例时,不需要进行进入同步块中,提高效率。
            synchronized (singleton.class) {
                if (singleton == null)   singleton = new Singleton();
            }
        }
        return singleton;
    }
}

实例化一个对象其实可以分为三个步骤:

分配内存空间
初始化对象
将内存空间的地址赋值给对象引用

由于操作系统对指令重排序,过程可能会变成如下过程:

分配内存空间
将内存空间引用赋值给对象引用
初始化对象

如果A线程执行完同步块之后,对象还未实例化,B线程紧接着判断对象不为null,但却返回了null,这将造成问题。
为了防止变量在实例化的过程中重排序,需在变量前添加volatile修饰。

 

2. 变量修改的可见性

可见性的问题是:A线程修改的成员变量,B线程看不到。
引发可见性的原因是:每个线程(对应的CPU)都拥有自己独立的缓存区,数据是在缓存区操作的。

public class VolatileTest {
    int a = 1;
    int b = 2;

    public void change(){
        a = 3;
        b = a;
    }

    public void print(){
        System.out.println("b="+b+";a="+a);
    }

    public static void main(String[] args) {
        while (true){
            final VolatileTest test = new VolatileTest();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.change();
                }
            }).start();
            
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();
        }
    }
}

...... 
b=2;a=1
b=3;a=1 // 这里
b=3;a=3
......

出现了b = 1 是因为线程A执行了 a = 3 后只保存在了自己的缓存中,没有将结果同步到内存中,此时线程B取到的结果就还是初始化实例时的结果(a == 1)。

a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。

 

3. 保证单次的读/写的原子性

volatile只能保证单次读写具有原子性。

强调一点:volatile可以保证变量单次读写的原子性,但是不能保证例如i ++ 操作的原子性,因为 i ++ 本质上是读、写两次操作。

看一个例子:

public class VolatileTest01 {
    volatile int i;

    public void addI(){
        i++;
    }

    public static void main(String[] args) throws InterruptedException {
        final  VolatileTest01 test01 = new VolatileTest01();
        //1000个线程对i进行操作。
        for (int n = 0; n < 1000; n++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10); //为了增加并发产生的几率
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test01.addI();
                }
            }).start();
        }
        Thread.sleep(10000);//等待10秒,保证上面程序执行完成
        System.out.println(test01.i);
    }
}

//结果总是小于1000 因为有些线程写完结果之后没有(来得及?)同步到内存中,即不可见。

i++ 的操作其实是:

从内存中读取 i 的值到线程缓存中
对 i 加 1
将 i 的值写回内存

volatile是无法保证这三个操作是原子性的,可以通过使用 AtomicInteger 或 Synchronized来保证 + 1 的原子性。

 
 

二. volatile的实现原理

1. 可见性的实现

volatile的可见性是基于内存屏障(Memory Barrier)实现的:
内存屏障是一个CPU指令,通过插入特定类型的内存屏障来禁止编译器重排序和处理器重排序

/**
 * 通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:
 * ......
 * 0x000000000295157f: and    $0x37f,%rax
 * 0x0000000002951586: mov    %rax,%rdi
 * 0x0000000002951589: or     %r15,%rdi
 * 0x000000000295158c: lock cmpxchg %rdi,(%rdx)  //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
 * 0x0000000002951591: jne    0x0000000002951a15
 * ......
 */
public class Test {
    private volatile int a;
    public void update() {
        a = 1;
    }
    public static void main(String[] args) {
        Test test = new Test();
        test.update();
    }
}

在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令。

lock 前缀的指令在多核处理器下会引发两件事情:

将当前处理器缓存行的数据写到系统内存

写回内存的操作,会让其他CPU里缓存了该内存地址的数据无效。

具体的:

  • 对声明了 volatile 的变量进行写操作时,JVM会发送一条lock前缀的指令,将变量所在缓存行的数据写到系统内存。

  • 缓存一致性协议(MESI):每个CPU会根据总线上传播的数据来检查自己缓存的值是不是过期了,当发现自己缓存行对应的内存地址被修改,就会将CPU缓存行设置为无效状态,当CPU对成员变量操作时,会重新从内存中读到CPU缓存。

缓存一致性:
缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。
LOCK# 因为锁总线效率太低,因此使用了多组缓存。为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议。缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探(snooping)" 协议。所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。
缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。

 
 

2. 有序性的实现

2.1. volatile 的 happens-before 关系

happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。

//假设线程A先执行writer方法,线程B后执行reader方法
class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    
    public void writer() {
        a = 1;              // 1 线程A修改共享变量
        flag = true;        // 2 线程A写volatile变量 
    } 
    
    public void reader() {
        if (flag) {         // 3 线程B会读到A的修改
        int i = a;          // 4 线程B读共享变量
        ……
        }
    }
}

2.2. volatile 禁止重排序

为了实现 volatile 内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

volatile 在写的前后分别插入内存屏障,而 volatile 读的后面插入两个内存屏障。

java并发关键字:volatile深入浅出:可见性、防止指令重排_第1张图片

 
 

三. volatile的应用场景

1. 双重检查(double-checked)

如上
 

2. 独立观察(independent observation)

例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

public class UserManager {
    public volatile String lastUser;
 
    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
}

 

3. 开销较低的读-写锁策略

如果读操作远远超过写操作,可以结合使用内部锁(原子性)和 volatile 变量(可见性)来减少公共代码路径的开销。
 
如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;
 
    public int getValue() { return value; }
 
    public synchronized int increment() {
        return value++;
    }
}

 
 

参考:
https://pdai.tech/md/java/thread/java-thread-x-key-volatile.html

你可能感兴趣的:(java并发,java,开发语言)