切换语言为:繁体
Java 中关键字: volatile 详细介绍

Java 中关键字: volatile 详细介绍

  • 爱糖宝
  • 2024-07-12
  • 2067
  • 0
  • 0

Volatile的概述

我们都知道Volatile可以保证可见性、有序性、但是不保证原子性。

volatile 关键字在多线程编程中解决了几个关键问题,主要包括:

  1. 内存可见性: 在没有 volatile 的情况下,每个线程可能只会看到自己工作内存中的变量副本,而不是主内存中的最新值。这是因为现代处理器为了性能,可能会将频繁访问的变量缓存在线程的本地缓存中。当一个线程修改了某个变量,如果不使用 volatile,其他线程可能不会立即看到这个变化,直到它们自己的缓存被刷新或者无效化。volatile 解决了这个问题,它强制每次读取和写入操作都直接从主内存中进行,确保了所有线程看到的都是最新的值。

  2. 指令重排序: 为了提高执行效率,编译器和处理器可能会重新排列指令的执行顺序,只要这种重排不会影响到单线程下的程序结果。但在多线程环境中,这种重排可能会导致不一致性和错误的行为。volatile 变量可以防止与之关联的读写操作被重排序,从而维护了代码的顺序性,这对于依赖于特定操作顺序的程序逻辑非常重要。

  3. 缓存一致性: 在多处理器系统中,每个处理器都有自己的缓存。如果没有适当的机制,处理器之间的缓存可能会变得不一致。volatile 变量通过确保所有处理器在读取或写入时都直接与主内存交互,从而维持了缓存的一致性。

  4. 不保证原子性:JMM 定义了原子性操作的概念,要求对于多线程并发修改的变量,必须使用同步机制来保证原子性。例如,即使一个变量是 volatile 的,对它的复合操作(如 i++ 或 x = y + z)在多线程环境下仍然可能产生竞态条件,因为这些操作通常涉及到读取、计算和写回多个步骤,而这些步骤可能会被其他线程的操作打断。

可见性

JMM 中有一个主内存和工作内存的概念。主内存是所有线程共享的内存区域,工作内存是每个线程独立的内存区域。当一个线程修改了主内存中的变量时,这个修改对其他线程是立即可见的。Volatile 关键字正是利用了 JMM 的这一特性,确保变量的可见性。

禁止指令重排序

JMM 对指令重排序做了限制,要求处理器按照顺序执行指令。但是为了提高执行效率,编译器和处理器可能会对指令进行重排序。在这种情况下,Volatile 关键字就派上用场了。它会禁止编译器和处理器对代码进行指令重排序优化,确保代码有序执行。

不保证原子性

什么是原子性和原子操作

在编程中,具备原子性的操作被称为原子操作。原子操作是指一系列的操作,要么全部发生,要么全部不发生,不会出现执行一半就终止的情况。

比如转账行为就是一个原子操作,该过程包含扣除余额、银行系统生成转账记录、对方余额增加等一系列操作。虽然整个过程包含多个操作,但由于这一系列操作被合并成一个原子操作,所以它们要么全部执行成功,要么全部不执行,不会出现执行一半的情况。比如我的余额已经扣除,但是对方的余额却不增加,这种情况是不会出现的,所以说转账行为是具备原子性的。而具有原子性的原子操作,天然具备线程安全的特性。

下面我们举一个不具备原子性的例子,比如 i++ 这一行代码在 CPU 中执行时,可能会从一行代码变为以下的 3 个指令:

  • 第一个步骤是读取;

  • 第二个步骤是增加;

  • 第三个步骤是保存。

这就说明 i++ 是不具备原子性的,同时也证明了 i++ 不是线程安全的,下面简单的描述下,如何发生的线程不安全问题,如下所示:

Java 中关键字: volatile 详细介绍

根据箭头指向依次看,线程 1 首先拿到 i=1 的结果,然后进行 i+1 操作,但假设此时 i+1 的结果还没有来得及被保存下来,线程 1 就被切换走了,于是 CPU 开始执行线程 2,它所做的事情和线程 1 是一样的 i++ 操作,但此时我们想一下,它拿到的 i 是多少?实际上和线程 1 拿到的 i 结果一样,同样是 1,为什么呢?因为线程 1 虽然对 i 进行了 +1 操作,但结果没有保存,所以线程 2 看不到修改后的结果。

然后假设等线程 2 对 i 进行 +1 操作后,又切换到线程 1,让线程 1 完成未完成的操作,即将 i+1 的结果 2 保存下来,然后又切换到线程 2 完成 i=2 的保存操作,虽然两个线程都执行了对 i 进行 +1 的操作,但结果却最终保存了 i=2,而不是我们期望的 i=3,这样就发生了线程安全问题,导致数据结果错误,这也是最典型的线程安全问题。

Java 中的原子操作有哪些

在了解了原子操作的特性之后,让我们来看一下 Java 中有哪些操作是具备原子性的。Java 中的以下几种操作是具备原子性的,属于原子操作:

  • 除了 long 和 double 之外的基本类型(int、byte、boolean、short、char、float)的读/写操作,都天然的具备原子性;

  • 所有引用 reference 的读/写操作;

  • 加了 volatile 后,所有变量的读/写操作(包含 long 和 double)。这也就意味着 long 和 double 加了 volatile 关键字之后,对它们的读写操作同样具备原子性;

  • 在 java.concurrent.Atomic 包中的一部分类的一部分方法是具备原子性的,比如 AtomicInteger 的 incrementAndGet 方法。

long 和 double 的原子性

在前面,我们讲述了 long 和 double 和其他的基本类型不太一样,好像不具备原子性,这是什么原因造成的呢? 官方文档对于上述问题的描述,如下所示:

Non-Atomic Treatment of double and long

For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.

Writes and reads of volatile long and double values are always atomic.

Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.

Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency's sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts.

Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.

从 JVM 规范中我们可以知道,long 和 double 的值需要占用 64 位的内存空间,而对于 64 位值的写入,可以分为两个 32 位的操作来进行。

这样一来,本来是一个整体的赋值操作,就可能被拆分为低 32 位和高 32 位的两个操作。如果在这两个操作之间发生了其他线程对这个值的读操作,就可能会读到一个错误、不完整的值。

JVM 的开发者可以自由选择是否把 64 位的 long 和 double 的读写操作作为原子操作去实现,并且规范推荐 JVM 将其实现为原子操作。当然,JVM 的开发者也有权利不这么做,这同样是符合规范的。

规范同样规定,如果使用 volatile 修饰了 long 和 double,那么其读写操作就必须具备原子性了。同时,规范鼓励程序员使用 volatile 关键字对这个问题加以控制,由于规范规定了对于 volatile long 和 volatile double 而言,JVM 必须保证其读写操作的原子性,所以加了 volatile 之后,对于程序员而言,就可以确保程序正确。

实际开发中

此时,你可能会有疑问,比如,如果之前对于上述问题不是很了解,在开发过程中没有给 long 和 double 加 volatile,好像也没有出现过问题?而且,在以后的开发过程中,是不是必须给 long 和 double 加 volatile 才是安全的?

其实在实际开发中,读取到“半个变量”的情况非常罕见,这个情况在目前主流的 Java 虚拟机中不会出现。因为 JVM 规范虽然不强制虚拟机把 long 和 double 的变量写操作实现为原子操作,但它其实是“强烈建议”虚拟机去把该操作作为原子操作来实现的。

而在目前各种平台下的主流虚拟机的实现中,几乎都会把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要为了避免读到“半个变量”而把 long 和 double 声明为 volatile 的。

在编程领域里,原子性意味着“一组操作要么全都操作成功,要么全都失败,不能只操作成功其中的一部分”。

JMM 定义了原子性操作的概念,要求对于多线程并发修改的变量,必须使用同步机制来保证原子性。Volatile 关键字虽然不能保证原子性,但是Volatile可以保证单次读、单次写的原子性。其实也不是Volatile来保证的,而是单个原子操作本来就具备原子性

可见性

可见性问题主要是指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。volatile关键字能有效的解决这个问题。

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=3;a=3 或 b=2;a=1。不过运行上面的代码(可能时间上要长一点),你会发现除了上两种结果之外,还出现了另外两种结果:

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

为什么会出现b=2;a=3和b=3;a=1这种结果呢? 正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。那b=3;a=1的结果是怎么出来的? 原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=2;a=3和b=3;a=1的结果了。

禁止指令重排序

从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下:

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;
    }
}

现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

  • 分配内存空间。

  • 初始化对象。

  • 将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

  • 分配内存空间。

  • 将内存空间的地址赋值给对应的引用。

  • 初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

保证原子性:单次读/写

volatile不能保证完全的原子性,只能保证单次读/单次写操作具有原子性

问题1: i++为什么不能保证原子性?

对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。

现在我们就通过下列程序来演示一下这个问题:

import java.util.concurrent.atomic.AtomicInteger;
class shareDate implements Runnable {
    volatile int a;
    AtomicInteger realA = new AtomicInteger();
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            a++;
            realA.incrementAndGet();
        }
    }
}
public class VolatileExample1 {
    public static void main(String[] args) throws InterruptedException {
        shareDate r = new shareDate();
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(r.a + "----" + r.realA.get());
    }
}

大家可能会误认为对变量a加上关键字volatile后,这段程序就是线程安全的。大家可以尝试运行上面的程序。下面是我本地运行的结果:1919 可能每个人运行的结果不相同。不过应该能看出,volatile是无法保证原子性的(否则结果应该是2000)。原因也很简单,a++其实是一个复合操作,包括三步骤:

  • 读取a的值。

  • 对a加1。

  • 将a的值写回内存。

volatile是无法保证这三个操作是具有原子性的,证明了 volatile 不能保证原子性。我们可以通过AtomicInteger或者Synchronized来保证+1操作的原子性。

问题2:单次读/单次写场景的案例

如果某个共享变量自始至终只是被各个线程所赋值或读取,而没有其他的操作(比如读取并在此基础上进行修改这样的复合操作)的话,那么我们就可以使用 volatile 来代替 synchronized 或者代替原子类,因为赋值操作自身是具有原子性的,volatile 同时又保证了可见性,这就足以保证线程安全了。

一个比较典型的场景就是布尔标记位的场景,例如 volatile boolean flag。因为通常情况下,boolean 类型的标记位是会被直接赋值的,此时不会存在复合操作(如 a++),只存在单一操作,就是去改变 flag 的值,而一旦 flag 被 volatile 修饰之后,就可以保证可见性了,那么这个 flag 就可以当作一个标记位,此时它的值一旦发生变化,所有线程都可以立刻看到,所以这里就很适合运用 volatile 了。

public class YesVolatile1 implements Runnable {
    volatile boolean done = false;
    AtomicInteger realA = new AtomicInteger();
    public static void main(String[] args) throws InterruptedException {
        Runnable r =  new YesVolatile1();
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(((YesVolatile1) r).done);
        System.out.println(((YesVolatile1) r).realA.get());
    }
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            setDone();
            realA.incrementAndGet();
        }
    }
    private void setDone() {
        done = true;
    }
}

在 1000 次循环的操作过程中调用的是 setDone() 方法,而这个 setDone() 方法就是把 done 这个变量设置为 true,而不是根据它原来的值再做判断,例如原来是 false,就设置成 true,或者原来是 true,就设置成 false,这些复杂的判断是没有的,setDone() 方法直接就把变量 done 的值设置为 true。那么这段代码最终运行的结果如下:

true
2000

无论运行多少次,控制台都会打印出 true 和 2000,打印出的 2000 已经印证出确实是执行了 2000 次操作,而最终的 true 结果证明了,在这种场景下,volatile 起到了保证线程安全的作用。

第二个例子区别于第一个例子最大的不同点就在于,第一个例子的操作是 a++,这是个复合操作,不具备原子性,而在本例中的操作仅仅是把 done 设置为 true,这样的赋值操作本身就是具备原子性的,所以在这个例子中,它是适合运用 volatile 的。

Volatile的实现原理

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读同一个volatile变量
        int i = a;          // 4 线程B读共享变量
        ……
        }
    }
}

根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。

  • 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。

  • 根据 volatile 规则:2 happens-before 3。

  • 根据 happens-before 的传递性规则:1 happens-before 4。

Java 中关键字: volatile 详细介绍

因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。

volatile 禁止重排序

为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。

Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。

JMM 会针对编译器制定 volatile 重排序规则表。

能否指令重排 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 禁止重排序
volatile读 禁止重排序 禁止重排序 禁止重排序
volatile写 禁止重排序 禁止重排序

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

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。

  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

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

内存屏障 说明
StoreStore 屏障 禁止上面的普通写和下面的 volatile 写重排序。
StoreLoad 屏障 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。
LoadLoad 屏障 禁止下面所有的普通读操作和上面的 volatile 读重排序。
LoadStore 屏障 禁止下面所有的普通写操作和上面的 volatile 读重排序。

Java 中关键字: volatile 详细介绍

Java 中关键字: volatile 详细介绍

内存屏障(Memory Barrier)

  • 内存屏障,又称内存栅栏,是一个 CPU 指令。

  • 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。

  • Java 通过插入特定类型的 Memory Barriers 来确保指令不会被重排序。 对于 Volatile 写操作,JVM 会在写操作后面插入一个 Write Barrier,来禁止写操作与其后面的操作重排序;对于 Volatile 读操作,JVM 会在读操作前面插入一个 Read Barrier,来禁止读操作与其前面的操作重排序。这样就保证了 Volatile 写/读操作的有序性。

内存屏障的底层实现

内存屏障是一种处理器指令,它能影响之前和之后的指令顺序。这种机制能确保特殊的内存顺序一致性。内存屏障同时也是一种硬件或软件机制,用于控制指令的执行顺序和内存的访问顺序。具体实现方式可能有所不同,常见的内存屏障实现方式有两种。

  1. CPU 层面的内存屏障:CPU 提供了指令级别的内存屏障指令,例如 x86 架构中的 MFENCE(内存栅栏)指令。这些指令可以确保所属线程的指令按照严格的顺序执行,保证数据的一致性。

  2. 编译器层面的内存屏障:编译器可以通过指令重排序和代码优化来提高程序的性能。为了保证 Volatile 关键字的语义,编译器会在相应的代码位置插入内存屏障,以确保指令的执行顺序和内存的访问顺序。

缓存一致性协议(MESI)

除了 Memory Barrier 外,Volatile 关键字还利用缓存一致性协议,如 MESI、修改、独占、共享、无效,来保证多个线程之间的可见性。

当一个线程修改了一个被 Volatile 修饰的变量时,会立即将修改的值刷新到主内存。同时,利用缓存一致性协议,强制把其他处理器的缓存行状态设置为无效。这样,当其他处理器再次需要读取这个变量的时候,因为在它的缓存里面这个变量已经是无效状态,它会从主内存重新读取,这样就保证了可见性。

写一段简单的 Java 代码,声明一个 volatile 变量,并赋值。

public class Test {
    private volatile int a;
    public void update() {
        a = 1;
    }
    public static void main(String[] args) {
        Test test = new Test();
        test.update();
    }
}

通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:

......
  0x0000000002951563: and    $0xffffffffffffff87,%rdi
  0x0000000002951567: je     0x00000000029515f8
  0x000000000295156d: test   $0x7,%rdi
  0x0000000002951574: jne    0x00000000029515bd
  0x0000000002951576: test   $0x300,%rdi
  0x000000000295157d: jne    0x000000000295159c
  0x000000000295157f: and    $0x37f,%rax
  0x0000000002951586: mov    %rax,%rdi
  0x0000000002951589: or     %r15,%rdi
  0x000000000295158c: lock cmpxchg %rdi,(%rdx)  //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
  0x0000000002951591: jne    0x0000000002951a15
  0x0000000002951597: jmpq   0x00000000029515f8
  0x000000000295159c: mov    0x8(%rdx),%edi
  0x000000000295159f: shl    $0x3,%rdi
  0x00000000029515a3: mov    0xa8(%rdi),%rdi
  0x00000000029515aa: or     %r15,%rdi
......

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

  • 将当前处理器缓存行的数据写回到系统内存。(从cpu写到内存中)

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

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。

如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。

lock 指令

在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证。

锁前缀(Lock Prefix)

在Pentium及早期的IA-32处理器中,lock前缀用于将一个指令变成一个原子操作。当CPU遇到带有lock前缀的指令时,它会发出LOCK#信号,这个信号会锁住前端总线(Front Side Bus, FSB),阻止其他处理器对共享资源的访问,直到该指令完成。这在单处理器系统中没有太大的性能影响,但在多处理器系统中,锁住总线会导致显著的延迟,因为所有其他处理器必须等待总线解锁才能继续它们的操作。

高速缓存锁(Cache Locking)

随着处理器技术的发展,为了减少锁总线带来的性能损失,后来的处理器开始使用缓存锁来替代总线锁。在这种情况下,当一个处理器执行带lock前缀的指令时,它不是锁住整个总线,而是锁住相关缓存行中的数据。这样,即使其他处理器可以继续访问总线,它们也不能修改被锁定的缓存行,直到锁被释放。这种方法大大减少了对总线的独占时间,从而提高了多处理器系统的整体性能。

MESI协议

MESI(Modified, Exclusive, Shared, Invalid)是一种常用的缓存一致性协议,用于管理多处理器系统中多个缓存之间的数据一致性。在MESI协议下,每个缓存行的状态可以是M(修改)、E(独占)、S(共享)或I(无效)。当一个处理器想要修改一个处于共享状态的缓存行时,它会发起一个“缓存行独占”请求,这会导致其他处理器的相应缓存行状态变为无效,从而确保数据的一致性。

数据结构

Volatile 关键字在 JVM 层面主要涉及到两个数据结构:对象头 ( Object Header) 和线程本地存储区 (Thread Local Area)。

对于每个 Java 对象,包括数组,它们在内存的布局当中都包含了一个对象头。对象头中包含了对象的 Hashcode、GC 分代年龄等信息,对于实例对象,还包含了指向其类元数据的指针以及锁信息。当对象被 Volatile 变量引用,锁信息会被更新,从而实现内存语义。

对于 volatile 变量,Java 内存模型提供了以下内存语义:

  • 可见性:对于 volatile 变量的写操作,对其他线程是立即可见的。

  • 禁止重排序:volatile 变量的写操作和读操作不能被编译器或处理器重排序。

在上述句子中,锁信息的更新是实现 volatile 变量可见性的关键。当对象被 volatile 变量引用时,锁信息会被更新,这意味着其他线程在访问该对象时,必须重新获取锁。因此,当一个线程修改了 volatile 变量的值,那么其他线程在读取该 volatile 变量的值时,可以看到最新的值。

class VolatileDemo {
    private volatile int number = 0;
    public void write() {
        number = 1;
    }
    public void read() {
        int value = number;
    }
    public static void main(String[] args) {
        VolatileDemo demo = new VolatileDemo();
        // 线程 1
        Thread thread1 = new Thread(() -> {
            demo.write();
        });
        // 线程 2
        Thread thread2 = new Thread(() -> {
            demo.read();
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(demo.number);
    }
}

在上述代码中,number 变量被定义为 volatile 变量。线程 1 修改了 number 变量的值,线程 2 读取 number 变量的值。根据 volatile 变量的内存语义,线程 2 在读取 number 变量的值时,可以看到最新的值,即 1。

线程本地存储区是每个线程在 JVM 内部开辟的一块区域。这块区域包含了线程的堆栈信息,以及线程对特定变量的 Lock 定位信息。当线程对 Volatile 变量解锁,JVM 会清空这个区域内对应的 Lock 信息,从而使下次读取时可以直接从主内存获取数据,实现同步。

这种机制保证了 Volatile 变量的修改对所有线程立即可见,以及 Volatile 变量单次读、单次写操作的原子性。

Volatile 的使用场景

通常情况下,volatile 可以用来修饰 boolean 类型的标记位,因为对于标记位来讲,直接的赋值操作本身就是具备原子性的,再加上 volatile 保证了可见性,那么就是线程安全的了。

而对于会被多个线程同时操作的计数器 Counter 的场景,这种场景的一个典型特点就是,它不仅仅是一个简单的赋值操作,而是需要先读取当前的值,然后在此基础上进行一定的修改,再把它给赋值回去。这样一来,我们的 volatile 就不足以保证这种情况的线程安全了。我们需要使用原子类来保证线程安全。

所以使用 volatile 必须具备的条件

  • 对变量的写操作不依赖于当前值。

  • 该变量没有包含在具有其他变量的不变式中。

  • 只有在状态真正独立于程序内其他内容时才能使用 volatile

模式1:状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

volatile boolean shutdownRequested;
......
public void shutdown() { shutdownRequested = true; }
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

模式2:一次性安全发布(one-time safe publication)

缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。

public class BackgroundFloobleLoader {
    public volatile Flooble theFlooble;
 
    public void initInBackground() {
        // do lots of stuff
        theFlooble = new Flooble();  // this is the only write to theFlooble
    }
}
 
public class SomeOtherClass {
    public void doWork() {
        while (true) { 
            // do some stuff...
            // use the Flooble, but only if it is ready
            if (floobleLoader.theFlooble != null) 
                doSomething(floobleLoader.theFlooble);
        }
    }
}

模式3:独立观察(independent observation)

安全使用 volatile 的另一种简单模式是定期 发布 观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 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;
    }
}

模式4:volatile bean 模式

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。

@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
 
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
 
    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }
 
    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }
 
    public void setAge(int age) { 
        this.age = age;
    }
}

模式5:开销较低的读-写锁策略

volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

public class CheesyCounter {
    private volatile int value;
 
    public int getValue() { return value; }
 
    public synchronized int increment() {
        return value++;
    }
}
  • getValue() 方法没有被同步。在没有 volatile 的情况下,如果一个线程修改了 value,即使是在 increment() 方法内部,其他线程调用 getValue() 方法时可能不会立即看到这个修改,因为 getValue() 方法没有 synchronized 修饰,所以它不会受到 synchronized 方法内存屏障的影响。

模式6:双重检查(double-checked)

就是我们上文举的例子。

单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。

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

模式7:定时任务调度

假设我们需要实现一个定时任务调度器,其内部需要用到一个 flag 变量作为是否执行任务的标志。由于 flag 的值由多个线程设置和修改,因此需要确保其先行发生关系。

public class TimerScheduler { 
    private volatile boolean flag;
    private Runnable task;
    public TimerScheduler(Runnable task) {
        this.task = task;
    }
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    public void run() {
        if (flag) {
            task.run();
        }
    }
}

在示例里,我们将 flag 变量声明为 Volatile,确保其他线程能够立即看到它的最新值。setFlag 方法用来设置 flag 变量的值,run 方法用于执行任务。

模式8:延迟初始化

假设我们需要延迟初始化某个类,可以使用 Volatile 关键字确保线程安全。

public class DelayedInitialization {
    private volatile boolean initialized;
    private Runnable initTask;
    
    public DelayedInitialization(Runnable initTask) { 
            this.initTask = initTask;
        }
        public void init() {
            if (!initialized) {
                synchronized (this) {
                    if (!initialized) {
                        initTask.run();
                        initialized = true;
                    }
                }
            }
        }
}

我们将 initialized 变量声明为Volatile,确保其他线程能够立即看到其最新值。使用 synchronized 关键字确保线程安全,在 init 方法中仅初始化一次。

0条评论

您的电子邮件等信息不会被公开,以下所有项均必填

OK! You can skip this field.