浅谈Volatile与JMM

2023-11-22 09:58:19 浏览数 (2)

前言

之前看关于volatile的文章好多都没有讲到JMM,在并发编程中了解JMM对我们开发有很大帮助,故自己了总结一下volatileJMM那密不可分的关系。

JMM(Java Memory Model)介绍

Java内存模型(Java Memory Model,JMM)是Java语言中用于描述多线程并发访问共享变量时的规范。它定义了线程如何与主内存和工作内存进行交互,以及对共享变量的访问和操作应该遵循的规则。在此之前,主流程序语言(如 C/C 等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,这导致在某些场景下必须针对不同的平台来编写不同的代码。 JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 Java 程序员呈现了一个一致的内存模型。通过JMM的规范,Java程序员可以利用各种同步机制(如synchronized、volatile等)来控制线程之间的互动和数据共享,从而编写正确且高效的多线程程序。

主内存与工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中“将变量存储到内存”和“从内存中取出变量”这样的底层细节。此处的变量与 Java 编程中所说的变量有所区别,在这里变量包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,也就不存在竞争问题。 Java 内存模型规定了所有的变量都存储在主内存中(此处的主内存与介绍物理硬件时的主内存名字一样,两者可以互相类比,但此处仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。 不同的线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图

内存间的交互

**内存间的交互:**一个变量如何从主内存拷贝到工作内存、以及如何从工作内存同步回主内存之类的实现。 Java 内存模型中定义了以下 8 种操作来完成内存间交互操作,虚拟机实现时必须保证下面每一个操作都是原子的、不可再分的(对于 64 位的类型,即 double 和 **long **类型的变量来说允许有例外情况,不过商业虚拟机实现一般可保证其原子性):

  1. **lock(锁定):**作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  2. **unlock(解锁):**作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. **read(读取):**作用于主内存的变量,它把一个变量的值从主内存传输到工作内存中,以便随后的 load 动作使用。
  4. **load(载入):**作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  6. **assign(赋值):**作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. **store(存储):**作用于工作内存的变量,它把工作内存中的一个变量的值传递到主内存中,以便随后的 write 操作使用。
  8. **write(写入):**作用于主内存的变量,它把 store 操作从工作内存中得到的变量值放入主内存的变量中。

**Java **内存模型还规定了执行上述 8 种基本操作时必须满足如下规则:

  • read/load、store/write 必须成对出现,也就是不允许从主内存中读取了数据工作内存不接受(不允许只有 read 没有 load),或工作内存数据传输到主内存,主内存不回写(不允许只有 store 没有 write)。
  • 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(loadassign)的变量,换句话说就是对一个变量实施 usestore 操作之前,必须先执行过了 assignload 操作。
  • 一个变量在同一个时刻只允许一条线程对其执行 lock 操作,但 lock 操作可以被同一个条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 loadassign 操作初始化变量的值。
  • 如果一个变量实现没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 **unlock **操作之前,必须先把此变量同步回主内存(执行 **store **和 **write **操作)。

并发编程的'三性'

  1. **可见性: **可见性指的是当一个线程修改了共享变量的值后,其他线程能够立即看到修改后的值。在多线程环境下,每个线程都有自己的工作内存,线程在执行时会将共享变量从主内存中拷贝到自己的工作内存中进行操作。当一个线程修改了工作内存中的共享变量后,需要将修改后的值刷新到主内存中,以便其他线程能够看到更新后的值。volatile关键字可以保证可见性,而synchronized关键字和Lock提供的锁机制也可以保证可见性。
  2. 原子性: 原子性指的是一个操作是不可中断的,要么全部执行成功,要么全部不执行。在Java内存模型中,对于基本类型的读写操作(如int、long)具有原子性保证。但对于复合操作,例如i 这样的操作,其实际上包含了读取、修改和写入三个步骤,这时候就无法保证原子性,可能会出现数据竞争的问题。为了保证原子性,可以使用synchronized关键字或者使用Atomic类提供的原子操作类。
  3. **有序性: **有序性指的是程序执行的结果按照一定的顺序来保证。在Java内存模型中,由于编译器和处理器的优化,指令的执行顺序可能会发生重排。但是对于多线程程序,有些指令的执行顺序是不能随意重排的,否则可能会导致程序出现错误。为了保证有序性,可以使用volatile关键字或者使用synchronized关键字或Lock提供的锁机制,它们都可以禁止指令重排序。

Volatile变量

volatileJava中的一个关键字,用于修饰变量。它具有以下两个主要作用:

  1. 保证可见性:当一个变量被volatile修饰后,它会具有可见性。可见性指的是当一个线程修改了这个变量的值,其他线程能够立即看到修改后的值。这是因为使用volatile修饰的变量在修改后会立即被更新到主内存中,而其他线程在读取该变量时会直接从主内存中读取。这种机制可以确保多个线程之间对共享变量的操作是同步的,避免了数据不一致的问题。
  2. 禁止指令重排序:在多线程环境下,为了提高程序的执行效率,编译器和处理器会对指令进行重排序。然而,重排序可能会导致多线程程序出现意想不到的结果。使用volatile关键字可以防止指令重排序,即保证了有序性。具体来说,volatile关键字会在特定的位置插入内存屏障,禁止在插入位置前后的指令重排序,从而确保程序的执行顺序符合预期。

缺点:虽然volatile关键字可以保证可见性和有序性,但它并不能保证原子性。对于复合操作,如i ,volatile关键字无法保证原子性,仍然需要使用其他手段来确保操作的原子性,比如使用synchronized关键字或者使用Atomic类提供的原子操作类。

Volatile使用场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。 使用volatile必须具备以下2个条件:

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

双重检查锁模式(Double-Checked Locking)

单例作为我们最常用的模式想必大家没少见过下面这种写法,这个方法中我们就有用到volatile关键字。

代码语言:javascript复制
public class Singleton {
    private static volatile Singleton singleton;
    
    private Singleton() {
    }
    
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

首先,双重检查锁模式通常用于延迟初始化变量或创建单例实例。它通过在加锁前后进行两次检查来减少锁的开销,从而提高性能。 然而,双重检查锁模式在多线程环境下可能会出现问题,其中一个主要问题是指令重排序。在Java中,对象的创建过程可能会发生指令重排序,这可能导致另一个线程获取到尚未完全初始化的对象。这种情况下,当另一个线程访问该对象时,可能会出现异常或不正确的行为。为了解决指令重排序的问题,需要使用volatile关键字。

控制循环可见性

代码语言:javascript复制
public class LoopTask extends Thread {
    private volatile boolean keepRunning = true;

    public void stopLoop() {
        keepRunning = false;
    }

    public void startLoop() {
        keepRunning = true;
    }

    @Override
    public void run() {
        while (keepRunning) {
            // 执行循环任务
        }
    }
}

在上面的示例中,一个线程通过调用stopLoop方法来修改keepRunning的值为false,从而停止循环。由于keepRunning被声明为volatile,其他线程在读取该变量时会及时看到修改后的值,从而正确地停止循环。

控制类参数可见性

代码语言:javascript复制
public class SharedData {
    private volatile int counter = 0;

    private volatile boolean a;


    public int getCounter() {
        return counter;
    }

    public void setCounter(int counter) {
        this.counter = counter;
    }

    public boolean isA() {
        return a;
    }

    public void setA(boolean a) {
        this.a = a;
    }
}

 SharedData sharedData = new SharedData();
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    System.out.println("getCounter"   sharedData.getCounter());
                    System.out.println("isA"   sharedData.isA());
                }
            }
        }).start();

在上面的示例中,多个线程可以同时访问counter变量,a变量,但由于它被声明为volatile,任何一个线程对counter的修改或变量a的修改都会立即被其他线程看到。这种写法一般在SDK中比较常见。

引用

深入理解Java内存模型(四)——volatile_Java_程晓明_InfoQ精选文章

0 人点赞