并发理论
Java 内存模型
Java 内存模型(即 Java Memory Model,简称 JMM)试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范。
CPU 缓存
CPU 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题。
通常情况下,当一个 CPU 需要读取主存数据时,它会将主存的数据读到 CPU 缓存中,甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。
当 CPU 需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。
加入 CPU 缓存带来了一些新的问题:
-
缓存一致性问题:当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有 MSI、MESI 等。 -
指令重排序问题: 为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行 乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。
主内存与工作内存
JMM 规定线程之间的共享变量存放在主内存(主内存就是硬件内存)中,每个线程还有自己的工作内存,存放该线程读/写共享变量的拷贝副本,工作内存存储在 CPU 高速缓存或者寄存器中。
线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。
主内存和工作内存的交互
JMM 定义了 8 个操作来完成主内存和工作内存的交互操作。
-
lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。 -
unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。 -
read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用 -
load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。 -
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。 -
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。 -
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作。 -
write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
内存模型三大特性
原子性
原子性指一次的操作或多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。
JMM 保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性。例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 JMM 允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。
AtomicInteger 等原子操作类能保证多个线程修改的原子性。除了使用原子操作类之外,也可以使用 synchronized 互斥锁来保证操作的原子性。它对应的内存间交互操作为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit。
可见性
可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。
主要有三种实现可见性的方式:
-
volatile,保证被 volatile 修饰的共享变量对所有线程总是可见的 -
synchronized, 对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。 -
final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到未初始化完成的对象),那么其它线程就能看见 final 字段的值。
注意: volatile 并不能保证操作的原子性。
有序性
有序性是指在一个线程内观察,所有的操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。
在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
volatile 和 synchronized 都可保证有序性:
-
volatile 关键字通过添加 内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。 -
synchronized 来保证有序性,它 保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
先行发生原则
除了使用 volatile 和 synchronized 来保证有序性之外,JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成。
-
单一线程原则(Single Thread Rule):在一个线程内,在程序前面的操作先行发生于后面的操作。 -
管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。 -
volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。 -
线程启动规则(Thread Start Rule):Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。 -
线程加入规则(Thread Join Rule):Thread 对象的结束先行发生于 join() 方法返回。 -
线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。 -
对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。 -
传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
如果两个操作之间的关系不在此列,并且无法从以上规则中推导出来的话,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
乐观锁和悲观锁思想
悲观锁
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
互斥同步属于一种悲观的并发策略:总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁。
Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
乐观锁
乐观锁基于冲突检测的乐观并发策略:先进行操作,如果没有其他线程争用共享数据,操作成功;如果数据存在竞争,就采用补偿措施(常见的有不断重试,直到成功)。这种乐观的并发策略的许多实现是不需要将线程挂起的,因此这种同步操作称为非阻塞同步。
CAS
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。
硬件支持的原子性操作最典型的是:CAS(Compare-and-Swap)。
当多个线程尝试使用 CAS 同时更新一个共享变量时,只有其中一个线程能够更新共享变量中的值,其他线程都失败,失败的线程不会被挂起,而是被告知在这次竞争中失败,并且可以再次尝试。
CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
使用 CAS 带来的问题:
-
ABA 问题
如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
-
只能保证一个共享变量的原子操作
JDK 1.5 以后,使用 AtomicReference 将多个共享变量封装为一个共享变量进行操作。
-
自旋时间长开销大
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给 CPU 带来非常大的执行开销。
获取更多干货内容,记得关注我哦。
本文由 mdnice 多平台发布