JMM与Volatile

了解了原子类,当然接下来就要了解AQS啦,不过这之前先来看看两个概念

JMM

Java内存模型是在硬件内存模型上的更高层的抽象,它屏蔽了各种硬件和操作系统访问的差异性,保证了Java程序在各种平台下对内存的访问都能达到一致的效果。

硬件内存模型

在现代计算机的硬件体系中,CPU的运算速度是非常快的,远远高于它从存储介质读取数据的速度,这里的存储介质有很多,比如磁盘、光盘、网卡、内存等,这些存储介质有一个很明显的特点——距离CPU越近的存储介质往往越小越贵越快,距离CPU越远的存储介质往往越大越便宜越慢。

所以,在程序运行的过程中,CPU大部分时间都浪费在了磁盘IO、网络通讯、数据库访问上,如果不想让CPU在那里白白等待,我们就必须想办法去把CPU的运算能力压榨出来,否则就会造成很大的浪费,而让CPU同时去处理多项任务则是最容易想到的,也是被证明非常有效的压榨手段,这也就是我们常说的“并发执行”。

但是,让CPU并发地执行多项任务并不是那么容易实现的事,因为所有的运算都不可能只依靠CPU的计算就能完成,往往还需要跟内存进行交互,如读取运算数据、存储运算结果等。

CPU与内存的交互往往是很慢的,所以这就要求我们要想办法在CPU和内存之间建立一种连接,使它们达到一种平衡,让运算能快速地进行,而这种连接就是我们常说的“高速缓存”。

高速缓存的速度是非常接近CPU的,但是它的引入又带来了新的问题,现代的CPU往往是有多个核心的每个核心都有自己的缓存而多个核心之间是不存在时间片的竞争的它们可以并行地执行,那么,怎么保证这些缓存与主内存中的数据的一致性就成为了一个难题。

为了解决缓存一致性的问题,多个核心在访问缓存时要遵循一些协议,在读写操作时根据协议来操作,这些协议有MSI、MESI、MOSI等,它们定义了何时应该访问缓存中的数据、何时应该让缓存失效、何时应该访问主内存中的数据等基本原则。

JMM

而随着CPU能力的不断提升,一层缓存就无法满足要求了,就逐渐衍生出了多级缓存。

按照数据读取顺序和CPU的紧密程度,CPU的缓存可以分为一级缓存(L1)、二级缓存(L2)、三级缓存(L3),每一级缓存存储的数据都是下一级的一部分。

这三种缓存的技术难度和制作成本是相对递减的,容量也是相对递增的。

所以,在有了多级缓存后,程序的运行就变成了:

当CPU要读取一个数据的时候,先从一级缓存中查找,如果没找到再从二级缓存中查找,如果没找到再从三级缓存中查找,如果没找到再从主内存中查找,然后再把找到的数据依次加载到多级缓存中,下次再使用相关的数据直接从缓存中查找即可。

而加载到缓存中的数据也不是说用到哪个就加载哪个,而是加载内存中连续的数据,一般来说是加载连续的64个字节,因此,如果访问一个 long 类型的数组时,当数组中的一个值被加载到缓存中时,另外 7 个元素也会被加载到缓存中,这就是缓存行的概念。

CPU缓存行、伪共享也提过

JMM

缓存行虽然能极大地提高程序运行的效率,但是在多线程对共享变量的访问过程中又带来了新的问题,也就是伪共享

除此之外,为了使CPU中的运算单元能够充分地被利用,CPU可能会对输入的代码进行乱序执行优化,然后在计算之后再将乱序执行的结果进行重组,保证该结果与顺序执行的结果一致,但并不保证程序中各个语句计算的先后顺序与代码的输入顺序一致,因此,如果一个计算任务依赖于另一个计算任务的结果,那么其顺序性并不能靠代码的先后顺序来保证

与CPU的乱序执行优化类似,java虚拟机的即时编译器也有类似的指令重排序优化

为了解决上面提到的多个缓存读写一致性以及乱序排序优化的问题,这就有了内存模型,它定义了共享内存系统中多线程读写操作行为的规范

Java内存模型

Java内存模型(Java Memory Model,JMM)是在硬件内存模型基础上更高层的抽象,它屏蔽了各种硬件和操作系统对内存访问的差异性,从而实现让Java程序在各种平台下都能达到一致的并发效果

Java内存模型定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节。这里所说的变量包括实例字段、静态字段,但不包括局部变量和方法参数,因为它们是线程私有的它们不会被共享,自然不存在竞争问题。

为了获得更好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器调整代码的执行顺序等这类权利。

Java内存模型规定了所有的变量都存储在主内存中,这里的主内存跟介绍硬件时所用的名字一样,两者可以类比,但此处仅指虚拟机中内存的一部分。

除了主内存,每条线程还有自己的工作内存,此处可与CPU的高速缓存进行类比。工作内存中保存着该线程使用到的变量的主内存副本的拷贝线程对变量的操作都必须在工作内存中进行,包括读取和赋值等而不能直接读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存来完成。

线程、工作内存、主内存三者的关系如下图所示:

JMM

注意,这里所说的主内存、工作内存跟Java虚拟机内存区域划分中的堆、栈是不同层次的内存划分,如果两者一定要勉强对应起来,主内存主要对应于堆中对象的实例部分,而工作内存主要对应与虚拟机栈中的部分区域。

从更低层次来说,主内存主要对应于硬件内存部分工作内存主要对应于CPU的高速缓存和寄存器部分但也不是绝对的,主内存也可能存在于高速缓存和寄存器中,工作内存也可能存在于硬件内存中

JMM

内存间的交互操作

关于主内存工作内存之间具体的交互协议,Java内存模型定义了以下8种具体的操作来完成:

(1)lock,锁定,作用于主内存的变量,它把主内存中的变量标识为一条线程独占状态;

(2)unlock,解锁,作用于主内存的变量,它把锁定的变量释放出来,释放出来的变量才可以被其它线程锁定;

(3)read,读取,作用于主内存的变量,它把一个变量从主内存传输到工作内存中,以便后续的load操作使用;

(4)load,载入,作用于工作内存的变量,它把read操作从主内存得到的变量放入工作内存的变量副本中;

(5)use,使用,作用于工作内存的变量,它把工作内存中的一个变量传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;

(6)assign,赋值,作用于工作内存的变量,它把一个从执行引擎接收到的变量赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时使用这个操作;

(7)store,存储,作用于工作内存的变量,它把工作内存中一个变量的值传递到主内存中,以便后续的write操作使用;

(8)write,写入,作用于主内存的变量,它把store操作从工作内存得到的变量的值放入到主内存的变量中;

如果要把一个变量从主内存复制到工作内存,那就要按顺序地执行read和load操作,同样地,如果要把一个变量从工作内存同步回主内存,就要按顺序地执行store和write操作。注意,这里只说明了要按顺序,并没有说一定要连续,也就是说可以在read与load之间、store与write之间插入其它操作比如,对主内存中的变量a和b的访问,可以按照以下顺序执行:

read a -> read b -> load b -> load a。

另外,Java内存模型还定义了执行上述8种操作的基本规则

(1)不允许read和load、store和write操作之一单独出现,即不允许出现从主内存读取了而工作内存不接受,或者从工作内存回写了但主内存不接受的情况出现;

(2)不允许一个线程丢弃它最近的assign操作,即变量在工作内存变化了必须把该变化同步回主内存;

(3)不允许一个线程无原因地(即未发生过assign操作)把一个变量从工作内存同步回主内存;

(4)一个新的变量必须在主内存中诞生,不允许工作内存中直接使用一个未被初始化(load或assign)过的变量,换句话说就是对一个变量的use和store操作之前必须执行过load和assign操作;

(5)一个变量同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一个线程执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才能被解锁。

(6)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值;

(7)如果一个变量没有被lock操作锁定,则不允许对其执行unlock操作,也不允许unlock一个其它线程锁定的变量;

(8)对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即执行store和write操作;

注意,这里的lock和unlock是实现synchronized的基础,Java并没有把lock和unlock操作直接开放给用户使用,但是却提供了两个更高层次的指令来隐式地使用这两个操作,即moniterenter和moniterexit。

原子性、可见性、有序性

Java内存模型就是为了解决多线程环境下共享变量的一致性问题,那么一致性包含哪些内容呢?

一致性主要包含三大特性:原子性、可见性、有序性,下面我们就来看看Java内存模型是怎么实现这三大特性的。

(1)原子性

原子性是指一段操作一旦开始就会一直运行到底,中间不会被其它线程打断,这段操作可以是一个操作,也可以是多个操作。

由Java内存模型来直接保证的原子性操作包括read、load、user、assign、store、write这两个操作,我们可以大致认为基本类型变量的读写是具备原子性的。

如果应用需要一个更大范围的原子性,Java内存模型还提供了lock和unlock这两个操作来满足这种需求,尽管不能直接使用这两个操作,但我们可以使用它们更具体的实现synchronized来实现。

因此,synchronized块之间的操作也是原子性的。

(2)可见性

可见性是指当一个线程修改了共享变量的值,其它线程能立即感知到这种变化。

Java内存模型是通过在变更修改后同步回主内存,在变量读取前从主内存刷新变量值来实现的,它是依赖主内存的,无论是普通变量还是volatile变量都是如此。

普通变量与volatile变量的主要区别是是否会在修改之后立即同步回主内存,以及是否在每次读取前立即从主内存刷新。因此我们可以说volatile变量保证了多线程环境下变量的可见性,但普通变量不能保证这一点。

除了volatile之外,还有两个关键字也可以保证可见性,它们是synchronized和final。

synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即执行store和write操作”这条规则获取的。

final的可见性是指被final修饰的字段在构造器中一旦被初始化完成,那么其它线程中就能看见这个final字段了。

(3)有序性

Java程序中天然的有序性可以总结为一句话:如果在本线程中观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。

前半句是指线程内表现为串行的语义,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。

Java中提供了volatile和synchronized两个关键字来保证有序性。

volatile天然就具有有序性,因为其禁止重排序。

synchronized的有序性是由“一个变量同一时刻只允许一条线程对其进行lock操作”这条规则获取的。

先行发生原则(Happens-Before)

如果Java内存模型的有序性都只依靠volatile和synchronized来完成,那么有一些操作就会变得很啰嗦,但是我们在编写Java并发代码时并没有感受到,这是因为Java语言天然定义了一个“先行发生”原则,这个原则非常重要,依靠这个原则我们可以很容易地判断在并发环境下两个操作是否可能存在竞争冲突问题。

先行发生,是指操作A先行发生于操作B,那么操作A产生的影响能够被操作B感知到,这种影响包括修改了共享内存中变量的值、发送了消息、调用了方法等。

下面我们看看Java内存模型定义的先行发生原则有哪些:

(1)程序次序原则

在一个线程内,按照程序书写的顺序执行,书写在前面的操作先行发生于书写在后面的操作,准确地讲是控制流顺序而不是代码顺序,因为要考虑分支、循环等情况。

(2)监视器锁定原则

一个unlock操作先行发生于后面对同一个锁的lock操作。

(3)volatile原则

对一个volatile变量的写操作先行发生于后面对该变量的读操作。

(4)线程启动原则

对线程的start()操作先行发生于线程内的任何操作。

(5)线程终止原则

线程中的所有操作先行发生于检测到线程终止,可以通过Thread.join()、Thread.isAlive()的返回值检测线程是否已经终止。

(6)线程中断原则

对线程的interrupt()的调用先行发生于线程的代码中检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否发生中断。

(7)对象终结原则

一个对象的初始化完成(构造方法执行结束)先行发生于它的finalize()方法的开始。(finalize()用在当垃圾回收器,因内存紧张,而去回收某些对象时,这时候会去调用其finalize()方法)

(8)传递性原则

如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

这里说的“先行发生”与“时间上的先发生”没有必然的关系。

比如,下面的代码:

int a = 0;

// 操作A:线程1对进行赋值操作
a = 1;

// 操作B:线程2获取a的值

int b = a;

如果线程1在时间顺序上先对a进行赋值,然后线程2再获取a的值,这能说明操作A先行发生于操作B吗?

显然不能,因为线程2可能读取的还是其工作内存中的值,或者说线程1并没有把a的值刷新回主内存呢,这时候线程2读取到的值可能还是0

所以,“时间上的先发生”不一定“先行发生”。

再看一个例子:

// 同一个线程中
int i = 1;

int j = 2;

根据第一条程序次序原则int i = 1;先行发生于int j = 2;,但是由于处理器优化,可能导致int j = 2;先执行,但是这并不影响先行发生原则的正确性,因为我们在这个线程中并不会感知到这点。

所以,“先行发生”不一定“时间上先发生”。

总结

(1)硬件内存架构使得我们必须建立内存模型来保证多线程环境下对共享内存访问的正确性

(2)Java内存模型定义了保证多线程环境下共享变量一致性的规则

(3)Java内存模型提供了工作内存与主内存交互的8大操作:lock、unlock、read、load、use、assign、store、write;

(4)Java内存模型对原子性、可见性、有序性提供了一些实现;

(5)先行发生的8大原则:程序次序原则、监视器锁定原则、volatile原则、线程启动原则、线程终止原则、线程中断原则、对象终结原则、传递性原则;

(6)先行发生不等于时间上的先发生

volatile

volatile可以说是Java虚拟机提供的最轻量级的同步机制了,但是大多时候遇到多线程问题一律使用synchronized或其它锁来解决。

语义一:可见性

前面介绍Java内存模型的时候,说过可见性是指当一个线程修改了共享变量的值,其它线程能立即感知到这种变化。

普通变量无法做到立即感知这一点,变量的值在线程之间的传递均需要通过主内存来完成,比如,线程A修改了一个普通变量的值,然后向主内存回写,另外一条线程B只有在线程A的回写完成之后再从主内存中读取变量的值,才能够读取到新变量的值,也就是新变量才能对线程B可见。

在这期间可能会出现不一致的情况,比如:

(1)线程A并不是修改完成后立即回写;

volatile

(线路A修改了变量x的值为5,但是还没有回写,线程B从主内存读取到的还旧值0)

(2)线程B还在用着自己工作内存中的值,而并不是立即从主内存读取值;

volatile

(线程A回写了变量x的值为5到主内存中,但是线程B还没有读取主内存的值,依旧在使用旧值0在进行运算)

基于以上两种情况,所以,普通变量都无法做到立即感知这一点。

但是,volatile变量可以做到立即感知这一点,也就是volatile可以保证可见性。

java内存模型规定,volatile变量的每次修改都必须立即回写到主内存中,volatile变量的每次使用都必须从主内存刷新最新的值

volatile

volatile的可见性可以通过下面的示例体现:

public class VolatileTest {
    // public static int finished = 0;
    public static volatile int finished = 0;

    private static void checkFinished() {
        while (finished == 0) {
            // do nothing
        }
        System.out.println("finished");
    }

    private static void finish() {
        finished = 1;
    }

    public static void main(String[] args) throws InterruptedException {
        // 起一个线程检测是否结束
        new Thread(() -> checkFinished()).start();

        Thread.sleep(100);

        // 主线程将finished标志置为1
        finish();

        System.out.println("main finished");

    }
}

在上面的代码中,针对finished变量,使用volatile修饰时这个程序可以正常结束,不使用volatile修饰时这个程序永远不会结束。

因为不使用volatile修饰时,checkFinished()所在的线程每次都是读取的它自己工作内存中的变量的值,这个值一直为0,所以一直都不会跳出while循环。

使用volatile修饰时,checkFinished()所在的线程每次都是从主内存中加载最新的值,当finished被主线程修改为1的时候,它会立即感知到,进而会跳出while循环。

语义二:禁止重排序

前面介绍Java内存模型的时候,说过Java中的有序性可以概括为一句话:如果在本线程中观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。

前半句是指线程内表现为串行的语义,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。

普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获得正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致,因为一个线程的方法执行过程中无法感知到这点,这就是“线程内表现为串行的语义”。

比如,下面的代码:

// 两个操作在一个线程
int i = 0;
int j = 1;

上面两句话没有依赖关系,JVM在执行的时候为了充分利用CPU的处理能力,可能会先执行int j = 1;这句,也就是重排序了,但是在线程内是无法感知的。

看似没有什么影响,但是如果是在多线程环境下呢?

我们再看一个例子:

public class VolatileTest3 {
    private static Config config = null;
    private static volatile boolean initialized = false;

    public static void main(String[] args) {
        // 线程1负责初始化配置信息
        new Thread(() -> {
            config = new Config();
            config.name = "config";
            initialized = true;
        }).start();

        // 线程2检测到配置初始化完成后使用配置信息
        new Thread(() -> {
            while (!initialized) {
                System.out.println(config.name);
            }

            // do sth with config
            String name = config.name;
        }).start();
    }
}

class Config {
    String name;
}

这个例子很简单,线程1负责初始化配置,线程2检测到配置初始化完毕,使用配置来干一些事。

在这个例子中,如果initialized不使用volatile来修饰,可能就会出现重排序,比如在初始化配置之前把initialized的值设置为了true,这样线程2读取到这个值为true了,就去使用配置了,这时候可能就会出现错误(config因为还没初始化)。

(此处这个例子只是用于说明重排序,实际运行时很难出现。)

所以,重排序是站在另一个线程的视角的,因为在本线程中,是无法感知到重排序的影响的。

而volatile变量是禁止重排序的,它能保证程序实际运行是按代码顺序执行的。

实现:内存屏障

上面volatile可以保证可见性和禁止重排序,那么它是怎么实现的呢?

内存屏障

内存屏障有两个作用:

(1)阻止屏障两侧的指令重排序;

(2)强制把写缓冲区/高速缓存中的数据回写到主内存,让缓存中相应的数据失效;

我们还是来看一个例子来理解内存屏障的影响:

public class VolatileTest4 {
    // a不使用volatile修饰
    public static long a = 0;
    // 消除缓存行的影响
    public static long p1, p2, p3, p4, p5, p6, p7;
    // b使用volatile修饰
    public static volatile long b = 0;
    // 消除缓存行的影响
    public static long q1, q2, q3, q4, q5, q6, q7;
    // c不使用volatile修饰
    public static long c = 0;

    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            while (a == 0) {
                long x = b;
            }
            System.out.println("a=" + a);
        }).start();

        new Thread(()->{
            while (c == 0) {
                long x = b;
            }
            System.out.println("c=" + c);
        }).start();

        Thread.sleep(100);

        a = 1;
        b = 1;
        c = 1;
    }
}

这段代码中,a和c不使用volatile修饰,b使用volatile修饰,而且我们在a/b、b/c之间各加入7个long字段消除伪共享的影响

在a和c的两个线程的while循环中我们获取一下b,结果发现程序可以正常结束

如果把long x = b;这行去掉,就会导致死循环

volatile变量的影响范围不仅仅只包含它自己,它会对其上下的变量值的读写都有影响。

缺陷

上面我们介绍了volatile关键字的两大语义,那么,volatile关键字是不是就是万能的了呢?

一致性主要包含三大特性:原子性、可见性、有序性。

volatile关键字可以保证可见性和有序性,那么volatile能保证原子性么?

请看下面的例子:

public class VolatileTest5 {
    public static volatile int counter = 0;

    public static void increment() {
        counter++;
    }

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 100; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        increment();
                    }
                }).start();
         }

        Thread.sleep(1000);

        System.out.println(counter);
    }
}

这段代码中,我们起了100个线程分别对counter自增1000次,一共应该是增加了100000,但是实际运行结果却永远不会达到100000。

让我们来看看increment()方法的字节码(IDEA下载jclasslib插件可以查看):

0 getstatic #2 <com/coolcoding/code/synchronize/VolatileTest5.counter>
3 iconst_1
4 iadd
5 putstatic #2 <com/coolcoding/code/synchronize/VolatileTest5.counter>
8 return

可以看到counter++被分解成了四条指令

(1)getstatic,获取counter当前的值并入栈

(2)iconst_1,入栈int类型的值1

(3)iadd,将栈顶的两个值相加

(4)putstatic,将相加的结果写回到counter中

由于counter是volatile修饰的,所以getstatic会从主内存刷新最新的值,putstatic也会把修改的值立即同步到主内存。

但是中间的两步iconst_1和iadd在执行的过程中,可能counter的值已经被修改了,这时并没有重新读取主内存中的最新值,所以volatile在counter++这个场景中并不能保证其原子性。

volatile关键字只能保证可见性和有序性,不能保证原子性,要解决原子性的问题,还是只能通过加锁或使用原子类的方式解决

进而,我们得出volatile关键字使用的场景

(1)运算的结果并不依赖于变量的当前值,或者能够确保只有单一的线程修改变量的值;

(2)变量不需要与其他状态变量共同参与不变约束。

说白了,就是volatile本身不保证原子性,那就要增加其它的约束条件来使其所在的场景本身就是原子的

比如:

private volatile int a = 0;

// 线程A
a = 1;

// 线程B
if (a == 1) {
    // do sth
}

a = 1;这个赋值操作本身就是原子的,所以可以使用volatile来修饰。

总结

(1)volatile关键字可以保证可见性;

(2)volatile关键字可以保证有序性;

(3)volatile关键字不可以保证原子性;

(4)volatile关键字的底层主要是通过内存屏障来实现的;

(5)volatile关键字的使用场景必须是场景本身就是原子的;

微信扫码订阅
UP更新不错过~
关注
  • 9
    点赞
  • 6
    收藏
  • 打赏
    打赏
  • 17
    评论

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

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:技术工厂 设计师:CSDN官方博客 返回首页
评论 17

打赏作者

早上真起不来!

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

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

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

打赏作者

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

抵扣说明:

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

余额充值