深入浅出并发关键字底层与内存语义

94 阅读13分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

大家好,我是追風者,今天来讨论一下并发底层的关键字 synchronized 和 volatile 的底层原理。

我们知道,对并发有影响的三大机制,一为内存可见性,二为原子操作,三为指令重排。

本文会先阐述一下 synchronized 关键字的原理,而后一步步了解内存可见性、原子操作和指令重排对并发编程的作用与影响,最后阐述一下 volatile 的内存语义。其中夹带了 JMM 和内存屏障的知识。好,下面开始吧。

synchronized 关键字

synchronized 也有三大作用:

  1. 原子性。即整个操作不可分割,为一个整体。

  2. 可见性。即内存可见性。

  3. 有序性。大体上保证共享资源区的前后不会重排。

synchronized 是 JVM 的关键字,它的同步原理在于对 monitor 对象的获取,通过monitorenter 和 monitorexit 的配对使用达到资源上锁的效果。两条指令前者插入在共享资源区的前面,后者插入共享资源区后面,访问到 monitorenter 就会尝试获取 monitor 对象对资源上锁,访问 monitorenter 就是对资源释放锁。

任何对象都与一个 monitor 对象关联,当该对象被某一线程获取到时,该对象就被上锁了。

synchronized 的锁

synchronized 还分为对象级锁和类级锁,对象级别的锁只锁住当前对象,如果同一类的其他对象是不能够被限制的。而当进行类级别锁时,锁住的是类对象,无论在哪里 new 出来的对象,都会是同步的。

synchronized 可以对对象、普通方法、静态方法、代码块和类对象进行上锁。

synchronized 修饰普通方法、对象和 this(修饰 this 也就是当前对象)时是对象级锁。拿修饰普通方法举例子,如果 A 类的 getNum() 在 B 类某方法使用,且 getNum() 是普通方法被 synchronized 修饰,A 类的 getString() 在 C 类的某方法调用,且也是普通方法被 synchronized 修饰。此时线程获取到 B 中的 getNum() 与 C 中的 getString() 是可以并发执行的,无需同步。本质就是更改了类的对象,那么对 A 在 B 中的对象上锁与 A 在 C 中上锁无关。

对象级锁的特殊例子:synchronized(String)是特例,因为JVM中有常量池的关系,所以字符串对象作为锁的情况下,不能够使用相同的字符串,即使是不同new出来的对象。

synchronized 修饰静态方法和类对象时,是对类对象进行上锁。拿静态方法举例子,如果 A 类的 getNum() 在 B 类某方法使用,且 getNum() 是静态方法被 synchronized 修饰,A 类的 getString() 在 C 类的某方法调用,且也是静态方法被 synchronized 修饰。此时线程获取到 B 中的 getNum() 与 C 中的 getString() 是同步执行的,不能够并发执行。本质是对类对象进行上锁,而类对象又是单例的,所以都会同步执行。

synchronized 对代码块上锁就是对任意一个对象进行上锁,只需要上锁标志即可。

值得注意的是,只有同一等级锁是同步执行的。如果一个是对象级锁,一个是类级锁,那么就可以并发执行。

lock 前缀指令 保证内存可见性

lock 前缀指令是 volatile 能够实现可见性的底层支柱。

lock 前缀的指令,会将当前处理器缓存行的数据==当场==回写到系统内存。回写操作回使得其他 CPU 里缓存了该内存地址的数据无效(缓存行是 CPU 在高速缓存中可分配的最小存储单元)。

为了提高 CPU 工作效率,机器会将内存中的部分数据缓存到高速缓存中,但在多线程场景下,无法保证多个 CPU 的数据可见性。为保证多处理器的缓存一致性,每个处理器会嗅探总线上传播的数据检查自己的缓存值是否过期,如果发现自己缓存行对应的内存地址被修改,则将当前处理器的缓存行无效化,当处理器对这个数据进行新的操作时,会重新从内存中获取新值。

处理器实现原子操作

什么是原子操作呢?化学反应上,原子被认定是不可再分的。在计算机的术语中,原子性也表示一段连续操作同时成功或同时失败,无法在该连续操作内插入其他操作。

那么处理器是如何实现原子操作的呢?

处理器使用 缓存加锁或总线加锁来实现原子操作。

第一种,缓存加锁。内存区域如果被处理器的缓存行缓存了,并且在 Lock 操作的期间被锁定住,当它执行锁操作回写内存时,修改内部的内存地址,并允许它的缓存一致性来保证操作的原子性(缓存一致性会阻止由两个以上的处理器同时修改内存区域数据),此时内存地址被修改了,内存回写后,其他的处理器的缓存行全部失效了。

第二种,总线加锁。总线锁就是使用处理器提供的一个 LOCK # 信号,使其他处理器请求被阻塞掉,发出 LOCK # 信号的处理器可以独占贡献内存。

Java 实现原子操作

Java 通过锁和循环 CAS 来实现原子操作。锁就不说了,同步操作,无法并发,也就不存在抢占操作了。

CAS 也就是 Compare And Swap,比较后交换,当目标值与预想值一致时,就可以进行交换操作了,否则就等待,是更新变量数据的一种方式。一次 CAS 可能不成功,所以进行循环操作,不断的判断与等待。

循环 CAS 还有几个问题:

  1. ABA 问题,即一个数据当前为 A ,被一个线程 ThreadA 修改为 B 后又修改为 A,但 ThreadA 还未完成操作。此时出现另一线程 ThreadB 进行 CAS,目标值为 A,预想也为 A,那么此时 ThreadB 就可以对数据进行更改了,但是 ThreadA 本该原子的一段连续操作就被打断了。
    这种情况可以添加版本号进行解决,即  ThreadA 修改数据的变化过程为 1A->2B->3A,此时 ThreadB 进行 CAS 发现数据为 3A 而非预想值的 1A,就无法中断 ThreadA 的原子操作了。

  2. 循环时间长,开销较大。由于 CAS 未成功,就直接循环请求,会浪费资源,导致 CPU 空转。

Java 内存模型——JMM

Java 的内存模型是 Java 并发编程的基础,也是因为这个内存模型才会导致并发数据不一致的问题。

Java 并发采用的是共享内存模型,Java 线程之间的通信时隐式进行的,具体在线程间通信时说。

我们知道,线程不是操作系统分配资源的最小单位,它只是调度的最小单元,线程使用的是进程锁分配的资源,并且该进程的所有线程之间贡献其资源。有了这个前提只是就不难理解 Java 内存模型了。

JMM 中有主内存和本地内存,主内存即各个线程共享的、进程分配到的内存资源。本地内存就是线程中私有的、进程资源的备份,就是 TCB 线程控制块中的数据栈。具体图1-1所示。

JMM.png

图1-1 JMM逻辑图

JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性的保证。

指令重排

执行程序时,编译器和处理器都会对指令进行重排序,而有时指令重排后会出现并发操作程序错误的结果,所有有的时候需要对指令重排进行一下限制。

指令重排分为三种:

  1. 编译器优化重排序。

  2. 指令级并行的重排序。

  3. 内存系统的重排序。

前者属于编译器重排序,后两者属于处理器重排序。JMM 通过内存屏障来进制特定类型的处理器重排序,来保证内存可见性。

内存屏障与 happens-before 关系原则

有了内存屏障,我们就可以根据所需禁用部分指令重排序了,以下为四种内存屏障。根据说明能够很好理解各种内存屏障的作用与效果。

屏障类型指令实例说明意义
LoadLoad BarriersLoad1; LoadLoad; Load2确保 Load1 数据的装载优先于 Load2 及所有后续装载指令的装载保证数据的一致性(防止重排序导致数据执行错误)
StoreStore BarriersStore1; StoreStore; Store2确保 Store1 数据对其他处理器可见(刷新到内存)先于 Store2 及所有后续存储指令的存储确保 Store2 准确获取 Store1 的结果,保证数据正确  
LoadStore BarriersLoad1; LoadStore; Store2确保 Load1 数据装载先于 Store2 及所有后续存储指令刷新到内存在Store2 的执行运行前,可获取到 Load1 最新数据,保证数据可见性
StoreLoad BarriersStore1; StoreLoad; Load2确保 Store1 数据对其他处理器可见(刷新到内存)先于 Load2 及所有后续装载指令的装载。StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成后,才执行该屏障之后的内存访问指令  保证该屏障后面的操作最后执行(与前面内存屏障进行比较)

happens-before 原则是一种概念原则,但是底层是实现了的,我们可以根据这个概念原则来推导插入的内存屏障类型和线程间的数据可见性。

在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。

happens-before 仅仅要求前一操作的结果对后一个操作可见,且前一个操作顺序排在第二个之前。

happens-before 还可以进行推导,即 A happens-before B,B happens-before C,那么 A 一定 happens-before C。

后面我们会根据该原则推导线程间通信。

重排序

相信大家看到指令重排时就会有问题了,如果指令重排了,会不会影响到程序结果的正确性呢?下面两个概念带你了解指令重排的两个限制。

数据依赖性:当两个操作访问一个变量时,并且这两个操作一个为写操作时,此时两个操作间具有数据依赖性。写后读、读后写、写后写都具有数据依赖性。对于有数据依赖性的操作重排序不会出现打乱执行顺序而导致影响程序结果的错误。

as-if-serial 原则:不管如何重排序,单线程的程序结果是不能改变的。

我们可以看到,上面的两条性质是相对于单线程而言的,即单线程程序一定不能因为指令重排而出现错误,否则指令重排也就没有意义了。但是重排序对多线程并发执行的影响是致命的,很有可能会造成判定无效的情况。例如:A 程序的多条语句是没有数据依赖性的,表示可以进行重排,B 程序的多条语句也是没有数据依赖性的,也是可以重排的,但是两个程序需要相互制约时,就有了控制依赖性,极有可能改变了多线程程序的语义(生产者消费者模式就是一个例子,如果对于临界区判断的变量被重排序导致数据错误时,锁也就没有意义了)。

volatile 关键字与内存语义

volatile 有两大作用:

  1. 可见性。

  2. 有序性。

对于 volatile 的原子性是有争议的,原因在于对 long 和 double 变量操作时,无法保证原子性了,需要加锁,原因在于 long 和 double 类型变量太长,无法一次读取执行。并且 volatile 只有 读/写 原子性。

volatile 与锁的内存语义一致。从内存的角度来说,volatile 的写与锁的释放有相同的内存语义,volatile 的读与锁的获取有相同的内存语义。

    class ReorderExample {
        int a = 0;
        boolean flag = false;
        public void writer() {
            a = 1;       // 1
            flag = true; // 2
        }
        
        public void reader() {
            if(flag) {         // 3
                int i = a * a; // 4
            }
        }
    }

volatile 的 happens-before 关系:

假设线程 A 执行 writer() 方法之后,线程 B 执行 reader() 方法。根据 happnes-before 规则进行分析:

1)根据程序次序规则,1 happens-before 2;3 happens-before 4。

2)根据 volatile 规则,2 happens-before 3。

3)根据 happens-before 的传递性规则,1 happens-before 4。

volatile 的内存语义:

当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中(当 volatile 写时候,其实是在调用 lock 前缀指令,而达到了内存可见性)。

当读一个 volatile 变量时,JMM 会把该线程对应的本内内存置无效,重新从主内存中读取。

volatile 写和读的线程通信:

线程 A 写一个 volatile 变量,实质上是线程 A 给接下来要读取该变量的线程发出了消息。 线程 B 读一个 volatile 变量,实质上是线程 B 接收到了 A 传递的消息。 A 写 B 读 实质上就是通过主内存发送消息的过程。

volatile 内存语义的实现:

在每个 volatile 写操作的前面插入一个 StoreStore 屏障。 写操作执行前必须保证上面数据对其他处理器可见。

在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。写操作之后要保证数据刷新到主内存。

在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。保证读操作比后续装载操作先装载数据,防止重排序导致数据执行错误。

在每个 volatile 读操作的后面插入一个 LoadStore 屏障。保证读操作比后续存储操作先装载数据,使后续存储操作获得到最新数据。

锁的内存语义

锁的内存语义与 volatile 一致就不说了,大体可以根据 volatile 理解一下。

如果文章有任何错误欢迎各位斧正,编程心得就是需要不断的交流才会拓宽视野,感谢各位。