并发编程之并发关键字篇--volatile

目录

volatile简介 

volatile实现原理

volatile的happens-before关系

volatile的内存语义

volatile的内存语义实现

synchronized和volatile的区别和联系

示例


volatile简介 

我们之前了解到synchronized是阻塞式同步,在线程竞争激烈的情况下会升级为重量级锁。而volatile是Java虚拟机提供的最轻量级的同步机制之一。但它同时不容易被正确理解,也至于在并发编程中很多程序员遇到线程安全的问题就会使用synchronized。它可以保证线程之间对被volatile修饰变量的可见性,即修改操作会被立刻传播到主内存中并通知其他线程,从而避免数据脏读问题。但它不能保证原子性,如果需要保证原子操作,可以使用synchronized或者java.util.concurrent.atomic包下的类。需要注意的是,在并发编程中,使用volatile需要谨慎,必须熟悉它的特性和使用场景,否则可能会产生意外的结果。

现在我们有了一个大概的印象就是:volatile 是 Java 中的一个关键字,用于修饰变量。它的作用是确保多个线程之间对该变量的读写操作都能够正确地进行同步,从而避免出现数据脏读的现象。

volatile实现原理

在 Java 中,volatile 关键字的实现原理可以从两个方面解释:一是编译器层面的语义约束,二是运行时层面的内存模型保证。

编译器层面的语义约束:

  • 禁止指令重排序:编译器在生成指令序列时,会遵循内存屏障(Memory Barrier)的规则,确保在 volatile 写操作之前的指令不会重排到其后,而在 volatile 写操作之后的指令不会重排到其前。这样可以保证写操作先行发生于后续的读操作。
  • 强制刷新缓存:编译器会将 volatile 变量的修改立即写入主内存,以便其他线程能够立即看到最新值。同时,在读取 volatile 变量时,编译器会强制从主内存读取,而不是使用线程的本地缓存。

运行时层面的内存模型保证:

  • 硬件内存屏障指令支持:JVM 在生成字节码时,会利用底层硬件的特性来支持 volatile 的语义。例如,利用 CPU 提供的 StoreLoad 屏障指令,可保证在该屏障之前的写操作都先行发生于该屏障之后的读操作,从而保证可见性。
  • 禁止线程缓存优化:volatile 变量的读写操作会绕过线程的本地缓存,直接读写主内存。这样可以避免多个线程之间出现数据不一致的问题,保证了可见性。

总结起来,volatile 关键字通过编译器层面的语义约束和运行时层面的内存模型保证,确保了对 volatile 变量的读写操作具备可见性和顺序性。这使得其在多线程并发编程中成为一种简单有效的同步手段,但需要注意的是,volatile 并不能保证原子性,如果需要原子操作,仍需配合其他机制,如锁或原子类。

volatile的happens-before关系

在 Java 内存模型中,happens-before 关系可以确保多线程并发执行时,某个操作的结果对另一个操作可见。volatile 变量的读写操作就具有 happens-before 关系,即:

  • 对 volatile 变量的写操作先行发生于后续的任意线程的读操作,这保证了之前对该变量所做的修改对其他线程可见;
  • 对 volatile 变量的读操作先行发生于后续的任意线程的写操作,这保证了其他线程修改后的最新值对该变量的读取操作可见。

这个特性可以帮助程序员避免由于指令重排序、缓存一致性等导致的数据竞争和线程间通信问题。

具体来说,考虑以下代码示例:

class VolatileExample {
  private volatile int counter = 0;

  public void increase() {
    counter++;
  }

  public int getCounter() {
    return counter;
  }
}

在上述代码中,counter 作为一个 volatile 变量,可以保证对其的读写操作具有 happens-before 关系,从而避免数据不一致的问题。例如,在一个线程上执行如下代码:

VolatileExample example = new VolatileExample();
example.increase(); // 对 volatile 变量的写操作
int count = example.getCounter(); // 对 volatile 变量的读操作

由于写操作先行发生于读操作,所以当线程执行 getCounter() 方法时,可以确保读取到的值是最新的,不会发生数据不一致问题。

需要注意的是,在并发程序中,volatile 变量虽然能够帮助开发者解决一些通信问题,但其并不能保证原子性。如果需要保证原子性操作,应该使用其他同步机制,如锁或原子类。

volatile的内存语义

public class VolatileExample {
    private int a = 0; // 非volatile变量
    private volatile boolean flag = false; // volatile变量

    public void writer() {
        a = 1;          //1 将a设置为1
        flag = true;    //2 将flag设置为true
    }

    public void reader() {
        if (flag) {      //3 检查flag的值,如果为true,则进入if语句块
            int i = a;  //4 读取a的值
        }
    }
}

对于线程A先执行writer方法,线程B随后执行reader方法的情况,假设flag是一个volatile变量。当线程A执行volatile写操作时,它会将flag的最新值写入主内存,并且会使得线程B的本地内存中flag的值无效。这意味着线程B需要从主内存中读取flag的最新值。

当线程B执行volatile读操作时,它会从主内存中获取flag的最新值,并且会使得线程B的本地内存中flag的值有效。这样,线程B就能够获取到线程A写入的最新值。

可以将这个过程类比为线程A向线程B发送了一个消息,告诉线程B当前的值已经失效,需要更新。而线程B在读取这个volatile变量时,就像是接收到了线程A刚刚发送的消息。由于值已经失效了,线程B只能去主内存获取最新的值。

总结一下我们之前的核心概念:happens-before和volatile的内存语义。这些概念的理解不仅能帮助我们更深入地掌握知识,还可以避免迷茫和困惑。当我们发现自己对学习如此热爱时,也许会感到特别欣喜。既然如此,让我们来分享一些关于volatile内存语义实现方面的干货吧!

volatile的内存语义实现

为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序要怎么办了?答案是可以添加内存屏障。

JMM内存屏障分为四类见下图,

并发编程之并发关键字篇--volatile_第1张图片

通过内存屏障的插入,可以有效地阻止编译器和处理器对指令序列的重排序,从而实现volatile的可见性和有序性。这样保证了volatile写操作之前的所有写操作都对其他线程可见,同时保证了volatile读操作之后的所有读操作也对其他线程可见。内存屏障的插入遵循JMM制定的volatile重排序规则表,确保了正确的内存语义:

并发编程之并发关键字篇--volatile_第2张图片

"NO"表示禁止重排序。为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障;
  • 在每个volatile写操作的后面插入一个StoreLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

需要注意的是:对于volatile写操作,会在写操作前后分别插入StoreStore屏障和StoreLoad屏障。对于volatile读操作,会在读操作后插入LoadLoad屏障和LoadStore屏障。

具体作用如下:

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

通过插入这些屏障,可以保证volatile的可见性和有序性,防止编译器和处理器对指令进行重排序,从而确保程序在多线程环境下的正确执行。需要注意的是,插入内存屏障会带来一定的性能开销,因此JMM采取了保守策略来最大程度地保证内存语义的正确性。

synchronized和volatile的区别和联系

volatile和synchronized是Java中用于处理多线程并发的关键字,它们有一些区别和联系。

1. 可见性:

  • volatile关键字保证了变量的可见性。当一个线程修改了volatile变量的值,其他线程能够立即看到最新的值。
  • synchronized关键字也提供了可见性保证。当一个线程释放锁时,它之前所做的修改将对其他线程可见。

2. 原子性:

  • volatile关键字不保证变量操作的原子性。如果对一个变量的操作不是原子操作(如递增操作),那么在多线程环境下使用volatile可能会导致数据不一致。
  • synchronized关键字提供了对代码块或方法的原子性操作,确保同一时间只能有一个线程执行该代码块或方法。

3. 顺序性:

  • volatile关键字可以防止指令重排序,保证了变量赋值操作的顺序性。
  • synchronized关键字同样提供了顺序性保证,通过获取和释放锁来确保代码的执行顺序。

4. 适用场景:

  • volatile适用于某个字段被多个线程访问,但是这些线程只是对变量进行读取和简单的赋值操作。
  • synchronized适用于复杂的临界区代码,需要保证多线程环境下的原子性和同步性。

5. 锁粒度:

  • volatile关键字只能用于修饰变量,没有锁粒度的概念。
  • synchronized关键字可以用于修饰代码块或方法,可以根据需要选择合适的锁粒度。

需要注意的是,虽然volatile和synchronized都可以用于实现线程间通信,但在应用场景和使用方式上存在差异。volatile适用于变量的状态标记、开关等简单的场景,而synchronized适用于复杂的临界区代码,提供了更强大的同步机制。在实际使用中,根据具体需求选择合适的关键字来确保线程安全和正确的并发行为。

示例

该示例演示了如何使用volatile关键字实现线程间通信。

public class Main {
    private volatile boolean flag = false;

    public static void main(String[] args) {
        Main example = new Main();

        // 创建并启动线程1
        Thread thread1 = new Thread(() -> {
            System.out.println("线程1正在运行...");
            while (!example.flag) {
                // 在循环中等待,直到flag为true
            }
            System.out.println("线程1已完成.");
        });
        thread1.start();

        // 创建并启动线程2
        Thread thread2 = new Thread(() -> {
            System.out.println("线程2正在运行...");
            try {
                Thread.sleep(1000); // 休眠1秒钟
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            example.flag = true; // 设置flag为true
            System.out.println("线程2已完成.");
        });
        thread2.start();
    }
}

在这个示例中,我们定义了一个布尔类型的flag变量,并将其声明为volatile。该变量用于在线程1和线程2之间进行通信。线程1在一个循环中等待直到flag变量的值为true,而线程2在1秒钟后设置flag变量的值为true。由于volatile关键字保证变量的可见性,因此线程1能够立即看到flag变量的值的变化,从而退出循环。

注意,使用volatile关键字并不能保证线程安全,因此在多线程环境下仍需要考虑同步和竞争条件。

你可能感兴趣的:(Java进阶篇,java,jvm,开发语言)