知芯

java-memory-model-and-concurrency

2019-07-21

内存模型


由于计算机的存储设备与处理器的运算速度有几个数量级的差距,现代计算机通过加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)到中间作为缓冲。这种解决方案引入了新的问题:缓存一致性(Cache Coerence) 。多处理器系统中,每个处理器都有自己的高速缓存,但它们共享同一主存(Main Memory).

当多个处理器的运算任务都涉及到同一块主存区域时,可能导致各自的缓存数据不一致。为了解决这个问题,各处理器需要在访问缓存时都遵循共同的协议。这类协议有 MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。

除了增加高速缓存之外,为了使处理器内部运算单元尽可能被充分利用,处理器可能会对收入代码进行乱序执行(Out-Of-Order Execution)优化,计算之后将执行结果进行重组,保证该结果与顺序执行的结果一致,但不保证程序中各个语句计算先后顺序与代码一致。Java虚拟机中的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。因此如果一个计算任务依赖另一个计算任务的中间结果,其顺序性不能靠代码的先后顺序来保证。

内存模型:可理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不同的内存模型,而Java虚拟机实现了自己的内存模型。并与硬件的内存模型很相似。

Java 内存模型

Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model, JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的,不会被共享。

Java内存模型规定了所有变量都存储在主内存(Main Memory),每条线程有自己的工作内存(Working Memory),线程的工作内存中保存了自己使用到的变量的主内存副本拷贝,线程对变量的所有操作都是在工作内存进行,不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

主内存(Main Memory) 这里的主内存仅仅指虚拟机内存的一部分,但可以和物理机主内存类比;

工作内存(Working Memory) 可以与物理机的告诉缓存类比。

Java 内存间交互操作

JMM定义了以下8中原子性操作(lock和unlock放到原子性讲)。JMM只要求read&loadstore&write操作必须按照顺序执行,而不保证是连续执行。

  • read(读取):把一个变量的值从主内存传输到工作内存中;
  • load(载入):把 read 操作从主内存得到的变量放入工作内存的变量副本中;
  • use(使用):把工作内存中一个变量的值传递给执行引擎;
  • assign(使用):把一个从执行引擎接收到的值赋给工作内存的变量;
  • store(存储):把工作内存中一个变量的值传送到主内存中;
  • write(写入):把 store 操作从工作内存中得到的变量放入主内存的变量中。

volatile

关键字volatile是JVM提供的最轻量的同步机制,当一个变量被定义为volatile之后具备了:

  • 可见性
    volatile修饰变量在每次使用前都先刷新,可以认为不存在一致性问题,但由于Java里面的运算并非原子操作(eg. race++ 包含4条字节码指令),volatile修饰的变量在并发下一样是不安全的。
    少数场景可直接使用:
    1. 运算结果不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值(eg. 修饰一个开关变量,当变为某个值时其他线程都停止)。
    2. 变量不需要与其他状态变量一起参与不变约束。
  • 禁止指令重排优化
    通过内存屏障实现屏障后面的指令不能排到屏障之前,也是内存屏障实现了可见性。

原子性、可见性、有序性

  • 原子性

    JMM 保证前面的8个基本操作的原子性,对于64位的数据类型(long和double),允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作,也就是允许虚拟机不保证64位数据类型的loadstorereadwrite这4个操作的原子性。

    JMM 还提供了 lockunlock 操作来保证更大范围的原子性,尽管虚拟机并未将其开放给用户,但可使用 monitorentermonitorexit 字节码指令来隐式地使用这两个操作,对应到 Java 代码中就是 synchronized 关键字,所以 synchronized 同步块也是原子性的

  • 可见性

    可见性是指当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。主要有以下3中方式:

    • volatile:保证新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
    • synchronized:对同步块加锁解锁,在执行 unlock 操作前必须把此变量值同步到主内存中。
    • final:被final关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 引用逃逸(其它线程可能通过引用访问到初始化了一半的对象),那么其它线程就能看见final字段的值。
  • 有序性

    • volatile 关键字通过添加内存屏障的方式来禁止指令重排。
    • synchronized 保证每个时刻只有一个线程执行同步代码,即让线程串行地执行同步代码,从而保证有序性。

      先行发生原则

      前面说的保证并发安全的定义实践起来比较麻烦,有一个等效判断原则——先行发生原则,来确定一个访问在并发环境下是否安全。
      先行发生原则的含义就是前面一个操作A的结果对后续操作B是一定可见的。Java中无需同步手段,天然的先行发生关系有:
  • 程序顺序规则:一个线程内按照控制流顺序,前面的操作Happens-Before于后面的操作。

  • 管程锁定规则:一个unlock操作Happens-Before于后面对同一个锁的lock操作。

  • volatile 变量规则:对一个volatile变量的写操作Happens-Before于对该变量的读操作。

  • 线程启动规则:Thread 对象的start方法Happens-Before于此线程的每一个动作。

  • 线程终止规则:线程中的所有操作都 Happens-Before于对该线程的终止检测,可通过Thread.join方法结束,或Thread.isAlive方法的返回值,检测到线程已经终止执行。

  • 线程中断规则:对线程interrupt方法的调用Happens-Before于被中断线程的代码检测到中断事件的发生。

  • 对象终结规则:一个对象的初始化完成Happens-Before于它的finalize方法的开始。

  • 传递性:如果操作 A Happens-Before于操作 B,操作 B Happens-Before于操作 C,那么操作 A 就Happens-Before于操作 C。

使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏

扫描二维码,分享此文章