欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

Java并发编程:JMM(Java内存模型)和volatile

程序员文章站 2022-04-25 19:25:33
1. 并发编程的3个概念 并发编程时,要想并发程序正确地执行,必须要保证原子性、可见性和有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。 1.1. 原子性 原子性:即一个或多个操作要么全部执行并且执行过程中不会被打断,要么都不执行。 一个经典的例子就是银行转账:从账户A向账户B转账100 ......

1. 并发编程的3个概念

并发编程时,要想并发程序正确地执行,必须要保证原子性、可见性和有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

1.1. 原子性

原子性:即一个或多个操作要么全部执行并且执行过程中不会被打断,要么都不执行

一个经典的例子就是银行转账:从账户A向账户B转账1000元,此时包含两个操作:账户A减去1000元,账户B加上1000元。这两个操作必须具备原子性才能保证转账安全。假如账户A减去1000元之后,操作被打断了,账户B却没有收到转过来的1000元,此时就出问题了。 

 

1.2. 可见性

可见性:即多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的最新值

例如下段代码,线程1修改i的值,线程2却没有立即看到线程1修改的i的最新值:

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;

假如执行线程1的是CPU1,执行线程2的是CPU2。当线程1执行 i=10 时,会将CPU1的高速缓存中i的值赋值为10,却没有立即写入主内存中。此时线程2执行 j=i,会先从主内存中读取i的值并加载到CPU2的高速缓存中,此时主内存中的i=0,那么就会使得j最终赋值为0,而不是10。

 

1.3. 有序性

有序性:即程序执行的顺序按代码的先后顺序执行

例如下面这段代码:

int i = 0;
boolean flag = false;
i = 1;
flag = true;

在代码顺序上 i=1 在 flag=true 前面,而 JVM 在真正执行代码的时候不一定能保证 i=1 在flag=true 前面执行,这里就发生了指令重排序

 

指令重排序

一般是为了提升程序运行效率,编译器或处理器通常会做指令重排序:

  • 编译器重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 处理器重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。CPU 在指令重排序时会考虑指令之间的数据依赖性,如果指令2必须依赖用到指令1的结果,那么CPU会保证指令1在指令2之前执行。

指令重排序不保证程序中各个语句的执行顺序和代码中的一致,但会保证程序最终执行结果和代码顺序执行的结果是一致的。比如上例中的代码, i=1 和 flag=true 两个语句先后执行对最终的程序结果没有影响,就有可能 CPU 先执行 flag=true,后执行 i=1

2. java 内存模型

由于 volatile 关键字是与 java 内存模型相关的,因此了解 volatile 前,需要先了解下 java 内存模型相关概念 

2.1. 硬件效率与缓存一致性

计算机执行程序时,每条指令都是在 CPU 中执行的,而执行指令过程中,势必涉及到数据的读取和写入。CPU 在与内存交互时,需要读取运算数据、存储结果数据,这些 I/O 操作的速度与 CPU 的处理速度有几个数量级的差距,所以不得不加入一层读写速度尽可能接近 CPU 运算速度的高速缓存(Cache)来作为内存与 CPU 之间的缓冲:将运算需要使用的数据复制到高速Cache中;运算结束后再从高速Cache同步回内存中。这样 CPU 就无需等待缓慢的内存读写了。

这在单线程中运行是没有问题的,但在多线程中运行就引入了 缓存一致性 的问题:在多处理系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。当多个 CPU 的运算任务都涉及同一主内存区域时,将可能导致各自的缓存数据不一致,此时同步回主内存时以谁的数据为准呢?

为了解决缓存一致性问题,通常有两种解决方法:

  1. 在总线加 LOCK# 锁的方式
  2. 缓存一致性协议

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

但这种方式有一个问题,在锁住总线期间,其他 CPU 无法访问内存,导致效率低下。

所有就出现了缓存一致性协议,最著名的就是 Intel 的 MESI 协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的, 它的核心思想是:CPU写数据时,如果操作的变量是共享变量(其他 CPU 的高速缓存中也存在该变量的副本),会发出信号通知其他 CPU 将该变量的缓存设置为无效状态,那么当其他 CPU 读取该变量时,就会从内存重新读取。

JVM 有自己的内存模型,在访问缓存时,遵循一些协议来解决缓存一致性的问题。

 

2.2. 主内存和工作内存

Java虚拟机规范中试图定义一种 Java 内存模型(JMM, Java Memory Model)来屏蔽硬件和操作系统的内存访问差异,实现 Java 程序在各种平台上达到一致的内存访问效果。

Java 内存模型主要目标:是定义程序中各个变量的访问规则,即存储变量到内存和从内存中取出变量这样的底层细节。为了较好的执行性能,Java 内存模型并没有限制使用 CPU 的寄存器和高速缓存来提升指令执行速度,也没有限制编译器对指令做重排序。也就是说:在 Java 内存模型中,也会存在缓存一致性问题和指令重排序问题。

Java 内存模型规定所有的变量(包括实例字段、静态字段、构成数组对象的元素,不包括线程私有的局部变量和方法参数,因为这些不会出现竞争问题)都存储在主内存中,每条线程有自己的工作内存(可与之前将讲的CPU高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存拷贝副本。线程对变量的所有操作(read,write)都必须在工作内存中进行,而不能直接读写主内存中的变量,线程间变量值的传递需要通过主内存来完成。如图所示: 

Java并发编程:JMM(Java内存模型)和volatile

 

2.3. JMM如何处理原子性

像以下语句:

x = 10;     //语句1
y = x;      //语句2
x++;        //语句3
x = x + 1;  //语句4

只有语句1才是原子性的操作,其他都不是原子性操作。 

语句1是直接将10赋值给x变量,也就是说线程执行这个语句时,会直接将10写入到工作内存中。 

语句2包含了两个操作,先读取x的值,然后将x的值写入到工作内存赋值给y,这两个操作合起来就不是原子性操作了。 

语句3和4都包括3个操作,先读取x的值,然后加1操作,最后写入新值。

单线程环境下,我们可以认为整个步骤都是原子性的。但多线程环境下则不同,只有基本数据类型的访问读写是具备原子性的,如果还需要提供更大范围的原子性保证,可以使用同步代码块 -- synchronized 关键字。在 synchronized 块之间的操作具备原子性。

 

2.4. JMM如何处理可见性

Java 内存模型是通过变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式实现可见性的。普通变量和 volatile 变量都如此,区别在于:

  • volatile 特殊规则保证了新值能立即同步回主内存,以及每次改前立即从主内存刷新。因此 volatile 变量保证了多线程操作时变量的可见性
  • 而普通变量无法保证这一点,因为普通的共享变量修改后,什么时候同步写回主内存是不确定的,其他线程读取时,此时内存中的可能还是原来的旧值。

除了 volatile 变量外,synchronized 和 final 关键字也能实现可见性。

synchronized 同步块的可见性是由:对一个变量执行 unlock 操作前,必须先把此变量同步回主内存中 这条规则获得的。

final 可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去,那在其他线程中就能看见final字段的值

 

2.5. JMM如何处理有序性

Java 程序中天然的有序性可概括为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句指:线程内表现为串行语义,后半句是指:指令重排序现象工作内存和主内存同步延迟现象

Java 中提供了 volatile 和 synchronized 关键字来保证线程之间操作的有序性。volatile 本身就包含了禁止指令重排序的语义,而 synchronized 是由一个变量在同一时刻只允许一条线程对其 lock 操作这条规则获得,这条规则决定了持有同一个锁的两个同步代码块只能串行的执行。

 

happens-before 原则

Java内存模型中,有序性保证不仅只有 synchronized 和 volatile,否则一切操作都将变得繁琐。Java 中还有一个 happens-before 原则,它是判断线程是否安全的主要依据。依靠这个规则,可以保证程序的有序性,如果两个操作的执行顺序无法从 happens-before 原则中推导出来,则他们就不能保证有序性,可以随意重排序。

happens-before(先行发生) 是 Java 内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,那么就是说发生操作B之前,操作A产生的影响能被操作B观察到。影响包括修改内存*享变量的值、发送了消息、调用了方法等。

下面是 Java 内存模型下的天然的先行发生关系,这些关系无需任何同步就已经存在:

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确的来说,应该是控制流顺序,而不是代码顺序,因为要考虑分支、循环等结构
  • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
  • volatile变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作
  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每个一个动作
  • 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程是否已经终止执行
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可通过 Thread.isinterrupted() 检测是否有中断发生
  • 对象终结规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

第一条程序次序原则,"书写在前面的操作先行发生于书写在后面的操作",这个应该是一段程序代码的执行在单线程中看起来是有序的,因为虚拟机可能对程序代码中不存在数据依赖性的指令进行重排序,但最终执行结果与顺序执行的结果是一致的。而在多线程中,无法保证程序执行的有序性。

第二条,第三条分别是关于 synchronized同步块 和 volatile 的规则。第四至第七条是关于 Thread 线程的规则。第八条是体现了 happens-before 原则的传递性。

下面是一个利用 happens-before 规则判断操作间是否具备顺序性的例子:

private int value=0;

public void setValue() {
    this.value = value;
}

public int setValue() {
    return value;
}

这段是一段普通的 getter/setter 方法,假如线程A先调用(时间上的先后)了 setValue(1),然后线程B调用了同一个对象的 getValue(),那么线程B的返回值是什么呢?

我们按以上 happens-before 规则分析:

  • 由于存在线程A和线程B调用,不在一个线程中,程序次序原则则不适用;
  • 没有同步快,也没有unlock和lock操作,所以管程锁定规则不适用;
  • 由于 value 没有被 volatile 修饰,所以volatile变量规则不适用;
  • 后面的线程启动、终止、中断、终结和这里没有关系;
  • 由于没有适用的 happens-before 规则,最后的传递性也不适用

因此,可以判定尽管线程A在操作时间上先与线程B,但无法确定线程B中 getValue() 的返回值,也就是说,这里的操作不是线程安全的。

该如何修复这个问题呢?可以有两种方法:

  • 将 getter/setter 定义为 synchronized 方法,这样可以套用 管程锁定规则
  • 使用 volatile 关键字修饰 value,这样可以套用 volatile变量规则

时间先后顺序和 happens-before 原则之间没有太大的关系,所以当我们衡量并发安全问题时,不要受到时间顺序的干扰,一切应以 happens-before 原则为准。

3. volatile 实现原理

volatile 关键字是 JVM 提供的最轻量级的同步机制,当一个变量定义为 volatile 后,它将具有普通变量没有的两种特性:

  1. 保证此变量对所有线程的可见性:当一个线程修改了该变量的值,新值对于其他线程来说是可以立即得知的。
  2. 禁止指令重排序优化。普通变量只能保证在方法执行过程中所有依赖赋值结果的地方都能获得正确的结果,而不能保证变量赋值操作的顺序和代码中的顺序一致,这也就是上文中提到的 Java 内存模型中所谓的"线程内表现为串行语义"。

 

3.1. volatile 保证原子性吗

基于 volatile 变量的运算在并发下并不一定是线程安全的。因为 Java 里的运算并非原子操作,例如下面是一个 volatile 变量自增运算的例子:

public class VolatileTest {

    public static volatile int race = 0;
     
    public void increase() {
        race++;
    }
     
    public static void main(String[] args) {
        Thread[] threads = new Thread[20];
        for(int i=0; i<20; i++){
            threads[i] = new Thread( new Runnable() {
                @Override
                public void run() {
                    for(int i=0; i<10000; i++)
                        increase();
                };
            });
            threads[i].start();
        }

        while(Thread.activeCount()>1)  // 等待所有累加的线程都结束
            Thread.yield();
        System.out.println(race);
    }
}

这段代码发起了20个线程,每个线程对 race 累加10000次,如果并发正确的话,输出结果应该是200000。而运行完这段代码后,每次输出的结果不一样,都是小于200000。

问题就在于 race 通过 volatile 修饰只能保证每次读取的都是最新的值,但不保证 race++ 是原子性的操作,它包括读取变量的初始化,加1操作,将新值同步写到主内存 三步。自增操作的三个子操作可能会分开执行。

假如某时刻 race 值为10,线程A 对 race 做自增操作,先读取 race 的最新值10,此时 volatile 保证了 race 的值在此刻是正确的,但执行加1的时候,其他线程可能已经将 race 的值加大了,此时线程A工作内存中的 race 值就变成了过期的数据,然后将过期较小的新值同步回主内存。此时,多个线程对 race 分别做了一次自增操作,但可能主内存中的 race 值只增加了1。

volatile 无法保证对变量的任何操作都是原子性的,可以使用 synchronized 或 java.util.concurrent 中的原子类来修改。

 

3.2. volatile 保证可见性吗

下面代码,线程A先执行,线程B后执行

//线程A
boolean stop = false;
while(!stop){
    doSomething();
}
 
//线程B
stop = true;

这段代码在大多数时候,能将线程A中的while循环结束,但有时候也会导致无法结束线程,造成一直while循环。原因在于:前面提到每个线程都有自己的工作内存,线程A运行时,会将 stop=false 的值同步一份在自己的工作内存中。当线程B更新了stop的值为true后,可能还没来得及同步到主内存中,就去做其他事情了。此时线程B中 stop=true 的修改对于线程A是可不见的,导致线程A会一直循环下去。

如果将stop使用 volatile 修饰后,就可以保证线程A能退出循环。在于:使用 volatile 关键字会强制将线程B修改的新值stop立即同步至主内存。当线程B修改时,会导致线程A工作内存中stop的缓存行无效,反映到硬件上,就是CPU的高速缓存中对应的缓存行无效。线程A的工作内存中stop的缓存行无效后,会到主内存中再次读取变量stop的新值。从而 volatile 保证了共享变量的可见性。

 

3.3. volatile 保证有序性吗

volatile 可以通过禁止指令重排序来保证有序性,有两层意思:

  • 当程序执行到 volatile 变量的读操作或写操作时:在其前面的操作肯定全部已经完成,且结果对后面的操作可见。在其后面的操作肯定还没进行
  • 指令重排序优化时,不能将 volatile 变量前面的语句放在其后面执行,也不能将 volatile 变量后面的语句放到其前面执行。

举个例子如下,flag是 volatile 变量,x/y都是非 volatile 变量:

x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;        //语句4
y = -1;       //语句5

在指令重排序时候,因为flag是 volatile 变量。所以执行到语句3时,语句1和语句2必定是执行完成了,且执行结果对语句3、语句4和语句5是可见的。不会将语句3放到语句1、语句2前面,也不会将语句3放到语句4、语句5后面。语句1和语句2的顺序,语句4和语句5的顺序是不做保证的。

下面是一个指令重排序会干扰程序并发执行的例子:

Map config;
volatile boolean init = false;  // 变量定义为volatile

// 线程A执行
// 读取配置信息,读取完后将init设置为true,以通知其他线程配置使用
config = loadConfig();
init = true;

// 线程B执行
// 等待init为true,代表线程A已经将配置初始化好
while(!init) {
    sleep();
}
doSomeThingWhihConfig(config);    // 使用线程A中初始化好的配置信息

假如 init 变量没有使用 volatile 修饰,可能由于指令重排序的优化,导致线程A最后一句 init=true 提前执行(指这句代码对应的汇编代码被提前执行),这样线程B中使用配置信息的代码就可能出错。而使用 volatile 对 init 变量进行修饰,就可以避免这种情况,因为执行到 init=true 时,可以保证 config 已经初始化好了。

 

3.4. 内存屏障

volatile 关键字是如何禁止指令重排序的?关键在于有 volatile 关键字和没有 volatile 关键字所生成的汇编代码,加入 volatile 修饰的变量,赋值后会多执行一个lock前缀指令,这个指令相当于一个内存屏障。通过内存屏障实现对内存操作的顺序限制,它提供了3个功能:

  • 确保指令重排序时不会把后面的指令排到内存屏障之前的位置,也不会把前面的指令排序到内存屏障的后面。这样形成了指令重排序无法越过内存屏障的效果
  • 强制将对工作内存的修改立即写入主内存
  • 如果是写操作,会导致其他 CPU 中对应的缓存行无效

只有一个 CPU 访问内存时,不需要内存屏障;但如果有两个或更多 CPU 访问同一块内存,且其中一个在观察另一个,就需要内存屏障来保证一致性了。

 

3.5. volatile 使用场景

某些情况下,volatile 同步机制的性能确实要优于锁(使用 synchronized 或 java.util.concurrent 包里面的锁),但由于对锁实现的很多优化和消除,使得很难量化的认为 volatile 会比 synchronized 快多少。如果 volatile 和自己比较的话,volatile 读操作的性能消耗与普通变量基本没有什么差别,但写操作可能慢一些,因为它需要在本地代码中插入许多内存屏障指令保证处理器不会乱序执行。即便如此,大多数场景下 volatile 的总开销仍然比锁低,volatile 无法保证操作的原子性,是无法替代 synchronized的。在 volatile 和锁之间选择的唯一依据是 volatile 的语义能否满足场景的需求。通常,使用 volatile 必须具备以下两个条件:

  • 对变量的写操作不依赖于当前值,例如 count++ 这样自增自减操作就不满足这个条件
  • 该变量没有包含在具有其他变量的不变式中

4. 参考