volatile

volatile主要对所修饰的变量提供两个功能:可见性和防止指令重排序。由于volatile关键字是与Java的内存模型有关的,因此在讲述volatile关键之前,我们先来了解一下与内存模型相关的概念和知识。

内存模型

计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,由于程序运行过程中的临时数据是存放在物理内存中的,但是CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。

为了最大化利用CPU资源,通过CPU增加高速缓存,引入线程、进程和指令优化来提高CPU的效率。

当程序在运行过程中,会将运算需要的数据从内存复制一份到CPU的高速缓存当中,CPU进行计算时可以直接从CPU高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到内存中。在多核CPU中,多个线程可能运行于不同的CPU中,每个线程运行时都有自己的高速缓存,如果一个变量在多个CPU中都存在缓存,那么就可能存在缓存不一致的问题。

 

缓存不一致的解决方案

1.总线锁

通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用存储这个变量的内存。

2.缓存一致性协议(MESI)

核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

MESI其中有两个地方会导致性能下降:一是更新invalid状态的cache时,需要从其他cpu或者内存中获取新的数据;二是使一个cache变为invalid时需要等待其他cpu的确认;如果cpu在这两个过程中一直等待的话,就会造成CPU浪费。

storebuffer

storebuffer主要是为了降低写入invalid状态的cache延时。既然写操作一定会发生,cpu可以先发出信号通知其他cpu这个cache已经失效,然后再将本次的写操作更新到storebuffer中,等到其他cpu都确认收到信号后再将结果写到内存中。

这样就避免了更新cache时阻塞等待其他cpu确认的耗时,但是也会导致cpu的更新并没有及时写入cache,所以当cpu需要读取cache时,它需要先确认storebuffer中是否有所需的数据,这个机制成为storeforwarding。值得注意的是,当cpu在读写自己的storebuffer时,对应的数据变更其他cpu是感知不到的。

invalidate queue

当cpu收到使某个cache失效的消息时,预期的行为是cpu马上执行这个失效操作。但实际上cpu并不会马上执行失效操作,而是先发送确认收到的消息,然后将失效操作加入到invalidate queue中,queue中的操作随后会在适当的时刻执行(并不一定是马上)。之所以需要invalidate queue同样是因为invalidate操作开销比较大,cpu为了执行invalidate操作必须丢弃cache,导致cache命中率下降。这样的好处是能够提高cpu的性能,但同时也导致cache中可能存在过期的数据。

内存屏障

针对storebuffer和invalidate queue这两个优化带来的问题,提出了内存屏障作为解决方案。内存屏障交给了编写程序的人的手里,利用它就可以规避上面提到的问题。内存屏障分为写屏障和读屏障,编写程序时可以在期望的地方加入内存屏障。写屏障会强制cpu清空storebuffer的内容,也就是将所有的变更都写入cache,随后变更也就写入了内存,使其对其他cpu可见;读屏障会强制cpu执行invalidatequeue中的所有invalidate操作,使自身的cache内容失效,从而使cpu从内存或者其他cpu中获取最新的cache数据。

Java内存模型(JMM)

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它指定了不同线程如何以及何时可以看到其他线程写入共享变量的值,以及在必要时如何同步对共享变量的访问。

JVM内部使用的Java内存模型在线程堆栈和堆之间划分内存。如下图:

Java虚拟机中运行的每个线程都有自己的线程堆栈。线程堆栈包含有关线程已调用哪些方法以到达当前执行点的信息。称为“调用堆栈”。当线程执行其代码时,调用堆栈将更改。

线程堆栈还包含正在执行的每个方法(调用堆栈上的所有方法)的所有局部变量。一个线程只能访问它自己的线程堆栈。由线程创建的局部变量对创建它的线程以外的所有其他线程都不可见。即使两个线程正在执行完全相同的代码,两个线程仍将在各自的线程堆栈中创建该代码的局部变量。因此,每个线程都有自己版本的每个局部变量。

原始类型(boolean、byte、short、char、int、long、float、double)的所有局部变量都完全存储在线程堆栈中,因此对其他线程不可见。一个线程可以将pritimive变量的副本传递给另一个线程,但它不能共享原始局部变量本身。

堆包含在Java应用程序中创建的所有对象,而不管是哪个线程创建的对象。这包括原语类型的对象版本(例如Byte、Integer、Long等)。不管对象是创建并分配给本地变量,还是创建为另一个对象的成员变量,对象仍然存储在堆中。

下面是一个图表,说明存储在线程堆栈上的调用堆栈和局部变量,以及存储在堆栈上的对象:

局部变量可以是基元类型,在这种情况下,它完全保留在线程堆栈中。局部变量也可以是对对象的引用。在这种情况下,引用(局部变量)存储在线程堆栈上,而对象本身(如果存储在堆上)则存储在线程堆栈上。对象可以包含方法,这些方法可以包含局部变量。这些局部变量也存储在线程堆栈中,即使方法所属的对象存储在堆中。对象的成员变量与对象本身一起存储在堆中。如果成员变量是基元类型,并且是对对象的引用,则都是这样。静态类变量也与类定义一起存储在堆中。堆上的对象可以被所有引用该对象的线程访问。当线程有权访问某个对象时,它也可以访问该对象的成员变量。如果两个线程同时调用同一个对象上的方法,它们都可以访问该对象的成员变量,但每个线程都有自己的本地变量副本。

两个线程有一组局部变量。其中一个局部变量(局部变量2)指向堆上的共享对象(对象3)。这两个线程对同一个对象都有不同的引用。它们的引用是局部变量,因此存储在每个线程的线程堆栈中(在每个线程上)。不过,这两个不同的引用指向堆中的同一个对象。共享对象(对象3)如何将对象2和对象4的引用作为成员变量(如从对象3到对象2和对象4的箭头所示)。通过对象3中的这些成员变量引用,两个线程可以访问对象2和对象4。

该图还显示了指向堆上两个不同对象的局部变量。在这种情况下,引用指向两个不同的对象(对象1和对象5),而不是同一个对象。理论上,如果两个线程都引用了两个对象,那么两个线程都可以访问对象1和对象5。但是在上面的图中,每个线程只有对两个对象之一的引用。

共享对象的可见性

如果两个或多个线程共享一个对象,而没有正确使用volatile声明或同步,则其他线程可能看不到一个线程对共享对象所做的更新。

假设共享对象最初存储在主内存中。然后,在CPU one上运行的线程将共享对象读入其CPU缓存。在那里它对共享对象进行了更改。只要CPU缓存没有被刷新回主内存,其他CPU上运行的线程就看不到更改后的共享对象版本。这样,每个线程可能会得到自己的共享对象副本,每个副本都位于不同的CPU缓存中。

下图说明了大致情况。在左侧CPU上运行的一个线程将共享对象复制到其CPU缓存中,并将其计数变量更改为2。此更改对在正确CPU上运行的其他线程不可见,因为要计数的更新尚未刷新回主内存。

要解决这个问题,可以使用Java的volatile关键字。volatile关键字可以确保直接从主存读取给定的变量,并且在更新时总是写回主存。

volatile

Java volatile关键字旨在解决变量可见性问题。通过声明变量volatile,所有对变量的写入都将立即写回主内存。此外,变量的所有读取都将直接从主存储器中读取。

实际上,Java volatile的可见性保证超出了volatile变量本身。能见度保证如下:

1.如果线程A写入volatile变量,而线程B随后读取相同的volatile变量,那么线程A在写入volatile变量之前可见的所有变量在线程B读取volatile变量之后也将可见。

2.如果线程A读取volatile变量,那么线程A在读取volatile变量时可见的所有变量也将从主存中重新读取。


public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

udpate()方法写入三个变量,其中只有days是volatile的。

完整的volatile可见性保证意味着,当一个值写入days时,线程可见的所有变量也将写入主内存。这意味着,当一个值被写入days时,年和月的值也被写入主存储器。

指令重排

出于性能原因,只要指令的语义保持不变,Java VM和CPU就可以对程序中的指令重新排序。例如


int a = 1;
int b = 2;
a++;
b++;

这些指令可以重新排序为以下顺序,而不会丢失程序的语义:


int a = 1;
a++;
int b = 2;
b++;

然而,当其中一个变量是volatile变量时,指令重新排序是一个挑战。再看一下上面的MyClass 类。update()方法将值写入days后,新写入的years和months值也将写入主内存。但是,如果Java VM重新排列了指令,比如:


public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

当days变量被修改时,months和years的值仍会写入主内存,但这次是在新值写入months和years之前发生的。因此,新值对其他线程来说是不可见的。重新排序的指令的语义已更改。

Happens-Before

为了解决指令重新排序的难题,除了可见性保证之外,Java volatile关键字还提供“happens before”保证。

如果读/写最初发生在对volatile变量的写入之前,则不能将对其他变量的读/写重新排序为在对volatile变量的写入之后发生。

在写入volatile变量之前的读/写操作保证在写入volatile变量之前发生。注意,例如,在对volatile的写操作之后的其他变量的读/写操作仍有可能在对volatile的写操作之前重新排序。但不是相反。允许从后到前,但不允许从前到后。

如果读取/写入最初发生在读取volatile变量之后,则不能将对其他变量的读取和写入重新排序为在读取volatile变量之前发生。注意,在volatile变量的读取之前发生的其他变量的读取可能会被重新排序为在volatile变量的读取之后发生。但不是相反。允许从前到后,但不允许从后到前。

原子性


public class VolatileDemo implements Runnable {
    public volatile int i = 0;
    public void method(){
        for (int j = 0; j < 100; j++) {
            i ++;
        }
        System.out.println(i);
    }
    @Override
    public void run() {
        method();
    }
    public static void main(String[] args) {
        VolatileDemo demo = new VolatileDemo();
        for (int i = 0; i < 100; i++) {
            new Thread(demo).start();
        }
    }
}

运行上面的程序,我们会发现结果一般都是小于10000的。这说明i++并不是一个原子操作,操作步骤分解如下:

  1. 从内存中取出i的值。

  2. 计算i的值。

  3. 将i的值写到内存中。

假如在第二步计算值的时候,另外一个线程也修改了i的值,这里就出现的脏读。所以volatile虽然能保证可见性,却不能保证原子性。

如果两个线程都在读写一个共享变量,那么使用volatile关键字是不够的。在这种情况下,需要使用synchronized来保证变量的读写是原子的。读取或写入volatile变量不会阻塞线程的读取或写入。为此,必须在关键部分周围使用synchronized关键字。

作为同步块的替代,您还可以使用java.util.concurrent包中的许多原子数据类型之一。例如,AtomicLong或AtomicReference或其他的一个.

更多精彩内容请关注微信公众号:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

徐楠_01

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

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

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

打赏作者

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

抵扣说明:

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

余额充值