??提起并发编程,就绕不开并发的三大特性(
1.原子性 、
2.可见性 、
3.有序性 );要解决并发编程中遇到的这三类问题,可能很多人也知道大名鼎鼎的
JMM 内存模型 ,但又有多少人知道
JMM 内存模型是如何解决这三大问题 的呢?本文就从原理层面来细细讲解。
??在《并发编程》板块,已经有介绍到 1.计算机原理结构 ? 2.MESI缓存一致性协议。这两篇内容介绍的也只是在硬件CPU层面上的解决方案。在 Java 软件层面,就是我们接下来要介绍的 JMM 内存模型 ,JMM 内存模型就是基于 CPU 底层架构的一种抽象 。(即:参考硬件层来解决相同的问题)
1.JMM 内存模型
??Java内存模型(Java Memory Model,简称:JMM)是一种抽象的概念,并不真实存在 。它就是围绕多线程中的原子性,有序性、可见性 三大问题展开的。
??JMM模型,描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存 (有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行 ,首先要将变量从主内存 拷贝的自己(当前线程)的工作内存 空间,然后在工作内存中对变量进行操作 ,操作完成后再将变量从工作内存写回主内存 ,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝。前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
提示:JMM 内存模型,与JVM内存区域模型 没有半毛钱关系,此处不做介绍
??工作内存的原理,就类似CPU缓存 。每个线程在运行时,都有自己单独的一个工作内存。
??不同版本JDK(Linux、Windows、Mac)的存在,可以实现 Java 在各个操作系统正常运行。一处编写,到处运行(Write Once,Run Anywhere) ,JMM 内存模型也有着功不可没的功劳。JMM模型本质上也是为了屏蔽底层的操作系统、硬件架构不同,不同操作系统,用这套规则,屏蔽底层的具体实现的不同。由Oracle公司开发的 JVM虚拟机去屏蔽(JMM是一组规范,就是为了屏蔽底层的不同)。
1.主内存
??主内存主要存储的是Java实例对象 ,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。
2.工作内存
??工作内存主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝) ,每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
3.JMM 线程操作内存的两条基本规定
- 关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写;
- 关于线程间工作内存:不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要经过主内存来完成。
??JMM 内存模型,先简单介绍到这里。它是如何解决多线程中的原子性 、可见性 、有序性 问题,在接下来介绍的三大特性中,会有详细介绍,请君继续往下了解。
4.JMM 模型八大内存交互指令
??这 8 种指令,都是原子操作!!!
指令 | 描述 |
---|
lock(锁定) | 作用于主内存的变量,把一个变量标记为一条线程独占状态 | read(读取) | 作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用 | load(载入) | 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中 | use(使用) | 作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎 | assign(赋值) | 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量 | store(存储) | 作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作 | write(写入) | 作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中 | unlock(解锁) | 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 |
5.JMM 模型内存交互操作
??把一个变量从主内存中复制到工作内存中,就需要按顺序地执行 read 和 load 操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行 store 和 write 操作。但 JMM 内存模型只要求上述8大操作(原子操作)必须按顺序执行,而没有保证必须是连续执行。
6.JMM 模型内存同步规则
- 不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中;
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或者 assign)的变量。即就是对一个变量实施use 和 store 操作之前,必须先自行 assign 和 load 操作;
- 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。lock 和 unlock 必须成对出现;
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行 load 或 assign 操作初始化变量的值;
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量;
- 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。
2.多线程三大特性
??三大特性:原子性 、可见性 、有序性 。并发编程中,为什么会出现问题,就是因为线程与线程之间的操作,是没有办法相互感知,要想相互感知,就需要用到 MESI 缓存一致性协议的加持了。
??接下来硬核内容,搞起。详解多线程三大特性 !!!
1.原子性
??原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
??在Java中,对基本数据类型 的变量的读取 和赋值 操作,都是原子性操作。
x=10;
y = x;
x++;
x = x+1;
??有点要注意的是: 对于 32 位系统的来说,long 类型数据和 double 类型数据(对于基本数据类型 byte、short、int、float、boolean、char 读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对 long 类型或者 double 类型的数据进行读写,是存在相互干扰的。因为对于 32 位虚拟机来说,每次原子读写是 32 位的,而 long 和 double 则是64位的存储单元,这样会导致一个线程在写时,操作完前 32 位的原子操作后,轮到 B 线程读取时,恰好只读取到了后 32 位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即 64 位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。从JDK 9 开始,Oracle官方已不再发布 32 位版本,只有 64 位的了。
1.原子性例子
??一个静态全局变量 int count,创建 10 个线程,每个线程执行1000次 count++。按实际逻辑,不管这 10 个线程以何种方式,何种步调工作,最终 count 值都应该为 10000。
public class AtomicTest {
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
count++;
}
}).start();
}
while(Thread.activeCount()>2){
Thread.yield();
}
System.out.println("执行结果:"+count);
}
}
??但是实际情况下,count 值最终都会小于10000。【在某些机缘巧合之下,也会出现 count = 10000 的情况,但大部分情况下会小于10000】 。
2.分析原因
??这是因为 count++ 在多线程环境下,不是一个原子性操作导致的!!!count++ 在代码执行过程中分为2步: 1. 从主内存加载 count 变量到工作内存; ?2. 执行 +1 操作。
??10个线程计算完成后,结果都保存在自己线程的工作内存中,在线程1写入到主内存后,线程2写入时,可能会将线程1写入的覆盖掉,从而导致最后的结果 < 10000。
3.解决方案
??有四种:1. 使用 synchronized 锁机制解决 ?2. 使用 Lock 锁机制解决 ? 3.使用 AtomicInteger 原子操作类解决 ?4.(不推荐)使用 Unsafe 类中的 monitorEnter 和 monitorExit 方法,手动的加锁、解锁 。
??1.加 synchronized 锁。就类似多线程开发的程序,单线程化执行,线程1只有在获得锁后,才能执行业务逻辑代码;线程2、线程3 只能等待线程 t1 锁的释放,t1 释放后,t2、t3继续抢锁,谁抢到谁执行。锁机制能够保证任一时刻只有一个线程访问该代码块(除 synchronized 关键字加锁外,使用 JUC 中的 Lock 锁也可以解决原子性,JUC 在后续系列文中会有介绍 ,地址:敬请期待,未编写)
??2.加 Lock 锁。同 synchronized 锁关键字原理相同。(JUC 中的 Lock 锁也可以解决原子性,JUC 在后续系列文中会有介绍,地址:敬请期待,未编写)
??3.使用 Atomic 原子类。Atomic 原子操作类,也是 JUC 包中为我们提供的一种解决方案。它的底层是基于 Unsafe 魔法类实现的,Unsafe 魔法类可以跨过 JVM 直接对内存进行操作 。Unsafe 类也会在后续系列中有介绍,地址:敬请期待,未编写。原子操作类,不只有 AtomicInteger 类,在 Atomic 包里一共有12个类,如下图所示,关于 Atomic 原子操作类,来这里简单了解一下吧,地址:敬请期待,未编写 ??4.使用 Unsafe 类中的 monitorEnter 和 monitorExit 方法,手动的加锁、解锁。这种方式使用 Unsafe 魔法类中为我们提供的 monitorEnter 和 monitorExit 方法,绕过 JVM 虚拟机直接对内存进行操作。也能解决原子性问题,但是不建议使用!!!因为 Unsafe 类太不安全,搞不好风险会很大的!!!
1.synchronized 锁
public class AtomicTest {
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
synchronized (AtomicTest.class) {
count++;
}
}
}).start();
}
while(Thread.activeCount()>2){
Thread.yield();
}
System.out.println("执行结果:"+count);
}
}
2.Lock 锁
public class AtomicTest {
private static int count = 0;
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
lock.lock();
count++;
lock.unlock();
}
}).start();
}
while(Thread.activeCount()>2){
Thread.yield();
}
System.out.println("执行结果:"+count);
}
}
3.(推荐)Atomic 原子类
public class AtomicTest {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
count.getAndIncrement();
}
}).start();
}
while(Thread.activeCount()>2){
Thread.yield();
}
System.out.println("执行结果:"+count);
}
}
4.(不推荐)Unafe 类中的 monitorEnter 和 monitorExit 方法,手动加锁、解锁
public class UnsafeInstance {
public static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
public class AtomicTest {
private static int count = 0;
private static Object lock = new Object();
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
UnsafeInstance.reflectGetUnsafe().monitorEnter(lock);
count++;
UnsafeInstance.reflectGetUnsafe().monitorExit(lock);
}
}).start();
}
while(Thread.activeCount()>2){
Thread.yield();
}
System.out.println("执行结果:"+count);
}
}
4.性能对比
??(JDK 6以前)使用 synchronized 锁,是重量级操作,基于悲观锁来解决问题,效率低下 ;
??(JDK 6以后)源码部分对 synchronized 关键字进行了比较大的优化[对 synchronized 引入了无锁、偏向锁、轻量级锁、重量级锁 4种锁状态],但在加锁情况下,效率肯定还是会大打折扣 。JDK 6 对 synchronzied 锁是如何优化的?来这里了解:敬请期待,未编写
??将文中代码10个线程,增加到 2w 个、5w个。
??JDK 8 环境下:synchronized 用时 1129ms、2387ms ,Atomic原子类用时 949ms、2339ms ,因为 JDK 6 对 synchronzied 锁的优化,用时差不多;
??JDK 6环境下,使用 synchronzied 锁就是重量级锁,synchronized 用时 2235ms、4700ms ,Atomic原子类用时 949ms、2339ms 。Atomic 原子类会比 synchronzied 效率快 50% 左右。
??解决原子性问题,JDK 6以前还是推荐使用原子操作类 ,JDK6 及以后版本,效率就差不多了,自行选择吧。不过现在企业大多用的都是 JDK 8 以上版本了吧,本对比实例就用作参考一下吧。
2.可见性
??可见性,指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值 。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。
??但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程 A 修改了共享变量 x 的值,还未写回主内存时,另外一个线程 B 又对主内存中同一个共享变量 x 进行操作,但此时线程 A 工作内存中的共享变量 x 对线程 B 来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题 。另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序乱序执行的问题,从而也就导致可见性问题。
1.可见性例子
??共享变量 initFlag ,默认值为 false。启动 2 个线程,线程1先执行,休眠 1s 后执行线程2,保证线程1先进入 while 循环,线程2 修改共享变量 initFlag 值后,线程1因为无法及时 感知到 initFlag 值的变化,导致线程 1 无法结束。
public class VisibilityTest {
private boolean initFlag = false;
public void load() {
int i = 0;
while (!initFlag) {
i++;
}
System.out.println( Thread.currentThread().getName() + ":嗅探到initFlag属性值改变,i=" + i);
}
public void refresh() {
this.initFlag = true;
System.out.println(Thread.currentThread().getName() + ":修改了initFlag值");
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
new Thread(test::load, "线程1").start();
TimeUnit.SECONDS.sleep(1);
new Thread(test::refresh, "线程2").start();
}
}
??代码运行后,load 方法会处于 while 循环中,睡眠 1s 后线程2执行,并修改共享变量 initFlag 的值,修改后按照我们常规的想法,线程 1 发现 initFlag 属性值改变,while() 循环无法满足,应该停止运行 。
??但是事与愿违,虽然线程2运行完毕,修改 initFlag 变量成功,线程 1 却根本无法感知 initFlag 变量的变化,所以也就没有停止运行了 。
2.分析原因
??这个和 JMM 内存模型有关系。线程 1 和线程 2 都会将共享变量 initFlag 的值拷贝一份到各自的工作内存中,线程在运行时使用的都是各自工作内存中的值。虽然线程 2 将 initFlag 值就行修改,但也仅限于将自己工作内存中的值进行了修改,并没有将其回写到主内存,导致线程1并不能够及时发现 initFlag 属性值的变化,线程 1 也就不会停止运行了。
??每个线程工作内存中的变量,只对自己线程可见,对其他线程都不可见,这就是多线程的可见性问题 。那啥时候停止呢?就看线程 2 工作内存中的 initFlag 变量值何时能够回写到主内存中,这个时间就不是可控的了。
3.解决方案
??有两种:1. 使用 synchronized 锁机制解决 ? 2.使用 volatile 关键字
??加 synchronized 锁。就类似多线程开发的程序,单线程化执行,线程1只有在获得锁后,才能执行业务逻辑代码;线程2、线程3 只能等待线程 t1 锁的释放,t1 释放后,t2、t3继续抢锁,谁抢到谁执行。锁机制能够保证任一时刻只有一个线程访问该代码块。
??2个线程中在对共享变量的读取或者写入都进行加锁处理,因为线程对应的都是同一把锁对象,所以相互会排斥。但是就算这样子也不能说明内存可见性的。其实真正解决这个问题的是 JMM 模型关于 synchronized 的两条规定:
- 线程解锁前,必须把共享变量的最新值刷新到主内存中;
- 线程加锁时,将清空工作内存中共享变量的值,从而在使用共享变量时,需要从主内存中重新读取最新的值(加锁与解锁需要统同一把锁)
??加 volatile 关键字。即:共享变量被 volatile 关键字修饰。被 volatile 关键字修饰的变量,在汇编指令运行阶段,该行代码会被 lock 标识修饰。规定当有 lock 标识时,修改后需要立即将该值同步回写到主内存(如下图所示,共享变量 initFlag 在被 refresh 方法修改时,被 lock 标识)。线程 2 修改共享变量值后,利用 MESI 缓存一致性协议, 其他线程通过 CPU 嗅探技术,就能够及时发现共享变量 initFlag 值的改变。嗅探到值的变化后,线程 1 则会将自己工作内存的共享变量 initFlag 状态置为 I(无效)状态,重新去主内存中加载共享变量 initFlag 的值,此时加载到的就是线程 1 修改后的值了。然后执行到 while() 循环时,发现不满足条件,线程 1 则会停止运行。 (此处可结合:MESI缓存一致性协议 分析)
汇编指令如下:
??如何查看汇编指令,参考:https://pan.baidu.com/s/1eVGFvZ7cvLciCFMqyCUtNw (提取码:wjc7)
1.synchronized 锁
public class VisibilityTest {
private boolean initFlag = false;
private Object lock = new Object();
public void load() {
int i = 0;
while (!initFlag) {
synchronized (lock) {
i++;
}
}
System.out.println( Thread.currentThread().getName() + ":嗅探到initFlag属性值改变,i=" + i);
}
public void refresh() {
synchronized (lock) {
this.initFlag = true;
}
System.out.println(Thread.currentThread().getName() + ":修改了initFlag值");
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
new Thread(test::load, "线程1").start();
TimeUnit.SECONDS.sleep(1);
new Thread(test::refresh, "线程2").start();
}
}
解析: ??在此处你可能会有疑惑,线程 1 获得锁后进入while 循环,它们用的又是同一把锁,线程 1 一直死循环,肯定无法释放锁,线程 2 怎么能够获取锁呢? ? ??记住:synchronized 是一个可重入锁,同一个对象的话可以多次获得同一把锁 。上述两个线程,都是同一个 test 对象,所以线程 2 也能够获得锁。线程 2 修改后,解锁前,必须把共享变量的最新值刷新到主内存中,根据 JMM 模型规范,线程 1 嗅探到 initFlag 变量改变后,它就能够结束执行了。可重入锁系列讲解,在后续文章也会有介绍。来这里了解:敬请期待,未编写 ? 代码修改成如下,使用两个对象调用两个线程,就又完犊子了,一直死在那了,不信你可以拿去试试,哈哈
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
VisibilityTest test2 = new VisibilityTest();
new Thread(test::load, "线程1").start();
TimeUnit.SECONDS.sleep(1);
new Thread(test2::refresh, "线程2").start();
}
2.(推荐)volatile 关键字
public class VisibilityTest {
private volatile boolean initFlag = false;
public void load() {
int i = 0;
while (!initFlag) {
i++;
}
System.out.println( Thread.currentThread().getName() + ":嗅探到initFlag属性值改变,i=" + i);
}
public void refresh() {
this.initFlag = true;
System.out.println(Thread.currentThread().getName() + ":修改了initFlag值");
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
new Thread(test::load, "线程1").start();
TimeUnit.SECONDS.sleep(1);
new Thread(test::refresh, "线程2").start();
}
}
4.volatile 关键字的作用
?volatile是Java虚拟机提供的轻量级的同步机制 。volatile关键字有如下两个作用:
- 保证被 volatile 修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
(解决可见性问题) - 禁止指令重排序优化。
(解决有序性问题)
5.volatile为何无法保证原子性
??volatile 可以保证可见性,也可以禁止指令重排优化(有序性),但是无法保证原子性。
以10个线程为例,进行 i++ 操作
??volatile 会在汇编阶段添加一个 lock 指令,在支持 MESI 协议的前提下,就会为其添加缓存行锁。两个线程同时往主内存写入数据时,先要 lock 锁住缓存行(lock操作如果有10个线程,也只能允许一个成功),如果线程1 加锁成功,根据 MESI 四种状态(M、E、S、I),触发MESI协议开始工作。
??其他线程,则处于一直嗅探阶段,线程 1 发现其他线程也有用到该内存地址,此时线程1变为 M 修改状态 ,然后向总线发送一个消息,其他所有线程嗅探到共享变量有修改操作时,都会将自己工作内存中的变量置为 I 无效状态 。(此处可结合:MESI缓存一致性协议 分析)
??那么这一轮循环中,只会有一个写入主内存,其他 9 个线程的数据是没有写入主内存的,已经处于无效状态了 。这就意味着已经少加了9 次操作,这就是 volatile不能解决原子性的原因。
3.有序性
??Java语言规范规定 JVM 线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫 指令的重排序 ,指令重排序,从而导致代码执行的顺序可能和我们编写代码的顺序不一致。
??指令重排序的意义: JVM 能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
下图为从源码到最终执行的指令序列示意图
1.原理介绍
??在介绍 有序性 之前,先得来简单介绍一下:as-if-serial 语义 和 happens-before 原则 。
1.as-if-serial 语义
??as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果永远不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。
??为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
??所以,单线程环境下,不管指令如何重排序,在 as-if-serial 语义的加持下,期待的结果与最终执行的结果,永远是一致的。指令重排序在单线程环境下,不受任何影响!!!
2.happens-before 原则
??我们编写的程序都要经过优化后(编译器和处理器会对我们的程序进行优化以提高运行效率)才会被运行,优化分为很多种,其中有一种优化叫做指令重排序,指令重排序需要遵守happens-before规则,不能说你想怎么排就怎么排。
??只靠 sychronized 和 volatile 关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,从JDK 5开始,Java 使用新的JSR-133内存模型,提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题题,它是判断数据是否存在竞争、线程是否安全的依据。happens-before 原则内容如下:
程序顺序原则 :即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行管理锁定规则 : 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。volatile变量规则 :volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性,简单的理解就是,volatile 变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。线程启动规则 :线程的 start() 方法先于它的每一个动作,即如果线程 A 在执行线程 B 的 start 方法之前修改了共享变量的值,那么当线程 B 执行 start 方法时,线程 A 对共享变量的修改对线程 B 可见传递性 : A 先于 B ,B 先于 C 那么 A 必然先于 C线程终止规则 :线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程 A 从线程 B 的 join 方法成功返回后,线程 B 对共享变量的修改将对线程 A 可见线程中断规则 :对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测线程是否中断对象终结规则 :对象的构造函数执行,结束先于 finalize() 方法
3.指令重排发生在什么阶段
- 编译期(是在字节码指令被编译器翻译成机器码阶段,通过调整指令顺序,在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值)
- CPU 执行期间(将 class 字节码文件转换成汇编指令后,CPU 在执行汇编语句时,也会酌情对语句进行指令重排序)
4.DCL双重检验锁,分析volatile禁止指令重排序
??volatile 关键字,除了解决可见性问题外,另一个作用就是禁止指令重排优化 。从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下 volatile 是如何实现禁止指令重排优化的。先了解一个概念:内存屏障(Memory Barrier)。
??内存屏障,又称内存栅栏,是一个 CPU 指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memory Barrier 则会告诉编译器和 CPU ,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier 的另外一个作用是强制刷出各种 CPU 的缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本。总之,volatile 变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
??下面看一个非常典型的禁止重排优化的例子双重校验锁(DCL,即 double-checked locking)单例模式代码,如下:(这段代码在菜鸟教程也有)
public class Singleton {
private static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance== null) {
synchronized (Singleton.class) {
if (instance== null) {
instance= new Singleton();
}
}
}
return instance;
}
}
??上述代码一个经典的单例模式的双重检测的代码(人为去掉 volatile 修饰),这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可能出现线程安全问题。原因在于:某一个线程执行到第一次检测,读取到的 instance 不为 null 时,instance 的引用对象可能没有完成初始化。
因为 instance= new Singleton(); 可以分为以下3步完成(伪代码)
memory = allocate();
instance(memory);
instance = memory;
因为步骤 2 和 步骤 3 之间并没有依赖关系,所以在步骤2 和 步骤3 间可能会重排序,执行顺序可能就是如下情况:
memory=allocate();
instance = memory;
instance(memory);
??由于步骤 2 和步骤 3 不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性 。
??所以当一条线程访问 instance 不为 null 时,由于 instance 实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单, 我们使用 volatile 禁止 instance 变量被执行指令重排优化即可。
private volatile static Singleton instance;
5.volatile 关键字的语义,在 JMM 模型中的实现
??前面提到过重排序分为编译器重排序 和处理器重排序 。为了实现volatile内存语义,JMM 内存模型会分别限制这两种类型的重排序类型。
1.JMM 针对编译器制定的 volatile 重排序规则
是否能重排序 | 第二个操作 |
---|
第一个操作 | 普通读 / 写 | volatile 读 | volatile 写 | 普通读 / 写 | | | NO | volatile 读 | NO | NO | NO | volatile 写 | | NO | NO |
结合表格,举例说明:
??第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写 时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。
从表格中可以看出:
??当第二个操作是 volatile 写 时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。 ??当第一个操作是 volatile 读 时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。 ??当第一个操作是 volatile 写 ,第二个操作是 volatile 读时,不能重排序。
2.JMM 针对编译器制定的 volatile 重排序实现方式
??为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障 来禁止特定类型的处理器重排序 。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
3.volatile写 插入屏障演示
??上图中 StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。
??这里比较有意思的是,volatile 写后面的 StoreLoad 屏障。此屏障的作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排序 。因为编译器常常无法准确判断在一个 volatile 写的后面 是否需要插入一个 StoreLoad 屏障(比如一个 volatile 写之后方法立即 return)。为了保证能正确 实现 volatile 的内存语义,JMM 在采取了保守策略:在每个 volatile 写的后面,或者在每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM 最终选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 volatile 写-读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里可以看到 JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
4.volatile读 插入屏障演示
??上图中 LoadLoad 屏障用来禁止处理器把上面的 volatile 读与下面的普通读重排序。LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序。
??上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障
5.volatile 读/写,具体代码演示
public class VolatileBarrierExample {
int a;
volatile int m1 = 1;
volatile int m2 = 2;
void readAndWrite() {
int i = m1;
int j = m2;
a = i + j;
m1 = i + 1;
m2 = j * 2;
}
}
优先 volatile 修饰的变量,如果没有被 volatile 修饰,则视为普通读 / 写。
int i = m1; ?读 m1 的值,针对 m1 来说,则是 (volatile 读),int j = m2; ?读 m2 的值,针对 m2 来说,则是 (volatile 读)a = i + j; ? a = i + j,没有被 volatile 修饰的变量,则是 (普通写)m1 = i + 1; ?m1 = i + 1,针对 m1 来说,则是 (volatile 写)m2 =j * 2; ?m2 =j * 2,针对 m2 来说,则是 (volatile 写)
??根据 3.JMM 针对编译器制定的 volatile 重排序实现方式,针对 readAndWrite() 方法,编译器在生成字节码时可以做如下的优化。最终插入屏障如下图所示: ??注意,最后的 StoreLoad 屏障不能省略 。因为第二个 volatile 写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器通常会在这里插 入一个 StoreLoad 屏障。
6.不同处理器平台,针对内存屏障的优化
??针对 5.volatile 读/写,具体代码演示 的优化,上面的优化针对的是任意处理器平台,由于不同的处理器有不同“松紧度” 的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以 X86 处理器为例,上图中除最后的 StoreLoad 屏障外,其他的屏障都会被省略 。
??前面保守策略下的 volatile 读和写,在 X86 处理器平台可以优化成如下图所示。前文提到过,X86 处理器仅会对写-读操作做重排序。X86 不会对读-读、读-写和写-写操作做重排序,因此在 X86 处理器中会省略掉这 3 种操作类型对应的内存屏障 。在 X86 中,JMM 仅需在 volatile 写后面插入一个 StoreLoad 屏障即可正确实现 volatile 写-读的内存语义。这意味着在 X86 处理器中,volatile 写的开销比 volatile 读的开销会大很多(因为执行 StoreLoad 屏障开销会比较大)。
2.实例分析
1.有序性例子
??两个线程,x = 0;y = 0;a = 0;b = 0;分别进行如下赋值操作,两个线程无限次(Integer.MAX_VALUE)循环,最终结果会有3种情况:1.(x = 1,y = 1) ? 2.(x = 0,y = 1) ? 3.(x = 1,y = 0) ? 如果发生指令重排序,则会出现第4种情况 4.(x = 0,y = 0)
public class ReOrderTest {
private static int x = 0;
private static int y = 0;
private static int a = 0;
private static int b = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
x = 0; y = 0;
a = 0; b = 0;
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
四种结果分析:(以下介绍内容中的1、2、3、4 ,分别对应的是两个线程中的4步操作。)
- 先执行 t2 的 3,再执行 t1 的 2 ,再执行 t1 的 1 ,最后执行 t2 的 4 ,最终结果为:
1,1 (这种概率较少) - 先执行 t1 的 1 和 2 ,再执行 t2 的 3 和 4 ,最终结果为:
0,1 - 先执行 t2 的 3 和 4 ,再执行 t1 的 1 和 2 ,最终结果为:
1,0 (如果代码进行了指令重排,则会)先执行 t1 的 2 或者 t2 的 2 ,最终结果为:0,0
4种情况,如图所示:
2.分析原因
??为什么会出现 0,0 情况?
??只有 x = b 优先于 a = 1 执行,y = a 优先于 b = 1 执行,才会出现 0,0 这种情况。这是因为 CPU 或者 JIT(即时编译器)对我们的代码进行了指令重排序,在多线程开发中导致的预期结果与实际结果不同的情况 。
??在并发编程中,有的指令重排序会影响到我们代码预期的结果,这是因为 a = 1;x = b;这段并没有遵循 happen-before 原则,还有就是 CPU 感知不到指令重排序会对我们最终的结果产生影响。(这里就是原因→)因为 a = 1 和 x = b 这两个运算,数据之间没有任何的依赖关系,CPU不会认为重排后会影响结果,所以会对这两个指令进行重排序。
3.解决方案
??有 3 种:1.使用 synchronized 锁机制解决 ? 2.使用 volatile 关键字 ? 3.(不推荐)使用 Unsafe 类种的 loadFence、storeFence、fullFence 方法,手动在代码中添加内存屏障
??1.加 synchronized 锁。就类似多线程开发的程序,单线程化执行,线程1只有在获得锁后,才能执行业务逻辑代码;线程2、线程3 只能等待线程 t1 锁的释放,t1 释放后,t2、t3继续抢锁,谁抢到谁执行。锁机制能够保证任一时刻只有一个线程访问该代码块。单线程环境下,遵循 as-if-serial 语义,不管怎么重排序,程序的执行结果永远不能被改变。
??2.使用 volatile 关键字。volatile是可以禁止 CPU/JIT 编译器对没有依赖关系的两行代码做指令重排优化 。volatile 底层是基于内存屏障 来实现的。JMM 模型中的内存屏障有 StoreStore 、StoreLoad 、LoadLoad 、LoadStore 四种。当代码在遇到内存屏障时,需立即将变量的最新数据写入主内存,保证共享变量的数据永远是最新的。
??3.使用 Unsafe 类种的 loadFence、storeFence、fullFence 方法,手动在代码中添加内存屏障。这种方式使用 Unsafe 魔法类中为我们提供的 loadFence 、 storeFence 、fullFence 方法,绕过 JVM 虚拟机直接对内存进行操作,在需要的代码之间手动添加内存屏障,也能解决有序性问题,但是不建议使用!!!因为 Unsafe 类太不安全,搞不好风险会很大的!!!
1.synchronized 锁
public class ReOrderTest {
private static int x = 0;
private static int y = 0;
private static int a = 0;
private static int b = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
x = 0; y = 0;
a = 0; b = 0;
Thread t1 = new Thread(() -> {
synchronized (lock) {
a = 1;
x = b;
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
2.(推荐)volatile 关键字
??使用 volatile 修饰变量 a 和 b。
??为什么不是修饰变量 x 和 y 呢? 要保证线程 t1 的 a = 1 和 线程 t2 的b = 1 先执行,不和后面的代码发送指令重排,肯定要修饰 a 和 b,在 a = 1 和 x = b 之间加内存屏障,保证JMM 不对这段代码进行指令重排优化。
public class ReOrderTest {
private static int x = 0;
private static int y = 0;
private static volatile int a = 0;
private static volatile int b = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
x = 0; y = 0;
a = 0; b = 0;
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
3.(不推荐)使用 Unsafe 类种的 loadFence、storeFence、fullFence 方法,手动在代码中添加内存屏障
Unsafe 类下,一共有 3 种内存屏障,分别是:
- loadFence ? 读屏障
- storeFence ? 写屏障
- fullFence ? 读写屏障(两个屏障都会加)
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
通过 Unsafe 类手动添加写屏障,代码如下所示:
Thread t1 = new Thread(() -> {
a = 1;
UnsafeInstance.reflectGetUnsafe().storeFence();
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
UnsafeInstance.reflectGetUnsafe().storeFence();
y = a;
});
最终代码:
public class ReOrderTest {
private static int x = 0;
private static int y = 0;
private static int a = 0;
private static int b = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
x = 0; y = 0;
a = 0; b = 0;
Thread t1 = new Thread(() -> {
a = 1;
UnsafeInstance.reflectGetUnsafe().storeFence();
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
UnsafeInstance.reflectGetUnsafe().storeFence();
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
??2021-11-19,《JMM内存模型 & 多线程三大特性》已更新,接下来将讲解: synchronized 关键字、JDK 6 中 synchronized 锁的优化,如有需要,请持续关注《并发编程》板块!!!
博主写作不易,加个关注呗
求关注、求点赞,加个关注不迷路 ヾ(?°?°?)ノ゙
我不能保证所写的内容都正确,但是可以保证不复制、不粘贴。保证每一句话、每一行代码都是亲手敲过的,错误也请指出,望轻喷 Thanks?(・ω・)ノ
|