《深入理解Java虚拟机》(七) volatile 变量

概述

今天的主角是volatile变量,在讲它之前我会稍微提一些必要的前置概念,例如:Java内存模型及其相关操作;如果你对这一部分很熟悉了可以直接跳到第二部分。

一、内存模型

物理机内存模型

当物理机中进行计算任务时,处理器与物理内存之间的I/O交互效率低已经成为桎梏,所以引入了高速缓存,处理器计算期间主要与高速缓存进行交互,计算完成才会把计算结果写入主内存。

Java内存模型

Java内存模型存在的意义是 让Java程序在各种平台下都能达到一致的内存访问效果 ;它的主要目的是定义程序中变量访问规则,即从内存到变量到处理器计算,最后回到内存的过程中的一些规则; Java内存模型的目的是让JVM的内充分利用各种平台的物理资源(寄存器、高速缓存等等)。

下文提到的主内存都是Java内存模型概念中的主内存

Java内存模型中有如下的规定:

  • 所有变量必须保存在主内存中,从下图结构来说Java内存模型的主内存,类似物理机的主内存,但是它实际上只是Java堆的一部分。

  • 每条线程都拥有自己独立的工作内存空间,从下图的结构来说,工作内存类似物理机高速缓存的概念;但是实际上它对应的应该是虚拟机栈中的部分区域(内存自动管理阶段提到过栈帧,这个应该很容易理解)。

  • 工作内存中需要保存操作变量的主内存副本;

  • 线程对变量的操作只能在工作内存中进行,而不能直接操作主内存;

  • 不同线程不能访问对方的工作内存中的变量副本。

操作

为了更好支撑Java内存模型提出的规定,Java内存模型定义了如下的操作,它们都是原子操作:

  • lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量
  • unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定
  • read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用
  • load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)
  • use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作
  • assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作
  • store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用
  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中

二、Volatile变量

上面介绍了Java内存模型,现在进入正题;Volatile变量—— 字面意思,它是变量,它是特殊的变量(废话,他不特殊为什么要单独讲它呢)。

volatile变量是Java虚拟机提供的最轻量级的同步机制,它拥有两个特性:

  • 定义为volatile的变量,拥有能够保证对所有线程的可见的特性(注意:只保证可见性);

  • 定义为volatile的变量,拥有 禁止指令重排序的特性

volatile修改变量后保证所有线程对其可见性

注意:
讨论可见性的语义环境:多个线程之间操作同一个volatile变量

所谓可见性:

  • 当多个线程执行一段代码(一个方法)包含一个volatile变量T值为X,假设线程:A、B、C一起执行,线程A改变T的值为Y,那么线程A对这个变量的修改,可能需要写回主内存,同时也需要立即通知其它也在执行这段代码(这个方法)的所有其它线程,把它们的工作内存中关于volate变量T的副本的值变更为新的值Y;

这里需要注意一个问题,上述Java内存模型概念中讲述的几个操作:

  • read(write)
  • load(store)
  • use(assign),

结合上述概念理解,如下过程

  1. 读过程
graph LR A(主内存变量) -- read -->B(放入工作内存中 )-- load -->C(保存到工作内存的变量副本)-- use -->D(传给执行引擎)
  1. 写过程
graph RL E(执行引擎计算结果)-- assign -->F(赋值变量副本)-- store -->G(变量进入主内存中 ) -- write -->H(把值写主内存变量)

既然volatile变量的特性描述说的是,只保证可见性,结合Java内存模型:

	公共的主内存,线程独占的工作内存;

为了要保证可见性,那么volatile变量每次在进行write操作时,不仅仅需要把计算后的值写入主内存分配的变量空间中,同时需要去操作其它各个线程的工作内存中的副本,需要把这些副本全部清理,修改为更新后的值。

细心的同学可能发现问题了,当废弃那些线程的副本时可能出现这样的情况:该volatile变量被废弃(其它线程对原值进行了修改)前已经被use操作给到了执行引擎,当前线程此次计算得到的计算结果是基于已经被废弃变量计算得到,如果将该基于废弃值得到的计算结果写入主内存,可能就会造成结果错误。(例如当多个线程对一个volatile变量进行累加操作时,就可能会发生错误)。

$ 应该还有汇编指令层面的更细粒度的细节说明volatile的线程安全问题。

所以说:volatile变量只保证可见性。

所以关于volatile变量的使用场景需要满足如下规则:

  • 计算结果与当前值(当前线程的副本变量)无关; 或者可以确保只有单一线程修改这个变量的值;

    例如:

    1. 表示开关的状态volatile变量T,有状态值a、b、c,此时此刻它是a,但是无法决定下一刻它该b还是c,此时volatile可以保证线程安全;
    2. 如果在累加操作中volatile变量T,此时它的值是5,那么通过计算,不论由哪一个线程执行,它的下一个状态的值则必须为6,这样的运算volatile变量无法保证线程安全。
  • 变量不需要与其它状态量共同参与不变约束;

volatile禁止指令重排序

有如下代码:

int a = 1,b = 2,c;//(1)
a = a +1;//(2)
c = a + 2;//(3)
b = b + 3;//(4)

当在汇编层面执行时

  • (1) 是声明,必须最先执行;
  • (3) 执行结果依赖(1),他们执行顺序不会被重排序
  • (4) 只依赖变量b,该指令可以被重排序提前执行,不影响最终结果。

实际上,保证禁止指令重排序的措施是插入一个内存屏障;

书中有这么一句话:

	对比一个变量,在volatile修饰前后的汇编代码发现,加了volatile修饰时,会多出一个Lock标记的指令。
  • 这个Lock标记指令,实际上就相当于一个内存屏障,它确保了指令不会被重排序,当执行到lock标记的语句时,它前面的语句必须已经执行完成,而它后面的语句也不能被重排序到它之前执行。
  • 它强制对缓存的修改立即写入主存
  • 如果是写操作,那么他会导致其它cpu中对应的缓存无效(可以任务它保证了当前修改对其它线程的可见性)。

Java内存模型关于volatile变量的特殊规则及其意义

操作指令顺序规则仅限于单个线程操作多个volatile变量;上文讨论可见性是在多个线程之间操作一个volatile变量,请注意区分前提条件。

假定T表示一个线程,X和Y分别表示两个volatile修饰的变量,当在X和Y上进行运算时,在进行read、load、use、assign、store和write操作的时候需要满足如下规则:

  1. 当T对 X 进行操作时,关于X的use操作的前一个操作必须是load操作,load操作的后一个动作必须是use,如此即可保证:每次使用volatile变量前,必须先从主内存刷新最新值,这个规定的目的是保证当前线程能看到别的线程对volatile变量的操作;

  2. 当T对X 的操作是assign时,它的后一个操作必须时store,操作store之前的动作必须是assign操作,由此可以保证,在工作内存中,每次对volatile变量修改后,必须立刻同步回主内存中,用于保证其它线程对当前线程的修改可见。。

  3. volatile变量不会被指令重排序优化,需要保证代码执行顺序与程序的顺序一致。

  • 动作A、B、C表示线程T对volatile变量X的操作,动作D、E、F表示线程T对volatile变量Y的操作;如果A先于D,那么C先于F;同理线程T对X和Y的:assign、store、write也满足此条件。
graph LR A(A: read X) -->B(B: load X) -->C( C : use X)
graph LR D(D: read Y) -->E(E: load Y) -->F( F : use Y)
posted @ 2021-06-19 15:30  bokerr  阅读(117)  评论(0编辑  收藏  举报