Java多线程探索(一):从硬件看多线程并发安全问题的根源

一、并发安全根源

并发编程出现安全问题的原因无非三个:有序性、可见性、原子性。这三个问题在硬件中有其具体的产生原因。

1、有序性。

程序的一个要求就是:在编码保证正确的前提下,执行结果必须是正确的,程序要得是硬件做正确的事,强调结果的准确性。但对于硬件来说,更高的性能是其孜孜不倦的目标,硬件追求的是正确的做事,强调过程的效率。两者是否可以兼得?硬件是否可以既有效率又正确的完成程序任务呢?答案是肯定的,在软硬件协调下可以保证尽快的正确的完成程序任务。

硬件对指令执行的优化:指令流水。

关于指令流水可以参考我的一片博客:指令流水简介

当了解了什么是指令流水、为什么指令流水后也就是明白了为什么硬件要进行指令重排,由于硬件只需要保证 As-If-Serial语义:不管怎么重排序,单线程程序的执行结果不能被改变。一个很关键的字眼是单线程!!那多线程怎么办?

在上面指令流水简介中你知道了硬件对于指令重排的最基本限制:结构相关、数据相关、控制相关。这三个相关限制将能够保障As-If-Serial语义,对于多线程程序的的这三个相关性不在硬件的考虑范围之内,需要程序自己进行有序性控制。

硬件提供内存屏障指令给程序以保障多线程的指令有序性。内存屏障指令通过禁止某些特殊场景的指令重排来控制多线程的有序性(当然内存屏障也用于保障可见性,下一个小结探索)。

JMM(Java内存模型)将内存屏障指令分为四类,但是由于Java跨平台的特性,不是所有的平台都支持这四种指令:

  1. StoreStore屏障:不准将屏障前的数据存储指令重排到屏障后的数据存储指令后面。可以确保前一个指令存储的数据的可见性(从缓存刷新到内存),同时保证后一个指令的数据不会被前一个指令覆盖,也就是在指令流水里面数据相关的写后写问题。
  2. StoreLoad屏障:不准将屏障前的数据存储指令重排到屏障后的数据加载指令后面。可以确保前一个指令存储的数据的可见性(从缓存刷新到内存),同时保证后面的数据加载指令不会加载到一个旧值,也就是在指令流水里面数据相关的写后读问题。
  3. LoadLoad屏障:不准将屏障前的数据加载指令重排到屏障后的数据加载指令后面。指令流水的数据相关中也没有读后读问题,该屏障用于解决特殊读和普通读的重排序问题(比如volatile变量的读和普通变量的读)。
  4. LoadStore屏障:不准将屏障前的数据加载指令重排到屏障后的数据存储指令后面。可以确保前一个指令不会加载到后面存储指令存储的新的值,也就是在指令流水里面数据相关的读后写问题。

注意:

  1. 上面的屏障指令类是JMM的分类,不是具体硬件的具体指令。
  2. 只有对同一个数据操作的时候才会出现这些情况,然而Java是以共享内存来进行线程间的消息传递的,所以多线程对同一个数据的操作司空见惯。
  3. 可以发现内存屏障的目的往往都和数据可见性相关,因为三个原因密不可分。
  4. Java编译器在编译的时候通过插入平台相关的内存屏障指令来保证多线程的并发安全,前提是正确的并发控制。

总结一下有序性:由于硬件指令重排的情况下,指令在执行的时候是乱序执行的,但是如果是单线程程序或者正确并发控制的多线程程序(正确的插入了内存屏障指令),硬件执行保障As-If-Serial语义,在保障正确结果的前提下尽量提高效率。所以可以又快又正确的执行程序。

二、可见性根源

上一节的内存屏障好像顺便就解决了数据可见性的问题,但是指令重排的数据相关是指令之间的数据相关。现代计算机基本上都是多核,且都是有高速缓存的,如何保障各个核心缓存之间的数据一致性和可见性就是一个大问题。还好有缓存一致性协议。

MESI缓存一致性协议:将缓存行(缓存的最小单位,往往是多个数据)用MESI四种状态进行标记,然后通过监听事件来进行缓存行的状态转换。以此来保障各个缓存中的数据一致性和数据可见性。

MESI就是四种状态的首字母缩写:

  1. M(Modified) :这行缓存数据有效,但是缓存行数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
  2. E(Exclusive) :这行缓存数据有效,缓存行数据和内存中的数据一致,只存在于本Cache中。
  3. S(Shared) :这行缓存数据有效,缓存行数据和内存中的数据一致,存在于很多Cache中(也可能只有这个Cache有,只是它自己不知道而已)。
  4. I(Invalid): 这行缓存数据无效,不能够使用,必须从内存重新加载。

CPU缓存一致性协议MESI这篇博客对缓存一致性协议有详细的介绍。

在了解了缓存一致性协议后,发现由于缓存一致性协议的优化(存储缓存和无效队列),缓存数据的更新不是实时的,而是异步更新。这就导致了数据不一致问题,仅仅缓存一致性协议无法解决可见性和一致性问题。

内存屏障的另外一个作用:强制刷新缓存。内存屏障缓存刷新可以保证数据的可见性,但是一致性仍然无法保证。

总线事物:Lock信号可以锁总线/缓存(也就是总线锁和缓存锁,导致其他CPU无法获取总线,也就无法访问内存,顺便实现了原子性),在Lock总线总线/缓存后再执行指令,在释放锁的时候将数据写入内存。

好了,现在Lock信号、内存屏障、缓存一致性协议三方合力,终于完美解决了可见性、顺序性,还顺便解决了原子性。更详细的缓存可以看看这篇好博客:理解CPU高速缓存的工作原理

三、原子性根源

原子性就是指一个不可分割的部分,要么全部,要么没有。

原子性的实现主要是依赖锁。下一篇博客我准备来和你一起探索虚拟机的锁机制。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章