关于微观下i++的并发执行

Context

最近一直没有更新文章是因为过得很充实,,
一方面,现在在做小程序的线上商城,小程序这边倒没什么难的,难的是服务器端怎么设计架构,涉及到不同的客户,不同的客户门店组织架构又不一样,硬件条件也不一样,商品也不一样,还需要跟 ERP 的同事进行沟通。。
另一方面,一直计划在房子合同到期(2019-04-04)之后换工作,所以积极准备面试,查漏补缺什么的。去年12月份时候,买了『深入理解Java虚拟机』和『Java并发编程的艺术』。前者全是干货,干到没法儿写博客做笔记,否则就是在照搬书上的内容了。后者,,额,有点晦涩,没有读下去的欲望,然后就放在省图计算机科学分类下的书架上了,嗯,上周末去省图发现那本书已经不见了🤣。
对了,与此同时,,发现了 vue-element-admin 框架,一直想试试 webpack,然后就在不忙的时候慢慢重构现有的后台页面。用封装好的组件写页面真省心,爽的飞起~

嗯,,今天的主要内容来自『深入理解Java虚拟机』的第五部分第一章–Java内存模型与线程

这是第二次读这一部分了,,第一次读的时候大水漫灌囫囵吞枣,全局把握(大误)。这次读的时候主要的是抠细节,温故知新,受益匪浅!下面进入正题儿~

Java内存模型与线程

硬件的效率与一致性

由于计算机的存储设备与处理器的运算速度有几个量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好的解决了处理器与内存之间的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存。

– Page 361

处理器、高速缓存、主内存间的交互关系

Java内存模型

Java 虚拟规范试图定义一种 Java 内存模型 (Java Memory Model, JMM) 来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。 

Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory),线程中保存了被该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
– Page 362-363
Java线程、工作内存、主内存间的交互关系

关于主内存和工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了一下 8 种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的( double 和 long 例外)。

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入): 作用于工作内存的变量,它把read操作的从主内存中得到的变量值放入工作的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

– Page 365

需要注意的是,上面👆提及的每一种操作都是原子的,,而不是『从内存中读取变量,然后修改变量,然后回写到内存』这整个过程是原子的。这意味着多线程情况下,不加干预时上面8种操作在线程间是交替进行的。如对主内存中的变量a、b 进行访问时,一种可能出现的顺序是 read a, read b, load b, load a..
@内存间交互操作

volatile关键字

当一个变量定义为 volatile 之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是立即得知的。而普通变量无法做到这一点,普通变量的值,在线程间传递均需要通过主内存来完成。例如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程A回写完成之后再从主内存进行读取操作,新变量的值才会对线程 B 可见。

– Page 366

不得不说的是,读到这里的时候很困惑,,volatile 是如何让被修改后的变量值立即被其他线程得知的呢???
这就不得不提到MESI协议,MESI协议使用四个状态位描述每一个缓存行(Cache Line)。关于缓存行,在这篇文章中只需要知道它是 cache 和 RAM 交换数据的最小单位,通常为 64 Byte就可以了。

ok,下面简单说说MESI协议。

  • M(Modified):表示当前 Cache 行中包含的数据与内存中的数据不一致,而且它仅在本 CPU 的 Cache 中有效,其他 CPU 的 Cache 中不存在拷贝,即这个 Cache 行的数据是当前处理器系统中最新的数据拷贝。当 CPU 对这个 Cache 行进行替换操作时,必然会引发系统总线的写周期,将 Cache 行中数据与内存中的数据同步。
  • E(Exclusive):与 Modified 状态类似,唯一的区别是 Modified 状态表示当前 Cache Line 中的数据与内存中的数据不一致,而 Exclusive 状态下的 Cache 与内存中的数据一致。
  • S(Shared):表示缓存行中包含的数据有效,而且在当前 CPU 和其他 CPU 中至少存在一个副本。在该缓存行中的数据是当前处理器系统中最新的数据拷贝,而且与内存中的数据一致。
    I(Invalid):表示当前缓存行中数据无效。

在 MESI 缓存一致性协议下,我们看看在Java中 i++ 操作是怎么执行的。

两个 CPU A、B(即多核多线程)同时执行 i++ 的操作,假设 i 初始值为0:

  1. CPU A 从主内存中读入(read) i 到工作内存,并载入(load)到副本,此时其他 CPU 的缓存中并不存在变量 i,所以 CPU A 中缓存行状态为 Exclusive。
  2. CPU B 从主内存中读入(read) i 到工作内存,并载入(load)到副本,发现 CPU A 的缓存中已经存在变量 i 了,那么CPU B 中工作缓存的相关缓存行设置为 Shared,CPU A 中的相关缓存行也设置为 Shared。
  3. CPU A 开始执行 i++,i 值加一等于一,但是只是在寄存器中,并未写入缓存,此时状态还是 Shared。
  4. CPU B 开始执行 i++,i值加一等于一,同上,还是 Shared。
  5. CPU A 将计算后的值重新赋值(assign)给缓存中的副本,i 值为1,缓存行状态改为 Modified,此时 CPU B 的缓存行状态改为 Invalid。
  6. CPU B 准备比赋值给缓存中 i 的副本时,发现缓存行已处于无效(Invalid)状态,需要从内存中重新读取(read),又因为 CPU A 中相关缓存行中有变量 i 且为 Modified 状态,那么要求CPU A 将计算后的 i 值回写入(write)内存,内存中的 i 值为1,CPU A的缓存行状态为 Exclusive,CPU B 将内存中的 i 值读入(read+load)自己的缓存中的变量i的副本,此时CPU A、B 的缓存行都为 Shared,i 值都为1。
  7. CPU B 将 工作内存中的 i 值副本写入(write)到内存中,i 值副本此时为1,内存中的 i 值亦为1,即把1赋值给1,CPU B 中缓存行的状态变为 Modified,CPU A中的缓存行值变为Invalid。

@MESI缓存一致性模型

以上👆即为不加人为干预情况(使用volatile、synchronized关键字)下 i++ 在多核多线程架构中可能产生的执行结果。所以 MESI 缓存一致性协议并不能保证内存一致性。另外,在上面的例子中,表述并不算太严谨,,比如关于提到的『工作内存』,在『深入理解Java虚拟机』中是这么说的:

为了获取更好的运行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。
– Page 364

还有,在1、2中,CPU A、B read 并 load i 到工作内存中的副本中,在书中是这样说的:

Java 内存模型只要求read、load和store、write操作必须按顺序执行,而没有保证是连续执行。也就是说,read与Load之间、stire与write之间是可插入其他质量的。如对内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。
– Page365

👆诸如此类,,所以,嗯,注意领会精神。

OK,进行下一话题..
倘若此时我们使用 volatile 关键字修饰变量 i。因为 volatile 表示不使用寄存器的值,每次都从内存读(不包括缓存)。所以,在上边例子中的第三步,assign 工作内存中 i 的副本之后,立即将值回写到主内存中。书中的表述为:

有 volatile 修饰的变量,在编译成汇编语言之后,比不使用 volatile 修饰变量多了一行lock addl $0x0,(%esp),查询 IA32 手册得知,它的作用是使得本 CPU 的 Cache 写入了内存,该写入动作也会引起别的 CPU 或者别的内核无效化(Invalidate)其 Cache,这种操作相当于对 Cache 中的变量做了一次前面介绍Java内存模式中所说的“store”和“write”操作。所以通过这样一个操作,可让前面 volatile 变量的修改对其他 CPU 立即可见。

写到这里,我们可能会觉得 i++ 被 volatile 修饰过之后,就可得到期望的 i 值。然而即使这样,我们得到的 i 值依然比期望值要小。

问题就出在自增运算“i++”之中,我们用 javap 反编译这段代码后会发现只有一行 i++ 的代码在 Class 文件中是由4条字节码指令构成的。

1
2
3
4
5
6
// i 为静态变量
0: getstatic
3: iconst_1
4: iadd
5: putstatic
>

从字节码层面上很容易就分析出并发失败的原因了:当 getstatic 指令把 i 值取到操作栈时,volatile 关键字保证了 i 的值在此时是正确的,但是在执行 iconst_1、iadd 这些指令的时候,其他线程可能已经把 i 的值加大了,而在操作栈顶的值就变成了过期的数据,所以 putstatic 指令执行后就可能把较小的 i 值同步回主内存之中。
(PS: 一条字节码指令,也并不意味着执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转化为若干条本地机器码指令。)

由于 volatile 变量只能保证可见性,在不符合一下两条规则的运算场景中,我们仍要通过锁(使用synchronized和java.util.conrurrent中的原子类)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束

所以,,此时如果要解决 i++ 的问题,靠 volatile 关键字是不行的,要么将 volatile 关键字改为 synchronized 关键字上锁(lock 作用于主内存,它把一个变量标识为一条线程独占的状态。);要么,将 i++ 替换为 Java 并发工具包中的 AtomicInteger 类:

1
2
3
AtomicInteger i = new AtomicInteger(0);
...
AtomicInteger.incrementAndGet();


参考链接:

  1. 不可不说的Java“锁”事
  2. 缓存一致性协议 mesi
  3. volatile实现可见性的原理
  4. cache一致性协议,MESI和MOESI
  5. 周志明-深入理解Java虚拟机
  6. 一针见血系列[8]: 什么叫内存可见性?什么叫寄存器可见性?
  7. MESI与内存屏障