Java内存模型 &Volatile

时间:2021-11-30 20:50:58

 

 

■ Java 内存模型

 1.1  Java 内存模型

  • Java线程间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见
  • JMM是一个抽象的概念,并非真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器的优化
  • JMM定义了线程和主内存之间的抽象关系:
    • 线程之间的共享变量存储在主内存中(从硬件角度来说就是内存条)
    • 每个线程都有一个私有的本地内存,本地内存中存储了该线程用来读/写共享变量的副本(从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等)
  • 同时JVM通过JMM来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果
  • 重要声明: JMM所描述的主内存、工作内存与Java内存区域的堆栈不是一回事,更准确是主内存就是内存条,为了提高性能,JVM可能会让工作内存优先存储在寄存器和高速缓存中,程序运行时主要访问读写的也是工作内存
  • 有兴趣的读者可以进一步研究SMP(对称多处理技术)

 

   1.2  JMM 核心原则

  • JMM的关键技术点都是围绕多线程的原子性可见性有序性展开的
  • 多线程并发的法宝:外互斥内可见

     1.2.1  JMM 核心原则

  • 原子性是指一个操作是不可中断的,即多线程环境下,操作不能被其他线程干扰 (原子性)
  • 可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更 (可见性)
  • Java中普通的共享变量不保证可见性,因为其的修改被写入内存的时机是不确定的,多线程并发下很可能出现"脏读"
  • 缓存优化或者硬件优化或指令重排以及编辑器的优化都可能导致一个线程修改不会立即被其他线程察觉
  • Java提供volatile保证可见性:写操作立即刷新到主内存,读操作直接从主内存读取
  • Java同时还可以通过加锁的同步性间接保证可见性:synchronized和Lock能保证同一时刻只有一个线程获取锁并执行同步代码,并在释放锁之后将变量的修改刷新到主内存中
  • 对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行 (有序性)
  • 但为了提供性能,编译器和处理器通常会对指令序列进行重新排序
  • 指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致,即可能产生"脏读"

 

   1.3  JMM 抽象结构

Java内存模型 &Volatile

 

     1.4  JMM 影响范围

  • 在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程间共享
  • 局部变量、方法定义参数和异常处理器参数不在线程间共享,即不会有可见性问题也不受JMM影响

 

■ 重排序

    2.1  数据依赖性

  • 数据依赖性:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性
  • 编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在依赖关系的两个操作的执行
  • 但不同处理器和不同线程之间的数据性不会被编译器和处理器考虑,其只会作用于单处理器和单线程环境
  • 下面三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变

    Java内存模型 &Volatile

       2.1.1  串行语义

  • 串行语义: 不管如何重排序,单线程的执行结果不能被改变,编译器和处理器必须遵守串行语义
  • 单线程下: 不存在依赖关系,可以重排序;存在依赖关系,禁止重排序

    2.2  重排序

  • 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段
  • 为了提高效率,指令序列执行遵循流水线模式,不同的指令交给不同的硬件处理,从而节省等待时间
  • 但流水线最害怕被中断(所有硬件设备会进入停顿期,再次满载又需要几个周期,性能损失很大),而重排序就是减少中断的一种重要手段
  • 但重排后的指令绝对不能改变原有的串行语义!这点在并发设计中必须要重点考虑!
    Java内存模型 &Volatile

 2.3  Happends-Before 规则

  • 指令重排是有原则的,并非所有的指令都可以随便改变执行位置,如以下情况:
  • 程序顺序原则(!!): 一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • volatile规则(!!): volatile变量的写先发生于读,这保证了volatile变量的可见性
  • 锁规则(!!): 解锁必然发生在随后的加锁之前
  • 传递规则(!!): A先于B,B先于C,那么A必然先于C
  • 线程启动规则: 线程的start()方法先于它的每一个动作
  • 线程中断规则: 线程的中断(Thread.interrupt())先于被中断线程的代码
  • 线程终止规则: 线程的所有操作先于线程的终止检测(Thread.join()方法结束,Thread.isAlive()检测)
  • 对象终结规则: 对象的构造函数执行(初始化)结束先于finalize()方法

 ■ volatile

  • volatile是线程同步的轻量级实现,主要作用是使变量在多线程间可见
  • volatile本身并不处理数据的原子性,而是强制对数据的读写及时影响到主内存里
  • volatile会强制从公共堆栈中取得变量的值,而不是从线程私有数据栈中取的变量的值
  • volatile变量自身具有三个语义特性:

      可见性:保证了不同线程对这个变量进行操作时的可见性,即变量一旦变更所有线程立即可见
      有限原子性:对任意单个volatile变量的简单读写操作具有原子性,复合操作不具有原子性(如i++)
      重排序禁止:禁止进行指令重排序

  

 3.1  volatile 工作原理

int a = 0;
volatile boolean flag = false;
public void write(){
a
= 1;
flag
= true;
}
public void read(){
if (flag){
int i = a;
}
}

  写内存语义: 当写一个volatile 变量时,JMM会把线程对应的本地内存中的共享变量,刷新到主内存  

Java内存模型 &Volatile

 

    读内存语义

  • 当读一个volatile 变量时,JMM会把线程对应的本地内存设置为无效,线程会直接从主内存读取共享变量
  • 此时实际上实际上是线程间通过主内存完成一次消息通信,即A向B发送消息

Java内存模型 &Volatile

 

  3.2  volatile 可见性原理

  • 为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序
  • 为了保证内存可见性,编译器会在生成指令序列的恰当位置插入内存屏障指令来禁止特定类型的处理器重排序
  • 实现原理是在指令序列执行过程中,通过在volatile写操作后面插入StoreLoad屏障(x86平台),仅对volatile写-读进行重排序(x86会忽略读-读、读-写、写-写的重排序)从而实现正确的内存语义
  • 从汇编角度来说,操作volatile变量会多出一个lock前缀指令,其相当于内存屏障
  • 执行该屏障开销昂贵,因为处理器通常会把写缓冲区的数据全部刷新到内存中
  • 为了保证各个CPU的缓存一致性,每个CPU通过嗅探在总线上传播的数据来检查自己的缓存的数据有效性,当发现自己缓存行对应的内存地址的数据被修改,就会将该缓存行设置为无效,当CPU读取该变量,发现所在的缓存行被设置为无效,就会重新从内存中读取数据到缓存中
  • 具体的实现原理已超出本文所限,读者有兴趣可参见 <<Java并发编程的艺术>> 的 第三章-Java内存模型

 

    3.3  volatile 非原子特性

public class VolatileDemo {
public volatile int count = 0;

public void addCount(){
count
++;
//需要注意的是 count++换成 count += 1 效果也是一样,因为都是复合操作,不单单只是的++这种操作
}

public static void main(String[] args) {
VolatileDemo volatileDemo
= new VolatileDemo();
Thread[] threads
= new Thread[10];
for (int i = 0 ; i < threads.length ; i++){
threads[i]
= new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j<1000; j++){
volatileDemo.addCount();
}
}
});
}
//我们减少new的开销,让结果更准确一些
for (int i = 0 ; i < threads.length ;i++){
threads[i].start();
}

//线程需要都执行完毕
while (Thread.activeCount() > 1){
//让主线程主动释放资源,也就是等会在干活
//用Thread.sleep(long)效果一样,但时间不好控制
Thread.yield();
}
System.out.println(
"结果是:" + volatileDemo.count);
}

}

//结果是:9936
//于我们的预期结果10000完全不相符,说明volatile根本就不能保证原子性

Java内存模型 &Volatile

 

  3.4  volatile 应用场景

    正确使用 volatile 的条件:

  • 条件一: 写入变量时并不依赖变量的当前值;或者能够确保只有单一线程能够修改变量的值
  • 条件二: 变量不需要与其他的状态变量共同参与不变约束
  • 条件三: 变量访问不需要额外加锁
  • 通俗点: 当一个变量依赖其他变量或变量的新值依赖旧值时,不能用volatile

   volatile 使用场景:

  • 使用场景:通常被 作为标识完成、中断、状态的标记,值变化应具有原子性
  • 充分利用其可见性:即volatile能够保证在读取的那个时刻读到的肯定是最新值
  • 重点声明: volatile主要使用的场合是在多线程中可以感知实例变量被变更了,并且可以获得最新的值使用,也就是用多线程读取共享变量时可以获得最新值使用,但不能保证你在使用最新值过程中最新值不发生变化!很可能在使用之后,最新值已经变更。原数据变成过期数据,这时候就会出现数据不一致(非同步)的问题

     正确使用 volatile:

============= 状态标识 ===========================================
//
使用:作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束//使用理由:状态标志并不依赖于程序内任何其他状态,且通常只有一种状态转换
//例子:判断业务是否结束
volatile boolean isOk = false;
public void isOk() { isOk = true; }
public void doWork() {
//循环监听状态位变化
while (!isOk) {
// do work
}
}
============= 独立观察 ===========================================
//使用:将 volatile变量用于多个独立观察结果的发布 //特点:是"状态标志"的拓展,该值随时会发生变化,同时会被反复使用,前者一般就是用一次 //使用理由:只是简单的赋值操作,不会做复合操作 //例子:将新节点作为最后一个节点 

class CustomLinkedList{
  public volatile Node lastNode;
......
public void add() { Node node = new Node(); .....
lastNode
= node; //将新节点作为最后一个节点 } }

 

PS:  JMM 和 volatile 博文暂时到此,有很多原理和高级主题后续会陆续迭代研究,请各位观众多多评语、鼓励,谢谢!