从Java内存模型的角度来解读volatile关键字

2021/9/15 7:06:12

本文主要是介绍从Java内存模型的角度来解读volatile关键字,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

从Java内存模型的角度来解读volatile关键字

在之前关于高并发线程同步控制的文章中,我们讲了Synchronized关键字的作用以及底层JVM的实现原理,也讲了另一把ReentrantLock可重入锁的作用及底层源码的实现。而为了保证多线程情况下线程之间的同步,除了加锁以外,还可以使用volatile关键字,它是JVM提供的一个轻量级的同步机制。

首先我们要明确并发编程的3个重要特性:

  • 原子性

    一次操作或者多次操作,要么所有的操作全部都执行并且不会受到任何外界因素的干扰而中断,要么就全部都不执行。这和事务的原子性定义是一致的,比如Synchronized关键字就是通过加锁的方式保证了代码片段的原子性。

  • 可见性

    当一个线程对一个共享变量进行了修改,那么其他的线程都可以立即看到共享变量修改后的最新值。

  • 有序性

    代码在执行的过程中的是有先后顺序的,java编译器以及运行期间的优化,都可能会导致代码实际执行顺序和编写代码的顺序不相同。

而我们这篇文章要讲的volatile关键字通过Java内存模型保证了线程之间的可见性,通过禁止指令重排保证了代码执行的有序性,但是不能保证操作的原子性。

因为volatile的实现涉及到了Java内存模型(JMM),在讲volatile和JMM之间,我们有必要先来了解一下CPU缓存模型

CPU缓存模型

CPU高速缓存,也就是Cache缓存,它是为了解决CPU处理速度和内存处理不对等的问题。比如我们业务开发中,系统大多数都会使用Redis做缓存,其目的就是为了解决程序处理速度和访问常规关系型数据库不对等的问题。我们要知道CPU处理速度要远大于内存的处理速度的,每当CPU要向内存中读写数据时,我们不能因为内存处理速度慢为拖慢了CPU的读写速度,所以就有了Cache这个高速缓存。Cache的处理速度是接近于CPU处理速度,但是不及CPU寄存器的处理速度,Cache相当于在CPU和内存之间架起了一座桥梁,来解决CPU和内存处理速度不匹配的问题。如下图所示。
在这里插入图片描述

既然Cache和CPU处理速度接近,那么CPU从内存中读取数据时,会先看看Cache有没有这个想要的数据,如果有说明命中了,就直接从Cache缓存中返回数据,如果没有命中,就先把内存中的相应数据载入缓存,再将其返回CPU。CPU向内存中写数据时也是同样的流程,这样就通过Cache解决了CPU与内存速度不一致的问题。这个方法虽然是好的,但是你有没有注意到一个问题,**内存和缓存之间可能出现数据不一致的问题!**由于读写数据经过了Cache,也就造成了数据在时间上的一定程度的“延迟”。CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议或者其他手段来解决。

Java内存模型(JMM)

简单介绍了CPU的内存之后,再去理解Java内存模型就显得容易的多了。首先我们要知道JMM本身是一种抽象的概念,并不是说真正存在着这样的模型,我们只是根据线程之间的操作,以及数据的变化抽象出来的这样的一种模型。JMM和JVM一样,它也是描述的是一组规范或者规则,通过JMM规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式

JMM和CPU缓存模型非常相似,在Java内存模型下,线程可以把主内存中的共享变量保存到本地内存(工作内存)中,而不是直接在主内存中进行读写,这就可能造成了一个线程在主内存中修改了一个变量的值,而另外一个线程还继续使用它在本地内存中的共享变量的拷贝,造成数据的不一致。
在这里插入图片描述

JMM为了保证并发线程的可见性、原子性、有序性,对同步机制做了规定:

  • 线程加锁前,必须读取主内存的最新值到自己的工作内存
  • 线程解锁前,必须把自己工作内存中的共享变量的值刷新回主内存
  • 加锁解锁必须是同一把锁

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存,工作内存是每个线程的私有数据区域(类似于JVM在运行时数据数据区中为每一个线程都分配一份线程私有的PC、虚拟机栈、本地方法栈)。而JMM中规定所有变量都储存在主内存,主内存是共享内存区域,所有线程都可以访问(类似于JVM运行时数据区中的堆空间和方法区,所有线程共享数据,都可以访问)。

但线程对变量的读写等操作必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存,然后再对变量进行读写操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量。各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问其他线程的工作内存,线程间的通信必须通过主内存来完成
在这里插入图片描述

volatile保证可见性

由于JMM规范,各个线程对主内存中的共享变量的操作都是各个线程各自拷贝到自己的工作内存中进行操作后再写回到主内存中,这就导致了各个线程之间的不可见性问题,各个线程之间需要同步机制

我们先举个例子

public class VolatileTest {
    public static void main(String[] args) {
        MyData myData = new MyData();

        //创建第一个线程
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + " 开始执行");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.addNumber();
            System.out.println(Thread.currentThread().getName() + "更新变量number:" + myData.number);
        }, "Thread1").start();

        //第二个线程 main线程
        while (myData.number == 0) {

        }

        System.out.println(Thread.currentThread().getName() + "main线程执行完成:number:" + myData.number);

    }
}

class MyData {
    int number = 0;

    public void addNumber() {
        this.number = 10;
    }
}

代码中的number变量没有被volatile关键字修饰,new Thread第一个线程将number值改为10,由于各个线程之间无法互相访问,所以main线程并不知道number值已经被修改,main线程拿到的number变量值还是刚开始时从主内存中拷贝到自己工作内存中值0,由于不可见性导致一直在循环中出不来。
在这里插入图片描述

number使用volatile修饰,new Thread第一个线程改变number值后,会通知main线程主内存的值已被修改,体现出volatile关键字保证了可见性

在这里插入图片描述
在这里插入图片描述

那为什么加了volatile关键字就能保证了可见性?实现的原理是什么呢?对volatile变量进行写操作时,汇编代码中会多出来一个Lock前缀的指令,这个指令会将当前CPU缓存中的数据写回到内存;同时这个写回内存的操作会使得在其它CPU里缓存了该内存地址的数据无效(这里CPU可以类比于线程,缓存类比于线程的本地内存!),这是CPU使用MESI(修改、独占、共享、无效)缓存一致性控制协议来维护内存缓存和其它CPU缓存的一致性,通过嗅探技术保证当前CPU内存缓存、系统内存、其它CPU内存缓存数据的一致性。实际上volatile的读写是满足happens-before原则的,当写一个volatile变量时,JMM会把该线程对于的本地内存中的共享变量值刷新到主内存,当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,然后从主内存中读取共享变量,同时要求对一个volatile变量的写要发生在对这个volatile变量的读之前。

那么问题又来了,happens-before原则是什么?从JDK 5开始,Java使用的是JSR-133内存模型,JSR-133使用happens-before概念来描述线程之间操作的内存可见性,在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这两个操作既可以是在一个线程之内,也可以是在不同线程之间。

happens-before原则在JMM中的落地实现,实际上是JMM禁止了编译器和处理器重排序!

volatile禁止指令重排

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,可能就会导致多线程程序出现内存可见性问题
在这里插入图片描述

处理器在进行重排序时必须考虑指令之间的数据依赖性多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果也就无法预测。处理器基本上都是使用写缓冲区临时保存向主内存写入的数据,虽然写缓冲区保证了处理器的性能,但是每个处理器上的写缓冲区仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生影响,也就是说处理器对内存的读写操作的执行顺序,不一定与内存实际发生的读写操作顺序一致!关键原因在于有写缓存区的存在,并且仅对自己的处理器可见!

如下图所示,处理器A想要执行先写后读的操作,但是从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区里的数据到主内存中,写操作A1才算真正执行完成了,虽然处理器A执行内存操作的顺序是A1->A2,但是内存实际操作的顺序A2->A1,也就是说在写缓冲区还没有把数据刷新回内存中,就已经执行了读操作。此时处理器A的内存操作顺序被重排序了,那么读取出来的值肯定会和内存中的数据不一致,也就导致了内存不可见的问题。

在这里插入图片描述

为了避免出现这种多线程环境下程序出现乱序执行的现象,volatile实现了禁止指令重排优化,从而实现了并发线程的**有序性和内存可见性。**由此我们可以看出volatile实现了内存可见性和有序性都依赖于禁止指令重排,那么禁止指令重排又是如何实现的?底层的原理又是什么?

实际上禁止指令重排的底层实现是通过插入内存屏障指令来实现的。内存屏障又称为内存栅栏,是一个CPU指令。为了保证内存可见性,java编译器在生成指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序。这个指令可以保证特定操作的执行顺序,也保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。

由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条Memory Barrier则会告诉编译器和处理器,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本

在这里插入图片描述

volatile不保证原子性

前面我们讲了volatile通过禁止指令重排保证了内存可见性、代码执行的有序性,但是volatile无法保证原子性。注意,对单个volatile变量进行单独的读或者写操作时是可以保证原子性的,但是如果是对多个volatile变量进行操作,或者进行volatile++这种复合操作时,这些操作整体上是不具有原子性的。

原子性是指保证数据整体的完整性,某个线程在执行时,中间不可被加塞或分割,需要整体完整,要么同时成功,要么同时失败。比如举个例子

public static void main(String[] args) {
        MyData myData = new MyData();
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    myData.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }

        //等待上面20个线程全部计算结束
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName() + " finally number is " + myData.number);
    }
}
class MyData {
    volatile int number = 0;

    public void addNumber() {
        this.number = 10;
    }

    public void addPlusPlus() {
        this.number++;
 }

volatile不保证原子性,同时addPlusPlus()方法也不是同步方法,多线程的情况下,各个线程修改完各自从主内存拷贝的number值之后,在写回主内存的时候可能会发生覆盖,导致部分写操作无效

在这里插入图片描述

针对上面的例子,我们可以从JVM字节码指令角度来分析一下,number++操作实际上在多线程下是非线程安全的

在这里插入图片描述

既然volatile不保证原子性,那么该如何解决呢?主要有两种解决方法

  • 使用JUC并发工具包下的Atomic原子类
  • 使用Synchronized关键字保证方法同步

Synchronized同步关键字在之前的文章已经详细讲过了,接下来我们就来讲讲Atomic原子类的相关内容。

Atomic原子类

在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。所以,所谓原子类说简单点就是具有原子或者原子操作特征的类。

所有的原子类都放在JUC并发包的atomic包下面
在这里插入图片描述

所有的原子类基本上可以分为4种类型:

  • 基本类型

    使用原子的方式更新基本类型

    • AtomicInteger:原子更新整型类
    • AtomicLong:原子更新长整型类
    • AtomicBoolean:原子更新布尔类
  • 数组类型

    使用原子的方式更新数组里的某个元素

    • AtomicIntegerArray:原子更新整型数组里的元素
    • AtomicLongArray:原子更新长整型数组里的元素
    • AtomicReferenceArray:原子更新引用类型数组里的元素
  • 引用类型

    原子更新基本类型的AtomicInteger只能更新一个变量,如果要原子更新多个变量,就需要使用原子更新引用类型类

    • AtomicReference:原子更新引用类型
    • AtomicStampedReference:原子更新引用类型里的字段。可以使用这个类原子更新带有版本号的引用类型,该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题
    • AtomicMarkableReference:原子更新带有标记位的引用类型
  • 对象属性类型

    如果需要原子地更新某个类里面的某个字段时,就需要使用原子更新字段类

    • AtomicIntegerFieldUpdater:原子更新整型字段的更新器
    • AtomicLongFieldUpdater:原子更新长整型字段的更新器
    • AtomicReferenceFieldUpdater:原子更新引用类型字段的更新器

实际上Atomic包里面的所有的原子类基本上底层都是使用Unsafe类实现的原子操作!

对于基本类型,我们以AtomicInteger这个类的getAndIncrement()方法为例,来看看是怎么保证原子操作的。

getAndIncrement()方法实际上底层调用的是Unsafe类的方法
在这里插入图片描述
然后Unsafe类的方法通过调用本地方法CAS
在这里插入图片描述
AtomicInteger 类主要利用 CAS (compare and swap) + do while自旋来保证原子操作。

但是采用CAS方式来实现原子操作有些缺点:

  • 自旋循环开销大
  • 只能保证一个共享变量的原子操作
  • ABA问题(可以使用带时间戳的原子引用AtomicStampedReference来解决,通过对比版本号来判断中途是否被其它线程修改过)

其它类型的原子类底层实现原理大同小异,基本上都是依赖于Unsafe这个类,然后通过CAS+自旋的方式实现了原子操作!

上面我们讲完了volatile关键字的三大特性,volatile可以保证变量的可见性但是不保证变量的原子性,而Synchronized二者都可以保证。相对于Synchronized关键字,volatile只能用于变量,主要是解决变量在多线程之间的可见性问题,而Synchronized可以用来修饰方法和代码块,主要是用来解决多线程之间访问资源的同步性。需要提醒的是volatile关键字和Synchronized关键字是一个互补的存在,而不是对立的存在!

多线程下单例模式的安全问题

举一个volatile和Synchronized结合使用的例子来结束这篇文章

单例模式的DCL(双重检锁)模式,虽然加了同步关键字,但是多线程下依然会有线程安全问题

public class SingletonDemo {
    private static SingletonDemo singletonDemo=null;
    private SingletonDemo(){
        System.out.println(Thread.currentThread().getName()+"\t 我是构造方法");
    }
    //DCL模式 Double Check Lock 双端检索机制:在加锁前后都进行判断
    public static SingletonDemo getInstance(){
        if (singletonDemo==null){
            synchronized (SingletonDemo.class){
                 if (singletonDemo==null){
                     singletonDemo=new SingletonDemo();
                 }
            }
        }
        return singletonDemo;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                SingletonDemo.getInstance();
            },String.valueOf(i+1)).start();
        }
    }
}

其中singletondemo=new SingletonDemo()创建一个单例对象可分为以下3步:

memory = allocate(); //1.分配内存
singletondemo(memory);	 //2.初始化对象
singletondemo= memory;	 //3.设置引用地址

初始化对象设置引用地址没有数据依赖关系,可能发生指令重排

如果发生指令重排,那么在第一次检测,读取到的singletondemo不为null时,singletondemo的引用对象有可能还没有完成初始化,两次检测都会跳过,返回一个对象还没有初始化完成的引用,导致线程安全问题

解决上述问题的方法,可以给singletondemo对象添加上volatile关键字,禁止指令重排



这篇关于从Java内存模型的角度来解读volatile关键字的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程