研磨java内存模型(三)

前言

前面在研磨java内存模型(二)中我们介绍了java内存模型的组成JVM 内存操作的并发问题,Java 内存间的交互操作。这里最后我们再来一起学习下内存交互基本操作的 3 个特性,happens-before 关系,内存屏障,volatile 型变量,synchronized 的特殊规则。

内存交互基本操作的 3 个特性

原子性(Atomicity) ,即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。

可见性(Visibility) 是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。JMM 是通过在线程 1 变量工作内存修改后将新值同步回主内存,线程 2 在变量读取前从主内存刷新变量值,这种依赖主内存作为传递媒介的方式来实现可见性。

有序性(Ordering)规则表现在以下两种场景:

  • 线程内,从某个线程的角度看方法的执行,指令会按照一种叫“串行”(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。

  • 线程间,这个线程“观察”到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。唯一起作用的约束是:对于同步方法,同步块(synchronized 关键字修饰)以及 volatile 字段的操作仍维持相对有序。

Java 内存模型的一系列运行规则看起来有点繁琐,但总结起来,是围绕原子性、可见性、有序性特征建立。归根究底,是为实现共享变量的在多个线程的工作内存的数据一致性,多线程并发,指令重排序优化的环境中程序能如预期运行

happens-before 关系

happens-before 关系:用于描述下 2 个操作的内存可见性。如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。

  • 单线程下的 happens-before,字节码的先后顺序天然包含 happens-before 关系:因为单线程内共享一份工作内存,不存在数据一致性的问题。 在程序控制流路径中靠前的字节码 happens-before 靠后的字节码,即靠前的字节码执行完之后操作结果对靠后的字节码可见。然而,这并不意味着前者一定在后者之前执行。实际上,如果后者不依赖前者的运行结果,那么它们可能会被重排序。

  • 多线程下的 happens-before,多线程由于每个线程有共享变量的副本,如果没有对共享变量做同步处理,线程 1 更新执行操作 A 共享变量的值之后,线程 2 开始执行操作 B,此时操作 A 产生的结果对操作 B 不一定可见。

为了方便程序开发,Java 内存模型实现了下述支持 happens-before 关系的操作: 

  • 程序次序规则,一个线程内,按照代码顺序,书写在前面的操作 happens-before 书写在后面的操作。

  • 锁定规则,一个 unLock 操作 happens-before 后面对同一个锁的 lock 操作。

  • volatile 变量规则,对一个变量的写操作 happens-before 后面对这个变量的读操作。

  • 传递规则,如果操作 A happens-before 操作 B,而操作 B 又 happens-before 操作 C,则可以得出操作 A happens-before 操作 C。

  • 线程启动规则,Thread 对象的 start() 方法 happens-before 此线程的每个一个动作。

  • 线程中断规则,对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生。

  • 线程终结规则,线程中所有的操作都 happens-before 线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行。

  • 对象终结规则,一个对象的初始化完成 happens-before 它的 finalize() 方法的开始。

内存屏障

Java 中如何保证底层操作的有序性和可见性?可以通过内存屏障。内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障可见性。

举个例子说明:

Store1; 
Store2;   
Load1;   
StoreLoad;  //内存屏障
Store3;   
Load2;   
Load3;

 对于上面的一组 CPU 指令(Store 表示写入指令,Load 表示读取指令,StoreLoad 代表写读内存屏障),StoreLoad 屏障之前的 Store 指令无法与 StoreLoad 屏障之后的 Load 指令进行交换位置,即重排序。但是 StoreLoad 屏障之前和之后的指令是可以互换位置的,即 Store1 可以和 Store2 互换,Load2 可以和 Load3 互换。Java 中对内存屏障的使用在一般的代码中不太容易见到,常见的有 volatile 和 synchronized 关键字修饰的代码块(后面再展开介绍),还可以通过 Unsafe 这个类来使用内存屏障。

volatile 型变量的特殊规则

volatile 主要有下面 2 种语义:

  • 保证可见性

  • 禁止进行指令重排序

保证可见性,保证了不同线程对该变量操作的内存可见性。这里保证可见性不等同于 volatile 变量并发操作的安全性,保证可见性具体一点解释:

  • 线程对变量进行修改之后,要立刻回写到主内存。

  • 线程对变量读取的时候,要从主内存中读,而不是从线程的工作内存。

但是如果多个线程同时把更新后的变量值同时刷新回主内存,可能导致得到的值不是预期结果。

举个例子:定义 volatile int count = 0,2 个线程同时执行 count++ 操作,每个线程都执行 500 次,最终结果小于 1000。原因是每个线程执行 count++ 需要以下 3 个步骤:

  • 线程从主内存读取最新的 count 的值。

  • 执行引擎把 count 值加 1,并赋值给线程工作内存。

  • 线程工作内存把 count 值保存到主内存。

有可能某一时刻 2 个线程在步骤 1 读取到的值都是 100,执行完步骤 2 得到的值都是 101,最后刷新了 2 次 101 保存到主内存。

禁止进行指令重排序,具体一点解释,禁止重排序的规则如下:

  • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行。

  • 在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

volatile 型变量使用场景

总结起来,就是“一次写入,到处读取”,某一线程负责更新变量,其他线程只读取变量(不更新变量),并根据变量的新值执行相应逻辑。例如状态标志位更新,观察者模型变量值发布。

final 型变量的特殊规则

我们知道,final 成员变量必须在声明的时候初始化或者在构造器中初始化,否则就会报编译错误。 

final 关键字的可见性是指:被 final 修饰的字段在声明时或者构造器中,一旦初始化完成,那么在其他线程无须同步就能正确看见 final 字段的值。这是因为一旦初始化完成,final 变量的值立刻回写到主内存。

synchronized 的特殊规则

通过 synchronized 关键字包住的代码区域,对数据的读写进行控制:

  • 读数据,当线程进入到该区域读取变量信息时,对数据的读取也不能从工作内存读取,只能从内存中读取,保证读到的是最新的值。

  • 写数据,在同步区内对变量的写入操作,在离开同步区时就将当前线程内的数据刷新到内存中,保证更新的数据对其他线程的可见性。

原文链接

Java内存模型原理,你真的理解吗?

猜你喜欢

转载自blog.csdn.net/qq_28165595/article/details/85029081