Java并发编程第5讲——volatile关键字(万字详解)

volatile关键字大家并不陌生,尤其是在面试的时候,它被称为“轻量级的synchronized”。但是它并不容易完全被正确的理解,以至于很多程序员都不习惯去用它,处理并发问题的时候一律使用“万能”的sychronized来解决,然而如果能正确地使用volatile的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

下面我们从volatile关键字的定义说起。

一、什么是volatile关键字

volatile关键字可以说是Java虚拟机提供的最轻量级的同步机制,和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量,用法也比较简单,只需要在声明一个可能被多线程访问的变量时,使用volatile修饰即可。

但是,在并发编程的三大特性——原子性、可见性、有序性中,volatile只能保证可见性和有序性(禁止指令重排),并不能保证原子性,而synchronized这三种特性都可以保证。

那么,volatile为什么不能保证原子性,而synchronized可以?还有可见性和有序性它俩又是怎么保证的呢?别急,接着往下看。

二、Java内存模型

由于volatile关键字与Java内存模型有较多的关联,所以在详细介绍volatile关键字之前,需要先了解一下Java内存模型。

2.1 什么是Java内存模型

Java内存模型(Java Memory Model,简称JMM)是Java虚拟机(JVM)对多线程程序中的内存访问和操作进行规范的一种抽象,并不真实存在它定义了线程如何与主存(共享内存)和工作内存(线程私有内存)进行交互,以及如何同步和互斥地访问共享数据。

简单地说就是JMM定义了程序中各种变量(共享)的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。

《Java虚拟机规范》中曾试图定义一种“Java内存模型”来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的访问效果,但这并非是一件易事,这个模型必须定义的足够严谨,才能让Java的并发内存访问操作不会产生歧义;但是也必须定义得足够宽松,使得虚拟机得实现能有足够得自由空间去利用硬件得各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度。Java内存模型自JDK1.2建立起来,随后又经过长时间的验证和修补,直到JDK5(JSR-133)发布后,也就是目前正在使用的Java内存模型,才终于成熟、完善起来了。

ps:JSR-133对旧内存模型的修补主要有两个:

  • 增强volatile的内存语义:旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制volatile变量与普通变量的重排序,使volatile的写-读和锁的释放-获取具有相同的内存语义。
  • 增强final的内存语义:在旧内存模型中,多次读取同一个final变量的值可能会不相同。为此,JSR-133为final增加了写和读重排序规则。在保证final引用不会从构造函数内逃逸出的情况下,final具有了初始化安全性。

2.2 主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存保存了被该线程使用到的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。线程、主内存、工作内存三者的交互关系如下图:

2.3 内存间交互操作

ps:这里做个简单了解即可,因为除了虚拟机开发团队外,大概没有其他开发人员会以这种方式来思考并发问题。下面会介绍该部分内容的等效判断原则——先行发生规则(happens-before),相较于这种方式更容易理解。

关于主内存与工作内存之间具体的交互协议,Java虚拟机定义了8种原子操作:

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

由上可见,如果要把一个变量从主内存复制到工作内存,那就要顺序执行read和load操作,而要把变量从工作内存同步回主内存,就要顺序执行strore和write操作。注意,这里是顺序执行而不代表它们会连续执行,如对主内存中的变量a、b进行访问时,可能出现的顺序是:read a、read b、load b、load a。

除此之外,针对上述8种基本操作,Java内存模型还制定了8种规则:

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

三、原子性、可见性和有序性

读到这,相信你已经对Java内存模型有了一定了解,那么下面我们就基于Java内存模型来介绍一下Java并发的三大特性:原子性、可见性和有序性。

3.1 原子性

3.1.1 什么是原子性

定义:一个操作不可拆分、不被中断,要么全部执行,要么都不执行。

ps:数据库中ACID的原子性指的是“要么都执行要么都回滚”,这是两个不同的概念。

首先说一句,volatile并不能保证原子性。

ps:synchronized关键字可以保证原子性,因为被synchronized修饰的方法或代码块,在进入之前都加了锁,同一时刻,有且仅有一个线程能执行被“锁”住的代码片段,这就保证了它内部的代码可以全部被执行,所以它具备原子性(基于monitorenter和monitorexit指令实现)。

3.1.2 举例说明

先举一个例子,加深一下对Java并发编程中原子性的理解。

//代码1
int a = 1;
//代码2
a++;
//代码3
int a = b;
//代码4
k =k + 1;

问:上述4个代码哪些是原子操作?你可能会说是代码1,但我告诉你,上述均不是原子操作!下面我们来分析一下(多线程情况下):

  • 代码1:在JMM中包含了两个操作,一是从主存中读取a的值到工作内存,二是在工作内存中将a的值设置为1。虽然涉及两个步骤,但这些步骤会被视为一个整体,也就是符合原子性。
  • 代码2:包含了三个操作。一是读取变量a的值,二是把变量a的值加一,三是将计算后的值再赋值给变量a。
  • 代码3:包含了两个操作。一是读取变量b的值,二是将变量b的值赋值给a。
  • 代码4:包含三个操作。一是读取变量k的值,二是将变量k的值加一,三是将计算后的值再赋值给变量k。

从Java内存模型来看,直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问读写时具备原子性的。

从应用场景来看,JVM保证原子性操作的主要方式如下:

  • 虽然Java内存模型还提供了lock和unlock操作来满足原子性的需求,但并未对用户开放使用,而是提了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,也就是我们熟悉的同步块——synchronized关键字。

  • AQS的锁机制:比如ReentrantLock、ReentrantReadWriteLock等。

  • CAS实现,java.util.concurrent.atomic包下的原子操作类,比如AtomicInteger、AtomicLong等。

3.1.3 为什么volatile不具备原子性

那volatile关键字为什么不能保证原子性呢?很简单的一点就是它不是锁,而且也没做任何可以保证原子性的处理,这当然不能保证原子性了。

下面看个经典的i++案例:

public class TestIncr {
    volatile int num = 0;
    public void add() {
        num++;
    }
    public static void main(String[] args) {
        TestIncr test = new TestIncr();
        //启动10个线程
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                //每个线程执行1000次+1操作
                for (int j = 0; j < 1000; j++) {
                    test.add();
                }
            }, String.valueOf(i)).start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("最后num的值为:" + test.num);
    }
}

多次执行的其中一个结果:

以上代码,我们的预期结果应该是10000才对,但执行起来发现,并不是每次都是10000,这就是因为i++这个操作没办法保证原子性。它其实包含三个指令:

  • 执行GETFIELD拿到主内存中的原始值num。

  • 执行IADD进行+1操作。

  • 执行PUTFIELD把工作内存中的值写回主内存中。

当多个线程并发执行PUTFILED指令的时候,会出现写回主存覆盖的问题,所以最终结果可能会比预期的结果要小,所以volatile不能保证原子性。

3.2 可见性

volatile的两大特性之一就是可以保证可见性,下面我们就详细介绍一下。

3.2.1 定义及实现

定义:指多个线程之间共享数据的可见性。即当一个线程修改了共享变量时,其它线程能立即看到这个修改。

当对volatile变量进行写操作的时候,JVM会向处理器发送一条Lock前缀的指令,将这个缓存中的变量写回到系统主存中。

所以,如果一个变量被volatile修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其它处理器的缓存由于遵守了缓存一致性协议(比如MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocaol等),也会把这个变量的值从主存加载到自己的缓存中,这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

ps:synchronized的可见性是由上述八种规则中的——“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则实现的。

3.2.2 举例说明

下面是一个简单示例,先看看不用volatile的效果:

public class TestVisibility {
    private  boolean flag = false;
    public void start() {
        new Thread(() -> {
            System.out.println("Thread 1 start");
            while (!flag) {
                // 不断循环,等待flag变为true
            }
            System.out.println("Thread 1 complet");
        }).start();
        // 确保线程1先启动
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        new Thread(() -> {
            System.out.println("Thread 2 start");
            // 修改flag的值为true
            flag = true;
            System.out.println("Thread 2 complet");
        }).start();
    }
    public static void main(String[] args) {
        TestVisibility example = new TestVisibility();
        example.start();
    }
}

运行结果:

从结果看,程序“卡”在了Thread 1的while循环中,说明Thread 1读到的flag还是flase,但是Thread 2已经把它改为true了,这是为什么?这是因为Thread 1在执行的时候,就把flag的副本保存在这就的工作内存中,之后就会一直读取自己线程工作内存中flag变量的值,而不会去主内存中重新获取新的值。

加了volatile修饰后的运行结果:

可以看到Thread 1和2都执行完了,volatile能确保对flag的写操作立即刷新到主内存,并且对flag的读操作会从主内存中获取最新的值。

3.3 有序性

volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是它可以禁止指令重排序,所以能在一定程度上保证有序性。

3.3.1 定义和实现

定义:一个线程中的所有操作必须按照程序的顺序来执行。

volatile的有序性是它本身的特性——禁止指令重排实现的,而禁止指令重排又是由内存屏障来实现的(下面有介绍)

ps:synchronized的有序性则是由“一个变量在同一时刻只允许一条线程对其进行Lock操作”这条规则实现的。

3.3.2 举例说明

最经典的例子当然是双重检测实现单例的例子了,如下:

public class Singleton {
    //私有化构造函数
    private Singleton(){}
    //单例对象(无volatile修饰)
    private static Singleton instance=null;
    public static Singleton getInstance(){
        //第一次检测
        if (instance==null){
            //加锁
            synchronized (Singleton.class){
                //第二次检测
                if (instance==null){
                    //初始化
                    instance=new Singleton();
                }
            }
        }
        return instance;
    }
}

以上代码,我们通过使用synchronized对Singleton.class加锁,可以保证同一时间只有一个线程可以执行到同步代码块中的内容,这就实现了一个单例。

但是,在极端情况下,上述的单例对象可能发生空指针异常,那么这是如何发生的呢?

我们假设线程1和线程2同时请求getSingleton()方法的时候:

  1. 线程1执行到instance=new Singleton();,开始初始化。
  2. 线程2执行到“第一次检测”的位置,判断singleton == null。
  3. 线程2经过判断发现singleton !=null,于是就直接执行return instance
  4. 线程2拿到singleton对象后,开始执行后续的操作。

以上过程看似没有什么问题,但在第4步执行后续操作的时候,是有可能抛空指针异常的,这是因为在第3步的时候,线程2拿到的singleton对象并不是一个完整的对象。

很明显instance=new Singleton();,这段代码出现了问题,那我们来分析一下,这个代码的执行过程可以简化成3步:

  1. JVM为对象分配一块内存M。
  2. 在内存上为对象进行初始化。
  3. 将内存M的地址赋值给singleton变量。

因为将内存的地址赋值给singleton变量是最后一步,所以线程1在这一步骤执行之前,线程2在对singleton == null判断一直都是true,那么它会一直阻塞,直到线程1执行完。

但是这个过程并不是一个原子操作,并且编译器可能会进行重排序,如果以上步骤被重排序为

  1. JVM为对象分配一块内存M。
  2. 将内存M的地址赋值给singleton变量。
  3. 在内存上为对象进行初始化。

这样的话线程1会先内存分配,再执行变量赋值,最后执行初始化。也就是说在线程1执行初始化之前,线程2对singleton == null的判断会提前得到一个false,于是便返回了一个不完整的对象,所以在执行后续操作时,就发生了空指针异常。

很明显,这是指令重排造成的问题,要解决的话,直接禁止它指令重排就行了,所以volatile就派上用场了,只需要用volatile修饰一下instance即可,这里代码就不做展示了。

3.4 有了synchronized为什么还需要volatile

介绍完并发中的三大特性,我们发现在这三种特性中,synchronized总能作为其中的一种解决方案,看起来很“万能”对吧😁。不过确实是这样,绝大部分的并发控制,都能用synchronized来完成,这就是出现“遇事不决,就synchronized”的原因。

虽然synchronized很“万能“,但它毕竟是锁,那么既然是锁,它天然就具备以下缺点:

  • 有性能损耗:虽然在JDK1.6种对synchronized做了很多优化,比如适应性自选、锁消除、锁粗化、轻量级锁和偏向锁等。但它毕竟是一种锁,所以,无论是同步方法还是代码块,在同步操作之前还是要进行加锁,同步操作之后解锁,这个加锁和解锁的过程都是有性能损耗的。
  • 产生阻塞:无论是同步方法还是代码块,换句话说,无论是ACC_SYNCHRONIZED还是monitorenter和monitorexit都是基于Monitor实现的。基于Monitor对象,当多个线程同时访问一段同步代码时,首先会进入Entry Set,当有一个线程获取到对象的锁之后,才能进行The Owner区域,其他线程还会继续等待。并且当某个线程调用了wait方法后,会释放锁并进入Wait Set等待,所以synchronized实现的锁本质上是一种阻塞锁。

所以volatiile比synchronized的性能更好,除此之外,volatile还有一个很好的附加功能,就是可以禁止指令重排,volatile借助内存屏障来帮助其解决可见性和有序性问题,有一个典型的例子就是3.3.2小节的双重校验锁实现的单例模式,在没有volatile修饰的intance变量时,可能会发生空指针异常,有volatile修饰时就可以用volatile禁止指令重排的特性完美解决此问题。

四、指令重排序

上面在介绍有序性的时候提到了“重排序”,这里介绍一下。

4.1 什么是重排序

定义:指在保证最终结果不受影响的前提下,可以改变程序中指令的执行顺序,以达到提高代码执行效率的效果。

具体来说,指令重排可能会包括以下几种情况:

  • 编译器优化:编译器在生成目标代码时可以对指令进行重排。
  • 处理器优化:处理器可以根据指令之间的依赖性重排指令的执行顺序,以便更有效地使用处理器资源。
  • 内存系统优化:处理器可以利用缓存和读写缓冲区等机制来重排对内存的读和写操作。

4.2 数据依赖性

如果两个操作访问同一变量,且这两个操作中有一个是写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为3种类型,如下图:

上面的三种情况,在单线程的情况下,只要重排序了两个操作的执行顺序,就会改变最终结果,因此这三种情况是不会被重排序的。

int a = 1;
int b = 2;

上面这段代码的两个操作并没有数据依赖性,改变两者的执行顺序也不会影响最终结果,因此有可能被重排序。

ps:补充一个as-if-serial语义——不管怎么重排序,(单线程)程序的执行结果不能改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖性关系的操作做重排序。

五、内存屏障(Memory barrier)

上面在介绍有序性的时候也提到了“内存屏障”,volatile禁止指令重排的特性就是基于内存屏障来实现的,下面我们来看一看。

5.1 什么是内存屏障

概念:在Java中,内存屏障是一种机制,用于控制指令重排和内存可见性,确保多线程程序的操作顺序和一致性。

5.2 volatile变量的内存屏障

volatile变量的内存屏障是通过一组指令来实现的,包括LoadLoad、LoadStore、StoreStore和StoreLoad。这些指令用于保证在volatile变量的读取和写入操作中,相邻指令之间顺序不会被改变:

  • LoadLoad:确保在读取一个volatile变量前,前面的所有读操作都已经完成。
  • LoadStore:确保在读取一个volatile变量后,后面的所有写操作都还没有开始。
  • StoreStore:确保在写入一个volatile变量前,前面的所有写操作都已经完成。
  • StoreLoad:确保在写入一个volatile变量后,后面的所有读操作都还没有开始。

当一个线程执行一个读取volatile变量的操作时,Java会插入LoadLoad和LoadStore屏障。LoadLoad屏障会防止该读操作和前面的任何读取操作被重排序,LoadStore屏障则会防止该读取操作和后续的写入操作被重排序。

当一个线程执行一个写入volatile变量的操作时,Java会插入StoreStore和StoreLoad屏障。StoreStore屏障会防止该写入操作和前面的任何写入操作被重排序,StoreLoad屏障则会防止该写入操作和后续的读取操作被重排序。

5.3 举个例子

public class VolatileTest {
    private volatile int value = 0;
    public void setValue(int newValue){
        //Store操作
        value = newValue;
    }
    public int getValue(){
        //Load操作
        return value;
    }
}

在上面例子中,由于value变量被volatile修饰,所以编译器和JVM会在编译和执行过程中插入内存屏障:

  • 当执行setValue()方法时,编译器会插入StoreStore屏障,确保value被修改之前,所有的写操作都已经完成。然后,编译器会插入StoreLoad屏障,确保在value被修改之后,所有的读操作都还没开始。
  • 当执行getValue()方法时,编译器会插入LoadLoad屏障,确保在读取value之前,前面的所有读操作都已经完成。然后,编译器会插入LoadStore屏障,确保在读取value之后,后面的所有写操作都还没有开始。

通过插入这些内存屏障,Java确保了voaltile变量的可见性和禁止重排序,从而使多线程访问volatile变量时能够正确地同步数据。

六、先行发生原则(happens-before)

在2.3节——内存间交互操作中,提到了happens-before原则。它是JMM最核心的概念,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则解决并发环境下两个操作之间是否可能存在冲突的所有问题,因此,对于Java程序员来说,理解happens-before是理解JMM的关键。

6.1 什么是happens-before原则

概念:“happens-before”原则是Java内存模型中的一种规则,用于确定在多线程环境下,对于多个操作之间的执行顺序和可见性。

如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

6.2 举个例子

举个例子来加深对happens-before原则的理解:

k = 1;//线程A中执行
j = k;//线程B中执行
k = 2;//线程C中执行

假设线程A中的操作“k=1”先行发生于线程B的操作“j=k”,那么可以确定在线程B的操作执行后,变量j的值一定是1,得出这个结论的依据有两个:一是先行发生原则,“k=1”的结果可以被观察到;而是线程C还没“登场”,线程A操作结束之后没有其它线程会修改k的值。

现在来考虑线程C,我们依然保持线程A和线程B的先行发生关系,而线程C出现在线程A和线程B操作之间,但是线程C与线程B没有先行发生关系,那j的值会是多少呢?答案是不确定!1和2都有可能,因为线程C对变量k的影响可能会被B观察到,也可能不会,这时候线程B就存在读取到过期数据的风险,不具备多线程安全性。

6.3 happens-before规则

《JSR-133:Java Memory Model and Thread Specification》定义了如下happens-before规则,也是Java内存模型下“天然的”先行发生关系,这些先行发生关系无须任何同步器就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对于一个volatile域的写,happens-before于任意后续这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  • interrupted()规则:对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则:一个对象初始化完成(构造函数执行结束)happens-before它的finalize()方法的开始。

Java语言无须任何同步手段保障就能成立的先行发生规则就只有上面这些了下面演示一下如何使用这些规则去判定操作间是否具备顺序性,对于读写共享变量的操作来说,就是线程是否安全,读者还可以从下面这个例子中感受一下“时间上的先后顺序”与“先行发生”之间有什么不同。

public class VolatileTest {
    private int value = 0;
    public void setValue(int newValue){
        value = newValue;
    }
    public int getValue(){
        return value;
    }
}

上面的代码是一组再普通不过的getter/setter方法,假设存在线程A和B,线程A先(时间上的先后)调用了“setValue(1)”,然后线程B调用了同一个对象的“getValue()”,那么线程B收到的返回值是什么?

我们依次分析一下先行发生原则中的各项规则,由于两个方法分别由线程A和线程B调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程A在操作时间上先于线程B,但是无法确定线程B中“getValue()”方法的返回结果,换句话说,这里面的操作不是线程安全的。

那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则;要么把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来实现先行发生关系。

通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”,那如果一个操作“先行发生”是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的,一个典型的例子就是多次提到的“指令重排序”,演示例子如下代码所示。

//以下操作在同一个线程中执行 
int i = 1;
int j = 2;

代码清单的两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1”的操作先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这点。

上面两个例子综合起来证明了一个结论:时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

七、总结

本篇文章从volatile的定义谈起,由于volatile与Java内存模型有较多的关联,所以接着介绍Java内存模型的相关概念、线程、主内存和工作内存之间的关系以及内存间的交互规则;随后详细介绍了volatile和synchronized在Java并发编程的三大特性——原子性、可见性和有序性中的表现(重点介绍volatile)。最后介绍了一下“重排序”、“内存屏障”和“先行发生原则(happens-before)”等重要概念。

 End:希望对大家有所帮助,如果有纰漏或者更好的想法,请您一定不要吝啬你的赐教🙋。

  • 7
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
### 回答1: volatileJava中的一个关键字,用于修饰变量。它的作用是告诉编译器,该变量可能会被多个线程同时访问,因此需要特殊处理,以保证线程安全。 具体来说,volatile关键字有以下几个特点: 1. 可见性:当一个线程修改了volatile变量的值,其他线程能够立即看到这个修改。 2. 有序性:volatile变量的读写操作会按照程序的顺序执行,不会被重排序。 3. 不保证原子性:虽然volatile变量能够保证可见性和有序性,但是它并不能保证多个线程同时修改变量时的原子性。 因此,如果需要保证原子性,需要使用synchronized关键字或者Lock接口来进行同步。 总之,volatile关键字Java中用于保证多线程访问变量的安全性的一种机制,它能够保证可见性和有序性,但是不能保证原子性。 ### 回答2: Java中的volatile关键字是一种轻量级的同步机制,用于确保多个线程之间的可见性和有序性。它可以用于修饰变量、类和方法。 1. 修饰变量:当一个变量被volatile修饰时,它会被立即写入到主内存中,并且每次读取变量时都会从主内存中重新获取最新的值。这样可以保证多个线程操作同一个变量时的可见性和一致性。 2. 修饰类:当一个类被volatile修饰时,它的实例变量就会被同步,而且每个线程都会获取最新的变量值。这样可以保证多线程操作同一对象时的可见性和一致性。 3. 修饰方法:当一个方法被volatile修饰时,它的调用会插入内存栅栏(memory barrier)指令,这可以保证方法调用前的修改操作都已经被写入主内存中,而方法调用后的读取操作也会重新从主内存中读取最新值。这样可以确保多线程之间的调用顺序和结果可见性。 需要注意的是,volatile并不能完全取代synchronized关键字,它只适用于并发度不高的场景,适用于只写入不读取的场景,不能保证复合操作的原子性。 总之,volatile关键字Java中具有广泛的应用,可以保证多线程之间的数据同步和可见性,但也需要谨慎使用,以免造成数据不一致和性能问题。 ### 回答3: Java中的volatile关键字意味着该变量在多个线程之间共享,并且每次访问该变量时都是最新的值。简单来说,volatile保证了线程之间的可见性和有序性。下面我们详细解释一下volatile的用法和作用。 1. 线程之间的可见性 volatile关键字保证了对该变量的读写操作对所有线程都是可见的。在没有用volatile关键字修饰变量的情况下,如果多个线程并发访问该变量,每个线程都会从自己的线程缓存中读取该变量的值,而不是直接从主存中读取。如果一个线程修改了该变量的值,但是其他线程不知道,那么可能导致其他线程获取到的数据不是最新的,从而引发一系列问题。而用了volatile关键字修饰该变量后,每次修改操作都会立即刷新到主存中,其他线程的缓存中的变量值也会被更新,从而保证了线程之间的可见性。 2. 线程之间的有序性 volatile关键字也保证了线程之间的有序性。多个线程并发访问同一个volatile变量时,JVM会保证每个线程按照程序指定的顺序执行操作。例如,在一个变量被volatile修饰的情况下,多个线程同时对该变量进行读写操作,JVM会保证先执行写操作的线程能够在后续的读操作中获取到最新的变量值。这么做的好处是,可以避免出现线程间操作顺序的乱序问题,从而保证了程序的正确性。 需要注意的是,并不是所有的变量都需要用volatile关键字修饰。只有在多个线程之间共享变量并且对变量的读写操作之间存在依赖关系的情况下,才需要使用volatile关键字。此外,volatile关键字不能保证原子性,如果需要保证操作的原子性,需要使用synchronized或者Lock等其他并发工具。 总之,volatile关键字Java中非常重要的关键字之一,它可以在多个线程之间保证可见性和有序性,从而保证了程序的正确性。在开发过程中,我们应该根据具体情况来选择是否使用volatile关键字,以及如何使用它。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

橡 皮 人

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值